diff options
Diffstat (limited to 'packages/excalidraw/renderer')
| -rw-r--r-- | packages/excalidraw/renderer/helpers.ts | 75 | ||||
| -rw-r--r-- | packages/excalidraw/renderer/interactiveScene.ts | 1232 | ||||
| -rw-r--r-- | packages/excalidraw/renderer/renderElement.ts | 1070 | ||||
| -rw-r--r-- | packages/excalidraw/renderer/renderNewElementScene.ts | 66 | ||||
| -rw-r--r-- | packages/excalidraw/renderer/renderSnaps.ts | 207 | ||||
| -rw-r--r-- | packages/excalidraw/renderer/roundRect.ts | 41 | ||||
| -rw-r--r-- | packages/excalidraw/renderer/staticScene.ts | 480 | ||||
| -rw-r--r-- | packages/excalidraw/renderer/staticSvgScene.ts | 744 |
8 files changed, 3915 insertions, 0 deletions
diff --git a/packages/excalidraw/renderer/helpers.ts b/packages/excalidraw/renderer/helpers.ts new file mode 100644 index 0000000..90f4009 --- /dev/null +++ b/packages/excalidraw/renderer/helpers.ts @@ -0,0 +1,75 @@ +import type { StaticCanvasAppState, AppState } from "../types"; + +import type { StaticCanvasRenderConfig } from "../scene/types"; + +import { THEME, THEME_FILTER } from "../constants"; + +export const fillCircle = ( + context: CanvasRenderingContext2D, + cx: number, + cy: number, + radius: number, + stroke = true, +) => { + context.beginPath(); + context.arc(cx, cy, radius, 0, Math.PI * 2); + context.fill(); + if (stroke) { + context.stroke(); + } +}; + +export const getNormalizedCanvasDimensions = ( + canvas: HTMLCanvasElement, + scale: number, +): [number, number] => { + // When doing calculations based on canvas width we should used normalized one + return [canvas.width / scale, canvas.height / scale]; +}; + +export const bootstrapCanvas = ({ + canvas, + scale, + normalizedWidth, + normalizedHeight, + theme, + isExporting, + viewBackgroundColor, +}: { + canvas: HTMLCanvasElement; + scale: number; + normalizedWidth: number; + normalizedHeight: number; + theme?: AppState["theme"]; + isExporting?: StaticCanvasRenderConfig["isExporting"]; + viewBackgroundColor?: StaticCanvasAppState["viewBackgroundColor"]; +}): CanvasRenderingContext2D => { + const context = canvas.getContext("2d")!; + + context.setTransform(1, 0, 0, 1, 0, 0); + context.scale(scale, scale); + + if (isExporting && theme === THEME.DARK) { + context.filter = THEME_FILTER; + } + + // Paint background + if (typeof viewBackgroundColor === "string") { + const hasTransparence = + viewBackgroundColor === "transparent" || + viewBackgroundColor.length === 5 || // #RGBA + viewBackgroundColor.length === 9 || // #RRGGBBA + /(hsla|rgba)\(/.test(viewBackgroundColor); + if (hasTransparence) { + context.clearRect(0, 0, normalizedWidth, normalizedHeight); + } + context.save(); + context.fillStyle = viewBackgroundColor; + context.fillRect(0, 0, normalizedWidth, normalizedHeight); + context.restore(); + } else { + context.clearRect(0, 0, normalizedWidth, normalizedHeight); + } + + return context; +}; diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts new file mode 100644 index 0000000..9aa8327 --- /dev/null +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -0,0 +1,1232 @@ +import { + getElementAbsoluteCoords, + getTransformHandlesFromCoords, + getTransformHandles, + getCommonBounds, +} from "../element"; + +import { roundRect } from "../renderer/roundRect"; + +import { + getScrollBars, + SCROLLBAR_COLOR, + SCROLLBAR_WIDTH, +} from "../scene/scrollbars"; + +import { renderSelectionElement } from "../renderer/renderElement"; +import { getClientColor, renderRemoteCursors } from "../clients"; +import { + isSelectedViaGroup, + getSelectedGroupIds, + getElementsInGroup, + selectGroupsFromGivenElements, +} from "../groups"; +import type { + TransformHandles, + TransformHandleType, +} from "../element/transformHandles"; +import { + getOmitSidesForDevice, + shouldShowBoundingBox, +} from "../element/transformHandles"; +import { arrayToMap, invariant, throttleRAF } from "../utils"; +import { + DEFAULT_TRANSFORM_HANDLE_SPACING, + FRAME_STYLE, + THEME, +} from "../constants"; +import { type InteractiveCanvasAppState } from "../types"; + +import { renderSnaps } from "../renderer/renderSnaps"; + +import type { + SuggestedBinding, + SuggestedPointBinding, +} from "../element/binding"; +import { + BINDING_HIGHLIGHT_OFFSET, + BINDING_HIGHLIGHT_THICKNESS, + maxBindingGap, +} from "../element/binding"; +import { LinearElementEditor } from "../element/linearElementEditor"; +import { + bootstrapCanvas, + fillCircle, + getNormalizedCanvasDimensions, +} from "./helpers"; +import oc from "open-color"; +import { + isElbowArrow, + isFrameLikeElement, + isImageElement, + isLinearElement, + isTextElement, +} from "../element/typeChecks"; +import type { + ElementsMap, + ExcalidrawBindableElement, + ExcalidrawElement, + ExcalidrawFrameLikeElement, + ExcalidrawImageElement, + ExcalidrawLinearElement, + ExcalidrawTextElement, + GroupId, + NonDeleted, +} from "../element/types"; +import type { + InteractiveCanvasRenderConfig, + InteractiveSceneRenderConfig, + RenderableElementsMap, +} from "../scene/types"; +import { + pointFrom, + type GlobalPoint, + type LocalPoint, + type Radians, +} from "@excalidraw/math"; +import { getCornerRadius } from "../shapes"; + +const renderElbowArrowMidPointHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, +) => { + invariant(appState.selectedLinearElement, "selectedLinearElement is null"); + + const { segmentMidPointHoveredCoords } = appState.selectedLinearElement; + + invariant(segmentMidPointHoveredCoords, "midPointCoords is null"); + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + + highlightPoint(segmentMidPointHoveredCoords, context, appState); + + context.restore(); +}; + +const renderLinearElementPointHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + elementsMap: ElementsMap, +) => { + const { elementId, hoverPointIndex } = appState.selectedLinearElement!; + if ( + appState.editingLinearElement?.selectedPointsIndices?.includes( + hoverPointIndex, + ) + ) { + return; + } + const element = LinearElementEditor.getElement(elementId, elementsMap); + + if (!element) { + return; + } + const point = LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + hoverPointIndex, + elementsMap, + ); + context.save(); + context.translate(appState.scrollX, appState.scrollY); + + highlightPoint(point, context, appState); + context.restore(); +}; + +const highlightPoint = <Point extends LocalPoint | GlobalPoint>( + point: Point, + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, +) => { + context.fillStyle = "rgba(105, 101, 219, 0.4)"; + + fillCircle( + context, + point[0], + point[1], + LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value, + false, + ); +}; + +const strokeRectWithRotation = ( + context: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + cx: number, + cy: number, + angle: number, + fill: boolean = false, + /** should account for zoom */ + radius: number = 0, +) => { + context.save(); + context.translate(cx, cy); + context.rotate(angle); + if (fill) { + context.fillRect(x - cx, y - cy, width, height); + } + if (radius && context.roundRect) { + context.beginPath(); + context.roundRect(x - cx, y - cy, width, height, radius); + context.stroke(); + context.closePath(); + } else { + context.strokeRect(x - cx, y - cy, width, height); + } + context.restore(); +}; + +const strokeDiamondWithRotation = ( + context: CanvasRenderingContext2D, + width: number, + height: number, + cx: number, + cy: number, + angle: number, +) => { + context.save(); + context.translate(cx, cy); + context.rotate(angle); + context.beginPath(); + context.moveTo(0, height / 2); + context.lineTo(width / 2, 0); + context.lineTo(0, -height / 2); + context.lineTo(-width / 2, 0); + context.closePath(); + context.stroke(); + context.restore(); +}; + +const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + point: Point, + radius: number, + isSelected: boolean, + isPhantomPoint = false, +) => { + context.strokeStyle = "#5e5ad8"; + context.setLineDash([]); + context.fillStyle = "rgba(255, 255, 255, 0.9)"; + if (isSelected) { + context.fillStyle = "rgba(134, 131, 226, 0.9)"; + } else if (isPhantomPoint) { + context.fillStyle = "rgba(177, 151, 252, 0.7)"; + } + + fillCircle( + context, + point[0], + point[1], + radius / appState.zoom.value, + !isPhantomPoint, + ); +}; + +const strokeEllipseWithRotation = ( + context: CanvasRenderingContext2D, + width: number, + height: number, + cx: number, + cy: number, + angle: number, +) => { + context.beginPath(); + context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2); + context.stroke(); +}; + +const renderBindingHighlightForBindableElement = ( + context: CanvasRenderingContext2D, + element: ExcalidrawBindableElement, + elementsMap: ElementsMap, + zoom: InteractiveCanvasAppState["zoom"], +) => { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const width = x2 - x1; + const height = y2 - y1; + + context.strokeStyle = "rgba(0,0,0,.05)"; + // When zooming out, make line width greater for visibility + const zoomValue = zoom.value < 1 ? zoom.value : 1; + context.lineWidth = BINDING_HIGHLIGHT_THICKNESS / zoomValue; + // To ensure the binding highlight doesn't overlap the element itself + const padding = context.lineWidth / 2 + BINDING_HIGHLIGHT_OFFSET; + + const radius = getCornerRadius( + Math.min(element.width, element.height), + element, + ); + + switch (element.type) { + case "rectangle": + case "text": + case "image": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + strokeRectWithRotation( + context, + x1 - padding, + y1 - padding, + width + padding * 2, + height + padding * 2, + x1 + width / 2, + y1 + height / 2, + element.angle, + undefined, + radius, + ); + break; + case "diamond": + const side = Math.hypot(width, height); + const wPadding = (padding * side) / height; + const hPadding = (padding * side) / width; + strokeDiamondWithRotation( + context, + width + wPadding * 2, + height + hPadding * 2, + x1 + width / 2, + y1 + height / 2, + element.angle, + ); + break; + case "ellipse": + strokeEllipseWithRotation( + context, + width + padding * 2, + height + padding * 2, + x1 + width / 2, + y1 + height / 2, + element.angle, + ); + break; + } +}; + +const renderBindingHighlightForSuggestedPointBinding = ( + context: CanvasRenderingContext2D, + suggestedBinding: SuggestedPointBinding, + elementsMap: ElementsMap, + zoom: InteractiveCanvasAppState["zoom"], +) => { + const [element, startOrEnd, bindableElement] = suggestedBinding; + + const threshold = maxBindingGap( + bindableElement, + bindableElement.width, + bindableElement.height, + zoom, + ); + + context.strokeStyle = "rgba(0,0,0,0)"; + context.fillStyle = "rgba(0,0,0,.05)"; + + const pointIndices = + startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1]; + pointIndices.forEach((index) => { + const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + index, + elementsMap, + ); + fillCircle(context, x, y, threshold); + }); +}; + +type ElementSelectionBorder = { + angle: number; + x1: number; + y1: number; + x2: number; + y2: number; + selectionColors: string[]; + dashed?: boolean; + cx: number; + cy: number; + activeEmbeddable: boolean; + padding?: number; +}; + +const renderSelectionBorder = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + elementProperties: ElementSelectionBorder, +) => { + const { + angle, + x1, + y1, + x2, + y2, + selectionColors, + cx, + cy, + dashed, + activeEmbeddable, + } = elementProperties; + const elementWidth = x2 - x1; + const elementHeight = y2 - y1; + + const padding = + elementProperties.padding ?? DEFAULT_TRANSFORM_HANDLE_SPACING * 2; + + const linePadding = padding / appState.zoom.value; + const lineWidth = 8 / appState.zoom.value; + const spaceWidth = 4 / appState.zoom.value; + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + context.lineWidth = (activeEmbeddable ? 4 : 1) / appState.zoom.value; + + const count = selectionColors.length; + for (let index = 0; index < count; ++index) { + context.strokeStyle = selectionColors[index]; + if (dashed) { + context.setLineDash([ + lineWidth, + spaceWidth + (lineWidth + spaceWidth) * (count - 1), + ]); + } + context.lineDashOffset = (lineWidth + spaceWidth) * index; + strokeRectWithRotation( + context, + x1 - linePadding, + y1 - linePadding, + elementWidth + linePadding * 2, + elementHeight + linePadding * 2, + cx, + cy, + angle, + ); + } + context.restore(); +}; + +const renderBindingHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + suggestedBinding: SuggestedBinding, + elementsMap: ElementsMap, +) => { + const renderHighlight = Array.isArray(suggestedBinding) + ? renderBindingHighlightForSuggestedPointBinding + : renderBindingHighlightForBindableElement; + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + renderHighlight(context, suggestedBinding as any, elementsMap, appState.zoom); + + context.restore(); +}; + +const renderFrameHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + frame: NonDeleted<ExcalidrawFrameLikeElement>, + elementsMap: ElementsMap, +) => { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap); + const width = x2 - x1; + const height = y2 - y1; + + context.strokeStyle = "rgb(0,118,255)"; + context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + strokeRectWithRotation( + context, + x1, + y1, + width, + height, + x1 + width / 2, + y1 + height / 2, + frame.angle, + false, + FRAME_STYLE.radius / appState.zoom.value, + ); + context.restore(); +}; + +const renderElementsBoxHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + elements: NonDeleted<ExcalidrawElement>[], +) => { + const individualElements = elements.filter( + (element) => element.groupIds.length === 0, + ); + + const elementsInGroups = elements.filter( + (element) => element.groupIds.length > 0, + ); + + const getSelectionFromElements = (elements: ExcalidrawElement[]) => { + const [x1, y1, x2, y2] = getCommonBounds(elements); + return { + angle: 0, + x1, + x2, + y1, + y2, + selectionColors: ["rgb(0,118,255)"], + dashed: false, + cx: x1 + (x2 - x1) / 2, + cy: y1 + (y2 - y1) / 2, + activeEmbeddable: false, + }; + }; + + const getSelectionForGroupId = (groupId: GroupId) => { + const groupElements = getElementsInGroup(elements, groupId); + return getSelectionFromElements(groupElements); + }; + + Object.entries(selectGroupsFromGivenElements(elementsInGroups, appState)) + .filter(([id, isSelected]) => isSelected) + .map(([id, isSelected]) => id) + .map((groupId) => getSelectionForGroupId(groupId)) + .concat( + individualElements.map((element) => getSelectionFromElements([element])), + ) + .forEach((selection) => + renderSelectionBorder(context, appState, selection), + ); +}; + +const renderLinearPointHandles = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + element: NonDeleted<ExcalidrawLinearElement>, + elementsMap: RenderableElementsMap, +) => { + if (!appState.selectedLinearElement) { + return; + } + context.save(); + context.translate(appState.scrollX, appState.scrollY); + context.lineWidth = 1 / appState.zoom.value; + const points: GlobalPoint[] = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); + + const { POINT_HANDLE_SIZE } = LinearElementEditor; + const radius = appState.editingLinearElement + ? POINT_HANDLE_SIZE + : POINT_HANDLE_SIZE / 2; + points.forEach((point, idx) => { + if (isElbowArrow(element) && idx !== 0 && idx !== points.length - 1) { + return; + } + + const isSelected = + !!appState.editingLinearElement?.selectedPointsIndices?.includes(idx); + + renderSingleLinearPoint(context, appState, point, radius, isSelected); + }); + + // Rendering segment mid points + if (isElbowArrow(element)) { + const fixedSegments = + element.fixedSegments?.map((segment) => segment.index) || []; + points.slice(0, -1).forEach((p, idx) => { + if ( + !LinearElementEditor.isSegmentTooShort( + element, + points[idx + 1], + points[idx], + idx, + appState.zoom, + ) + ) { + renderSingleLinearPoint( + context, + appState, + pointFrom<GlobalPoint>( + (p[0] + points[idx + 1][0]) / 2, + (p[1] + points[idx + 1][1]) / 2, + ), + POINT_HANDLE_SIZE / 2, + false, + !fixedSegments.includes(idx + 1), + ); + } + }); + } else { + const midPoints = LinearElementEditor.getEditorMidPoints( + element, + elementsMap, + appState, + ).filter( + (midPoint, idx, midPoints): midPoint is GlobalPoint => + midPoint !== null && + !(isElbowArrow(element) && (idx === 0 || idx === midPoints.length - 1)), + ); + + midPoints.forEach((segmentMidPoint) => { + if (appState.editingLinearElement || points.length === 2) { + renderSingleLinearPoint( + context, + appState, + segmentMidPoint, + POINT_HANDLE_SIZE / 2, + false, + true, + ); + } + }); + } + + context.restore(); +}; + +const renderTransformHandles = ( + context: CanvasRenderingContext2D, + renderConfig: InteractiveCanvasRenderConfig, + appState: InteractiveCanvasAppState, + transformHandles: TransformHandles, + angle: number, +): void => { + Object.keys(transformHandles).forEach((key) => { + const transformHandle = transformHandles[key as TransformHandleType]; + if (transformHandle !== undefined) { + const [x, y, width, height] = transformHandle; + + context.save(); + context.lineWidth = 1 / appState.zoom.value; + if (renderConfig.selectionColor) { + context.strokeStyle = renderConfig.selectionColor; + } + if (key === "rotation") { + fillCircle(context, x + width / 2, y + height / 2, width / 2); + // prefer round corners if roundRect API is available + } else if (context.roundRect) { + context.beginPath(); + context.roundRect(x, y, width, height, 2 / appState.zoom.value); + context.fill(); + context.stroke(); + } else { + strokeRectWithRotation( + context, + x, + y, + width, + height, + x + width / 2, + y + height / 2, + angle, + true, // fill before stroke + ); + } + context.restore(); + } + }); +}; + +const renderCropHandles = ( + context: CanvasRenderingContext2D, + renderConfig: InteractiveCanvasRenderConfig, + appState: InteractiveCanvasAppState, + croppingElement: ExcalidrawImageElement, + elementsMap: ElementsMap, +): void => { + const [x1, y1, , , cx, cy] = getElementAbsoluteCoords( + croppingElement, + elementsMap, + ); + + const LINE_WIDTH = 3; + const LINE_LENGTH = 20; + + const ZOOMED_LINE_WIDTH = LINE_WIDTH / appState.zoom.value; + const ZOOMED_HALF_LINE_WIDTH = ZOOMED_LINE_WIDTH / 2; + + const HALF_WIDTH = cx - x1 + ZOOMED_LINE_WIDTH; + const HALF_HEIGHT = cy - y1 + ZOOMED_LINE_WIDTH; + + const HORIZONTAL_LINE_LENGTH = Math.min( + LINE_LENGTH / appState.zoom.value, + HALF_WIDTH, + ); + const VERTICAL_LINE_LENGTH = Math.min( + LINE_LENGTH / appState.zoom.value, + HALF_HEIGHT, + ); + + context.save(); + context.fillStyle = renderConfig.selectionColor; + context.strokeStyle = renderConfig.selectionColor; + context.lineWidth = ZOOMED_LINE_WIDTH; + + const handles: Array< + [ + [number, number], + [number, number], + [number, number], + [number, number], + [number, number], + ] + > = [ + [ + // x, y + [-HALF_WIDTH, -HALF_HEIGHT], + // horizontal line: first start and to + [0, ZOOMED_HALF_LINE_WIDTH], + [HORIZONTAL_LINE_LENGTH, ZOOMED_HALF_LINE_WIDTH], + // vertical line: second start and to + [ZOOMED_HALF_LINE_WIDTH, 0], + [ZOOMED_HALF_LINE_WIDTH, VERTICAL_LINE_LENGTH], + ], + [ + [HALF_WIDTH - ZOOMED_HALF_LINE_WIDTH, -HALF_HEIGHT], + [ZOOMED_HALF_LINE_WIDTH, ZOOMED_HALF_LINE_WIDTH], + [ + -HORIZONTAL_LINE_LENGTH + ZOOMED_HALF_LINE_WIDTH, + ZOOMED_HALF_LINE_WIDTH, + ], + [0, 0], + [0, VERTICAL_LINE_LENGTH], + ], + [ + [-HALF_WIDTH, HALF_HEIGHT], + [0, -ZOOMED_HALF_LINE_WIDTH], + [HORIZONTAL_LINE_LENGTH, -ZOOMED_HALF_LINE_WIDTH], + [ZOOMED_HALF_LINE_WIDTH, 0], + [ZOOMED_HALF_LINE_WIDTH, -VERTICAL_LINE_LENGTH], + ], + [ + [HALF_WIDTH - ZOOMED_HALF_LINE_WIDTH, HALF_HEIGHT], + [ZOOMED_HALF_LINE_WIDTH, -ZOOMED_HALF_LINE_WIDTH], + [ + -HORIZONTAL_LINE_LENGTH + ZOOMED_HALF_LINE_WIDTH, + -ZOOMED_HALF_LINE_WIDTH, + ], + [0, 0], + [0, -VERTICAL_LINE_LENGTH], + ], + ]; + + handles.forEach((handle) => { + const [[x, y], [x1s, y1s], [x1t, y1t], [x2s, y2s], [x2t, y2t]] = handle; + + context.save(); + context.translate(cx, cy); + context.rotate(croppingElement.angle); + + context.beginPath(); + context.moveTo(x + x1s, y + y1s); + context.lineTo(x + x1t, y + y1t); + context.stroke(); + + context.beginPath(); + context.moveTo(x + x2s, y + y2s); + context.lineTo(x + x2t, y + y2t); + context.stroke(); + context.restore(); + }); + + context.restore(); +}; + +const renderTextBox = ( + text: NonDeleted<ExcalidrawTextElement>, + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + selectionColor: InteractiveCanvasRenderConfig["selectionColor"], +) => { + context.save(); + const padding = (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value; + const width = text.width + padding * 2; + const height = text.height + padding * 2; + const cx = text.x + width / 2; + const cy = text.y + height / 2; + const shiftX = -(width / 2 + padding); + const shiftY = -(height / 2 + padding); + context.translate(cx + appState.scrollX, cy + appState.scrollY); + context.rotate(text.angle); + context.lineWidth = 1 / appState.zoom.value; + context.strokeStyle = selectionColor; + context.strokeRect(shiftX, shiftY, width, height); + context.restore(); +}; + +const _renderInteractiveScene = ({ + canvas, + elementsMap, + visibleElements, + selectedElements, + allElementsMap, + scale, + appState, + renderConfig, + device, +}: InteractiveSceneRenderConfig) => { + if (canvas === null) { + return { atLeastOneVisibleElement: false, elementsMap }; + } + + const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( + canvas, + scale, + ); + + const context = bootstrapCanvas({ + canvas, + scale, + normalizedWidth, + normalizedHeight, + }); + + // Apply zoom + context.save(); + context.scale(appState.zoom.value, appState.zoom.value); + + let editingLinearElement: NonDeleted<ExcalidrawLinearElement> | undefined = + undefined; + + visibleElements.forEach((element) => { + // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to + // ShapeCache returns empty hence making sure that we get the + // correct element from visible elements + if (appState.editingLinearElement?.elementId === element.id) { + if (element) { + editingLinearElement = element as NonDeleted<ExcalidrawLinearElement>; + } + } + }); + + if (editingLinearElement) { + renderLinearPointHandles( + context, + appState, + editingLinearElement, + elementsMap, + ); + } + + // Paint selection element + if (appState.selectionElement && !appState.isCropping) { + try { + renderSelectionElement( + appState.selectionElement, + context, + appState, + renderConfig.selectionColor, + ); + } catch (error: any) { + console.error(error); + } + } + + if ( + appState.editingTextElement && + isTextElement(appState.editingTextElement) + ) { + const textElement = allElementsMap.get(appState.editingTextElement.id) as + | ExcalidrawTextElement + | undefined; + if (textElement && !textElement.autoResize) { + renderTextBox( + textElement, + context, + appState, + renderConfig.selectionColor, + ); + } + } + + if (appState.isBindingEnabled) { + appState.suggestedBindings + .filter((binding) => binding != null) + .forEach((suggestedBinding) => { + renderBindingHighlight( + context, + appState, + suggestedBinding!, + elementsMap, + ); + }); + } + + if (appState.frameToHighlight) { + renderFrameHighlight( + context, + appState, + appState.frameToHighlight, + elementsMap, + ); + } + + if (appState.elementsToHighlight) { + renderElementsBoxHighlight(context, appState, appState.elementsToHighlight); + } + + const isFrameSelected = selectedElements.some((element) => + isFrameLikeElement(element), + ); + + // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to + // ShapeCache returns empty hence making sure that we get the + // correct element from visible elements + if ( + selectedElements.length === 1 && + appState.editingLinearElement?.elementId === selectedElements[0].id + ) { + renderLinearPointHandles( + context, + appState, + selectedElements[0] as NonDeleted<ExcalidrawLinearElement>, + elementsMap, + ); + } + + if ( + isElbowArrow(selectedElements[0]) && + appState.selectedLinearElement && + appState.selectedLinearElement.segmentMidPointHoveredCoords + ) { + renderElbowArrowMidPointHighlight(context, appState); + } else if ( + appState.selectedLinearElement && + appState.selectedLinearElement.hoverPointIndex >= 0 && + !( + isElbowArrow(selectedElements[0]) && + appState.selectedLinearElement.hoverPointIndex > 0 && + appState.selectedLinearElement.hoverPointIndex < + selectedElements[0].points.length - 1 + ) + ) { + renderLinearElementPointHighlight(context, appState, elementsMap); + } + + // Paint selected elements + if (!appState.multiElement && !appState.editingLinearElement) { + const showBoundingBox = shouldShowBoundingBox(selectedElements, appState); + + const isSingleLinearElementSelected = + selectedElements.length === 1 && isLinearElement(selectedElements[0]); + // render selected linear element points + if ( + isSingleLinearElementSelected && + appState.selectedLinearElement?.elementId === selectedElements[0].id && + !selectedElements[0].locked + ) { + renderLinearPointHandles( + context, + appState, + selectedElements[0] as ExcalidrawLinearElement, + elementsMap, + ); + } + const selectionColor = renderConfig.selectionColor || oc.black; + + if (showBoundingBox) { + // Optimisation for finding quickly relevant element ids + const locallySelectedIds = arrayToMap(selectedElements); + + const selections: ElementSelectionBorder[] = []; + + for (const element of elementsMap.values()) { + const selectionColors = []; + const remoteClients = renderConfig.remoteSelectedElementIds.get( + element.id, + ); + if ( + !( + // Elbow arrow elements cannot be selected when bound on either end + ( + isSingleLinearElementSelected && + isElbowArrow(element) && + (element.startBinding || element.endBinding) + ) + ) + ) { + // local user + if ( + locallySelectedIds.has(element.id) && + !isSelectedViaGroup(appState, element) + ) { + selectionColors.push(selectionColor); + } + // remote users + if (remoteClients) { + selectionColors.push( + ...remoteClients.map((socketId) => { + const background = getClientColor( + socketId, + appState.collaborators.get(socketId), + ); + return background; + }), + ); + } + } + + if (selectionColors.length) { + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + true, + ); + selections.push({ + angle: element.angle, + x1, + y1, + x2, + y2, + selectionColors, + dashed: !!remoteClients, + cx, + cy, + activeEmbeddable: + appState.activeEmbeddable?.element === element && + appState.activeEmbeddable.state === "active", + padding: + element.id === appState.croppingElementId || + isImageElement(element) + ? 0 + : undefined, + }); + } + } + + const addSelectionForGroupId = (groupId: GroupId) => { + const groupElements = getElementsInGroup(elementsMap, groupId); + const [x1, y1, x2, y2] = getCommonBounds(groupElements); + selections.push({ + angle: 0, + x1, + x2, + y1, + y2, + selectionColors: [oc.black], + dashed: true, + cx: x1 + (x2 - x1) / 2, + cy: y1 + (y2 - y1) / 2, + activeEmbeddable: false, + }); + }; + + 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, appState, selection), + ); + } + // Paint resize transformHandles + context.save(); + context.translate(appState.scrollX, appState.scrollY); + + if (selectedElements.length === 1) { + context.fillStyle = oc.white; + const transformHandles = getTransformHandles( + selectedElements[0], + appState.zoom, + elementsMap, + "mouse", // when we render we don't know which pointer type so use mouse, + getOmitSidesForDevice(device), + ); + if ( + !appState.viewModeEnabled && + showBoundingBox && + // do not show transform handles when text is being edited + !isTextElement(appState.editingTextElement) && + // do not show transform handles when image is being cropped + !appState.croppingElementId + ) { + renderTransformHandles( + context, + renderConfig, + appState, + transformHandles, + selectedElements[0].angle, + ); + } + + if (appState.croppingElementId && !appState.isCropping) { + const croppingElement = elementsMap.get(appState.croppingElementId); + + if (croppingElement && isImageElement(croppingElement)) { + renderCropHandles( + context, + renderConfig, + appState, + croppingElement, + elementsMap, + ); + } + } + } else if (selectedElements.length > 1 && !appState.isRotating) { + const dashedLinePadding = + (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value; + context.fillStyle = oc.white; + const [x1, y1, x2, y2] = getCommonBounds(selectedElements); + const initialLineDash = context.getLineDash(); + context.setLineDash([2 / appState.zoom.value]); + const lineWidth = context.lineWidth; + context.lineWidth = 1 / appState.zoom.value; + context.strokeStyle = selectionColor; + 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, (x1 + x2) / 2, (y1 + y2) / 2], + 0 as Radians, + appState.zoom, + "mouse", + isFrameSelected + ? { ...getOmitSidesForDevice(device), rotation: true } + : getOmitSidesForDevice(device), + ); + if (selectedElements.some((element) => !element.locked)) { + renderTransformHandles( + context, + renderConfig, + appState, + transformHandles, + 0, + ); + } + } + context.restore(); + } + + appState.searchMatches.forEach(({ id, focus, matchedLines }) => { + const element = elementsMap.get(id); + + if (element && isTextElement(element)) { + const [elementX1, elementY1, , , cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + true, + ); + + context.save(); + if (appState.theme === THEME.LIGHT) { + if (focus) { + context.fillStyle = "rgba(255, 124, 0, 0.4)"; + } else { + context.fillStyle = "rgba(255, 226, 0, 0.4)"; + } + } else if (focus) { + context.fillStyle = "rgba(229, 82, 0, 0.4)"; + } else { + context.fillStyle = "rgba(99, 52, 0, 0.4)"; + } + + context.translate(appState.scrollX, appState.scrollY); + context.translate(cx, cy); + context.rotate(element.angle); + + matchedLines.forEach((matchedLine) => { + context.fillRect( + elementX1 + matchedLine.offsetX - cx, + elementY1 + matchedLine.offsetY - cy, + matchedLine.width, + matchedLine.height, + ); + }); + + context.restore(); + } + }); + + renderSnaps(context, appState); + + context.restore(); + + renderRemoteCursors({ + context, + renderConfig, + appState, + normalizedWidth, + normalizedHeight, + }); + + // Paint scrollbars + let scrollBars; + if (renderConfig.renderScrollbars) { + scrollBars = getScrollBars( + visibleElements, + normalizedWidth, + normalizedHeight, + appState, + ); + + 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(); + } + + return { + scrollBars, + atLeastOneVisibleElement: visibleElements.length > 0, + elementsMap, + }; +}; + +/** throttled to animation framerate */ +export const renderInteractiveSceneThrottled = throttleRAF( + (config: InteractiveSceneRenderConfig) => { + const ret = _renderInteractiveScene(config); + config.callback?.(ret); + }, + { trailing: true }, +); + +/** + * Interactive scene is the ui-canvas where we render bounding boxes, selections + * and other ui stuff. + */ +export const renderInteractiveScene = < + U extends typeof _renderInteractiveScene, + T extends boolean = false, +>( + renderConfig: InteractiveSceneRenderConfig, + throttle?: T, +): T extends true ? void : ReturnType<U> => { + if (throttle) { + renderInteractiveSceneThrottled(renderConfig); + return undefined as T extends true ? void : ReturnType<U>; + } + const ret = _renderInteractiveScene(renderConfig); + renderConfig.callback(ret); + return ret as T extends true ? void : ReturnType<U>; +}; diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts new file mode 100644 index 0000000..d93469c --- /dev/null +++ b/packages/excalidraw/renderer/renderElement.ts @@ -0,0 +1,1070 @@ +import type { + ExcalidrawElement, + ExcalidrawTextElement, + NonDeletedExcalidrawElement, + ExcalidrawFreeDrawElement, + ExcalidrawImageElement, + ExcalidrawTextElementWithContainer, + ExcalidrawFrameLikeElement, + NonDeletedSceneElementsMap, + ElementsMap, +} from "../element/types"; +import { + isTextElement, + isLinearElement, + isFreeDrawElement, + isInitializedImageElement, + isArrowElement, + hasBoundTextElement, + isMagicFrameElement, + isImageElement, +} from "../element/typeChecks"; +import { getElementAbsoluteCoords } from "../element/bounds"; +import type { RoughCanvas } from "roughjs/bin/canvas"; + +import type { + StaticCanvasRenderConfig, + RenderableElementsMap, + InteractiveCanvasRenderConfig, +} from "../scene/types"; +import { distance, getFontString, isRTL } from "../utils"; +import rough from "roughjs/bin/rough"; +import type { + AppState, + StaticCanvasAppState, + Zoom, + InteractiveCanvasAppState, + ElementsPendingErasure, + PendingExcalidrawElements, +} from "../types"; +import { getDefaultAppState } from "../appState"; +import { + BOUND_TEXT_PADDING, + DEFAULT_REDUCED_GLOBAL_ALPHA, + ELEMENT_READY_TO_ERASE_OPACITY, + FRAME_STYLE, + MIME_TYPES, + THEME, +} from "../constants"; +import type { StrokeOptions } from "perfect-freehand"; +import { getStroke } from "perfect-freehand"; +import { + getBoundTextElement, + getContainerCoords, + getContainerElement, + getBoundTextMaxHeight, + getBoundTextMaxWidth, +} from "../element/textElement"; +import { LinearElementEditor } from "../element/linearElementEditor"; + +import { getContainingFrame } from "../frame"; +import { ShapeCache } from "../scene/ShapeCache"; +import { getVerticalOffset } from "../fonts"; +import { isRightAngleRads } from "@excalidraw/math"; +import { getCornerRadius } from "../shapes"; +import { getUncroppedImageElement } from "../element/cropElement"; +import { getLineHeightInPx } from "../element/textMeasurements"; + +// using a stronger invert (100% vs our regular 93%) and saturate +// as a temp hack to make images in dark theme look closer to original +// color scheme (it's still not quite there and the colors look slightly +// desatured, alas...) +export const IMAGE_INVERT_FILTER = + "invert(100%) hue-rotate(180deg) saturate(1.25)"; + +const defaultAppState = getDefaultAppState(); + +const isPendingImageElement = ( + element: ExcalidrawElement, + renderConfig: StaticCanvasRenderConfig, +) => + isInitializedImageElement(element) && + !renderConfig.imageCache.has(element.fileId); + +const shouldResetImageFilter = ( + element: ExcalidrawElement, + renderConfig: StaticCanvasRenderConfig, + appState: StaticCanvasAppState, +) => { + return ( + appState.theme === THEME.DARK && + isInitializedImageElement(element) && + !isPendingImageElement(element, renderConfig) && + renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg + ); +}; + +const getCanvasPadding = (element: ExcalidrawElement) => { + switch (element.type) { + case "freedraw": + return element.strokeWidth * 12; + case "text": + return element.fontSize / 2; + default: + return 20; + } +}; + +export const getRenderOpacity = ( + element: ExcalidrawElement, + containingFrame: ExcalidrawFrameLikeElement | null, + elementsPendingErasure: ElementsPendingErasure, + pendingNodes: Readonly<PendingExcalidrawElements> | null, + globalAlpha: number = 1, +) => { + // multiplying frame opacity with element opacity to combine them + // (e.g. frame 50% and element 50% opacity should result in 25% opacity) + let opacity = + (((containingFrame?.opacity ?? 100) * element.opacity) / 10000) * + globalAlpha; + + // if pending erasure, multiply again to combine further + // (so that erasing always results in lower opacity than original) + if ( + elementsPendingErasure.has(element.id) || + (pendingNodes && pendingNodes.some((node) => node.id === element.id)) || + (containingFrame && elementsPendingErasure.has(containingFrame.id)) + ) { + opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100; + } + + return opacity; +}; + +export interface ExcalidrawElementWithCanvas { + element: ExcalidrawElement | ExcalidrawTextElement; + canvas: HTMLCanvasElement; + theme: AppState["theme"]; + scale: number; + angle: number; + zoomValue: AppState["zoom"]["value"]; + canvasOffsetX: number; + canvasOffsetY: number; + boundTextElementVersion: number | null; + imageCrop: ExcalidrawImageElement["crop"] | null; + containingFrameOpacity: number; + boundTextCanvas: HTMLCanvasElement; +} + +const cappedElementCanvasSize = ( + element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, + zoom: Zoom, +): { + width: number; + height: number; + scale: number; +} => { + // these limits are ballpark, they depend on specific browsers and device. + // We've chosen lower limits to be safe. We might want to change these limits + // based on browser/device type, if we get reports of low quality rendering + // on zoom. + // + // ~ safari mobile canvas area limit + const AREA_LIMIT = 16777216; + // ~ safari width/height limit based on developer.mozilla.org. + const WIDTH_HEIGHT_LIMIT = 32767; + + const padding = getCanvasPadding(element); + + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const elementWidth = + isLinearElement(element) || isFreeDrawElement(element) + ? distance(x1, x2) + : element.width; + const elementHeight = + isLinearElement(element) || isFreeDrawElement(element) + ? distance(y1, y2) + : element.height; + + let width = elementWidth * window.devicePixelRatio + padding * 2; + let height = elementHeight * window.devicePixelRatio + padding * 2; + + let scale: number = zoom.value; + + // rescale to ensure width and height is within limits + if ( + width * scale > WIDTH_HEIGHT_LIMIT || + height * scale > WIDTH_HEIGHT_LIMIT + ) { + scale = Math.min(WIDTH_HEIGHT_LIMIT / width, WIDTH_HEIGHT_LIMIT / height); + } + + // rescale to ensure canvas area is within limits + if (width * height * scale * scale > AREA_LIMIT) { + scale = Math.sqrt(AREA_LIMIT / (width * height)); + } + + width = Math.floor(width * scale); + height = Math.floor(height * scale); + + return { width, height, scale }; +}; + +const generateElementCanvas = ( + element: NonDeletedExcalidrawElement, + elementsMap: NonDeletedSceneElementsMap, + zoom: Zoom, + renderConfig: StaticCanvasRenderConfig, + appState: StaticCanvasAppState, +): ExcalidrawElementWithCanvas | null => { + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d")!; + const padding = getCanvasPadding(element); + + const { width, height, scale } = cappedElementCanvasSize( + element, + elementsMap, + zoom, + ); + + if (!width || !height) { + return null; + } + + canvas.width = width; + canvas.height = height; + + let canvasOffsetX = -100; + let canvasOffsetY = 0; + + if (isLinearElement(element) || isFreeDrawElement(element)) { + const [x1, y1] = getElementAbsoluteCoords(element, elementsMap); + + canvasOffsetX = + element.x > x1 + ? distance(element.x, x1) * window.devicePixelRatio * scale + : 0; + + canvasOffsetY = + element.y > y1 + ? distance(element.y, y1) * window.devicePixelRatio * scale + : 0; + + context.translate(canvasOffsetX, canvasOffsetY); + } + + context.save(); + context.translate(padding * scale, padding * scale); + context.scale( + window.devicePixelRatio * scale, + window.devicePixelRatio * scale, + ); + + const rc = rough.canvas(canvas); + + // in dark theme, revert the image color filter + if (shouldResetImageFilter(element, renderConfig, appState)) { + context.filter = IMAGE_INVERT_FILTER; + } + + drawElementOnCanvas(element, rc, context, renderConfig, appState); + + context.restore(); + + const boundTextElement = getBoundTextElement(element, elementsMap); + const boundTextCanvas = document.createElement("canvas"); + const boundTextCanvasContext = boundTextCanvas.getContext("2d")!; + + if (isArrowElement(element) && boundTextElement) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + // Take max dimensions of arrow canvas so that when canvas is rotated + // the arrow doesn't get clipped + const maxDim = Math.max(distance(x1, x2), distance(y1, y2)); + boundTextCanvas.width = + maxDim * window.devicePixelRatio * scale + padding * scale * 10; + boundTextCanvas.height = + maxDim * window.devicePixelRatio * scale + padding * scale * 10; + boundTextCanvasContext.translate( + boundTextCanvas.width / 2, + boundTextCanvas.height / 2, + ); + boundTextCanvasContext.rotate(element.angle); + boundTextCanvasContext.drawImage( + canvas!, + -canvas.width / 2, + -canvas.height / 2, + canvas.width, + canvas.height, + ); + + const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords( + boundTextElement, + elementsMap, + ); + + boundTextCanvasContext.rotate(-element.angle); + const offsetX = (boundTextCanvas.width - canvas!.width) / 2; + const offsetY = (boundTextCanvas.height - canvas!.height) / 2; + const shiftX = + boundTextCanvas.width / 2 - + (boundTextCx - x1) * window.devicePixelRatio * scale - + offsetX - + padding * scale; + + const shiftY = + boundTextCanvas.height / 2 - + (boundTextCy - y1) * window.devicePixelRatio * scale - + offsetY - + padding * scale; + boundTextCanvasContext.translate(-shiftX, -shiftY); + // Clear the bound text area + boundTextCanvasContext.clearRect( + -(boundTextElement.width / 2 + BOUND_TEXT_PADDING) * + window.devicePixelRatio * + scale, + -(boundTextElement.height / 2 + BOUND_TEXT_PADDING) * + window.devicePixelRatio * + scale, + (boundTextElement.width + BOUND_TEXT_PADDING * 2) * + window.devicePixelRatio * + scale, + (boundTextElement.height + BOUND_TEXT_PADDING * 2) * + window.devicePixelRatio * + scale, + ); + } + + return { + element, + canvas, + theme: appState.theme, + scale, + zoomValue: zoom.value, + canvasOffsetX, + canvasOffsetY, + boundTextElementVersion: + getBoundTextElement(element, elementsMap)?.version || null, + containingFrameOpacity: + getContainingFrame(element, elementsMap)?.opacity || 100, + boundTextCanvas, + angle: element.angle, + imageCrop: isImageElement(element) ? element.crop : null, + }; +}; + +export const DEFAULT_LINK_SIZE = 14; + +const IMAGE_PLACEHOLDER_IMG = document.createElement("img"); +IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent( + `<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`, +)}`; + +const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img"); +IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent( + `<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`, +)}`; + +const drawImagePlaceholder = ( + element: ExcalidrawImageElement, + context: CanvasRenderingContext2D, +) => { + context.fillStyle = "#E7E7E7"; + context.fillRect(0, 0, element.width, element.height); + + const imageMinWidthOrHeight = Math.min(element.width, element.height); + + const size = Math.min( + imageMinWidthOrHeight, + Math.min(imageMinWidthOrHeight * 0.4, 100), + ); + + context.drawImage( + element.status === "error" + ? IMAGE_ERROR_PLACEHOLDER_IMG + : IMAGE_PLACEHOLDER_IMG, + element.width / 2 - size / 2, + element.height / 2 - size / 2, + size, + size, + ); +}; + +const drawElementOnCanvas = ( + element: NonDeletedExcalidrawElement, + rc: RoughCanvas, + context: CanvasRenderingContext2D, + renderConfig: StaticCanvasRenderConfig, + appState: StaticCanvasAppState, +) => { + switch (element.type) { + case "rectangle": + case "iframe": + case "embeddable": + case "diamond": + case "ellipse": { + context.lineJoin = "round"; + context.lineCap = "round"; + rc.draw(ShapeCache.get(element)!); + break; + } + case "arrow": + case "line": { + context.lineJoin = "round"; + context.lineCap = "round"; + + ShapeCache.get(element)!.forEach((shape) => { + rc.draw(shape); + }); + break; + } + case "freedraw": { + // Draw directly to canvas + context.save(); + context.fillStyle = element.strokeColor; + + const path = getFreeDrawPath2D(element) as Path2D; + const fillShape = ShapeCache.get(element); + + if (fillShape) { + rc.draw(fillShape); + } + + context.fillStyle = element.strokeColor; + context.fill(path); + + context.restore(); + break; + } + case "image": { + const img = isInitializedImageElement(element) + ? renderConfig.imageCache.get(element.fileId)?.image + : undefined; + if (img != null && !(img instanceof Promise)) { + if (element.roundness && context.roundRect) { + context.beginPath(); + context.roundRect( + 0, + 0, + element.width, + element.height, + getCornerRadius(Math.min(element.width, element.height), element), + ); + context.clip(); + } + + const { x, y, width, height } = element.crop + ? element.crop + : { + x: 0, + y: 0, + width: img.naturalWidth, + height: img.naturalHeight, + }; + + context.drawImage( + img, + x, + y, + width, + height, + 0 /* hardcoded for the selection box*/, + 0, + element.width, + element.height, + ); + } else { + drawImagePlaceholder(element, context); + } + break; + } + default: { + if (isTextElement(element)) { + const rtl = isRTL(element.text); + const shouldTemporarilyAttach = rtl && !context.canvas.isConnected; + if (shouldTemporarilyAttach) { + // to correctly render RTL text mixed with LTR, we have to append it + // to the DOM + document.body.appendChild(context.canvas); + } + context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr"); + context.save(); + context.font = getFontString(element); + context.fillStyle = element.strokeColor; + context.textAlign = element.textAlign as CanvasTextAlign; + + // Canvas does not support multiline text by default + const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); + + const horizontalOffset = + element.textAlign === "center" + ? element.width / 2 + : element.textAlign === "right" + ? element.width + : 0; + + const lineHeightPx = getLineHeightInPx( + element.fontSize, + element.lineHeight, + ); + + const verticalOffset = getVerticalOffset( + element.fontFamily, + element.fontSize, + lineHeightPx, + ); + + for (let index = 0; index < lines.length; index++) { + context.fillText( + lines[index], + horizontalOffset, + index * lineHeightPx + verticalOffset, + ); + } + context.restore(); + if (shouldTemporarilyAttach) { + context.canvas.remove(); + } + } else { + throw new Error(`Unimplemented type ${element.type}`); + } + } + } +}; + +export const elementWithCanvasCache = new WeakMap< + ExcalidrawElement, + ExcalidrawElementWithCanvas +>(); + +const generateElementWithCanvas = ( + element: NonDeletedExcalidrawElement, + elementsMap: NonDeletedSceneElementsMap, + renderConfig: StaticCanvasRenderConfig, + appState: StaticCanvasAppState, +) => { + const zoom: Zoom = renderConfig ? appState.zoom : defaultAppState.zoom; + const prevElementWithCanvas = elementWithCanvasCache.get(element); + const shouldRegenerateBecauseZoom = + prevElementWithCanvas && + prevElementWithCanvas.zoomValue !== zoom.value && + !appState?.shouldCacheIgnoreZoom; + const boundTextElement = getBoundTextElement(element, elementsMap); + const boundTextElementVersion = boundTextElement?.version || null; + const imageCrop = isImageElement(element) ? element.crop : null; + + const containingFrameOpacity = + getContainingFrame(element, elementsMap)?.opacity || 100; + + if ( + !prevElementWithCanvas || + shouldRegenerateBecauseZoom || + prevElementWithCanvas.theme !== appState.theme || + prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion || + prevElementWithCanvas.imageCrop !== imageCrop || + prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity || + // since we rotate the canvas when copying from cached canvas, we don't + // regenerate the cached canvas. But we need to in case of labels which are + // cached alongside the arrow, and we want the labels to remain unrotated + // with respect to the arrow. + (isArrowElement(element) && + boundTextElement && + element.angle !== prevElementWithCanvas.angle) + ) { + const elementWithCanvas = generateElementCanvas( + element, + elementsMap, + zoom, + renderConfig, + appState, + ); + + if (!elementWithCanvas) { + return null; + } + + elementWithCanvasCache.set(element, elementWithCanvas); + + return elementWithCanvas; + } + return prevElementWithCanvas; +}; + +const drawElementFromCanvas = ( + elementWithCanvas: ExcalidrawElementWithCanvas, + context: CanvasRenderingContext2D, + renderConfig: StaticCanvasRenderConfig, + appState: StaticCanvasAppState, + allElementsMap: NonDeletedSceneElementsMap, +) => { + const element = elementWithCanvas.element; + const padding = getCanvasPadding(element); + const zoom = elementWithCanvas.scale; + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap); + const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio; + const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio; + + context.save(); + context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio); + + const boundTextElement = getBoundTextElement(element, allElementsMap); + + if (isArrowElement(element) && boundTextElement) { + const offsetX = + (elementWithCanvas.boundTextCanvas.width - + elementWithCanvas.canvas!.width) / + 2; + const offsetY = + (elementWithCanvas.boundTextCanvas.height - + elementWithCanvas.canvas!.height) / + 2; + context.translate(cx, cy); + context.drawImage( + elementWithCanvas.boundTextCanvas, + (-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding, + (-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding, + elementWithCanvas.boundTextCanvas.width / zoom, + elementWithCanvas.boundTextCanvas.height / zoom, + ); + } else { + // we translate context to element center so that rotation and scale + // originates from the element center + context.translate(cx, cy); + + context.rotate(element.angle); + + if ( + "scale" in elementWithCanvas.element && + !isPendingImageElement(element, renderConfig) + ) { + context.scale( + elementWithCanvas.element.scale[0], + elementWithCanvas.element.scale[1], + ); + } + + // revert afterwards we don't have account for it during drawing + context.translate(-cx, -cy); + + context.drawImage( + elementWithCanvas.canvas!, + (x1 + appState.scrollX) * window.devicePixelRatio - + (padding * elementWithCanvas.scale) / elementWithCanvas.scale, + (y1 + appState.scrollY) * window.devicePixelRatio - + (padding * elementWithCanvas.scale) / elementWithCanvas.scale, + elementWithCanvas.canvas!.width / elementWithCanvas.scale, + elementWithCanvas.canvas!.height / elementWithCanvas.scale, + ); + + if ( + import.meta.env.VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX === + "true" && + hasBoundTextElement(element) + ) { + const textElement = getBoundTextElement( + element, + allElementsMap, + ) as ExcalidrawTextElementWithContainer; + const coords = getContainerCoords(element); + context.strokeStyle = "#c92a2a"; + context.lineWidth = 3; + context.strokeRect( + (coords.x + appState.scrollX) * window.devicePixelRatio, + (coords.y + appState.scrollY) * window.devicePixelRatio, + getBoundTextMaxWidth(element, textElement) * window.devicePixelRatio, + getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio, + ); + } + } + context.restore(); + + // Clear the nested element we appended to the DOM +}; + +export const renderSelectionElement = ( + element: NonDeletedExcalidrawElement, + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + selectionColor: InteractiveCanvasRenderConfig["selectionColor"], +) => { + context.save(); + context.translate(element.x + appState.scrollX, element.y + appState.scrollY); + context.fillStyle = "rgba(0, 0, 200, 0.04)"; + + // render from 0.5px offset to get 1px wide line + // https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540 + // TODO can be be improved by offseting to the negative when user selects + // from right to left + const offset = 0.5 / appState.zoom.value; + + context.fillRect(offset, offset, element.width, element.height); + context.lineWidth = 1 / appState.zoom.value; + context.strokeStyle = selectionColor; + context.strokeRect(offset, offset, element.width, element.height); + + context.restore(); +}; + +export const renderElement = ( + element: NonDeletedExcalidrawElement, + elementsMap: RenderableElementsMap, + allElementsMap: NonDeletedSceneElementsMap, + rc: RoughCanvas, + context: CanvasRenderingContext2D, + renderConfig: StaticCanvasRenderConfig, + appState: StaticCanvasAppState, +) => { + const reduceAlphaForSelection = + appState.openDialog?.name === "elementLinkSelector" && + !appState.selectedElementIds[element.id] && + !appState.hoveredElementIds[element.id]; + + context.globalAlpha = getRenderOpacity( + element, + getContainingFrame(element, elementsMap), + renderConfig.elementsPendingErasure, + renderConfig.pendingFlowchartNodes, + reduceAlphaForSelection ? DEFAULT_REDUCED_GLOBAL_ALPHA : 1, + ); + + switch (element.type) { + case "magicframe": + case "frame": { + if (appState.frameRendering.enabled && appState.frameRendering.outline) { + context.save(); + context.translate( + element.x + appState.scrollX, + element.y + appState.scrollY, + ); + context.fillStyle = "rgba(0, 0, 200, 0.04)"; + + context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; + context.strokeStyle = FRAME_STYLE.strokeColor; + + // TODO change later to only affect AI frames + if (isMagicFrameElement(element)) { + context.strokeStyle = + appState.theme === THEME.LIGHT ? "#7affd7" : "#1d8264"; + } + + if (FRAME_STYLE.radius && context.roundRect) { + context.beginPath(); + context.roundRect( + 0, + 0, + element.width, + element.height, + FRAME_STYLE.radius / appState.zoom.value, + ); + context.stroke(); + context.closePath(); + } else { + context.strokeRect(0, 0, element.width, element.height); + } + + context.restore(); + } + break; + } + case "freedraw": { + // TODO investigate if we can do this in situ. Right now we need to call + // beforehand because math helpers (such as getElementAbsoluteCoords) + // rely on existing shapes + ShapeCache.generateElementShape(element, null); + + if (renderConfig.isExporting) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const cx = (x1 + x2) / 2 + appState.scrollX; + const cy = (y1 + y2) / 2 + appState.scrollY; + const shiftX = (x2 - x1) / 2 - (element.x - x1); + const shiftY = (y2 - y1) / 2 - (element.y - y1); + context.save(); + context.translate(cx, cy); + context.rotate(element.angle); + context.translate(-shiftX, -shiftY); + drawElementOnCanvas(element, rc, context, renderConfig, appState); + context.restore(); + } else { + const elementWithCanvas = generateElementWithCanvas( + element, + allElementsMap, + renderConfig, + appState, + ); + if (!elementWithCanvas) { + return; + } + + drawElementFromCanvas( + elementWithCanvas, + context, + renderConfig, + appState, + allElementsMap, + ); + } + + break; + } + case "rectangle": + case "diamond": + case "ellipse": + case "line": + case "arrow": + case "image": + case "text": + case "iframe": + case "embeddable": { + // TODO investigate if we can do this in situ. Right now we need to call + // beforehand because math helpers (such as getElementAbsoluteCoords) + // rely on existing shapes + ShapeCache.generateElementShape(element, renderConfig); + if (renderConfig.isExporting) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const cx = (x1 + x2) / 2 + appState.scrollX; + const cy = (y1 + y2) / 2 + appState.scrollY; + let shiftX = (x2 - x1) / 2 - (element.x - x1); + let shiftY = (y2 - y1) / 2 - (element.y - y1); + if (isTextElement(element)) { + const container = getContainerElement(element, elementsMap); + if (isArrowElement(container)) { + const boundTextCoords = + LinearElementEditor.getBoundTextElementPosition( + container, + element as ExcalidrawTextElementWithContainer, + elementsMap, + ); + shiftX = (x2 - x1) / 2 - (boundTextCoords.x - x1); + shiftY = (y2 - y1) / 2 - (boundTextCoords.y - y1); + } + } + context.save(); + context.translate(cx, cy); + + if (shouldResetImageFilter(element, renderConfig, appState)) { + context.filter = "none"; + } + const boundTextElement = getBoundTextElement(element, elementsMap); + + if (isArrowElement(element) && boundTextElement) { + const tempCanvas = document.createElement("canvas"); + + const tempCanvasContext = tempCanvas.getContext("2d")!; + + // Take max dimensions of arrow canvas so that when canvas is rotated + // the arrow doesn't get clipped + const maxDim = Math.max(distance(x1, x2), distance(y1, y2)); + const padding = getCanvasPadding(element); + tempCanvas.width = + maxDim * appState.exportScale + padding * 10 * appState.exportScale; + tempCanvas.height = + maxDim * appState.exportScale + padding * 10 * appState.exportScale; + + tempCanvasContext.translate( + tempCanvas.width / 2, + tempCanvas.height / 2, + ); + tempCanvasContext.scale(appState.exportScale, appState.exportScale); + + // Shift the canvas to left most point of the arrow + shiftX = element.width / 2 - (element.x - x1); + shiftY = element.height / 2 - (element.y - y1); + + tempCanvasContext.rotate(element.angle); + const tempRc = rough.canvas(tempCanvas); + + tempCanvasContext.translate(-shiftX, -shiftY); + + drawElementOnCanvas( + element, + tempRc, + tempCanvasContext, + renderConfig, + appState, + ); + + tempCanvasContext.translate(shiftX, shiftY); + + tempCanvasContext.rotate(-element.angle); + + // Shift the canvas to center of bound text + const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords( + boundTextElement, + elementsMap, + ); + const boundTextShiftX = (x1 + x2) / 2 - boundTextCx; + const boundTextShiftY = (y1 + y2) / 2 - boundTextCy; + tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY); + + // Clear the bound text area + tempCanvasContext.clearRect( + -boundTextElement.width / 2, + -boundTextElement.height / 2, + boundTextElement.width, + boundTextElement.height, + ); + context.scale(1 / appState.exportScale, 1 / appState.exportScale); + context.drawImage( + tempCanvas, + -tempCanvas.width / 2, + -tempCanvas.height / 2, + tempCanvas.width, + tempCanvas.height, + ); + } else { + context.rotate(element.angle); + + if (element.type === "image") { + // note: scale must be applied *after* rotating + context.scale(element.scale[0], element.scale[1]); + } + + context.translate(-shiftX, -shiftY); + drawElementOnCanvas(element, rc, context, renderConfig, appState); + } + + context.restore(); + // not exporting → optimized rendering (cache & render from element + // canvases) + } else { + const elementWithCanvas = generateElementWithCanvas( + element, + allElementsMap, + renderConfig, + appState, + ); + + if (!elementWithCanvas) { + return; + } + + const currentImageSmoothingStatus = context.imageSmoothingEnabled; + + if ( + // do not disable smoothing during zoom as blurry shapes look better + // on low resolution (while still zooming in) than sharp ones + !appState?.shouldCacheIgnoreZoom && + // angle is 0 -> always disable smoothing + (!element.angle || + // or check if angle is a right angle in which case we can still + // disable smoothing without adversely affecting the result + // We need less-than comparison because of FP artihmetic + isRightAngleRads(element.angle)) + ) { + // Disabling smoothing makes output much sharper, especially for + // text. Unless for non-right angles, where the aliasing is really + // terrible on Chromium. + // + // Note that `context.imageSmoothingQuality="high"` has almost + // zero effect. + // + context.imageSmoothingEnabled = false; + } + + if ( + element.id === appState.croppingElementId && + isImageElement(elementWithCanvas.element) && + elementWithCanvas.element.crop !== null + ) { + context.save(); + context.globalAlpha = 0.1; + + const uncroppedElementCanvas = generateElementCanvas( + getUncroppedImageElement(elementWithCanvas.element, elementsMap), + allElementsMap, + appState.zoom, + renderConfig, + appState, + ); + + if (uncroppedElementCanvas) { + drawElementFromCanvas( + uncroppedElementCanvas, + context, + renderConfig, + appState, + allElementsMap, + ); + } + + context.restore(); + } + + drawElementFromCanvas( + elementWithCanvas, + context, + renderConfig, + appState, + allElementsMap, + ); + + // reset + context.imageSmoothingEnabled = currentImageSmoothingStatus; + } + break; + } + default: { + // @ts-ignore + throw new Error(`Unimplemented type ${element.type}`); + } + } + + context.globalAlpha = 1; +}; + +export const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]); + +export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) { + const svgPathData = getFreeDrawSvgPath(element); + const path = new Path2D(svgPathData); + pathsCache.set(element, path); + return path; +} + +export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) { + return pathsCache.get(element); +} + +export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) { + // If input points are empty (should they ever be?) return a dot + const inputPoints = element.simulatePressure + ? element.points + : element.points.length + ? element.points.map(([x, y], i) => [x, y, element.pressures[i]]) + : [[0, 0, 0.5]]; + + // Consider changing the options for simulated pressure vs real pressure + const options: StrokeOptions = { + simulatePressure: element.simulatePressure, + size: element.strokeWidth * 4.25, + thinning: 0.6, + smoothing: 0.5, + streamline: 0.5, + easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine + last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup + }; + + return getSvgPathFromStroke(getStroke(inputPoints as number[][], options)); +} + +function med(A: number[], B: number[]) { + return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2]; +} + +// Trim SVG path data so number are each two decimal points. This +// improves SVG exports, and prevents rendering errors on points +// with long decimals. +const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g; + +function getSvgPathFromStroke(points: number[][]): string { + if (!points.length) { + return ""; + } + + const max = points.length - 1; + + return points + .reduce( + (acc, point, i, arr) => { + if (i === max) { + acc.push(point, med(point, arr[0]), "L", arr[0], "Z"); + } else { + acc.push(point, med(point, arr[i + 1])); + } + return acc; + }, + ["M", points[0], "Q"], + ) + .join(" ") + .replace(TO_FIXED_PRECISION, "$1"); +} diff --git a/packages/excalidraw/renderer/renderNewElementScene.ts b/packages/excalidraw/renderer/renderNewElementScene.ts new file mode 100644 index 0000000..caa7f58 --- /dev/null +++ b/packages/excalidraw/renderer/renderNewElementScene.ts @@ -0,0 +1,66 @@ +import type { NewElementSceneRenderConfig } from "../scene/types"; +import { throttleRAF } from "../utils"; +import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers"; +import { renderElement } from "./renderElement"; + +const _renderNewElementScene = ({ + canvas, + rc, + newElement, + elementsMap, + allElementsMap, + scale, + appState, + renderConfig, +}: NewElementSceneRenderConfig) => { + if (canvas) { + const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( + canvas, + scale, + ); + + const context = bootstrapCanvas({ + canvas, + scale, + normalizedWidth, + normalizedHeight, + }); + + // Apply zoom + context.save(); + context.scale(appState.zoom.value, appState.zoom.value); + + if (newElement && newElement.type !== "selection") { + renderElement( + newElement, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + } else { + context.clearRect(0, 0, normalizedWidth, normalizedHeight); + } + } +}; + +export const renderNewElementSceneThrottled = throttleRAF( + (config: NewElementSceneRenderConfig) => { + _renderNewElementScene(config); + }, + { trailing: true }, +); + +export const renderNewElementScene = ( + renderConfig: NewElementSceneRenderConfig, + throttle?: boolean, +) => { + if (throttle) { + renderNewElementSceneThrottled(renderConfig); + return; + } + + _renderNewElementScene(renderConfig); +}; diff --git a/packages/excalidraw/renderer/renderSnaps.ts b/packages/excalidraw/renderer/renderSnaps.ts new file mode 100644 index 0000000..878e8e5 --- /dev/null +++ b/packages/excalidraw/renderer/renderSnaps.ts @@ -0,0 +1,207 @@ +import { pointFrom, type GlobalPoint, type LocalPoint } from "@excalidraw/math"; +import { THEME } from "../constants"; +import type { PointSnapLine, PointerSnapLine } from "../snapping"; +import type { InteractiveCanvasAppState } from "../types"; + +const SNAP_COLOR_LIGHT = "#ff6b6b"; +const SNAP_COLOR_DARK = "#ff0000"; +const SNAP_WIDTH = 1; +const SNAP_CROSS_SIZE = 2; + +export const renderSnaps = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, +) => { + if (!appState.snapLines.length) { + return; + } + + // in dark mode, we need to adjust the color to account for color inversion. + // Don't change if zen mode, because we draw only crosses, we want the + // colors to be more visible + const snapColor = + appState.theme === THEME.LIGHT || appState.zenModeEnabled + ? SNAP_COLOR_LIGHT + : SNAP_COLOR_DARK; + // in zen mode make the cross more visible since we don't draw the lines + const snapWidth = + (appState.zenModeEnabled ? SNAP_WIDTH * 1.5 : SNAP_WIDTH) / + appState.zoom.value; + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + + for (const snapLine of appState.snapLines) { + if (snapLine.type === "pointer") { + context.lineWidth = snapWidth; + context.strokeStyle = snapColor; + + drawPointerSnapLine(snapLine, context, appState); + } else if (snapLine.type === "gap") { + context.lineWidth = snapWidth; + context.strokeStyle = snapColor; + + drawGapLine( + snapLine.points[0], + snapLine.points[1], + snapLine.direction, + appState, + context, + ); + } else if (snapLine.type === "points") { + context.lineWidth = snapWidth; + context.strokeStyle = snapColor; + drawPointsSnapLine(snapLine, context, appState); + } + } + + context.restore(); +}; + +const drawPointsSnapLine = ( + pointSnapLine: PointSnapLine, + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, +) => { + if (!appState.zenModeEnabled) { + const firstPoint = pointSnapLine.points[0]; + const lastPoint = pointSnapLine.points[pointSnapLine.points.length - 1]; + + drawLine(firstPoint, lastPoint, context); + } + + for (const point of pointSnapLine.points) { + drawCross(point, appState, context); + } +}; + +const drawPointerSnapLine = ( + pointerSnapLine: PointerSnapLine, + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, +) => { + drawCross(pointerSnapLine.points[0], appState, context); + if (!appState.zenModeEnabled) { + drawLine(pointerSnapLine.points[0], pointerSnapLine.points[1], context); + } +}; + +const drawCross = <Point extends LocalPoint | GlobalPoint>( + [x, y]: Point, + appState: InteractiveCanvasAppState, + context: CanvasRenderingContext2D, +) => { + context.save(); + const size = + (appState.zenModeEnabled ? SNAP_CROSS_SIZE * 1.5 : SNAP_CROSS_SIZE) / + appState.zoom.value; + context.beginPath(); + + context.moveTo(x - size, y - size); + context.lineTo(x + size, y + size); + + context.moveTo(x + size, y - size); + context.lineTo(x - size, y + size); + + context.stroke(); + context.restore(); +}; + +const drawLine = <Point extends LocalPoint | GlobalPoint>( + from: Point, + to: Point, + context: CanvasRenderingContext2D, +) => { + context.beginPath(); + context.lineTo(from[0], from[1]); + context.lineTo(to[0], to[1]); + context.stroke(); +}; + +const drawGapLine = <Point extends LocalPoint | GlobalPoint>( + from: Point, + to: Point, + direction: "horizontal" | "vertical", + appState: InteractiveCanvasAppState, + context: CanvasRenderingContext2D, +) => { + // a horizontal gap snap line + // |–––––––||–––––––| + // ^ ^ ^ ^ + // \ \ \ \ + // (1) (2) (3) (4) + + const FULL = 8 / appState.zoom.value; + const HALF = FULL / 2; + const QUARTER = FULL / 4; + + if (direction === "horizontal") { + const halfPoint = [(from[0] + to[0]) / 2, from[1]]; + // (1) + if (!appState.zenModeEnabled) { + drawLine( + pointFrom(from[0], from[1] - FULL), + pointFrom(from[0], from[1] + FULL), + context, + ); + } + + // (3) + drawLine( + pointFrom(halfPoint[0] - QUARTER, halfPoint[1] - HALF), + pointFrom(halfPoint[0] - QUARTER, halfPoint[1] + HALF), + context, + ); + drawLine( + pointFrom(halfPoint[0] + QUARTER, halfPoint[1] - HALF), + pointFrom(halfPoint[0] + QUARTER, halfPoint[1] + HALF), + context, + ); + + if (!appState.zenModeEnabled) { + // (4) + drawLine( + pointFrom(to[0], to[1] - FULL), + pointFrom(to[0], to[1] + FULL), + context, + ); + + // (2) + drawLine(from, to, context); + } + } else { + const halfPoint = [from[0], (from[1] + to[1]) / 2]; + // (1) + if (!appState.zenModeEnabled) { + drawLine( + pointFrom(from[0] - FULL, from[1]), + pointFrom(from[0] + FULL, from[1]), + context, + ); + } + + // (3) + drawLine( + pointFrom(halfPoint[0] - HALF, halfPoint[1] - QUARTER), + pointFrom(halfPoint[0] + HALF, halfPoint[1] - QUARTER), + context, + ); + drawLine( + pointFrom(halfPoint[0] - HALF, halfPoint[1] + QUARTER), + pointFrom(halfPoint[0] + HALF, halfPoint[1] + QUARTER), + context, + ); + + if (!appState.zenModeEnabled) { + // (4) + drawLine( + pointFrom(to[0] - FULL, to[1]), + pointFrom(to[0] + FULL, to[1]), + context, + ); + + // (2) + drawLine(from, to, context); + } + } +}; diff --git a/packages/excalidraw/renderer/roundRect.ts b/packages/excalidraw/renderer/roundRect.ts new file mode 100644 index 0000000..bbb9830 --- /dev/null +++ b/packages/excalidraw/renderer/roundRect.ts @@ -0,0 +1,41 @@ +/** + * https://stackoverflow.com/a/3368118 + * Draws a rounded rectangle using the current state of the canvas. + * @param {CanvasRenderingContext2D} context + * @param {Number} x The top left x coordinate + * @param {Number} y The top left y coordinate + * @param {Number} width The width of the rectangle + * @param {Number} height The height of the rectangle + * @param {Number} radius The corner radius + */ +export const roundRect = ( + context: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number, + strokeColor?: string, +) => { + context.beginPath(); + context.moveTo(x + radius, y); + context.lineTo(x + width - radius, y); + context.quadraticCurveTo(x + width, y, x + width, y + radius); + context.lineTo(x + width, y + height - radius); + context.quadraticCurveTo( + x + width, + y + height, + x + width - radius, + y + height, + ); + context.lineTo(x + radius, y + height); + context.quadraticCurveTo(x, y + height, x, y + height - radius); + context.lineTo(x, y + radius); + context.quadraticCurveTo(x, y, x + radius, y); + context.closePath(); + context.fill(); + if (strokeColor) { + context.strokeStyle = strokeColor; + } + context.stroke(); +}; diff --git a/packages/excalidraw/renderer/staticScene.ts b/packages/excalidraw/renderer/staticScene.ts new file mode 100644 index 0000000..90ed8af --- /dev/null +++ b/packages/excalidraw/renderer/staticScene.ts @@ -0,0 +1,480 @@ +import { FRAME_STYLE } from "../constants"; +import { getElementAbsoluteCoords } from "../element"; + +import { + elementOverlapsWithFrame, + getTargetFrame, + shouldApplyFrameClip, +} from "../frame"; +import { + isEmbeddableElement, + isIframeLikeElement, + isTextElement, +} from "../element/typeChecks"; +import { renderElement } from "../renderer/renderElement"; +import { createPlaceholderEmbeddableLabel } from "../element/embeddable"; +import type { StaticCanvasAppState, Zoom } from "../types"; +import type { + ElementsMap, + ExcalidrawFrameLikeElement, + NonDeletedExcalidrawElement, +} from "../element/types"; +import type { + StaticCanvasRenderConfig, + StaticSceneRenderConfig, +} from "../scene/types"; +import { + EXTERNAL_LINK_IMG, + ELEMENT_LINK_IMG, + getLinkHandleFromCoords, +} from "../components/hyperlink/helpers"; +import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers"; +import { throttleRAF } from "../utils"; +import { getBoundTextElement } from "../element/textElement"; +import { isElementLink } from "../element/elementLink"; + +const GridLineColor = { + Bold: "#dddddd", + Regular: "#e5e5e5", +} as const; + +const strokeGrid = ( + context: CanvasRenderingContext2D, + /** grid cell pixel size */ + gridSize: number, + /** setting to 1 will disble bold lines */ + gridStep: number, + scrollX: number, + scrollY: number, + zoom: Zoom, + width: number, + height: number, +) => { + const offsetX = (scrollX % gridSize) - gridSize; + const offsetY = (scrollY % gridSize) - gridSize; + + const actualGridSize = gridSize * zoom.value; + + const spaceWidth = 1 / zoom.value; + + context.save(); + + // Offset rendering by 0.5 to ensure that 1px wide lines are crisp. + // We only do this when zoomed to 100% because otherwise the offset is + // fractional, and also visibly offsets the elements. + // We also do this per-axis, as each axis may already be offset by 0.5. + if (zoom.value === 1) { + context.translate(offsetX % 1 ? 0 : 0.5, offsetY % 1 ? 0 : 0.5); + } + + // vertical lines + for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) { + const isBold = + gridStep > 1 && Math.round(x - scrollX) % (gridStep * gridSize) === 0; + // don't render regular lines when zoomed out and they're barely visible + if (!isBold && actualGridSize < 10) { + continue; + } + + const lineWidth = Math.min(1 / zoom.value, isBold ? 4 : 1); + context.lineWidth = lineWidth; + const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)]; + + context.beginPath(); + context.setLineDash(isBold ? [] : lineDash); + context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; + context.moveTo(x, offsetY - gridSize); + context.lineTo(x, Math.ceil(offsetY + height + gridSize * 2)); + context.stroke(); + } + + for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) { + const isBold = + gridStep > 1 && Math.round(y - scrollY) % (gridStep * gridSize) === 0; + if (!isBold && actualGridSize < 10) { + continue; + } + + const lineWidth = Math.min(1 / zoom.value, isBold ? 4 : 1); + context.lineWidth = lineWidth; + const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)]; + + context.beginPath(); + context.setLineDash(isBold ? [] : lineDash); + context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; + context.moveTo(offsetX - gridSize, y); + context.lineTo(Math.ceil(offsetX + width + gridSize * 2), y); + context.stroke(); + } + context.restore(); +}; + +const frameClip = ( + frame: ExcalidrawFrameLikeElement, + context: CanvasRenderingContext2D, + renderConfig: StaticCanvasRenderConfig, + appState: StaticCanvasAppState, +) => { + context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY); + context.beginPath(); + if (context.roundRect) { + context.roundRect( + 0, + 0, + frame.width, + frame.height, + FRAME_STYLE.radius / appState.zoom.value, + ); + } else { + context.rect(0, 0, frame.width, frame.height); + } + context.clip(); + context.translate( + -(frame.x + appState.scrollX), + -(frame.y + appState.scrollY), + ); +}; + +type LinkIconCanvas = HTMLCanvasElement & { zoom: number }; + +const linkIconCanvasCache: { + regularLink: LinkIconCanvas | null; + elementLink: LinkIconCanvas | null; +} = { + regularLink: null, + elementLink: null, +}; + +const renderLinkIcon = ( + element: NonDeletedExcalidrawElement, + context: CanvasRenderingContext2D, + appState: StaticCanvasAppState, + elementsMap: ElementsMap, +) => { + if (element.link && !appState.selectedElementIds[element.id]) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const [x, y, width, height] = getLinkHandleFromCoords( + [x1, y1, x2, y2], + element.angle, + appState, + ); + const centerX = x + width / 2; + const centerY = y + height / 2; + context.save(); + context.translate(appState.scrollX + centerX, appState.scrollY + centerY); + context.rotate(element.angle); + + const canvasKey = isElementLink(element.link) + ? "elementLink" + : "regularLink"; + + let linkCanvas = linkIconCanvasCache[canvasKey]; + + if (!linkCanvas || linkCanvas.zoom !== appState.zoom.value) { + linkCanvas = Object.assign(document.createElement("canvas"), { + zoom: appState.zoom.value, + }); + linkCanvas.width = width * window.devicePixelRatio * appState.zoom.value; + linkCanvas.height = + height * window.devicePixelRatio * appState.zoom.value; + linkIconCanvasCache[canvasKey] = linkCanvas; + + const linkCanvasCacheContext = linkCanvas.getContext("2d")!; + linkCanvasCacheContext.scale( + window.devicePixelRatio * appState.zoom.value, + window.devicePixelRatio * appState.zoom.value, + ); + linkCanvasCacheContext.fillStyle = "#fff"; + linkCanvasCacheContext.fillRect(0, 0, width, height); + + if (canvasKey === "elementLink") { + linkCanvasCacheContext.drawImage(ELEMENT_LINK_IMG, 0, 0, width, height); + } else { + linkCanvasCacheContext.drawImage( + EXTERNAL_LINK_IMG, + 0, + 0, + width, + height, + ); + } + + linkCanvasCacheContext.restore(); + } + context.drawImage(linkCanvas, x - centerX, y - centerY, width, height); + context.restore(); + } +}; +const _renderStaticScene = ({ + canvas, + rc, + elementsMap, + allElementsMap, + visibleElements, + scale, + appState, + renderConfig, +}: StaticSceneRenderConfig) => { + if (canvas === null) { + return; + } + + const { renderGrid = true, isExporting } = renderConfig; + + const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( + canvas, + scale, + ); + + const context = bootstrapCanvas({ + canvas, + scale, + normalizedWidth, + normalizedHeight, + theme: appState.theme, + isExporting, + viewBackgroundColor: appState.viewBackgroundColor, + }); + + // Apply zoom + context.scale(appState.zoom.value, appState.zoom.value); + + // Grid + if (renderGrid) { + strokeGrid( + context, + appState.gridSize, + appState.gridStep, + appState.scrollX, + appState.scrollY, + appState.zoom, + normalizedWidth / appState.zoom.value, + normalizedHeight / appState.zoom.value, + ); + } + + const groupsToBeAddedToFrame = new Set<string>(); + + visibleElements.forEach((element) => { + if ( + element.groupIds.length > 0 && + appState.frameToHighlight && + appState.selectedElementIds[element.id] && + (elementOverlapsWithFrame( + element, + appState.frameToHighlight, + elementsMap, + ) || + element.groupIds.find((groupId) => groupsToBeAddedToFrame.has(groupId))) + ) { + element.groupIds.forEach((groupId) => + groupsToBeAddedToFrame.add(groupId), + ); + } + }); + + const inFrameGroupsMap = new Map<string, boolean>(); + + // Paint visible elements + visibleElements + .filter((el) => !isIframeLikeElement(el)) + .forEach((element) => { + try { + const frameId = element.frameId || appState.frameToHighlight?.id; + + if ( + isTextElement(element) && + element.containerId && + elementsMap.has(element.containerId) + ) { + // will be rendered with the container + return; + } + + context.save(); + + if ( + frameId && + appState.frameRendering.enabled && + appState.frameRendering.clip + ) { + const frame = getTargetFrame(element, elementsMap, appState); + if ( + frame && + shouldApplyFrameClip( + element, + frame, + appState, + elementsMap, + inFrameGroupsMap, + ) + ) { + frameClip(frame, context, renderConfig, appState); + } + renderElement( + element, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + } else { + renderElement( + element, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + } + + const boundTextElement = getBoundTextElement(element, elementsMap); + if (boundTextElement) { + renderElement( + boundTextElement, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + } + + context.restore(); + + if (!isExporting) { + renderLinkIcon(element, context, appState, elementsMap); + } + } catch (error: any) { + console.error( + error, + element.id, + element.x, + element.y, + element.width, + element.height, + ); + } + }); + + // render embeddables on top + visibleElements + .filter((el) => isIframeLikeElement(el)) + .forEach((element) => { + try { + const render = () => { + renderElement( + element, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + + if ( + isIframeLikeElement(element) && + (isExporting || + (isEmbeddableElement(element) && + renderConfig.embedsValidationStatus.get(element.id) !== + true)) && + element.width && + element.height + ) { + const label = createPlaceholderEmbeddableLabel(element); + renderElement( + label, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + } + if (!isExporting) { + renderLinkIcon(element, context, appState, elementsMap); + } + }; + // - when exporting the whole canvas, we DO NOT apply clipping + // - when we are exporting a particular frame, apply clipping + // if the containing frame is not selected, apply clipping + const frameId = element.frameId || appState.frameToHighlight?.id; + + if ( + frameId && + appState.frameRendering.enabled && + appState.frameRendering.clip + ) { + context.save(); + + const frame = getTargetFrame(element, elementsMap, appState); + + if ( + frame && + shouldApplyFrameClip( + element, + frame, + appState, + elementsMap, + inFrameGroupsMap, + ) + ) { + frameClip(frame, context, renderConfig, appState); + } + render(); + context.restore(); + } else { + render(); + } + } catch (error: any) { + console.error(error); + } + }); + + // render pending nodes for flowcharts + renderConfig.pendingFlowchartNodes?.forEach((element) => { + try { + renderElement( + element, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + } catch (error) { + console.error(error); + } + }); +}; + +/** throttled to animation framerate */ +export const renderStaticSceneThrottled = throttleRAF( + (config: StaticSceneRenderConfig) => { + _renderStaticScene(config); + }, + { trailing: true }, +); + +/** + * Static scene is the non-ui canvas where we render elements. + */ +export const renderStaticScene = ( + renderConfig: StaticSceneRenderConfig, + throttle?: boolean, +) => { + if (throttle) { + renderStaticSceneThrottled(renderConfig); + return; + } + + _renderStaticScene(renderConfig); +}; diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts new file mode 100644 index 0000000..b14faf7 --- /dev/null +++ b/packages/excalidraw/renderer/staticSvgScene.ts @@ -0,0 +1,744 @@ +import type { Drawable } from "roughjs/bin/core"; +import type { RoughSVG } from "roughjs/bin/svg"; +import { + FRAME_STYLE, + MAX_DECIMALS_FOR_SVG_EXPORT, + MIME_TYPES, + SVG_NS, +} from "../constants"; +import { normalizeLink, toValidURL } from "../data/url"; +import { getElementAbsoluteCoords, hashString } from "../element"; +import { + createPlaceholderEmbeddableLabel, + getEmbedLink, +} from "../element/embeddable"; +import { LinearElementEditor } from "../element/linearElementEditor"; +import { + getBoundTextElement, + getContainerElement, +} from "../element/textElement"; +import { + isArrowElement, + isIframeLikeElement, + isInitializedImageElement, + isTextElement, +} from "../element/typeChecks"; +import type { + ExcalidrawElement, + ExcalidrawTextElementWithContainer, + NonDeletedExcalidrawElement, +} from "../element/types"; +import { getContainingFrame } from "../frame"; +import { ShapeCache } from "../scene/ShapeCache"; +import type { RenderableElementsMap, SVGRenderConfig } from "../scene/types"; +import type { AppState, BinaryFiles } from "../types"; +import { getFontFamilyString, isRTL, isTestEnv } from "../utils"; +import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement"; +import { getVerticalOffset } from "../fonts"; +import { getCornerRadius, isPathALoop } from "../shapes"; +import { getUncroppedWidthAndHeight } from "../element/cropElement"; +import { getLineHeightInPx } from "../element/textMeasurements"; + +const roughSVGDrawWithPrecision = ( + rsvg: RoughSVG, + drawable: Drawable, + precision?: number, +) => { + if (typeof precision === "undefined") { + return rsvg.draw(drawable); + } + const pshape: Drawable = { + sets: drawable.sets, + shape: drawable.shape, + options: { ...drawable.options, fixedDecimalPlaceDigits: precision }, + }; + return rsvg.draw(pshape); +}; + +const maybeWrapNodesInFrameClipPath = ( + element: NonDeletedExcalidrawElement, + root: SVGElement, + nodes: SVGElement[], + frameRendering: AppState["frameRendering"], + elementsMap: RenderableElementsMap, +) => { + if (!frameRendering.enabled || !frameRendering.clip) { + return null; + } + const frame = getContainingFrame(element, elementsMap); + if (frame) { + const g = root.ownerDocument!.createElementNS(SVG_NS, "g"); + g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`); + nodes.forEach((node) => g.appendChild(node)); + return g; + } + + return null; +}; + +const renderElementToSvg = ( + element: NonDeletedExcalidrawElement, + elementsMap: RenderableElementsMap, + rsvg: RoughSVG, + svgRoot: SVGElement, + files: BinaryFiles, + offsetX: number, + offsetY: number, + renderConfig: SVGRenderConfig, +) => { + const offset = { x: offsetX, y: offsetY }; + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + let cx = (x2 - x1) / 2 - (element.x - x1); + let cy = (y2 - y1) / 2 - (element.y - y1); + if (isTextElement(element)) { + const container = getContainerElement(element, elementsMap); + if (isArrowElement(container)) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(container, elementsMap); + + const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( + container, + element as ExcalidrawTextElementWithContainer, + elementsMap, + ); + cx = (x2 - x1) / 2 - (boundTextCoords.x - x1); + cy = (y2 - y1) / 2 - (boundTextCoords.y - y1); + offsetX = offsetX + boundTextCoords.x - element.x; + offsetY = offsetY + boundTextCoords.y - element.y; + } + } + const degree = (180 * element.angle) / Math.PI; + + // element to append node to, most of the time svgRoot + let root = svgRoot; + + // if the element has a link, create an anchor tag and make that the new root + if (element.link) { + const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); + anchorTag.setAttribute("href", normalizeLink(element.link)); + root.appendChild(anchorTag); + root = anchorTag; + } + + const addToRoot = (node: SVGElement, element: ExcalidrawElement) => { + if (isTestEnv()) { + node.setAttribute("data-id", element.id); + } + root.appendChild(node); + }; + + const opacity = + ((getContainingFrame(element, elementsMap)?.opacity ?? 100) * + element.opacity) / + 10000; + + switch (element.type) { + case "selection": { + // Since this is used only during editing experience, which is canvas based, + // this should not happen + throw new Error("Selection rendering is not supported for SVG"); + } + case "rectangle": + case "diamond": + case "ellipse": { + const shape = ShapeCache.generateElementShape(element, null); + const node = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute("stroke-linecap", "round"); + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [node], + renderConfig.frameRendering, + elementsMap, + ); + + addToRoot(g || node, element); + break; + } + case "iframe": + case "embeddable": { + // render placeholder rectangle + const shape = ShapeCache.generateElementShape(element, renderConfig); + const node = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + const opacity = element.opacity / 100; + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute("stroke-linecap", "round"); + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + addToRoot(node, element); + + const label: ExcalidrawElement = + createPlaceholderEmbeddableLabel(element); + renderElementToSvg( + label, + elementsMap, + rsvg, + root, + files, + label.x + offset.x - element.x, + label.y + offset.y - element.y, + renderConfig, + ); + + // render embeddable element + iframe + const embeddableNode = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + embeddableNode.setAttribute("stroke-linecap", "round"); + embeddableNode.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + while (embeddableNode.firstChild) { + embeddableNode.removeChild(embeddableNode.firstChild); + } + const radius = getCornerRadius( + Math.min(element.width, element.height), + element, + ); + + const embedLink = getEmbedLink(toValidURL(element.link || "")); + + // if rendering embeddables explicitly disabled or + // embedding documents via srcdoc (which doesn't seem to work for SVGs) + // replace with a link instead + if ( + renderConfig.renderEmbeddables === false || + embedLink?.type === "document" + ) { + const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); + anchorTag.setAttribute("href", normalizeLink(element.link || "")); + anchorTag.setAttribute("target", "_blank"); + anchorTag.setAttribute("rel", "noopener noreferrer"); + anchorTag.style.borderRadius = `${radius}px`; + + embeddableNode.appendChild(anchorTag); + } else { + const foreignObject = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "foreignObject", + ); + foreignObject.style.width = `${element.width}px`; + foreignObject.style.height = `${element.height}px`; + foreignObject.style.border = "none"; + const div = foreignObject.ownerDocument!.createElementNS(SVG_NS, "div"); + div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); + div.style.width = "100%"; + div.style.height = "100%"; + const iframe = div.ownerDocument!.createElement("iframe"); + iframe.src = embedLink?.link ?? ""; + iframe.style.width = "100%"; + iframe.style.height = "100%"; + iframe.style.border = "none"; + iframe.style.borderRadius = `${radius}px`; + iframe.style.top = "0"; + iframe.style.left = "0"; + iframe.allowFullscreen = true; + div.appendChild(iframe); + foreignObject.appendChild(div); + + embeddableNode.appendChild(foreignObject); + } + addToRoot(embeddableNode, element); + break; + } + case "line": + case "arrow": { + const boundText = getBoundTextElement(element, elementsMap); + const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask"); + if (boundText) { + maskPath.setAttribute("id", `mask-${element.id}`); + const maskRectVisible = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + offsetX = offsetX || 0; + offsetY = offsetY || 0; + maskRectVisible.setAttribute("x", "0"); + maskRectVisible.setAttribute("y", "0"); + maskRectVisible.setAttribute("fill", "#fff"); + maskRectVisible.setAttribute( + "width", + `${element.width + 100 + offsetX}`, + ); + maskRectVisible.setAttribute( + "height", + `${element.height + 100 + offsetY}`, + ); + + maskPath.appendChild(maskRectVisible); + const maskRectInvisible = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( + element, + boundText, + elementsMap, + ); + + const maskX = offsetX + boundTextCoords.x - element.x; + const maskY = offsetY + boundTextCoords.y - element.y; + + maskRectInvisible.setAttribute("x", maskX.toString()); + maskRectInvisible.setAttribute("y", maskY.toString()); + maskRectInvisible.setAttribute("fill", "#000"); + maskRectInvisible.setAttribute("width", `${boundText.width}`); + maskRectInvisible.setAttribute("height", `${boundText.height}`); + maskRectInvisible.setAttribute("opacity", "1"); + maskPath.appendChild(maskRectInvisible); + } + const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + if (boundText) { + group.setAttribute("mask", `url(#mask-${element.id})`); + } + group.setAttribute("stroke-linecap", "round"); + + const shapes = ShapeCache.generateElementShape(element, renderConfig); + shapes.forEach((shape) => { + const node = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + if ( + element.type === "line" && + isPathALoop(element.points) && + element.backgroundColor !== "transparent" + ) { + node.setAttribute("fill-rule", "evenodd"); + } + group.appendChild(node); + }); + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [group, maskPath], + renderConfig.frameRendering, + elementsMap, + ); + if (g) { + addToRoot(g, element); + root.appendChild(g); + } else { + addToRoot(group, element); + root.append(maskPath); + } + break; + } + case "freedraw": { + const backgroundFillShape = ShapeCache.generateElementShape( + element, + renderConfig, + ); + const node = backgroundFillShape + ? roughSVGDrawWithPrecision( + rsvg, + backgroundFillShape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ) + : svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + node.setAttribute("stroke", "none"); + const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path"); + path.setAttribute("fill", element.strokeColor); + path.setAttribute("d", getFreeDrawSvgPath(element)); + node.appendChild(path); + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [node], + renderConfig.frameRendering, + elementsMap, + ); + + addToRoot(g || node, element); + break; + } + case "image": { + const width = Math.round(element.width); + const height = Math.round(element.height); + const fileData = + isInitializedImageElement(element) && files[element.fileId]; + if (fileData) { + const { reuseImages = true } = renderConfig; + + let symbolId = `image-${fileData.id}`; + + let uncroppedWidth = element.width; + let uncroppedHeight = element.height; + if (element.crop) { + ({ width: uncroppedWidth, height: uncroppedHeight } = + getUncroppedWidthAndHeight(element)); + + symbolId = `image-crop-${fileData.id}-${hashString( + `${uncroppedWidth}x${uncroppedHeight}`, + )}`; + } + + if (!reuseImages) { + symbolId = `image-${element.id}`; + } + + let symbol = svgRoot.querySelector(`#${symbolId}`); + if (!symbol) { + symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol"); + symbol.id = symbolId; + + const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image"); + image.setAttribute("href", fileData.dataURL); + image.setAttribute("preserveAspectRatio", "none"); + + if (element.crop || !reuseImages) { + image.setAttribute("width", `${uncroppedWidth}`); + image.setAttribute("height", `${uncroppedHeight}`); + } else { + image.setAttribute("width", "100%"); + image.setAttribute("height", "100%"); + } + + symbol.appendChild(image); + + (root.querySelector("defs") || root).prepend(symbol); + } + + const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use"); + use.setAttribute("href", `#${symbolId}`); + + // in dark theme, revert the image color filter + if ( + renderConfig.exportWithDarkMode && + fileData.mimeType !== MIME_TYPES.svg + ) { + use.setAttribute("filter", IMAGE_INVERT_FILTER); + } + + let normalizedCropX = 0; + let normalizedCropY = 0; + + if (element.crop) { + const { width: uncroppedWidth, height: uncroppedHeight } = + getUncroppedWidthAndHeight(element); + normalizedCropX = + element.crop.x / (element.crop.naturalWidth / uncroppedWidth); + normalizedCropY = + element.crop.y / (element.crop.naturalHeight / uncroppedHeight); + } + + const adjustedCenterX = cx + normalizedCropX; + const adjustedCenterY = cy + normalizedCropY; + + use.setAttribute("width", `${width + normalizedCropX}`); + use.setAttribute("height", `${height + normalizedCropY}`); + use.setAttribute("opacity", `${opacity}`); + + // We first apply `scale` transforms (horizontal/vertical mirroring) + // on the <use> element, then apply translation and rotation + // on the <g> element which wraps the <use>. + // Doing this separately is a quick hack to to work around compositing + // the transformations correctly (the transform-origin was not being + // applied correctly). + if (element.scale[0] !== 1 || element.scale[1] !== 1) { + use.setAttribute( + "transform", + `translate(${adjustedCenterX} ${adjustedCenterY}) scale(${ + element.scale[0] + } ${ + element.scale[1] + }) translate(${-adjustedCenterX} ${-adjustedCenterY})`, + ); + } + + const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + + if (element.crop) { + const mask = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask"); + mask.setAttribute("id", `mask-image-crop-${element.id}`); + mask.setAttribute("fill", "#fff"); + const maskRect = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + + maskRect.setAttribute("x", `${normalizedCropX}`); + maskRect.setAttribute("y", `${normalizedCropY}`); + maskRect.setAttribute("width", `${width}`); + maskRect.setAttribute("height", `${height}`); + + mask.appendChild(maskRect); + root.appendChild(mask); + g.setAttribute("mask", `url(#${mask.id})`); + } + + g.appendChild(use); + g.setAttribute( + "transform", + `translate(${offsetX - normalizedCropX} ${ + offsetY - normalizedCropY + }) rotate(${degree} ${adjustedCenterX} ${adjustedCenterY})`, + ); + + if (element.roundness) { + const clipPath = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "clipPath", + ); + clipPath.id = `image-clipPath-${element.id}`; + + const clipRect = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + const radius = getCornerRadius( + Math.min(element.width, element.height), + element, + ); + clipRect.setAttribute("width", `${element.width}`); + clipRect.setAttribute("height", `${element.height}`); + clipRect.setAttribute("rx", `${radius}`); + clipRect.setAttribute("ry", `${radius}`); + clipPath.appendChild(clipRect); + addToRoot(clipPath, element); + + g.setAttributeNS(SVG_NS, "clip-path", `url(#${clipPath.id})`); + } + + const clipG = maybeWrapNodesInFrameClipPath( + element, + root, + [g], + renderConfig.frameRendering, + elementsMap, + ); + addToRoot(clipG || g, element); + } + break; + } + // frames are not rendered and only acts as a container + case "frame": + case "magicframe": { + if ( + renderConfig.frameRendering.enabled && + renderConfig.frameRendering.outline + ) { + const rect = document.createElementNS(SVG_NS, "rect"); + + rect.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + + rect.setAttribute("width", `${element.width}px`); + rect.setAttribute("height", `${element.height}px`); + // Rounded corners + rect.setAttribute("rx", FRAME_STYLE.radius.toString()); + rect.setAttribute("ry", FRAME_STYLE.radius.toString()); + + rect.setAttribute("fill", "none"); + rect.setAttribute("stroke", FRAME_STYLE.strokeColor); + rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString()); + + addToRoot(rect, element); + } + break; + } + default: { + if (isTextElement(element)) { + const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); + const lineHeightPx = getLineHeightInPx( + element.fontSize, + element.lineHeight, + ); + const horizontalOffset = + element.textAlign === "center" + ? element.width / 2 + : element.textAlign === "right" + ? element.width + : 0; + const verticalOffset = getVerticalOffset( + element.fontFamily, + element.fontSize, + lineHeightPx, + ); + const direction = isRTL(element.text) ? "rtl" : "ltr"; + const textAnchor = + element.textAlign === "center" + ? "middle" + : element.textAlign === "right" || direction === "rtl" + ? "end" + : "start"; + for (let i = 0; i < lines.length; i++) { + const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text"); + text.textContent = lines[i]; + text.setAttribute("x", `${horizontalOffset}`); + text.setAttribute("y", `${i * lineHeightPx + verticalOffset}`); + text.setAttribute("font-family", getFontFamilyString(element)); + text.setAttribute("font-size", `${element.fontSize}px`); + text.setAttribute("fill", element.strokeColor); + text.setAttribute("text-anchor", textAnchor); + text.setAttribute("style", "white-space: pre;"); + text.setAttribute("direction", direction); + text.setAttribute("dominant-baseline", "alphabetic"); + node.appendChild(text); + } + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [node], + renderConfig.frameRendering, + elementsMap, + ); + + addToRoot(g || node, element); + } else { + // @ts-ignore + throw new Error(`Unimplemented type ${element.type}`); + } + } + } +}; + +export const renderSceneToSvg = ( + elements: readonly NonDeletedExcalidrawElement[], + elementsMap: RenderableElementsMap, + rsvg: RoughSVG, + svgRoot: SVGElement, + files: BinaryFiles, + renderConfig: SVGRenderConfig, +) => { + if (!svgRoot) { + return; + } + + // render elements + elements + .filter((el) => !isIframeLikeElement(el)) + .forEach((element) => { + if (!element.isDeleted) { + if ( + isTextElement(element) && + element.containerId && + elementsMap.has(element.containerId) + ) { + // will be rendered with the container + return; + } + + try { + renderElementToSvg( + element, + elementsMap, + rsvg, + svgRoot, + files, + element.x + renderConfig.offsetX, + element.y + renderConfig.offsetY, + renderConfig, + ); + + const boundTextElement = getBoundTextElement(element, elementsMap); + if (boundTextElement) { + renderElementToSvg( + boundTextElement, + elementsMap, + rsvg, + svgRoot, + files, + boundTextElement.x + renderConfig.offsetX, + boundTextElement.y + renderConfig.offsetY, + renderConfig, + ); + } + } catch (error: any) { + console.error(error); + } + } + }); + + // render embeddables on top + elements + .filter((el) => isIframeLikeElement(el)) + .forEach((element) => { + if (!element.isDeleted) { + try { + renderElementToSvg( + element, + elementsMap, + rsvg, + svgRoot, + files, + element.x + renderConfig.offsetX, + element.y + renderConfig.offsetY, + renderConfig, + ); + } catch (error: any) { + console.error(error); + } + } + }); +}; |
