diff --git a/src/components/App.tsx b/src/components/App.tsx index b280d079..309f29de 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1165,7 +1165,23 @@ class App extends React.Component { ), ); } + this.renderScene(); + this.history.record(this.state, this.scene.getElementsIncludingDeleted()); + // Do not notify consumers if we're still loading the scene. Among other + // potential issues, this fixes a case where the tab isn't focused during + // init, which would trigger onChange with empty elements, which would then + // override whatever is in localStorage currently. + if (!this.state.isLoading) { + this.props.onChange?.( + this.scene.getElementsIncludingDeleted(), + this.state, + this.files, + ); + } + } + + private renderScene = () => { const cursorButton: { [id: string]: string | undefined; } = {}; @@ -1202,6 +1218,7 @@ class App extends React.Component { ); cursorButton[socketId] = user.button; }); + const renderingElements = this.scene .getNonDeletedElements() .filter((element) => { @@ -1223,42 +1240,43 @@ class App extends React.Component { }); renderScene( - renderingElements, - this.state, - this.state.selectionElement, - window.devicePixelRatio, - this.rc!, - this.canvas!, { - scrollX: this.state.scrollX, - scrollY: this.state.scrollY, - viewBackgroundColor: this.state.viewBackgroundColor, - zoom: this.state.zoom, - remotePointerViewportCoords: pointerViewportCoords, - remotePointerButton: cursorButton, - remoteSelectedElementIds, - remotePointerUsernames: pointerUsernames, - remotePointerUserStates: pointerUserStates, - shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom, - theme: this.state.theme, - imageCache: this.imageCache, - isExporting: false, - renderScrollbars: !this.device.isMobile, - }, - ({ atLeastOneVisibleElement, scrollBars }) => { - if (scrollBars) { - currentScrollBars = scrollBars; - } - const scrolledOutside = - // hide when editing text - isTextElement(this.state.editingElement) - ? false - : !atLeastOneVisibleElement && renderingElements.length > 0; - if (this.state.scrolledOutside !== scrolledOutside) { - this.setState({ scrolledOutside }); - } + elements: renderingElements, + appState: this.state, + scale: window.devicePixelRatio, + rc: this.rc!, + canvas: this.canvas!, + renderConfig: { + scrollX: this.state.scrollX, + scrollY: this.state.scrollY, + viewBackgroundColor: this.state.viewBackgroundColor, + zoom: this.state.zoom, + remotePointerViewportCoords: pointerViewportCoords, + remotePointerButton: cursorButton, + remoteSelectedElementIds, + remotePointerUsernames: pointerUsernames, + remotePointerUserStates: pointerUserStates, + shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom, + theme: this.state.theme, + imageCache: this.imageCache, + isExporting: false, + renderScrollbars: !this.device.isMobile, + }, + callback: ({ atLeastOneVisibleElement, scrollBars }) => { + if (scrollBars) { + currentScrollBars = scrollBars; + } + const scrolledOutside = + // hide when editing text + isTextElement(this.state.editingElement) + ? false + : !atLeastOneVisibleElement && renderingElements.length > 0; + if (this.state.scrolledOutside !== scrolledOutside) { + this.setState({ scrolledOutside }); + } - this.scheduleImageRefresh(); + this.scheduleImageRefresh(); + }, }, THROTTLE_NEXT_RENDER && window.EXCALIDRAW_THROTTLE_RENDER === true, ); @@ -1266,21 +1284,7 @@ class App extends React.Component { if (!THROTTLE_NEXT_RENDER) { THROTTLE_NEXT_RENDER = true; } - - this.history.record(this.state, this.scene.getElementsIncludingDeleted()); - - // Do not notify consumers if we're still loading the scene. Among other - // potential issues, this fixes a case where the tab isn't focused during - // init, which would trigger onChange with empty elements, which would then - // override whatever is in localStorage currently. - if (!this.state.isLoading) { - this.props.onChange?.( - this.scene.getElementsIncludingDeleted(), - this.state, - this.files, - ); - } - } + }; private onScroll = debounce(() => { const { offsetTop, offsetLeft } = this.getCanvasOffsets(); diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index 7db36002..361883b9 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -284,492 +284,475 @@ const renderLinearElementPointHighlight = ( context.restore(); }; -export const _renderScene = ( - elements: readonly NonDeletedExcalidrawElement[], - appState: AppState, - selectionElement: NonDeletedExcalidrawElement | null, - scale: number, - rc: RoughCanvas, - canvas: HTMLCanvasElement, - renderConfig: RenderConfig, +export const _renderScene = ({ + elements, + appState, + scale, + rc, + canvas, + renderConfig, +}: { + elements: readonly NonDeletedExcalidrawElement[]; + appState: AppState; + scale: number; + rc: RoughCanvas; + canvas: HTMLCanvasElement; + renderConfig: RenderConfig; +}) => // extra options passed to the renderer -) => { - if (canvas === null) { - return { atLeastOneVisibleElement: false }; - } + { + if (canvas === null) { + return { atLeastOneVisibleElement: false }; + } + const { + renderScrollbars = true, + renderSelection = true, + renderGrid = true, + isExporting, + } = renderConfig; - const { - renderScrollbars = true, - renderSelection = true, - renderGrid = true, - isExporting, - } = renderConfig; + const context = canvas.getContext("2d")!; - const context = canvas.getContext("2d")!; + context.setTransform(1, 0, 0, 1, 0, 0); + context.save(); + context.scale(scale, scale); - context.setTransform(1, 0, 0, 1, 0, 0); - context.save(); - context.scale(scale, scale); + // When doing calculations based on canvas width we should used normalized one + const normalizedCanvasWidth = canvas.width / scale; + const normalizedCanvasHeight = canvas.height / scale; - // When doing calculations based on canvas width we should used normalized one - const normalizedCanvasWidth = canvas.width / scale; - const normalizedCanvasHeight = canvas.height / scale; + if (isExporting && renderConfig.theme === "dark") { + context.filter = THEME_FILTER; + } - if (isExporting && renderConfig.theme === "dark") { - context.filter = THEME_FILTER; - } - - // Paint background - if (typeof renderConfig.viewBackgroundColor === "string") { - const hasTransparence = - renderConfig.viewBackgroundColor === "transparent" || - renderConfig.viewBackgroundColor.length === 5 || // #RGBA - renderConfig.viewBackgroundColor.length === 9 || // #RRGGBBA - /(hsla|rgba)\(/.test(renderConfig.viewBackgroundColor); - if (hasTransparence) { + // Paint background + if (typeof renderConfig.viewBackgroundColor === "string") { + const hasTransparence = + renderConfig.viewBackgroundColor === "transparent" || + renderConfig.viewBackgroundColor.length === 5 || // #RGBA + renderConfig.viewBackgroundColor.length === 9 || // #RRGGBBA + /(hsla|rgba)\(/.test(renderConfig.viewBackgroundColor); + if (hasTransparence) { + context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight); + } + context.save(); + context.fillStyle = renderConfig.viewBackgroundColor; + context.fillRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight); + context.restore(); + } else { context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight); } + + // Apply zoom context.save(); - context.fillStyle = renderConfig.viewBackgroundColor; - context.fillRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight); - context.restore(); - } else { - context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight); - } + context.scale(renderConfig.zoom.value, renderConfig.zoom.value); - // Apply zoom - context.save(); - context.scale(renderConfig.zoom.value, renderConfig.zoom.value); - - // Grid - if (renderGrid && appState.gridSize) { - strokeGrid( - context, - appState.gridSize, - -Math.ceil(renderConfig.zoom.value / appState.gridSize) * - appState.gridSize + - (renderConfig.scrollX % appState.gridSize), - -Math.ceil(renderConfig.zoom.value / appState.gridSize) * - appState.gridSize + - (renderConfig.scrollY % appState.gridSize), - normalizedCanvasWidth / renderConfig.zoom.value, - normalizedCanvasHeight / renderConfig.zoom.value, - ); - } - - // Paint visible elements - const visibleElements = elements.filter((element) => - isVisibleElement(element, normalizedCanvasWidth, normalizedCanvasHeight, { - zoom: renderConfig.zoom, - offsetLeft: appState.offsetLeft, - offsetTop: appState.offsetTop, - scrollX: renderConfig.scrollX, - scrollY: renderConfig.scrollY, - }), - ); - - visibleElements.forEach((element) => { - try { - renderElement(element, rc, context, renderConfig); - if (!isExporting) { - renderLinkIcon(element, context, appState); - } - } catch (error: any) { - console.error(error); - } - }); - - if (appState.editingLinearElement) { - const element = LinearElementEditor.getElement( - appState.editingLinearElement.elementId, - ); - if (element) { - renderLinearPointHandles(context, appState, renderConfig, element); - } - } - - // Paint selection element - if (selectionElement) { - try { - renderElement(selectionElement, rc, context, renderConfig); - } catch (error: any) { - console.error(error); - } - } - - if (isBindingEnabled(appState)) { - appState.suggestedBindings - .filter((binding) => binding != null) - .forEach((suggestedBinding) => { - renderBindingHighlight(context, renderConfig, suggestedBinding!); - }); - } - - if ( - appState.selectedLinearElement && - appState.selectedLinearElement.hoverPointIndex >= 0 - ) { - renderLinearElementPointHighlight(context, appState, renderConfig); - } - // Paint selected elements - if ( - renderSelection && - !appState.multiElement && - !appState.editingLinearElement - ) { - const locallySelectedElements = getSelectedElements(elements, appState); - const showBoundingBox = shouldShowBoundingBox(locallySelectedElements); - - const locallySelectedIds = locallySelectedElements.map( - (element) => element.id, - ); - const isSingleLinearElementSelected = - locallySelectedElements.length === 1 && - isLinearElement(locallySelectedElements[0]); - // render selected linear element points - if ( - isSingleLinearElementSelected && - appState.selectedLinearElement?.elementId === - locallySelectedElements[0].id && - !locallySelectedElements[0].locked - ) { - renderLinearPointHandles( + // Grid + if (renderGrid && appState.gridSize) { + strokeGrid( context, - appState, - renderConfig, - locallySelectedElements[0] as ExcalidrawLinearElement, - ); - } - if (showBoundingBox) { - const selections = elements.reduce((acc, element) => { - const selectionColors = []; - // local user - if ( - locallySelectedIds.includes(element.id) && - !isSelectedViaGroup(appState, element) - ) { - selectionColors.push(oc.black); - } - // remote users - if (renderConfig.remoteSelectedElementIds[element.id]) { - selectionColors.push( - ...renderConfig.remoteSelectedElementIds[element.id].map( - (socketId) => { - const { background } = getClientColors(socketId, appState); - return background; - }, - ), - ); - } - if (selectionColors.length) { - const [elementX1, elementY1, elementX2, elementY2] = - getElementAbsoluteCoords(element); - acc.push({ - angle: element.angle, - elementX1, - elementY1, - elementX2, - elementY2, - selectionColors, - }); - } - return acc; - }, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[] }[]); - - const addSelectionForGroupId = (groupId: GroupId) => { - const groupElements = getElementsInGroup(elements, groupId); - const [elementX1, elementY1, elementX2, elementY2] = - getCommonBounds(groupElements); - selections.push({ - angle: 0, - elementX1, - elementX2, - elementY1, - elementY2, - selectionColors: [oc.black], - }); - }; - - for (const groupId of getSelectedGroupIds(appState)) { - // TODO: support multiplayer selected group IDs - addSelectionForGroupId(groupId); - } - - if (appState.editingGroupId) { - addSelectionForGroupId(appState.editingGroupId); - } - selections.forEach((selection) => - renderSelectionBorder( - context, - renderConfig, - selection, - isSingleLinearElementSelected ? DEFAULT_SPACING * 2 : DEFAULT_SPACING, - ), - ); - } - // Paint resize transformHandles - context.save(); - context.translate(renderConfig.scrollX, renderConfig.scrollY); - - if (locallySelectedElements.length === 1) { - context.fillStyle = oc.white; - const transformHandles = getTransformHandles( - locallySelectedElements[0], - renderConfig.zoom, - "mouse", // when we render we don't know which pointer type so use mouse - ); - if (!appState.viewModeEnabled && showBoundingBox) { - renderTransformHandles( - context, - renderConfig, - transformHandles, - locallySelectedElements[0].angle, - ); - } - } else if (locallySelectedElements.length > 1 && !appState.isRotating) { - const dashedLinePadding = 4 / renderConfig.zoom.value; - context.fillStyle = oc.white; - const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements); - const initialLineDash = context.getLineDash(); - context.setLineDash([2 / renderConfig.zoom.value]); - const lineWidth = context.lineWidth; - context.lineWidth = 1 / renderConfig.zoom.value; - strokeRectWithRotation( - context, - x1 - dashedLinePadding, - y1 - dashedLinePadding, - x2 - x1 + dashedLinePadding * 2, - y2 - y1 + dashedLinePadding * 2, - (x1 + x2) / 2, - (y1 + y2) / 2, - 0, - ); - context.lineWidth = lineWidth; - context.setLineDash(initialLineDash); - const transformHandles = getTransformHandlesFromCoords( - [x1, y1, x2, y2], - 0, - renderConfig.zoom, - "mouse", - OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, - ); - if (locallySelectedElements.some((element) => !element.locked)) { - renderTransformHandles(context, renderConfig, transformHandles, 0); - } - } - context.restore(); - } - - // Reset zoom - context.restore(); - - // Paint remote pointers - for (const clientId in renderConfig.remotePointerViewportCoords) { - let { x, y } = renderConfig.remotePointerViewportCoords[clientId]; - - x -= appState.offsetLeft; - y -= appState.offsetTop; - - const width = 9; - const height = 14; - - const isOutOfBounds = - x < 0 || - x > normalizedCanvasWidth - width || - y < 0 || - y > normalizedCanvasHeight - height; - - x = Math.max(x, 0); - x = Math.min(x, normalizedCanvasWidth - width); - y = Math.max(y, 0); - y = Math.min(y, normalizedCanvasHeight - height); - - const { background, stroke } = getClientColors(clientId, appState); - - context.save(); - context.strokeStyle = stroke; - context.fillStyle = background; - - const userState = renderConfig.remotePointerUserStates[clientId]; - if (isOutOfBounds || userState === UserIdleState.AWAY) { - context.globalAlpha = 0.48; - } - - if ( - renderConfig.remotePointerButton && - renderConfig.remotePointerButton[clientId] === "down" - ) { - context.beginPath(); - context.arc(x, y, 15, 0, 2 * Math.PI, false); - context.lineWidth = 3; - context.strokeStyle = "#ffffff88"; - context.stroke(); - context.closePath(); - - context.beginPath(); - context.arc(x, y, 15, 0, 2 * Math.PI, false); - context.lineWidth = 1; - context.strokeStyle = stroke; - context.stroke(); - context.closePath(); - } - - context.beginPath(); - context.moveTo(x, y); - context.lineTo(x + 1, y + 14); - context.lineTo(x + 4, y + 9); - context.lineTo(x + 9, y + 10); - context.lineTo(x, y); - context.fill(); - context.stroke(); - - const username = renderConfig.remotePointerUsernames[clientId]; - - let idleState = ""; - if (userState === UserIdleState.AWAY) { - idleState = hasEmojiSupport ? "⚫️" : ` (${UserIdleState.AWAY})`; - } else if (userState === UserIdleState.IDLE) { - idleState = hasEmojiSupport ? "💤" : ` (${UserIdleState.IDLE})`; - } else if (userState === UserIdleState.ACTIVE) { - idleState = hasEmojiSupport ? "🟢" : ""; - } - - const usernameAndIdleState = `${ - username ? `${username} ` : "" - }${idleState}`; - - if (!isOutOfBounds && usernameAndIdleState) { - const offsetX = x + width; - const offsetY = y + height; - const paddingHorizontal = 4; - const paddingVertical = 4; - const measure = context.measureText(usernameAndIdleState); - const measureHeight = - measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent; - - // Border - context.fillStyle = stroke; - context.fillRect( - offsetX - 1, - offsetY - 1, - measure.width + 2 * paddingHorizontal + 2, - measureHeight + 2 * paddingVertical + 2, - ); - // Background - context.fillStyle = background; - context.fillRect( - offsetX, - offsetY, - measure.width + 2 * paddingHorizontal, - measureHeight + 2 * paddingVertical, - ); - context.fillStyle = oc.white; - - context.fillText( - usernameAndIdleState, - offsetX + paddingHorizontal, - offsetY + paddingVertical + measure.actualBoundingBoxAscent, + appState.gridSize, + -Math.ceil(renderConfig.zoom.value / appState.gridSize) * + appState.gridSize + + (renderConfig.scrollX % appState.gridSize), + -Math.ceil(renderConfig.zoom.value / appState.gridSize) * + appState.gridSize + + (renderConfig.scrollY % appState.gridSize), + normalizedCanvasWidth / renderConfig.zoom.value, + normalizedCanvasHeight / renderConfig.zoom.value, ); } - context.restore(); - context.closePath(); - } - - // Paint scrollbars - let scrollBars; - if (renderScrollbars) { - scrollBars = getScrollBars( - elements, - normalizedCanvasWidth, - normalizedCanvasHeight, - renderConfig, + // Paint visible elements + const visibleElements = elements.filter((element) => + isVisibleElement(element, normalizedCanvasWidth, normalizedCanvasHeight, { + zoom: renderConfig.zoom, + offsetLeft: appState.offsetLeft, + offsetTop: appState.offsetTop, + scrollX: renderConfig.scrollX, + scrollY: renderConfig.scrollY, + }), ); - context.save(); - context.fillStyle = SCROLLBAR_COLOR; - context.strokeStyle = "rgba(255,255,255,0.8)"; - [scrollBars.horizontal, scrollBars.vertical].forEach((scrollBar) => { - if (scrollBar) { - roundRect( - context, - scrollBar.x, - scrollBar.y, - scrollBar.width, - scrollBar.height, - SCROLLBAR_WIDTH / 2, - ); + visibleElements.forEach((element) => { + try { + renderElement(element, rc, context, renderConfig); + if (!isExporting) { + renderLinkIcon(element, context, appState); + } + } catch (error: any) { + console.error(error); } }); - context.restore(); - } - context.restore(); - return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars }; -}; + if (appState.editingLinearElement) { + const element = LinearElementEditor.getElement( + appState.editingLinearElement.elementId, + ); + if (element) { + renderLinearPointHandles(context, appState, renderConfig, element); + } + } + + // Paint selection element + if (appState.selectionElement) { + try { + renderElement(appState.selectionElement, rc, context, renderConfig); + } catch (error: any) { + console.error(error); + } + } + + if (isBindingEnabled(appState)) { + appState.suggestedBindings + .filter((binding) => binding != null) + .forEach((suggestedBinding) => { + renderBindingHighlight(context, renderConfig, suggestedBinding!); + }); + } + + if ( + appState.selectedLinearElement && + appState.selectedLinearElement.hoverPointIndex >= 0 + ) { + renderLinearElementPointHighlight(context, appState, renderConfig); + } + // Paint selected elements + if ( + renderSelection && + !appState.multiElement && + !appState.editingLinearElement + ) { + const locallySelectedElements = getSelectedElements(elements, appState); + const showBoundingBox = shouldShowBoundingBox(locallySelectedElements); + + const locallySelectedIds = locallySelectedElements.map( + (element) => element.id, + ); + const isSingleLinearElementSelected = + locallySelectedElements.length === 1 && + isLinearElement(locallySelectedElements[0]); + // render selected linear element points + if ( + isSingleLinearElementSelected && + appState.selectedLinearElement?.elementId === + locallySelectedElements[0].id && + !locallySelectedElements[0].locked + ) { + renderLinearPointHandles( + context, + appState, + renderConfig, + locallySelectedElements[0] as ExcalidrawLinearElement, + ); + } + if (showBoundingBox) { + const selections = elements.reduce((acc, element) => { + const selectionColors = []; + // local user + if ( + locallySelectedIds.includes(element.id) && + !isSelectedViaGroup(appState, element) + ) { + selectionColors.push(oc.black); + } + // remote users + if (renderConfig.remoteSelectedElementIds[element.id]) { + selectionColors.push( + ...renderConfig.remoteSelectedElementIds[element.id].map( + (socketId) => { + const { background } = getClientColors(socketId, appState); + return background; + }, + ), + ); + } + if (selectionColors.length) { + const [elementX1, elementY1, elementX2, elementY2] = + getElementAbsoluteCoords(element); + acc.push({ + angle: element.angle, + elementX1, + elementY1, + elementX2, + elementY2, + selectionColors, + }); + } + return acc; + }, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[] }[]); + + const addSelectionForGroupId = (groupId: GroupId) => { + const groupElements = getElementsInGroup(elements, groupId); + const [elementX1, elementY1, elementX2, elementY2] = + getCommonBounds(groupElements); + selections.push({ + angle: 0, + elementX1, + elementX2, + elementY1, + elementY2, + selectionColors: [oc.black], + }); + }; + + for (const groupId of getSelectedGroupIds(appState)) { + // TODO: support multiplayer selected group IDs + addSelectionForGroupId(groupId); + } + + if (appState.editingGroupId) { + addSelectionForGroupId(appState.editingGroupId); + } + selections.forEach((selection) => + renderSelectionBorder( + context, + renderConfig, + selection, + isSingleLinearElementSelected + ? DEFAULT_SPACING * 2 + : DEFAULT_SPACING, + ), + ); + } + // Paint resize transformHandles + context.save(); + context.translate(renderConfig.scrollX, renderConfig.scrollY); + + if (locallySelectedElements.length === 1) { + context.fillStyle = oc.white; + const transformHandles = getTransformHandles( + locallySelectedElements[0], + renderConfig.zoom, + "mouse", // when we render we don't know which pointer type so use mouse + ); + if (!appState.viewModeEnabled && showBoundingBox) { + renderTransformHandles( + context, + renderConfig, + transformHandles, + locallySelectedElements[0].angle, + ); + } + } else if (locallySelectedElements.length > 1 && !appState.isRotating) { + const dashedLinePadding = 4 / renderConfig.zoom.value; + context.fillStyle = oc.white; + const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements); + const initialLineDash = context.getLineDash(); + context.setLineDash([2 / renderConfig.zoom.value]); + const lineWidth = context.lineWidth; + context.lineWidth = 1 / renderConfig.zoom.value; + strokeRectWithRotation( + context, + x1 - dashedLinePadding, + y1 - dashedLinePadding, + x2 - x1 + dashedLinePadding * 2, + y2 - y1 + dashedLinePadding * 2, + (x1 + x2) / 2, + (y1 + y2) / 2, + 0, + ); + context.lineWidth = lineWidth; + context.setLineDash(initialLineDash); + const transformHandles = getTransformHandlesFromCoords( + [x1, y1, x2, y2], + 0, + renderConfig.zoom, + "mouse", + OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, + ); + if (locallySelectedElements.some((element) => !element.locked)) { + renderTransformHandles(context, renderConfig, transformHandles, 0); + } + } + context.restore(); + } + + // Reset zoom + context.restore(); + + // Paint remote pointers + for (const clientId in renderConfig.remotePointerViewportCoords) { + let { x, y } = renderConfig.remotePointerViewportCoords[clientId]; + + x -= appState.offsetLeft; + y -= appState.offsetTop; + + const width = 9; + const height = 14; + + const isOutOfBounds = + x < 0 || + x > normalizedCanvasWidth - width || + y < 0 || + y > normalizedCanvasHeight - height; + + x = Math.max(x, 0); + x = Math.min(x, normalizedCanvasWidth - width); + y = Math.max(y, 0); + y = Math.min(y, normalizedCanvasHeight - height); + + const { background, stroke } = getClientColors(clientId, appState); + + context.save(); + context.strokeStyle = stroke; + context.fillStyle = background; + + const userState = renderConfig.remotePointerUserStates[clientId]; + if (isOutOfBounds || userState === UserIdleState.AWAY) { + context.globalAlpha = 0.48; + } + + if ( + renderConfig.remotePointerButton && + renderConfig.remotePointerButton[clientId] === "down" + ) { + context.beginPath(); + context.arc(x, y, 15, 0, 2 * Math.PI, false); + context.lineWidth = 3; + context.strokeStyle = "#ffffff88"; + context.stroke(); + context.closePath(); + + context.beginPath(); + context.arc(x, y, 15, 0, 2 * Math.PI, false); + context.lineWidth = 1; + context.strokeStyle = stroke; + context.stroke(); + context.closePath(); + } + + context.beginPath(); + context.moveTo(x, y); + context.lineTo(x + 1, y + 14); + context.lineTo(x + 4, y + 9); + context.lineTo(x + 9, y + 10); + context.lineTo(x, y); + context.fill(); + context.stroke(); + + const username = renderConfig.remotePointerUsernames[clientId]; + + let idleState = ""; + if (userState === UserIdleState.AWAY) { + idleState = hasEmojiSupport ? "⚫️" : ` (${UserIdleState.AWAY})`; + } else if (userState === UserIdleState.IDLE) { + idleState = hasEmojiSupport ? "💤" : ` (${UserIdleState.IDLE})`; + } else if (userState === UserIdleState.ACTIVE) { + idleState = hasEmojiSupport ? "🟢" : ""; + } + + const usernameAndIdleState = `${ + username ? `${username} ` : "" + }${idleState}`; + + if (!isOutOfBounds && usernameAndIdleState) { + const offsetX = x + width; + const offsetY = y + height; + const paddingHorizontal = 4; + const paddingVertical = 4; + const measure = context.measureText(usernameAndIdleState); + const measureHeight = + measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent; + + // Border + context.fillStyle = stroke; + context.fillRect( + offsetX - 1, + offsetY - 1, + measure.width + 2 * paddingHorizontal + 2, + measureHeight + 2 * paddingVertical + 2, + ); + // Background + context.fillStyle = background; + context.fillRect( + offsetX, + offsetY, + measure.width + 2 * paddingHorizontal, + measureHeight + 2 * paddingVertical, + ); + context.fillStyle = oc.white; + + context.fillText( + usernameAndIdleState, + offsetX + paddingHorizontal, + offsetY + paddingVertical + measure.actualBoundingBoxAscent, + ); + } + + context.restore(); + context.closePath(); + } + + // Paint scrollbars + let scrollBars; + if (renderScrollbars) { + scrollBars = getScrollBars( + elements, + normalizedCanvasWidth, + normalizedCanvasHeight, + renderConfig, + ); + + context.save(); + context.fillStyle = SCROLLBAR_COLOR; + context.strokeStyle = "rgba(255,255,255,0.8)"; + [scrollBars.horizontal, scrollBars.vertical].forEach((scrollBar) => { + if (scrollBar) { + roundRect( + context, + scrollBar.x, + scrollBar.y, + scrollBar.width, + scrollBar.height, + SCROLLBAR_WIDTH / 2, + ); + } + }); + context.restore(); + } + + context.restore(); + return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars }; + }; const renderSceneThrottled = throttleRAF( - ( - elements: readonly NonDeletedExcalidrawElement[], - appState: AppState, - selectionElement: NonDeletedExcalidrawElement | null, - scale: number, - rc: RoughCanvas, - canvas: HTMLCanvasElement, - renderConfig: RenderConfig, - callback?: (data: ReturnType) => void, - ) => { - const ret = _renderScene( - elements, - appState, - selectionElement, - scale, - rc, - canvas, - renderConfig, - ); - callback?.(ret); + (config: { + elements: readonly NonDeletedExcalidrawElement[]; + appState: AppState; + scale: number; + rc: RoughCanvas; + canvas: HTMLCanvasElement; + renderConfig: RenderConfig; + callback?: (data: ReturnType) => void; + }) => { + const ret = _renderScene(config); + config.callback?.(ret); }, { trailing: true }, ); /** renderScene throttled to animation framerate */ export const renderScene = ( - elements: readonly NonDeletedExcalidrawElement[], - appState: AppState, - selectionElement: NonDeletedExcalidrawElement | null, - scale: number, - rc: RoughCanvas, - canvas: HTMLCanvasElement, - renderConfig: RenderConfig, - callback?: (data: ReturnType) => void, + config: { + elements: readonly NonDeletedExcalidrawElement[]; + appState: AppState; + scale: number; + rc: RoughCanvas; + canvas: HTMLCanvasElement; + renderConfig: RenderConfig; + callback?: (data: ReturnType) => void; + }, /** Whether to throttle rendering. Defaults to false. * When throttling, no value is returned. Use the callback instead. */ throttle?: T, ): T extends true ? void : ReturnType => { if (throttle) { - renderSceneThrottled( - elements, - appState, - selectionElement, - scale, - rc, - canvas, - renderConfig, - callback, - ); + renderSceneThrottled(config); return undefined as T extends true ? void : ReturnType; } - const ret = _renderScene( - elements, - appState, - selectionElement, - scale, - rc, - canvas, - renderConfig, - ); - callback?.(ret); + const ret = _renderScene(config); + config.callback?.(ret); return ret as T extends true ? void : ReturnType; }; diff --git a/src/scene/export.ts b/src/scene/export.ts index 9dacc755..6fb070b3 100644 --- a/src/scene/export.ts +++ b/src/scene/export.ts @@ -51,22 +51,29 @@ export const exportToCanvas = async ( files, }); - renderScene(elements, appState, null, scale, rough.canvas(canvas), canvas, { - viewBackgroundColor: exportBackground ? viewBackgroundColor : null, - scrollX: -minX + exportPadding, - scrollY: -minY + exportPadding, - zoom: defaultAppState.zoom, - remotePointerViewportCoords: {}, - remoteSelectedElementIds: {}, - shouldCacheIgnoreZoom: false, - remotePointerUsernames: {}, - remotePointerUserStates: {}, - theme: appState.exportWithDarkMode ? "dark" : "light", - imageCache, - renderScrollbars: false, - renderSelection: false, - renderGrid: false, - isExporting: true, + renderScene({ + elements, + appState, + scale, + rc: rough.canvas(canvas), + canvas, + renderConfig: { + viewBackgroundColor: exportBackground ? viewBackgroundColor : null, + scrollX: -minX + exportPadding, + scrollY: -minY + exportPadding, + zoom: defaultAppState.zoom, + remotePointerViewportCoords: {}, + remoteSelectedElementIds: {}, + shouldCacheIgnoreZoom: false, + remotePointerUsernames: {}, + remotePointerUserStates: {}, + theme: appState.exportWithDarkMode ? "dark" : "light", + imageCache, + renderScrollbars: false, + renderSelection: false, + renderGrid: false, + isExporting: true, + }, }); return canvas;