[2/3] renderer changes to support "sandwich mode" highlighting (#1418)

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!]
This commit is contained in:
alex 2023-06-01 16:22:47 +01:00 committed by GitHub
parent 674a829d1f
commit a11a741de4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 817 additions and 341 deletions

View file

@ -280,11 +280,11 @@ export class App extends EventEmitter<TLEventMap> {
getParentTransform(shape: TLShape): Matrix2d; getParentTransform(shape: TLShape): Matrix2d;
getPointInParentSpace(shapeId: TLShapeId, point: VecLike): Vec2d; getPointInParentSpace(shapeId: TLShapeId, point: VecLike): Vec2d;
getPointInShapeSpace(shape: TLShape, point: VecLike): Vec2d; getPointInShapeSpace(shape: TLShape, point: VecLike): Vec2d;
getShapeById<T extends TLShape = TLShape>(id: TLParentId): T | undefined;
// (undocumented) // (undocumented)
getShapesAndDescendantsInOrder(ids: TLShapeId[]): TLShape[]; getShapeAndDescendantIds(ids: TLShapeId[]): Set<TLShapeId>;
getShapeById<T extends TLShape = TLShape>(id: TLParentId): T | undefined;
getShapeIdsInPage(pageId: TLPageId): Set<TLShapeId>;
getShapesAtPoint(point: VecLike): TLShape[]; getShapesAtPoint(point: VecLike): TLShape[];
getShapesInPage(pageId: TLPageId): TLShape[];
getShapeUtil<C extends { getShapeUtil<C extends {
new (...args: any[]): TLShapeUtil<any>; new (...args: any[]): TLShapeUtil<any>;
type: string; type: string;
@ -410,9 +410,11 @@ export class App extends EventEmitter<TLEventMap> {
get renderingShapes(): { get renderingShapes(): {
id: TLShapeId; id: TLShapeId;
index: number; index: number;
backgroundIndex: number;
opacity: number; opacity: number;
isCulled: boolean; isCulled: boolean;
isInViewport: boolean; isInViewport: boolean;
maskedPageBounds: Box2d | undefined;
}[]; }[];
reorderShapes(operation: 'backward' | 'forward' | 'toBack' | 'toFront', ids: TLShapeId[]): this; reorderShapes(operation: 'backward' | 'forward' | 'toBack' | 'toFront', ids: TLShapeId[]): this;
reparentShapesById(ids: TLShapeId[], parentId: TLParentId, insertIndex?: string): this; reparentShapesById(ids: TLShapeId[], parentId: TLParentId, insertIndex?: string): this;
@ -2057,6 +2059,8 @@ export class TLFrameUtil extends TLBoxUtil<TLFrameShape> {
// (undocumented) // (undocumented)
onResizeEnd: OnResizeEndHandler<TLFrameShape>; onResizeEnd: OnResizeEndHandler<TLFrameShape>;
// (undocumented) // (undocumented)
providesBackgroundForChildren(): boolean;
// (undocumented)
render(shape: TLFrameShape): JSX.Element; render(shape: TLFrameShape): JSX.Element;
// (undocumented) // (undocumented)
toSvg(shape: TLFrameShape, font: string, colors: TLExportColors): Promise<SVGElement> | SVGElement; toSvg(shape: TLFrameShape, font: string, colors: TLExportColors): Promise<SVGElement> | SVGElement;
@ -2237,6 +2241,10 @@ export class TLHighlightUtil extends TLShapeUtil<TLHighlightShape> {
// (undocumented) // (undocumented)
render(shape: TLHighlightShape): JSX.Element; render(shape: TLHighlightShape): JSX.Element;
// (undocumented) // (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; toSvg(shape: TLHighlightShape, _font: string | undefined, colors: TLExportColors): SVGPathElement;
// (undocumented) // (undocumented)
static type: string; static type: string;
@ -2542,8 +2550,13 @@ export abstract class TLShapeUtil<T extends TLUnknownShape = TLUnknownShape> {
onTranslateStart?: OnTranslateStartHandler<T>; onTranslateStart?: OnTranslateStartHandler<T>;
outline(shape: T): Vec2dModel[]; outline(shape: T): Vec2dModel[];
point(shape: T): Vec2dModel; point(shape: T): Vec2dModel;
// @internal
providesBackgroundForChildren(shape: T): boolean;
abstract render(shape: T): any; abstract render(shape: T): any;
// @internal
renderBackground?(shape: T): any;
snapPoints(shape: T): Vec2d[]; snapPoints(shape: T): Vec2d[];
toBackgroundSvg?(shape: T, font: string | undefined, colors: TLExportColors): null | Promise<SVGElement> | SVGElement;
toSvg?(shape: T, font: string | undefined, colors: TLExportColors): Promise<SVGElement> | SVGElement; toSvg?(shape: T, font: string | undefined, colors: TLExportColors): Promise<SVGElement> | SVGElement;
transform(shape: T): Matrix2d; transform(shape: T): Matrix2d;
// (undocumented) // (undocumented)

View file

@ -1764,9 +1764,9 @@ export class App extends EventEmitter<TLEventMap> {
} }
/** Get shapes on a page. */ /** Get shapes on a page. */
getShapesInPage(pageId: TLPageId) { getShapeIdsInPage(pageId: TLPageId): Set<TLShapeId> {
const result = this.store.query.exec('shape', { parentId: { eq: pageId } }) 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 --------------------- */ /* --------------------- Shapes --------------------- */
@ -2196,14 +2196,22 @@ export class App extends EventEmitter<TLEventMap> {
return this.viewportPageBounds.includes(pageBounds) return this.viewportPageBounds.includes(pageBounds)
} }
/** private computeUnorderedRenderingShapes(
* Get the shapes that should be displayed in the current viewport. ids: TLParentId[],
* {
* @public cullingBounds,
*/ cullingBoundsExpanded,
@computed get renderingShapes() { erasingIdsSet,
editingId,
}: {
cullingBounds?: Box2d
cullingBoundsExpanded?: Box2d
erasingIdsSet?: Set<TLShapeId>
editingId?: TLShapeId | null
} = {}
) {
// Here we get the shape as well as any of its children, as well as their // 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 // 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 // ancestors; but we don't apply this effect more than once among a set
// of descendants so that it does not compound. // of descendants so that it does not compound.
@ -2212,6 +2220,106 @@ export class App extends EventEmitter<TLEventMap> {
// allows the DOM nodes to be reused even when they become children // allows the DOM nodes to be reused even when they become children
// of other nodes. // 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 // 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 // that the shape should be displayed at. Steve, this is the past you
// telling the present you not to change this. // telling the present you not to change this.
@ -2222,55 +2330,6 @@ export class App extends EventEmitter<TLEventMap> {
// drain. By always sorting by 'id' we keep the shapes always in the // 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' // same order; but we later use index to set the element's 'z-index'
// to change the "rendered" position in z-space. // 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) return renderingShapes.sort(sortById)
} }
@ -3008,8 +3067,8 @@ export class App extends EventEmitter<TLEventMap> {
* @readonly * @readonly
* @public * @public
*/ */
@computed get shapesArray(): TLShape[] { @computed get shapesArray() {
return Array.from(this.shapeIds).map((id) => this.store.get(id)!) return Array.from(this.shapeIds, (id) => this.store.get(id)! as TLShape)
} }
/** /**
@ -5592,29 +5651,28 @@ export class App extends EventEmitter<TLEventMap> {
document.body.removeChild(fakeContainerEl) document.body.removeChild(fakeContainerEl)
// ---Figure out which shapes we need to include // ---Figure out which shapes we need to include
const shapeIdsToInclude = this.getShapeAndDescendantIds(ids)
const shapes = this.getShapesAndDescendantsInOrder(ids) const renderingShapes = this.computeUnorderedRenderingShapes([this.currentPageId]).filter(
({ id }) => shapeIdsToInclude.has(id)
// --- 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 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 // Expand by an extra 32 pixels
bbox.expandBy(padding) bbox.expandBy(padding)
} }
@ -5641,7 +5699,7 @@ export class App extends EventEmitter<TLEventMap> {
// Add current background color, or else background will be transparent // Add current background color, or else background will be transparent
if (background) { if (background) {
if (isSingleFrameShape) { if (singleFrameShapeId) {
svg.style.setProperty('background', colors.solid) svg.style.setProperty('background', colors.solid)
} else { } else {
svg.style.setProperty('background-color', colors.background) svg.style.setProperty('background-color', colors.background)
@ -5665,83 +5723,112 @@ export class App extends EventEmitter<TLEventMap> {
svg.append(defs) svg.append(defs)
// Must happen in order, not using a promise.all, or else the order of the const unorderedShapeElements = (
// elements in the svg will be wrong. 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 const shape = this.getShapeById(id)!
for (let i = 0, n = shapes.length; i < n; i++) { const util = this.getShapeUtil(shape)
shape = shapes[i]
// Don't render the frame if we're only exporting a single frame let font: string | undefined
if (isSingleFrameShape && i === 0) continue if ('font' in shape.props) {
if (shape.props.font) {
let font: string | undefined if (fontsUsedInExport.has(shape.props.font)) {
font = fontsUsedInExport.get(shape.props.font)!
if ('font' in shape.props) { } else {
if (shape.props.font) { // For some reason these styles aren't present in the fake element
if (fontsUsedInExport.has(shape.props.font)) { // so we need to get them from the real element
font = fontsUsedInExport.get(shape.props.font)! font = realContainerStyle.getPropertyValue(`--tl-font-${shape.props.font}`)
} else { fontsUsedInExport.set(shape.props.font, font)
// 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) { if (!shapeSvgElement && !backgroundSvgElement) {
const bounds = this.getPageBounds(shape)! const bounds = this.getPageBounds(shape)!
const elm = window.document.createElementNS('http://www.w3.org/2000/svg', 'rect') const elm = window.document.createElementNS('http://www.w3.org/2000/svg', 'rect')
elm.setAttribute('width', bounds.width + '') elm.setAttribute('width', bounds.width + '')
elm.setAttribute('height', bounds.height + '') elm.setAttribute('height', bounds.height + '')
elm.setAttribute('fill', colors.solid) elm.setAttribute('fill', colors.solid)
elm.setAttribute('stroke', colors.pattern.grey) elm.setAttribute('stroke', colors.pattern.grey)
elm.setAttribute('stroke-width', '1') elm.setAttribute('stroke-width', '1')
utilSvgElement = elm shapeSvgElement = elm
} }
// If the node implements toSvg, use that let pageTransform = this.getPageTransform(shape)!.toCssString()
const shapeSvg = utilSvgElement 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) { // Create svg mask if shape has a frame as parent
if (shape.props.scale !== 1) { const pageMask = this.getPageMaskById(shape.id)
pageTransform = `${pageTransform} scale(${shape.props.scale}, ${shape.props.scale})` 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) // Create a polyline mask that does the clipping
if ('opacity' in shape.props) shapeSvg.setAttribute('opacity', shape.props.opacity + '') 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 // Create group that uses the clip path and wraps the shape elements
const pageMask = this.getPageMaskById(shape.id) if (shapeSvgElement) {
if (shapeSvg && pageMask) { const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g')
// Create a clip path and add it to defs outerElement.setAttribute('clip-path', `url(#${id})`)
const clipPathEl = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath') outerElement.appendChild(shapeSvgElement)
defs.appendChild(clipPathEl) shapeSvgElement = outerElement
const id = nanoid() }
clipPathEl.id = id
// Create a polyline mask that does the clipping if (backgroundSvgElement) {
const mask = document.createElementNS('http://www.w3.org/2000/svg', 'path') const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g')
mask.setAttribute('d', `M${pageMask.map(({ x, y }) => `${x},${y}`).join('L')}Z`) outerElement.setAttribute('clip-path', `url(#${id})`)
clipPathEl.appendChild(mask) outerElement.appendChild(backgroundSvgElement)
backgroundSvgElement = outerElement
}
}
// Create a group that uses the clip path and wraps the shape const elements = []
const outerElement = document.createElementNS('http://www.w3.org/2000/svg', 'g') if (shapeSvgElement) {
outerElement.setAttribute('clip-path', `url(#${id})`) elements.push({ zIndex: index, element: shapeSvgElement })
}
if (backgroundSvgElement) {
elements.push({ zIndex: backgroundIndex, element: backgroundSvgElement })
}
outerElement.appendChild(shapeSvg) return elements
svg.appendChild(outerElement) })
} else { )
svg.appendChild(shapeSvg) ).flat()
}
for (const { element } of unorderedShapeElements.sort((a, b) => a.zIndex - b.zIndex)) {
svg.appendChild(element)
} }
// Add styles to the defs // Add styles to the defs
@ -5749,42 +5836,44 @@ export class App extends EventEmitter<TLEventMap> {
const style = window.document.createElementNS('http://www.w3.org/2000/svg', 'style') const style = window.document.createElementNS('http://www.w3.org/2000/svg', 'style')
// Insert fonts into app // Insert fonts into app
const fontInstances: any[] = [] const fontInstances: FontFace[] = []
if ('fonts' in document) { if ('fonts' in document) {
document.fonts.forEach((font) => fontInstances.push(font)) document.fonts.forEach((font) => fontInstances.push(font))
} }
for (const font of fontInstances) { await Promise.all(
const fileReader = new FileReader() fontInstances.map(async (font) => {
const fileReader = new FileReader()
let isUsed = false let isUsed = false
fontsUsedInExport.forEach((fontName) => { fontsUsedInExport.forEach((fontName) => {
if (fontName.includes(font.family)) { if (fontName.includes(font.family)) {
isUsed = true 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<string>((resolve, reject) => {
fileReader.onload = () => resolve(fileReader.result as string)
fileReader.onerror = () => reject(fileReader.error)
fileReader.readAsDataURL(fontFile)
}) })
const newFontFaceRule = '\n' + fontFaceRule.replaceAll(url, base64Font) if (!isUsed) return
styles += newFontFaceRule
} 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<string>((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 style.textContent = styles
@ -5842,7 +5931,7 @@ export class App extends EventEmitter<TLEventMap> {
// If there is no space on pageId, or if the selected shapes // 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 // 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) alertMaxShapes(this, pageId)
return this return this
} }
@ -7119,30 +7208,22 @@ export class App extends EventEmitter<TLEventMap> {
return this return this
} }
getShapesAndDescendantsInOrder(ids: TLShapeId[]) { getShapeAndDescendantIds(ids: TLShapeId[]): Set<TLShapeId> {
const idsToInclude: TLShapeId[] = [] const idsToInclude = new Set<TLShapeId>()
const visitedIds = new Set<string>()
const idsToCheck = [...ids] const idsToCheck = [...ids]
while (idsToCheck.length > 0) { while (idsToCheck.length > 0) {
const id = idsToCheck.pop() const id = idsToCheck.pop()
if (!id) break if (!id) break
if (visitedIds.has(id)) continue if (idsToInclude.has(id)) continue
idsToInclude.push(id) idsToInclude.add(id)
this.getSortedChildIds(id).forEach((id) => { this.getSortedChildIds(id).forEach((id) => {
idsToCheck.push(id) idsToCheck.push(id)
}) })
} }
// Map the ids into nodes AND their descendants return idsToInclude
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
} }
/** /**

View file

@ -148,6 +148,10 @@ export class TLFrameUtil extends TLBoxUtil<TLFrameShape> {
) )
} }
providesBackgroundForChildren(): boolean {
return true
}
override canReceiveNewChildrenOfType = (_type: TLShape['type']) => { override canReceiveNewChildrenOfType = (_type: TLShape['type']) => {
return true return true
} }

View file

@ -12,12 +12,15 @@ import { TLDrawShapeSegment, TLHighlightShape } from '@tldraw/tlschema'
import { last, rng } from '@tldraw/utils' import { last, rng } from '@tldraw/utils'
import { SVGContainer } from '../../../components/SVGContainer' import { SVGContainer } from '../../../components/SVGContainer'
import { getSvgPathFromStroke, getSvgPathFromStrokePoints } from '../../../utils/svg' import { getSvgPathFromStroke, getSvgPathFromStrokePoints } from '../../../utils/svg'
import { App } from '../../App'
import { ShapeFill } from '../shared/ShapeFill' import { ShapeFill } from '../shared/ShapeFill'
import { TLExportColors } from '../shared/TLExportColors' import { TLExportColors } from '../shared/TLExportColors'
import { useForceSolid } from '../shared/useForceSolid' import { useForceSolid } from '../shared/useForceSolid'
import { getFreehandOptions, getPointsFromSegments } from '../TLDrawUtil/getPath' import { getFreehandOptions, getPointsFromSegments } from '../TLDrawUtil/getPath'
import { OnResizeHandler, TLShapeUtil } from '../TLShapeUtil' import { OnResizeHandler, TLShapeUtil } from '../TLShapeUtil'
const OVERLAY_OPACITY = 0.4
/** @public */ /** @public */
export class TLHighlightUtil extends TLShapeUtil<TLHighlightShape> { export class TLHighlightUtil extends TLShapeUtil<TLHighlightShape> {
static type = 'highlight' static type = 'highlight'
@ -103,59 +106,11 @@ export class TLHighlightUtil extends TLShapeUtil<TLHighlightShape> {
} }
render(shape: TLHighlightShape) { render(shape: TLHighlightShape) {
const forceSolid = useForceSolid() return <HighlightRenderer app={this.app} shape={shape} opacity={OVERLAY_OPACITY} />
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' renderBackground(shape: TLHighlightShape) {
return <HighlightRenderer app={this.app} shape={shape} />
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 (
<SVGContainer id={shape.id}>
<ShapeFill fill="none" color={shape.props.color} d={solidStrokePath} />
<path
d={getSvgPathFromStroke(strokeOutlinePoints, true)}
strokeLinecap="round"
fill="currentColor"
/>
</SVGContainer>
)
}
return (
<SVGContainer id={shape.id}>
<ShapeFill fill="none" color={shape.props.color} d={solidStrokePath} />
<path
d={solidStrokePath}
strokeLinecap="round"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeDashoffset="0"
/>
</SVGContainer>
)
} }
indicator(shape: TLHighlightShape) { indicator(shape: TLHighlightShape) {
@ -185,35 +140,11 @@ export class TLHighlightUtil extends TLShapeUtil<TLHighlightShape> {
} }
toSvg(shape: TLHighlightShape, _font: string | undefined, colors: TLExportColors) { 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) toBackgroundSvg(shape: TLHighlightShape, font: string | undefined, colors: TLExportColors) {
const allPointsFromSegments = getPointsFromSegments(shape.props.segments) return highlighterToSvg(this.app, shape, 1, colors)
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
} }
override onResize: OnResizeHandler<TLHighlightShape> = (shape, info) => { override onResize: OnResizeHandler<TLHighlightShape> = (shape, info) => {
@ -252,3 +183,105 @@ function getDot(point: VecLike, sw: number) {
r * 2 r * 2
},0` },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 (
<SVGContainer id={shape.id} style={{ opacity }}>
<ShapeFill fill="none" color={shape.props.color} d={solidStrokePath} />
<path
d={getSvgPathFromStroke(strokeOutlinePoints, true)}
strokeLinecap="round"
fill="currentColor"
/>
</SVGContainer>
)
}
return (
<SVGContainer id={shape.id} style={{ opacity }}>
<ShapeFill fill="none" color={shape.props.color} d={solidStrokePath} />
<path
d={solidStrokePath}
strokeLinecap="round"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeDashoffset="0"
/>
</SVGContainer>
)
}
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
}

View file

@ -165,6 +165,14 @@ export abstract class TLShapeUtil<T extends TLUnknownShape = TLUnknownShape> {
*/ */
abstract indicator(shape: T): any 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. * Get an array of handle models for the shape. This is an optional method.
* *
@ -337,6 +345,21 @@ export abstract class TLShapeUtil<T extends TLUnknownShape = TLUnknownShape> {
colors: TLExportColors colors: TLExportColors
): SVGElement | Promise<SVGElement> ): SVGElement | Promise<SVGElement>
/**
* 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<SVGElement> | null
/** /**
* Get whether a point intersects the shape. * Get whether a point intersects the shape.
* *
@ -375,6 +398,19 @@ export abstract class TLShapeUtil<T extends TLUnknownShape = TLUnknownShape> {
return 0 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 // Events
/** /**

View file

@ -27,11 +27,13 @@ opacity based on its own opacity and that of its parent's.
export const Shape = track(function Shape({ export const Shape = track(function Shape({
id, id,
index, index,
backgroundIndex,
opacity, opacity,
isCulled, isCulled,
}: { }: {
id: TLShapeId id: TLShapeId
index: number index: number
backgroundIndex: number
opacity: number opacity: number
isCulled: boolean isCulled: boolean
}) { }) {
@ -41,65 +43,63 @@ export const Shape = track(function Shape({
const events = useShapeEvents(id) const events = useShapeEvents(id)
const rContainer = React.useRef<HTMLDivElement>(null) const containerRef = React.useRef<HTMLDivElement>(null)
const backgroundContainerRef = React.useRef<HTMLDivElement>(null)
const setProperty = React.useCallback((property: string, value: string) => {
containerRef.current?.style.setProperty(property, value)
backgroundContainerRef.current?.style.setProperty(property, value)
}, [])
useQuickReactor( useQuickReactor(
'set shape container transform position', 'set shape container transform position',
() => { () => {
const elm = rContainer.current
if (!elm) return
const shape = app.getShapeById(id) const shape = app.getShapeById(id)
const pageTransform = app.getPageTransformById(id) const pageTransform = app.getPageTransformById(id)
if (!shape || !pageTransform) return null if (!shape || !pageTransform) return null
const transform = Matrix2d.toCssString(pageTransform) const transform = Matrix2d.toCssString(pageTransform)
elm.style.setProperty('transform', transform) setProperty('transform', transform)
}, },
[app] [app, setProperty]
) )
useQuickReactor( useQuickReactor(
'set shape container clip path / color', 'set shape container clip path / color',
() => { () => {
const elm = rContainer.current
const shape = app.getShapeById(id) const shape = app.getShapeById(id)
if (!elm) return
if (!shape) return null if (!shape) return null
const clipPath = app.getClipPathById(id) const clipPath = app.getClipPathById(id)
elm.style.setProperty('clip-path', clipPath ?? 'none') setProperty('clip-path', clipPath ?? 'none')
if ('color' in shape.props) { 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( useQuickReactor(
'set shape height and width', 'set shape height and width',
() => { () => {
const elm = rContainer.current
const shape = app.getShapeById(id) const shape = app.getShapeById(id)
if (!elm) return
if (!shape) return null if (!shape) return null
const util = app.getShapeUtil(shape) const util = app.getShapeUtil(shape)
const bounds = util.bounds(shape) const bounds = util.bounds(shape)
elm.style.setProperty('width', Math.ceil(bounds.width) + 'px') setProperty('width', Math.ceil(bounds.width) + 'px')
elm.style.setProperty('height', Math.ceil(bounds.height) + 'px') setProperty('height', Math.ceil(bounds.height) + 'px')
}, },
[app] [app]
) )
// Set the opacity of the container when the opacity changes // Set the opacity of the container when the opacity changes
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const elm = rContainer.current setProperty('opacity', opacity + '')
if (!elm) return containerRef.current?.style.setProperty('z-index', index + '')
elm.style.setProperty('opacity', opacity + '') backgroundContainerRef.current?.style.setProperty('z-index', backgroundIndex + '')
elm.style.setProperty('z-index', index + '') }, [opacity, index, backgroundIndex, setProperty])
}, [opacity, index])
const shape = app.getShapeById(id) const shape = app.getShapeById(id)
@ -108,31 +108,53 @@ export const Shape = track(function Shape({
const util = app.getShapeUtil(shape) const util = app.getShapeUtil(shape)
return ( return (
<div <>
key={id} {util.renderBackground && (
ref={rContainer} <div
className="tl-shape" ref={backgroundContainerRef}
data-shape-type={shape.type} className="tl-shape tl-shape-background"
draggable={false} data-shape-type={shape.type}
onPointerDown={events.onPointerDown} draggable={false}
onPointerMove={events.onPointerMove}
onPointerUp={events.onPointerUp}
onPointerEnter={events.onPointerEnter}
onPointerLeave={events.onPointerLeave}
>
{isCulled && util.canUnmount(shape) ? (
<CulledShape shape={shape} util={util} />
) : (
<OptionalErrorBoundary
fallback={ShapeErrorFallback ? (error) => <ShapeErrorFallback error={error} /> : null}
onError={(error) =>
app.annotateError(error, { origin: 'react.shape', willCrashApp: false })
}
> >
<InnerShape shape={shape} util={util} /> {isCulled ? (
</OptionalErrorBoundary> <CulledShape shape={shape} util={util} />
) : (
<OptionalErrorBoundary
fallback={ShapeErrorFallback ? (error) => <ShapeErrorFallback error={error} /> : null}
onError={(error) =>
app.annotateError(error, { origin: 'react.shape', willCrashApp: false })
}
>
<InnerShapeBackground shape={shape} util={util} />
</OptionalErrorBoundary>
)}
</div>
)} )}
</div> <div
ref={containerRef}
className="tl-shape"
data-shape-type={shape.type}
draggable={false}
onPointerDown={events.onPointerDown}
onPointerMove={events.onPointerMove}
onPointerUp={events.onPointerUp}
onPointerEnter={events.onPointerEnter}
onPointerLeave={events.onPointerLeave}
>
{isCulled && util.canUnmount(shape) ? (
<CulledShape shape={shape} util={util} />
) : (
<OptionalErrorBoundary
fallback={ShapeErrorFallback ? (error) => <ShapeErrorFallback error={error} /> : null}
onError={(error) =>
app.annotateError(error, { origin: 'react.shape', willCrashApp: false })
}
>
<InnerShape shape={shape} util={util} />
</OptionalErrorBoundary>
)}
</div>
</>
) )
}) })
@ -143,6 +165,19 @@ const InnerShape = React.memo(
(prev, next) => prev.shape.props === next.shape.props (prev, next) => prev.shape.props === next.shape.props
) )
const InnerShapeBackground = React.memo(
function InnerShapeBackground<T extends TLShape>({
shape,
util,
}: {
shape: T
util: TLShapeUtil<T>
}) {
return useStateTracking('InnerShape:' + util.type, () => util.renderBackground?.(shape))
},
(prev, next) => prev.shape.props === next.shape.props
)
const CulledShape = React.memo( const CulledShape = React.memo(
function CulledShap<T extends TLShape>({ shape, util }: { shape: T; util: TLShapeUtil<T> }) { function CulledShap<T extends TLShape>({ shape, util }: { shape: T; util: TLShapeUtil<T> }) {
const bounds = util.bounds(shape) const bounds = util.bounds(shape)

View file

@ -1,14 +1,159 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Matches a snapshot: Basic SVG 1`] = ` exports[`Matches a snapshot: Basic SVG 1`] = `
"<svg direction=\\"ltr\\" width=\\"564\\" height=\\"564\\" viewBox=\\"-32 -32 564 564\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\" style=\\"background-color: transparent;\\"><defs><mask id=\\"hash_pattern_mask\\"> <wrapper>
<rect x=\\"0\\" y=\\"0\\" width=\\"8\\" height=\\"8\\" fill=\\"white\\"></rect> <svg
<g strokelinecap=\\"round\\" stroke=\\"black\\"> direction="ltr"
<line x1=\\"0.6666666666666666\\" y1=\\"2\\" x2=\\"2\\" y2=\\"0.6666666666666666\\"></line> height="564"
<line x1=\\"3.333333333333333\\" y1=\\"4.666666666666666\\" x2=\\"4.666666666666666\\" y2=\\"3.333333333333333\\"></line> stroke-linecap="round"
<line x1=\\"6\\" y1=\\"7.333333333333333\\" x2=\\"7.333333333333333\\" y2=\\"6\\"></line> stroke-linejoin="round"
</g> style="background-color: transparent;"
</mask><pattern id=\\"hash_pattern\\" width=\\"8\\" height=\\"8\\" patternUnits=\\"userSpaceOnUse\\"> viewBox="-32 -32 564 564"
<rect x=\\"0\\" y=\\"0\\" width=\\"8\\" height=\\"8\\" fill=\\"\\" mask=\\"url(#hash_pattern_mask)\\"></rect> width="564"
</pattern><style></style></defs><g transform=\\"matrix(1, 0, 0, 1, 0, 0)\\" opacity=\\"1\\"><path d=\\"M0, 0L100, 0,100, 100,0, 100Z\\" stroke-width=\\"3.5\\" stroke=\\"\\" fill=\\"none\\"></path><g><text font-size=\\"22px\\" font-family=\\"\\" font-style=\\"normal\\" font-weight=\\"normal\\" line-height=\\"29.700000000000003px\\" dominant-baseline=\\"mathematical\\" alignment-baseline=\\"mathematical\\"><tspan alignment-baseline=\\"mathematical\\" x=\\"16px\\" y=\\"-181px\\">Hello&nbsp;world</tspan></text><text font-size=\\"22px\\" font-family=\\"\\" font-style=\\"normal\\" font-weight=\\"normal\\" line-height=\\"29.700000000000003px\\" dominant-baseline=\\"mathematical\\" alignment-baseline=\\"mathematical\\" fill=\\"\\" stroke=\\"none\\"><tspan alignment-baseline=\\"mathematical\\" x=\\"16px\\" y=\\"-181px\\">Hello&nbsp;world</tspan></text></g></g><path d=\\"M0, 0L50, 0,50, 50,0, 50Z\\" stroke-width=\\"3.5\\" stroke=\\"\\" fill=\\"none\\" transform=\\"matrix(1, 0, 0, 1, 100, 100)\\" opacity=\\"1\\"></path><path d=\\"M0, 0L100, 0,100, 100,0, 100Z\\" stroke-width=\\"3.5\\" stroke=\\"\\" fill=\\"none\\" transform=\\"matrix(1, 0, 0, 1, 400, 400)\\" opacity=\\"1\\"></path></svg>" >
<defs>
<mask
id="hash_pattern_mask"
>
<rect
fill="white"
height="8"
width="8"
x="0"
y="0"
/>
<g
stroke="black"
strokelinecap="round"
>
<line
x1="0.6666666666666666"
x2="2"
y1="2"
y2="0.6666666666666666"
/>
<line
x1="3.333333333333333"
x2="4.666666666666666"
y1="4.666666666666666"
y2="3.333333333333333"
/>
<line
x1="6"
x2="7.333333333333333"
y1="7.333333333333333"
y2="6"
/>
</g>
</mask>
<pattern
height="8"
id="hash_pattern"
patternUnits="userSpaceOnUse"
width="8"
>
<rect
fill=""
height="8"
mask="url(#hash_pattern_mask)"
width="8"
x="0"
y="0"
/>
</pattern>
<style />
</defs>
<g
opacity="1"
transform="matrix(1, 0, 0, 1, 0, 0)"
>
<g>
<path
d="M0, 0L100, 0,100, 100,0, 100Z"
fill="none"
stroke=""
stroke-width="3.5"
/>
<g>
<text
alignment-baseline="mathematical"
dominant-baseline="mathematical"
font-family=""
font-size="22px"
font-style="normal"
font-weight="normal"
line-height="29.700000000000003px"
>
<tspan
alignment-baseline="mathematical"
x="16px"
y="-181px"
>
Hello world
</tspan>
</text>
<text
alignment-baseline="mathematical"
dominant-baseline="mathematical"
fill=""
font-family=""
font-size="22px"
font-style="normal"
font-weight="normal"
line-height="29.700000000000003px"
stroke="none"
>
<tspan
alignment-baseline="mathematical"
x="16px"
y="-181px"
>
Hello world
</tspan>
</text>
</g>
</g>
</g>
<g
opacity="1"
transform="matrix(1, 0, 0, 1, 100, 100)"
>
<path
d="M0, 0L50, 0,50, 50,0, 50Z"
fill="none"
stroke=""
stroke-width="3.5"
/>
</g>
<g
opacity="1"
transform="matrix(1, 0, 0, 1, 400, 400)"
>
<path
d="M0, 0L100, 0,100, 100,0, 100Z"
fill="none"
stroke=""
stroke-width="3.5"
/>
</g>
</svg>
</wrapper>
`; `;

View file

@ -85,7 +85,7 @@ it('Matches a snapshot', async () => {
const elm = document.createElement('wrapper') const elm = document.createElement('wrapper')
elm.appendChild(svg) elm.appendChild(svg)
expect(elm.innerHTML).toMatchSnapshot('Basic SVG') expect(elm).toMatchSnapshot('Basic SVG')
}) })
it('Accepts a scale option', async () => { it('Accepts a scale option', async () => {

View file

@ -1,13 +1,44 @@
import { TLShapeId } from '@tldraw/tlschema' import { TLShapeId } from '@tldraw/tlschema'
import { assert, assertExists } from '@tldraw/utils'
import { TestApp } from '../TestApp' import { TestApp } from '../TestApp'
import { TL } from '../jsx' import { TL } from '../jsx'
let app: TestApp let app: TestApp
let ids: Record<string, TLShapeId>
/**
* 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<number, number>()
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(() => { beforeEach(() => {
app = new TestApp() app = new TestApp()
ids = app.createShapesFromJsx([ app.setScreenBounds({ x: 0, y: 0, w: 1800, h: 900 })
})
function createShapes() {
return app.createShapesFromJsx([
<TL.geo ref="A" x={100} y={100} w={100} h={100} />, <TL.geo ref="A" x={100} y={100} w={100} h={100} />,
<TL.frame ref="B" x={200} y={200} w={300} h={300}> <TL.frame ref="B" x={200} y={200} w={300} h={300}>
<TL.geo ref="C" x={200} y={200} w={50} h={50} /> <TL.geo ref="C" x={200} y={200} w={50} h={50} />
@ -15,11 +46,10 @@ beforeEach(() => {
<TL.geo ref="D" x={1000} y={1000} w={50} h={50} /> <TL.geo ref="D" x={1000} y={1000} w={50} h={50} />
</TL.frame>, </TL.frame>,
]) ])
}
app.setScreenBounds({ x: 0, y: 0, w: 1800, h: 900 })
})
it('updates the culling viewport', () => { it('updates the culling viewport', () => {
const ids = createShapes()
app.updateCullingBounds = jest.fn(app.updateCullingBounds) app.updateCullingBounds = jest.fn(app.updateCullingBounds)
app.pan(-201, -201) app.pan(-201, -201)
jest.advanceTimersByTime(500) jest.advanceTimersByTime(500)
@ -29,6 +59,7 @@ it('updates the culling viewport', () => {
}) })
it('lists shapes in viewport', () => { it('lists shapes in viewport', () => {
const ids = createShapes()
expect( expect(
app.renderingShapes.map(({ id, isCulled, isInViewport }) => [id, isCulled, isInViewport]) app.renderingShapes.map(({ id, isCulled, isInViewport }) => [id, isCulled, isInViewport])
).toStrictEqual([ ).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 the results to be sorted correctly by id
expect(app.renderingShapes.map(({ id, index }) => [id, index])).toStrictEqual([ expect(normalizeIndexes(app.renderingShapes)).toStrictEqual([
[ids.A, 0], [ids.A, 2, 0],
[ids.B, 1], [ids.B, 3, 1],
[ids.C, 2], [ids.C, 6, 4], // the background of C is above B
[ids.D, 3], [ids.D, 7, 5],
// A is at the back, then B, and then B's children // 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]) app.reorderShapes('toBack', [ids.B])
// The items should still be sorted by id // The items should still be sorted by id
expect(app.renderingShapes.map(({ id, index }) => [id, index])).toStrictEqual([ expect(normalizeIndexes(app.renderingShapes)).toStrictEqual([
[ids.A, 3], [ids.A, 7, 1],
[ids.B, 0], [ids.B, 2, 0],
[ids.C, 1], [ids.C, 5, 3],
[ids.D, 2], [ids.D, 6, 4],
// B is now at the back, then its children, and finally A is now in the front // B is now at the back, then its children, and A is below the group
])
})
it('handles frames in frames', () => {
const ids = app.createShapesFromJsx([
<TL.geo ref="A" x={0} y={0} w={10} h={10} />,
<TL.frame ref="B" x={100} y={0} w={100} h={100}>
<TL.geo ref="C" x={100} y={0} w={10} h={10} />
<TL.frame ref="D" x={150} y={0} w={100} h={100}>
<TL.geo ref="E" x={150} y={0} w={10} h={10} />
</TL.frame>
<TL.geo ref="F" x={100} y={0} w={10} h={10} />
</TL.frame>,
<TL.geo ref="G" x={100} y={0} w={10} h={10} />,
])
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([
<TL.geo ref="A" x={0} y={0} w={10} h={10} />,
<TL.frame ref="B" x={100} y={0} w={100} h={100}>
<TL.geo ref="C" x={100} y={0} w={10} h={10} />
<TL.group ref="D" x={150} y={0}>
<TL.geo ref="E" x={150} y={0} w={10} h={10} />
</TL.group>
<TL.geo ref="F" x={100} y={0} w={10} h={10} />
</TL.frame>,
<TL.geo ref="G" x={100} y={0} w={10} h={10} />,
])
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([
<TL.geo ref="A" x={0} y={0} w={10} h={10} />,
<TL.group ref="B" x={100} y={0}>
<TL.geo ref="C" x={100} y={0} w={10} h={10} />
<TL.frame ref="D" x={150} y={0} w={100} h={100}>
<TL.geo ref="E" x={150} y={0} w={10} h={10} />
</TL.frame>
<TL.geo ref="F" x={100} y={0} w={10} h={10} />
</TL.group>,
<TL.geo ref="G" x={100} y={0} w={10} h={10} />,
])
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([
<TL.geo ref="A" x={0} y={0} w={10} h={10} />,
<TL.group ref="B" x={100} y={0}>
<TL.geo ref="C" x={100} y={0} w={10} h={10} />
<TL.group ref="D" x={150} y={0}>
<TL.geo ref="E" x={150} y={0} w={10} h={10} />
</TL.group>
<TL.geo ref="F" x={100} y={0} w={10} h={10} />
</TL.group>,
<TL.geo ref="G" x={100} y={0} w={10} h={10} />,
])
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],
]) ])
}) })

View file

@ -315,8 +315,8 @@ export class Box2d {
union(box: Box2dModel) { union(box: Box2dModel) {
const minX = Math.min(this.minX, box.x) const minX = Math.min(this.minX, box.x)
const minY = Math.min(this.minY, box.y) const minY = Math.min(this.minY, box.y)
const maxX = Math.max(this.maxX, box.x + box.w) const maxX = Math.max(this.maxX, box.w + box.x)
const maxY = Math.max(this.maxY, box.y + box.h) const maxY = Math.max(this.maxY, box.h + box.y)
this.x = minX this.x = minX
this.y = minY this.y = minY