From a11a741de48278bf8f10fc1f55838ad2b6448361 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 1 Jun 2023 16:22:47 +0100 Subject: [PATCH] [2/3] renderer changes to support "sandwich mode" highlighting (#1418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This diff modifies our canvas/rendering code to support shapes rendering into a "background layer". The background layer isn't a layer in the sense of our own html/svg/indicator layers, but is instead part of the HTML canvas layer and is created by allocating z-indexes to shapes below all others. For most shapes, the background starts at the canvas. If a shape is in a frame, then the frame is treated as the background. ![Kapture 2023-05-19 at 11 38 12](https://github.com/tldraw/tldraw/assets/1489520/3ab6e0c0-f71e-4bfd-a996-c5411be28a71) Exports now use the `renderingShapes` algorithm which fixed a small bug with exports where opacity wouldn't get correctly propagated down through child shapes. ### The plan 1. initial highlighter shape/tool #1401 2. sandwich rendering for highlighter shapes #1418 **>you are here<** 3. shape styling - new colours and sizes, lightweight perfect freehand changes ### Change Type - [x] `minor` — New Feature ### Test Plan not yet! - [x] Unit Tests ### Release Notes [not yet!] --- packages/editor/api-report.md | 19 +- packages/editor/src/lib/app/App.ts | 451 +++++++++++------- .../shapeutils/TLFrameUtil/TLFrameUtil.tsx | 4 + .../TLHighlightUtil/TLHighlightUtil.tsx | 193 ++++---- .../src/lib/app/shapeutils/TLShapeUtil.ts | 36 ++ packages/editor/src/lib/components/Shape.tsx | 121 +++-- .../__snapshots__/getSvg.test.ts.snap | 165 ++++++- .../src/lib/test/commands/getSvg.test.ts | 2 +- .../test/commands/renderingShapes.test.tsx | 163 ++++++- packages/primitives/src/lib/Box2d.ts | 4 +- 10 files changed, 817 insertions(+), 341 deletions(-) diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index fe797a70d..8ceef7dcf 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -280,11 +280,11 @@ export class App extends EventEmitter { getParentTransform(shape: TLShape): Matrix2d; getPointInParentSpace(shapeId: TLShapeId, point: VecLike): Vec2d; getPointInShapeSpace(shape: TLShape, point: VecLike): Vec2d; - getShapeById(id: TLParentId): T | undefined; // (undocumented) - getShapesAndDescendantsInOrder(ids: TLShapeId[]): TLShape[]; + getShapeAndDescendantIds(ids: TLShapeId[]): Set; + getShapeById(id: TLParentId): T | undefined; + getShapeIdsInPage(pageId: TLPageId): Set; getShapesAtPoint(point: VecLike): TLShape[]; - getShapesInPage(pageId: TLPageId): TLShape[]; getShapeUtil; type: string; @@ -410,9 +410,11 @@ export class App extends EventEmitter { get renderingShapes(): { id: TLShapeId; index: number; + backgroundIndex: number; opacity: number; isCulled: boolean; isInViewport: boolean; + maskedPageBounds: Box2d | undefined; }[]; reorderShapes(operation: 'backward' | 'forward' | 'toBack' | 'toFront', ids: TLShapeId[]): this; reparentShapesById(ids: TLShapeId[], parentId: TLParentId, insertIndex?: string): this; @@ -2057,6 +2059,8 @@ export class TLFrameUtil extends TLBoxUtil { // (undocumented) onResizeEnd: OnResizeEndHandler; // (undocumented) + providesBackgroundForChildren(): boolean; + // (undocumented) render(shape: TLFrameShape): JSX.Element; // (undocumented) toSvg(shape: TLFrameShape, font: string, colors: TLExportColors): Promise | SVGElement; @@ -2237,6 +2241,10 @@ export class TLHighlightUtil extends TLShapeUtil { // (undocumented) render(shape: TLHighlightShape): JSX.Element; // (undocumented) + renderBackground(shape: TLHighlightShape): JSX.Element; + // (undocumented) + toBackgroundSvg(shape: TLHighlightShape, font: string | undefined, colors: TLExportColors): SVGPathElement; + // (undocumented) toSvg(shape: TLHighlightShape, _font: string | undefined, colors: TLExportColors): SVGPathElement; // (undocumented) static type: string; @@ -2542,8 +2550,13 @@ export abstract class TLShapeUtil { onTranslateStart?: OnTranslateStartHandler; outline(shape: T): Vec2dModel[]; point(shape: T): Vec2dModel; + // @internal + providesBackgroundForChildren(shape: T): boolean; abstract render(shape: T): any; + // @internal + renderBackground?(shape: T): any; snapPoints(shape: T): Vec2d[]; + toBackgroundSvg?(shape: T, font: string | undefined, colors: TLExportColors): null | Promise | SVGElement; toSvg?(shape: T, font: string | undefined, colors: TLExportColors): Promise | SVGElement; transform(shape: T): Matrix2d; // (undocumented) diff --git a/packages/editor/src/lib/app/App.ts b/packages/editor/src/lib/app/App.ts index 48e394535..fb117db91 100644 --- a/packages/editor/src/lib/app/App.ts +++ b/packages/editor/src/lib/app/App.ts @@ -1764,9 +1764,9 @@ export class App extends EventEmitter { } /** Get shapes on a page. */ - getShapesInPage(pageId: TLPageId) { + getShapeIdsInPage(pageId: TLPageId): Set { const result = this.store.query.exec('shape', { parentId: { eq: pageId } }) - return this.getShapesAndDescendantsInOrder(result.map((s) => s.id)) + return this.getShapeAndDescendantIds(result.map((s) => s.id)) } /* --------------------- Shapes --------------------- */ @@ -2196,14 +2196,22 @@ export class App extends EventEmitter { return this.viewportPageBounds.includes(pageBounds) } - /** - * Get the shapes that should be displayed in the current viewport. - * - * @public - */ - @computed get renderingShapes() { + private computeUnorderedRenderingShapes( + ids: TLParentId[], + { + cullingBounds, + cullingBoundsExpanded, + erasingIdsSet, + editingId, + }: { + cullingBounds?: Box2d + cullingBoundsExpanded?: Box2d + erasingIdsSet?: Set + editingId?: TLShapeId | null + } = {} + ) { // Here we get the shape as well as any of its children, as well as their - // opacities. If the shape is beign erased, and none of its ancestors are + // opacities. If the shape is being erased, and none of its ancestors are // being erased, then we reduce the opacity of the shape and all of its // ancestors; but we don't apply this effect more than once among a set // of descendants so that it does not compound. @@ -2212,6 +2220,106 @@ export class App extends EventEmitter { // allows the DOM nodes to be reused even when they become children // of other nodes. + const renderingShapes: { + id: TLShapeId + index: number + backgroundIndex: number + opacity: number + isCulled: boolean + isInViewport: boolean + maskedPageBounds: Box2d | undefined + }[] = [] + + let nextIndex = MAX_SHAPES_PER_PAGE + let nextBackgroundIndex = 0 + + const addShapeById = (id: TLParentId, parentOpacity: number, isAncestorErasing: boolean) => { + if (PageRecordType.isId(id)) { + for (const childId of this.getSortedChildIds(id)) { + addShapeById(childId, parentOpacity, isAncestorErasing) + } + return + } + + const shape = this.getShapeById(id) + if (!shape) return + + // todo: move opacity to a property of shape, rather than a property of props + let opacity = (+(shape.props as { opacity: string }).opacity ?? 1) * parentOpacity + let isShapeErasing = false + + if (!isAncestorErasing && erasingIdsSet?.has(id)) { + isShapeErasing = true + opacity *= 0.32 + } + + // If a child is outside of its parent's clipping bounds, then bounds will be undefined. + const maskedPageBounds = this.getMaskedPageBoundsById(id) + + // Whether the shape is on screen. Use the "strict" viewport here. + const isInViewport = maskedPageBounds + ? cullingBounds?.includes(maskedPageBounds) ?? true + : false + + // Whether the shape should actually be culled / unmounted. + // - Use the "expanded" culling viewport to include shapes that are just off-screen. + // - Editing shapes should never be culled. + const isCulled = maskedPageBounds + ? (editingId !== id && !cullingBoundsExpanded?.includes(maskedPageBounds)) ?? true + : true + + renderingShapes.push({ + id, + index: nextIndex, + backgroundIndex: nextBackgroundIndex, + opacity, + isCulled, + isInViewport, + maskedPageBounds, + }) + + nextIndex += 1 + nextBackgroundIndex += 1 + + const childIds = this.getSortedChildIds(id) + if (!childIds.length) return + + let backgroundIndexToRestore = null + if (this.getShapeUtil(shape).providesBackgroundForChildren(shape)) { + backgroundIndexToRestore = nextBackgroundIndex + nextBackgroundIndex = nextIndex + nextIndex += MAX_SHAPES_PER_PAGE + } + + for (const childId of childIds) { + addShapeById(childId, opacity, isAncestorErasing || isShapeErasing) + } + + if (backgroundIndexToRestore !== null) { + nextBackgroundIndex = backgroundIndexToRestore + } + } + + for (const id of ids) { + addShapeById(id, 1, false) + } + + return renderingShapes + } + + /** + * Get the shapes that should be displayed in the current viewport. + * + * @public + */ + @computed get renderingShapes() { + const renderingShapes = this.computeUnorderedRenderingShapes([this.currentPageId], { + cullingBounds: this.cullingBounds, + cullingBoundsExpanded: this.cullingBoundsExpanded, + erasingIdsSet: this.erasingIdsSet, + editingId: this.editingId, + }) + // Its IMPORTANT that the result be sorted by id AND include the index // that the shape should be displayed at. Steve, this is the past you // telling the present you not to change this. @@ -2222,55 +2330,6 @@ export class App extends EventEmitter { // drain. By always sorting by 'id' we keep the shapes always in the // same order; but we later use index to set the element's 'z-index' // to change the "rendered" position in z-space. - - const { currentPageId, cullingBounds, cullingBoundsExpanded, erasingIdsSet, editingId } = this - - const renderingShapes: { - id: TLShapeId - index: number - opacity: number - isCulled: boolean - isInViewport: boolean - }[] = [] - - const getShapeToDisplay = ( - id: TLShapeId, - parentOpacity: number, - isAncestorErasing: boolean - ) => { - const shape = this.getShapeById(id) - - if (!shape) return - - // todo: move opacity to a property of shape, rather than a property of props - let opacity = (+(shape.props as { opacity: string }).opacity ?? 1) * parentOpacity - let isShapeErasing = false - - if (!isAncestorErasing && erasingIdsSet.has(id)) { - isShapeErasing = true - opacity *= 0.32 - } - - // If a child is outside of its parent's clipping bounds, then bounds will be undefined. - const bounds = this.getMaskedPageBoundsById(id) - - // Whether the shape is on screen. Use the "strict" viewport here. - const isInViewport = bounds ? cullingBounds.includes(bounds) : false - - // Whether the shape should actually be culled / unmounted. - // - Use the "expanded" culling viewport to include shapes that are just off-screen. - // - Editing shapes should never be culled. - const isCulled = bounds ? editingId !== id && !cullingBoundsExpanded.includes(bounds) : true - - renderingShapes.push({ id, index: renderingShapes.length, opacity, isCulled, isInViewport }) - - this.getSortedChildIds(id).forEach((id) => { - getShapeToDisplay(id, opacity, isAncestorErasing || isShapeErasing) - }) - } - - this.getSortedChildIds(currentPageId).forEach((shapeId) => getShapeToDisplay(shapeId, 1, false)) - return renderingShapes.sort(sortById) } @@ -3008,8 +3067,8 @@ export class App extends EventEmitter { * @readonly * @public */ - @computed get shapesArray(): TLShape[] { - return Array.from(this.shapeIds).map((id) => this.store.get(id)!) + @computed get shapesArray() { + return Array.from(this.shapeIds, (id) => this.store.get(id)! as TLShape) } /** @@ -5592,29 +5651,28 @@ export class App extends EventEmitter { document.body.removeChild(fakeContainerEl) // ---Figure out which shapes we need to include - - const shapes = this.getShapesAndDescendantsInOrder(ids) - - // --- Common bounding box of all shapes - - // Get the common bounding box for the selected nodes (with some padding) - const bbox = Box2d.FromPoints( - shapes - .map((shape) => { - const pageMask = this.getPageMaskById(shape.id) - if (pageMask) { - return pageMask - } - const pageTransform = this.getPageTransform(shape)! - const pageOutline = Matrix2d.applyToPoints(pageTransform, this.getOutline(shape)) - return pageOutline - }) - .flat() + const shapeIdsToInclude = this.getShapeAndDescendantIds(ids) + const renderingShapes = this.computeUnorderedRenderingShapes([this.currentPageId]).filter( + ({ id }) => shapeIdsToInclude.has(id) ) - const isSingleFrameShape = ids.length === 1 && shapes[0].type === 'frame' + // --- Common bounding box of all shapes + let bbox = null + for (const { maskedPageBounds } of renderingShapes) { + if (!maskedPageBounds) continue + if (bbox) { + bbox.union(maskedPageBounds) + } else { + bbox = maskedPageBounds.clone() + } + } - if (!isSingleFrameShape) { + // no unmasked shapes to export + if (!bbox) return + + const singleFrameShapeId = + ids.length === 1 && this.getShapeById(ids[0])?.type === 'frame' ? ids[0] : null + if (!singleFrameShapeId) { // Expand by an extra 32 pixels bbox.expandBy(padding) } @@ -5641,7 +5699,7 @@ export class App extends EventEmitter { // Add current background color, or else background will be transparent if (background) { - if (isSingleFrameShape) { + if (singleFrameShapeId) { svg.style.setProperty('background', colors.solid) } else { svg.style.setProperty('background-color', colors.background) @@ -5665,83 +5723,112 @@ export class App extends EventEmitter { svg.append(defs) - // Must happen in order, not using a promise.all, or else the order of the - // elements in the svg will be wrong. + const unorderedShapeElements = ( + await Promise.all( + renderingShapes.map(async ({ id, opacity, index, backgroundIndex }) => { + // Don't render the frame if we're only exporting a single frame + if (id === singleFrameShapeId) return [] - let shape: TLShape - for (let i = 0, n = shapes.length; i < n; i++) { - shape = shapes[i] + const shape = this.getShapeById(id)! + const util = this.getShapeUtil(shape) - // Don't render the frame if we're only exporting a single frame - if (isSingleFrameShape && i === 0) continue - - let font: string | undefined - - if ('font' in shape.props) { - if (shape.props.font) { - if (fontsUsedInExport.has(shape.props.font)) { - font = fontsUsedInExport.get(shape.props.font)! - } else { - // For some reason these styles aren't present in the fake element - // so we need to get them from the real element - font = realContainerStyle.getPropertyValue(`--tl-font-${shape.props.font}`) - fontsUsedInExport.set(shape.props.font, font) + let font: string | undefined + if ('font' in shape.props) { + if (shape.props.font) { + if (fontsUsedInExport.has(shape.props.font)) { + font = fontsUsedInExport.get(shape.props.font)! + } else { + // For some reason these styles aren't present in the fake element + // so we need to get them from the real element + font = realContainerStyle.getPropertyValue(`--tl-font-${shape.props.font}`) + fontsUsedInExport.set(shape.props.font, font) + } + } } - } - } - const util = this.getShapeUtil(shape) + let shapeSvgElement = await util.toSvg?.(shape, font, colors) + let backgroundSvgElement = await util.toBackgroundSvg?.(shape, font, colors) - let utilSvgElement = await util.toSvg?.(shape, font, colors) + // wrap the shapes in groups so we can apply properties without overwriting ones from the shape util + if (shapeSvgElement) { + const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g') + outerElement.appendChild(shapeSvgElement) + shapeSvgElement = outerElement + } + if (backgroundSvgElement) { + const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g') + outerElement.appendChild(backgroundSvgElement) + backgroundSvgElement = outerElement + } - if (!utilSvgElement) { - const bounds = this.getPageBounds(shape)! - const elm = window.document.createElementNS('http://www.w3.org/2000/svg', 'rect') - elm.setAttribute('width', bounds.width + '') - elm.setAttribute('height', bounds.height + '') - elm.setAttribute('fill', colors.solid) - elm.setAttribute('stroke', colors.pattern.grey) - elm.setAttribute('stroke-width', '1') - utilSvgElement = elm - } + if (!shapeSvgElement && !backgroundSvgElement) { + const bounds = this.getPageBounds(shape)! + const elm = window.document.createElementNS('http://www.w3.org/2000/svg', 'rect') + elm.setAttribute('width', bounds.width + '') + elm.setAttribute('height', bounds.height + '') + elm.setAttribute('fill', colors.solid) + elm.setAttribute('stroke', colors.pattern.grey) + elm.setAttribute('stroke-width', '1') + shapeSvgElement = elm + } - // If the node implements toSvg, use that - const shapeSvg = utilSvgElement + let pageTransform = this.getPageTransform(shape)!.toCssString() + if ('scale' in shape.props) { + if (shape.props.scale !== 1) { + pageTransform = `${pageTransform} scale(${shape.props.scale}, ${shape.props.scale})` + } + } - let pageTransform = this.getPageTransform(shape)!.toCssString() + shapeSvgElement?.setAttribute('transform', pageTransform) + backgroundSvgElement?.setAttribute('transform', pageTransform) + shapeSvgElement?.setAttribute('opacity', opacity + '') + backgroundSvgElement?.setAttribute('opacity', opacity + '') - if ('scale' in shape.props) { - if (shape.props.scale !== 1) { - pageTransform = `${pageTransform} scale(${shape.props.scale}, ${shape.props.scale})` - } - } + // Create svg mask if shape has a frame as parent + const pageMask = this.getPageMaskById(shape.id) + if (pageMask) { + // Create a clip path and add it to defs + const clipPathEl = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath') + defs.appendChild(clipPathEl) + const id = nanoid() + clipPathEl.id = id - shapeSvg.setAttribute('transform', pageTransform) - if ('opacity' in shape.props) shapeSvg.setAttribute('opacity', shape.props.opacity + '') + // Create a polyline mask that does the clipping + const mask = document.createElementNS('http://www.w3.org/2000/svg', 'path') + mask.setAttribute('d', `M${pageMask.map(({ x, y }) => `${x},${y}`).join('L')}Z`) + clipPathEl.appendChild(mask) - // Create svg mask if shape has a frame as parent - const pageMask = this.getPageMaskById(shape.id) - if (shapeSvg && pageMask) { - // Create a clip path and add it to defs - const clipPathEl = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath') - defs.appendChild(clipPathEl) - const id = nanoid() - clipPathEl.id = id + // Create group that uses the clip path and wraps the shape elements + if (shapeSvgElement) { + const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g') + outerElement.setAttribute('clip-path', `url(#${id})`) + outerElement.appendChild(shapeSvgElement) + shapeSvgElement = outerElement + } - // Create a polyline mask that does the clipping - const mask = document.createElementNS('http://www.w3.org/2000/svg', 'path') - mask.setAttribute('d', `M${pageMask.map(({ x, y }) => `${x},${y}`).join('L')}Z`) - clipPathEl.appendChild(mask) + if (backgroundSvgElement) { + const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g') + outerElement.setAttribute('clip-path', `url(#${id})`) + outerElement.appendChild(backgroundSvgElement) + backgroundSvgElement = outerElement + } + } - // Create a group that uses the clip path and wraps the shape - const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g') - outerElement.setAttribute('clip-path', `url(#${id})`) + const elements = [] + if (shapeSvgElement) { + elements.push({ zIndex: index, element: shapeSvgElement }) + } + if (backgroundSvgElement) { + elements.push({ zIndex: backgroundIndex, element: backgroundSvgElement }) + } - outerElement.appendChild(shapeSvg) - svg.appendChild(outerElement) - } else { - svg.appendChild(shapeSvg) - } + return elements + }) + ) + ).flat() + + for (const { element } of unorderedShapeElements.sort((a, b) => a.zIndex - b.zIndex)) { + svg.appendChild(element) } // Add styles to the defs @@ -5749,42 +5836,44 @@ export class App extends EventEmitter { const style = window.document.createElementNS('http://www.w3.org/2000/svg', 'style') // Insert fonts into app - const fontInstances: any[] = [] + const fontInstances: FontFace[] = [] if ('fonts' in document) { document.fonts.forEach((font) => fontInstances.push(font)) } - for (const font of fontInstances) { - const fileReader = new FileReader() + await Promise.all( + fontInstances.map(async (font) => { + const fileReader = new FileReader() - let isUsed = false + let isUsed = false - fontsUsedInExport.forEach((fontName) => { - if (fontName.includes(font.family)) { - isUsed = true - } - }) - - if (!isUsed) continue - - const url = (font as any).$$_url - - const fontFaceRule = (font as any).$$_fontface - - if (url) { - const fontFile = await (await fetch(url)).blob() - - const base64Font = await new Promise((resolve, reject) => { - fileReader.onload = () => resolve(fileReader.result as string) - fileReader.onerror = () => reject(fileReader.error) - fileReader.readAsDataURL(fontFile) + fontsUsedInExport.forEach((fontName) => { + if (fontName.includes(font.family)) { + isUsed = true + } }) - const newFontFaceRule = '\n' + fontFaceRule.replaceAll(url, base64Font) - styles += newFontFaceRule - } - } + if (!isUsed) return + + const url = (font as any).$$_url + + const fontFaceRule = (font as any).$$_fontface + + if (url) { + const fontFile = await (await fetch(url)).blob() + + const base64Font = await new Promise((resolve, reject) => { + fileReader.onload = () => resolve(fileReader.result as string) + fileReader.onerror = () => reject(fileReader.error) + fileReader.readAsDataURL(fontFile) + }) + + const newFontFaceRule = '\n' + fontFaceRule.replaceAll(url, base64Font) + styles += newFontFaceRule + } + }) + ) style.textContent = styles @@ -5842,7 +5931,7 @@ export class App extends EventEmitter { // If there is no space on pageId, or if the selected shapes // would take the new page above the limit, don't move the shapes - if (this.getShapesInPage(pageId).length + content.shapes.length > MAX_SHAPES_PER_PAGE) { + if (this.getShapeIdsInPage(pageId).size + content.shapes.length > MAX_SHAPES_PER_PAGE) { alertMaxShapes(this, pageId) return this } @@ -7119,30 +7208,22 @@ export class App extends EventEmitter { return this } - getShapesAndDescendantsInOrder(ids: TLShapeId[]) { - const idsToInclude: TLShapeId[] = [] - const visitedIds = new Set() + getShapeAndDescendantIds(ids: TLShapeId[]): Set { + const idsToInclude = new Set() const idsToCheck = [...ids] while (idsToCheck.length > 0) { const id = idsToCheck.pop() if (!id) break - if (visitedIds.has(id)) continue - idsToInclude.push(id) + if (idsToInclude.has(id)) continue + idsToInclude.add(id) this.getSortedChildIds(id).forEach((id) => { idsToCheck.push(id) }) } - // Map the ids into nodes AND their descendants - const shapes = idsToInclude.map((s) => this.getShapeById(s)!).filter((s) => s.type !== 'group') - - // Sort by the shape's appearance in the sorted shapes array - const { sortedShapesArray } = this - shapes.sort((a, b) => sortedShapesArray.indexOf(a) - sortedShapesArray.indexOf(b)) - - return shapes + return idsToInclude } /** diff --git a/packages/editor/src/lib/app/shapeutils/TLFrameUtil/TLFrameUtil.tsx b/packages/editor/src/lib/app/shapeutils/TLFrameUtil/TLFrameUtil.tsx index 1b131ae27..e78b9361f 100644 --- a/packages/editor/src/lib/app/shapeutils/TLFrameUtil/TLFrameUtil.tsx +++ b/packages/editor/src/lib/app/shapeutils/TLFrameUtil/TLFrameUtil.tsx @@ -148,6 +148,10 @@ export class TLFrameUtil extends TLBoxUtil { ) } + providesBackgroundForChildren(): boolean { + return true + } + override canReceiveNewChildrenOfType = (_type: TLShape['type']) => { return true } diff --git a/packages/editor/src/lib/app/shapeutils/TLHighlightUtil/TLHighlightUtil.tsx b/packages/editor/src/lib/app/shapeutils/TLHighlightUtil/TLHighlightUtil.tsx index 1cc484ba9..d627d22bd 100644 --- a/packages/editor/src/lib/app/shapeutils/TLHighlightUtil/TLHighlightUtil.tsx +++ b/packages/editor/src/lib/app/shapeutils/TLHighlightUtil/TLHighlightUtil.tsx @@ -12,12 +12,15 @@ import { TLDrawShapeSegment, TLHighlightShape } from '@tldraw/tlschema' import { last, rng } from '@tldraw/utils' import { SVGContainer } from '../../../components/SVGContainer' import { getSvgPathFromStroke, getSvgPathFromStrokePoints } from '../../../utils/svg' +import { App } from '../../App' import { ShapeFill } from '../shared/ShapeFill' import { TLExportColors } from '../shared/TLExportColors' import { useForceSolid } from '../shared/useForceSolid' import { getFreehandOptions, getPointsFromSegments } from '../TLDrawUtil/getPath' import { OnResizeHandler, TLShapeUtil } from '../TLShapeUtil' +const OVERLAY_OPACITY = 0.4 + /** @public */ export class TLHighlightUtil extends TLShapeUtil { static type = 'highlight' @@ -103,59 +106,11 @@ export class TLHighlightUtil extends TLShapeUtil { } render(shape: TLHighlightShape) { - const forceSolid = useForceSolid() - const strokeWidth = this.app.getStrokeWidth(shape.props.size) - const allPointsFromSegments = getPointsFromSegments(shape.props.segments) + return + } - const showAsComplete = shape.props.isComplete || last(shape.props.segments)?.type === 'straight' - - let sw = strokeWidth - if (!forceSolid && !shape.props.isPen && allPointsFromSegments.length === 1) { - sw += rng(shape.id)() * (strokeWidth / 6) - } - - const options = getFreehandOptions( - { isComplete: shape.props.isComplete, isPen: shape.props.isPen, dash: 'draw' }, - sw, - showAsComplete, - forceSolid - ) - const strokePoints = getStrokePoints(allPointsFromSegments, options) - - const solidStrokePath = - strokePoints.length > 1 - ? getSvgPathFromStrokePoints(strokePoints, false) - : getDot(allPointsFromSegments[0], sw) - - if (!forceSolid || strokePoints.length < 2) { - setStrokePointRadii(strokePoints, options) - const strokeOutlinePoints = getStrokeOutlinePoints(strokePoints, options) - - return ( - - - - - ) - } - - return ( - - - - - ) + renderBackground(shape: TLHighlightShape) { + return } indicator(shape: TLHighlightShape) { @@ -185,35 +140,11 @@ export class TLHighlightUtil extends TLShapeUtil { } toSvg(shape: TLHighlightShape, _font: string | undefined, colors: TLExportColors) { - const { color } = shape.props + return highlighterToSvg(this.app, shape, OVERLAY_OPACITY, colors) + } - const strokeWidth = this.app.getStrokeWidth(shape.props.size) - const allPointsFromSegments = getPointsFromSegments(shape.props.segments) - - const showAsComplete = shape.props.isComplete || last(shape.props.segments)?.type === 'straight' - - let sw = strokeWidth - if (!shape.props.isPen && allPointsFromSegments.length === 1) { - sw += rng(shape.id)() * (strokeWidth / 6) - } - - const options = getFreehandOptions( - { dash: 'draw', isComplete: shape.props.isComplete, isPen: shape.props.isPen }, - sw, - showAsComplete, - false - ) - const strokePoints = getStrokePoints(allPointsFromSegments, options) - - setStrokePointRadii(strokePoints, options) - const strokeOutlinePoints = getStrokeOutlinePoints(strokePoints, options) - - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') - path.setAttribute('d', getSvgPathFromStroke(strokeOutlinePoints, true)) - path.setAttribute('fill', colors.fill[color]) - path.setAttribute('stroke-linecap', 'round') - - return path + toBackgroundSvg(shape: TLHighlightShape, font: string | undefined, colors: TLExportColors) { + return highlighterToSvg(this.app, shape, 1, colors) } override onResize: OnResizeHandler = (shape, info) => { @@ -252,3 +183,105 @@ function getDot(point: VecLike, sw: number) { r * 2 },0` } + +function HighlightRenderer({ + app, + shape, + opacity, +}: { + app: App + shape: TLHighlightShape + opacity?: number +}) { + const forceSolid = useForceSolid() + const strokeWidth = app.getStrokeWidth(shape.props.size) + const allPointsFromSegments = getPointsFromSegments(shape.props.segments) + + const showAsComplete = shape.props.isComplete || last(shape.props.segments)?.type === 'straight' + + let sw = strokeWidth + if (!forceSolid && !shape.props.isPen && allPointsFromSegments.length === 1) { + sw += rng(shape.id)() * (strokeWidth / 6) + } + + const options = getFreehandOptions( + { isComplete: shape.props.isComplete, isPen: shape.props.isPen, dash: 'draw' }, + sw, + showAsComplete, + forceSolid + ) + const strokePoints = getStrokePoints(allPointsFromSegments, options) + + const solidStrokePath = + strokePoints.length > 1 + ? getSvgPathFromStrokePoints(strokePoints, false) + : getDot(allPointsFromSegments[0], sw) + + if (!forceSolid || strokePoints.length < 2) { + setStrokePointRadii(strokePoints, options) + const strokeOutlinePoints = getStrokeOutlinePoints(strokePoints, options) + + return ( + + + + + ) + } + + return ( + + + + + ) +} + +function highlighterToSvg( + app: App, + shape: TLHighlightShape, + opacity: number, + colors: TLExportColors +) { + const { color } = shape.props + + const strokeWidth = app.getStrokeWidth(shape.props.size) + const allPointsFromSegments = getPointsFromSegments(shape.props.segments) + + const showAsComplete = shape.props.isComplete || last(shape.props.segments)?.type === 'straight' + + let sw = strokeWidth + if (!shape.props.isPen && allPointsFromSegments.length === 1) { + sw += rng(shape.id)() * (strokeWidth / 6) + } + + const options = getFreehandOptions( + { dash: 'draw', isComplete: shape.props.isComplete, isPen: shape.props.isPen }, + sw, + showAsComplete, + false + ) + const strokePoints = getStrokePoints(allPointsFromSegments, options) + + setStrokePointRadii(strokePoints, options) + const strokeOutlinePoints = getStrokeOutlinePoints(strokePoints, options) + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') + path.setAttribute('d', getSvgPathFromStroke(strokeOutlinePoints, true)) + path.setAttribute('fill', colors.fill[color]) + path.setAttribute('stroke-linecap', 'round') + path.setAttribute('opacity', opacity.toString()) + + return path +} diff --git a/packages/editor/src/lib/app/shapeutils/TLShapeUtil.ts b/packages/editor/src/lib/app/shapeutils/TLShapeUtil.ts index 3ad5f7b11..c225e95a8 100644 --- a/packages/editor/src/lib/app/shapeutils/TLShapeUtil.ts +++ b/packages/editor/src/lib/app/shapeutils/TLShapeUtil.ts @@ -165,6 +165,14 @@ export abstract class TLShapeUtil { */ abstract indicator(shape: T): any + /** + * Get a JSX element for the shape (as an HTML element) to be rendered as part of the canvas background - behind any other shape content. + * + * @param shape - The shape. + * @internal + */ + renderBackground?(shape: T): any + /** * Get an array of handle models for the shape. This is an optional method. * @@ -337,6 +345,21 @@ export abstract class TLShapeUtil { colors: TLExportColors ): SVGElement | Promise + /** + * Get the shape's background layer as an SVG object. + * + * @param shape - The shape. + * @param color - The shape's CSS color (actual). + * @param font - The shape's CSS font (actual). + * @returns An SVG element. + * @public + */ + toBackgroundSvg?( + shape: T, + font: string | undefined, + colors: TLExportColors + ): SVGElement | Promise | null + /** * Get whether a point intersects the shape. * @@ -375,6 +398,19 @@ export abstract class TLShapeUtil { return 0 } + /** + * Does this shape provide a background for its children? If this is true, + * then any children with a `renderBackground` method will have their + * backgrounds rendered _above_ this shape. Otherwise, the children's + * backgrounds will be rendered above either the next ancestor that provides + * a background, or the canvas background. + * + * @internal + */ + providesBackgroundForChildren(shape: T): boolean { + return false + } + // Events /** diff --git a/packages/editor/src/lib/components/Shape.tsx b/packages/editor/src/lib/components/Shape.tsx index 40927ce36..c383b59d8 100644 --- a/packages/editor/src/lib/components/Shape.tsx +++ b/packages/editor/src/lib/components/Shape.tsx @@ -27,11 +27,13 @@ opacity based on its own opacity and that of its parent's. export const Shape = track(function Shape({ id, index, + backgroundIndex, opacity, isCulled, }: { id: TLShapeId index: number + backgroundIndex: number opacity: number isCulled: boolean }) { @@ -41,65 +43,63 @@ export const Shape = track(function Shape({ const events = useShapeEvents(id) - const rContainer = React.useRef(null) + const containerRef = React.useRef(null) + const backgroundContainerRef = React.useRef(null) + + const setProperty = React.useCallback((property: string, value: string) => { + containerRef.current?.style.setProperty(property, value) + backgroundContainerRef.current?.style.setProperty(property, value) + }, []) useQuickReactor( 'set shape container transform position', () => { - const elm = rContainer.current - if (!elm) return - const shape = app.getShapeById(id) const pageTransform = app.getPageTransformById(id) if (!shape || !pageTransform) return null const transform = Matrix2d.toCssString(pageTransform) - elm.style.setProperty('transform', transform) + setProperty('transform', transform) }, - [app] + [app, setProperty] ) useQuickReactor( 'set shape container clip path / color', () => { - const elm = rContainer.current const shape = app.getShapeById(id) - if (!elm) return if (!shape) return null const clipPath = app.getClipPathById(id) - elm.style.setProperty('clip-path', clipPath ?? 'none') + setProperty('clip-path', clipPath ?? 'none') if ('color' in shape.props) { - elm.style.setProperty('color', app.getCssColor(shape.props.color)) + setProperty('color', app.getCssColor(shape.props.color)) } }, - [app] + [app, setProperty] ) useQuickReactor( 'set shape height and width', () => { - const elm = rContainer.current const shape = app.getShapeById(id) - if (!elm) return if (!shape) return null const util = app.getShapeUtil(shape) const bounds = util.bounds(shape) - elm.style.setProperty('width', Math.ceil(bounds.width) + 'px') - elm.style.setProperty('height', Math.ceil(bounds.height) + 'px') + setProperty('width', Math.ceil(bounds.width) + 'px') + setProperty('height', Math.ceil(bounds.height) + 'px') }, [app] ) // Set the opacity of the container when the opacity changes React.useLayoutEffect(() => { - const elm = rContainer.current - if (!elm) return - elm.style.setProperty('opacity', opacity + '') - elm.style.setProperty('z-index', index + '') - }, [opacity, index]) + setProperty('opacity', opacity + '') + containerRef.current?.style.setProperty('z-index', index + '') + backgroundContainerRef.current?.style.setProperty('z-index', backgroundIndex + '') + }, [opacity, index, backgroundIndex, setProperty]) const shape = app.getShapeById(id) @@ -108,31 +108,53 @@ export const Shape = track(function Shape({ const util = app.getShapeUtil(shape) return ( -
- {isCulled && util.canUnmount(shape) ? ( - - ) : ( - : null} - onError={(error) => - app.annotateError(error, { origin: 'react.shape', willCrashApp: false }) - } + <> + {util.renderBackground && ( +
- - + {isCulled ? ( + + ) : ( + : null} + onError={(error) => + app.annotateError(error, { origin: 'react.shape', willCrashApp: false }) + } + > + + + )} +
)} -
+
+ {isCulled && util.canUnmount(shape) ? ( + + ) : ( + : null} + onError={(error) => + app.annotateError(error, { origin: 'react.shape', willCrashApp: false }) + } + > + + + )} +
+ ) }) @@ -143,6 +165,19 @@ const InnerShape = React.memo( (prev, next) => prev.shape.props === next.shape.props ) +const InnerShapeBackground = React.memo( + function InnerShapeBackground({ + shape, + util, + }: { + shape: T + util: TLShapeUtil + }) { + return useStateTracking('InnerShape:' + util.type, () => util.renderBackground?.(shape)) + }, + (prev, next) => prev.shape.props === next.shape.props +) + const CulledShape = React.memo( function CulledShap({ shape, util }: { shape: T; util: TLShapeUtil }) { const bounds = util.bounds(shape) diff --git a/packages/editor/src/lib/test/commands/__snapshots__/getSvg.test.ts.snap b/packages/editor/src/lib/test/commands/__snapshots__/getSvg.test.ts.snap index 86ffbf46c..d4cfe4618 100644 --- a/packages/editor/src/lib/test/commands/__snapshots__/getSvg.test.ts.snap +++ b/packages/editor/src/lib/test/commands/__snapshots__/getSvg.test.ts.snap @@ -1,14 +1,159 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Matches a snapshot: Basic SVG 1`] = ` -" - - - - - - - - - Hello worldHello world" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Hello world + + + + + Hello world + + + + + + + + + + + + + `; diff --git a/packages/editor/src/lib/test/commands/getSvg.test.ts b/packages/editor/src/lib/test/commands/getSvg.test.ts index 620856444..de217a7f9 100644 --- a/packages/editor/src/lib/test/commands/getSvg.test.ts +++ b/packages/editor/src/lib/test/commands/getSvg.test.ts @@ -85,7 +85,7 @@ it('Matches a snapshot', async () => { const elm = document.createElement('wrapper') elm.appendChild(svg) - expect(elm.innerHTML).toMatchSnapshot('Basic SVG') + expect(elm).toMatchSnapshot('Basic SVG') }) it('Accepts a scale option', async () => { diff --git a/packages/editor/src/lib/test/commands/renderingShapes.test.tsx b/packages/editor/src/lib/test/commands/renderingShapes.test.tsx index 0eef5c7c0..0de082e2b 100644 --- a/packages/editor/src/lib/test/commands/renderingShapes.test.tsx +++ b/packages/editor/src/lib/test/commands/renderingShapes.test.tsx @@ -1,13 +1,44 @@ import { TLShapeId } from '@tldraw/tlschema' +import { assert, assertExists } from '@tldraw/utils' import { TestApp } from '../TestApp' import { TL } from '../jsx' let app: TestApp -let ids: Record + +/** + * When we're comparing shape indexes, we don't actually care about the specific + * indexes involved. We're only interested in the ordering of those indexes. + * This function rewrites indexes to be a range of consecutive numbers starting + * at 0, where in reality the way we allocate indexes might produce gaps. + */ +function normalizeIndexes( + renderingShapes: { id: TLShapeId; index: number; backgroundIndex: number }[] +): [id: TLShapeId, index: number, backgroundIndex: number][] { + const allIndexes = renderingShapes + .flatMap(({ index, backgroundIndex }) => [index, backgroundIndex]) + .sort((a, b) => a - b) + + const positionsByIndex = new Map() + for (let position = 0; position < allIndexes.length; position++) { + const index = allIndexes[position] + assert(!positionsByIndex.has(index), `Duplicate index ${index}`) + positionsByIndex.set(index, position) + } + + return renderingShapes.map(({ id, index, backgroundIndex }) => [ + id, + assertExists(positionsByIndex.get(index)), + assertExists(positionsByIndex.get(backgroundIndex)), + ]) +} beforeEach(() => { app = new TestApp() - ids = app.createShapesFromJsx([ + app.setScreenBounds({ x: 0, y: 0, w: 1800, h: 900 }) +}) + +function createShapes() { + return app.createShapesFromJsx([ , @@ -15,11 +46,10 @@ beforeEach(() => { , ]) - - app.setScreenBounds({ x: 0, y: 0, w: 1800, h: 900 }) -}) +} it('updates the culling viewport', () => { + const ids = createShapes() app.updateCullingBounds = jest.fn(app.updateCullingBounds) app.pan(-201, -201) jest.advanceTimersByTime(500) @@ -29,6 +59,7 @@ it('updates the culling viewport', () => { }) it('lists shapes in viewport', () => { + const ids = createShapes() expect( app.renderingShapes.map(({ id, isCulled, isInViewport }) => [id, isCulled, isInViewport]) ).toStrictEqual([ @@ -75,13 +106,14 @@ it('lists shapes in viewport', () => { ]) }) -it('lists shapes in viewport sorted by id', () => { +it('lists shapes in viewport sorted by id with correct indexes & background indexes', () => { + const ids = createShapes() // Expect the results to be sorted correctly by id - expect(app.renderingShapes.map(({ id, index }) => [id, index])).toStrictEqual([ - [ids.A, 0], - [ids.B, 1], - [ids.C, 2], - [ids.D, 3], + expect(normalizeIndexes(app.renderingShapes)).toStrictEqual([ + [ids.A, 2, 0], + [ids.B, 3, 1], + [ids.C, 6, 4], // the background of C is above B + [ids.D, 7, 5], // A is at the back, then B, and then B's children ]) @@ -89,11 +121,108 @@ it('lists shapes in viewport sorted by id', () => { app.reorderShapes('toBack', [ids.B]) // The items should still be sorted by id - expect(app.renderingShapes.map(({ id, index }) => [id, index])).toStrictEqual([ - [ids.A, 3], - [ids.B, 0], - [ids.C, 1], - [ids.D, 2], - // B is now at the back, then its children, and finally A is now in the front + expect(normalizeIndexes(app.renderingShapes)).toStrictEqual([ + [ids.A, 7, 1], + [ids.B, 2, 0], + [ids.C, 5, 3], + [ids.D, 6, 4], + // B is now at the back, then its children, and A is below the group + ]) +}) + +it('handles frames in frames', () => { + const ids = app.createShapesFromJsx([ + , + + + + + + + , + , + ]) + + expect(normalizeIndexes(app.renderingShapes)).toStrictEqual([ + [ids.A, 3, 0], + [ids.B, 4, 1], + [ids.C, 8, 5], // frame B creates a background, so C's background layer is above B's foreground + [ids.D, 9, 6], + [ids.E, 11, 10], // frame D creates a background too + [ids.F, 12, 7], // F is above the nested frame, but it's background is still below frame D + [ids.G, 13, 2], // G is on top of everything, but its BG is behind both frames + ]) +}) + +it('handles groups in frames', () => { + const ids = app.createShapesFromJsx([ + , + + + + + + + , + , + ]) + + expect(normalizeIndexes(app.renderingShapes)).toStrictEqual([ + [ids.A, 3, 0], + [ids.B, 4, 1], + [ids.C, 9, 5], // frame B creates a background, so C's background layer is above B's foreground + [ids.D, 10, 6], + [ids.E, 11, 7], // group D doesn't create a background, so E's background remains in order + [ids.F, 12, 8], + [ids.G, 13, 2], // G is on top of everything, but its BG is behind the frame + ]) +}) + +it('handles frames in groups', () => { + const ids = app.createShapesFromJsx([ + , + + + + + + + , + , + ]) + + expect(normalizeIndexes(app.renderingShapes)).toStrictEqual([ + [ids.A, 6, 0], + [ids.B, 7, 1], + [ids.C, 8, 2], // groups don't create backgrounds, so things within the group stay in order + [ids.D, 9, 3], + [ids.E, 11, 10], // frame G creates a background, so the BG of E is skipped up above D + [ids.F, 12, 4], // but F after the frame returns to the normal background ordering + [ids.G, 13, 5], // + ]) +}) + +it('handles groups in groups', () => { + const ids = app.createShapesFromJsx([ + , + + + + + + + , + , + ]) + + expect(normalizeIndexes(app.renderingShapes)).toStrictEqual([ + // as groups don't create backgrounds, everything is consecutive + [ids.A, 7, 0], + [ids.B, 8, 1], + [ids.C, 9, 2], + [ids.D, 10, 3], + [ids.E, 11, 4], + [ids.F, 12, 5], + [ids.G, 13, 6], ]) }) diff --git a/packages/primitives/src/lib/Box2d.ts b/packages/primitives/src/lib/Box2d.ts index 65a0190fe..f9200a186 100644 --- a/packages/primitives/src/lib/Box2d.ts +++ b/packages/primitives/src/lib/Box2d.ts @@ -315,8 +315,8 @@ export class Box2d { union(box: Box2dModel) { const minX = Math.min(this.minX, box.x) const minY = Math.min(this.minY, box.y) - const maxX = Math.max(this.maxX, box.x + box.w) - const maxY = Math.max(this.maxY, box.y + box.h) + const maxX = Math.max(this.maxX, box.w + box.x) + const maxY = Math.max(this.maxY, box.h + box.y) this.x = minX this.y = minY