[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:
parent
674a829d1f
commit
a11a741de4
10 changed files with 817 additions and 341 deletions
|
@ -280,11 +280,11 @@ export class App extends EventEmitter<TLEventMap> {
|
|||
getParentTransform(shape: TLShape): Matrix2d;
|
||||
getPointInParentSpace(shapeId: TLShapeId, point: VecLike): Vec2d;
|
||||
getPointInShapeSpace(shape: TLShape, point: VecLike): Vec2d;
|
||||
getShapeById<T extends TLShape = TLShape>(id: TLParentId): T | undefined;
|
||||
// (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[];
|
||||
getShapesInPage(pageId: TLPageId): TLShape[];
|
||||
getShapeUtil<C extends {
|
||||
new (...args: any[]): TLShapeUtil<any>;
|
||||
type: string;
|
||||
|
@ -410,9 +410,11 @@ export class App extends EventEmitter<TLEventMap> {
|
|||
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<TLFrameShape> {
|
|||
// (undocumented)
|
||||
onResizeEnd: OnResizeEndHandler<TLFrameShape>;
|
||||
// (undocumented)
|
||||
providesBackgroundForChildren(): boolean;
|
||||
// (undocumented)
|
||||
render(shape: TLFrameShape): JSX.Element;
|
||||
// (undocumented)
|
||||
toSvg(shape: TLFrameShape, font: string, colors: TLExportColors): Promise<SVGElement> | SVGElement;
|
||||
|
@ -2237,6 +2241,10 @@ export class TLHighlightUtil extends TLShapeUtil<TLHighlightShape> {
|
|||
// (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<T extends TLUnknownShape = TLUnknownShape> {
|
|||
onTranslateStart?: OnTranslateStartHandler<T>;
|
||||
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> | SVGElement;
|
||||
toSvg?(shape: T, font: string | undefined, colors: TLExportColors): Promise<SVGElement> | SVGElement;
|
||||
transform(shape: T): Matrix2d;
|
||||
// (undocumented)
|
||||
|
|
|
@ -1764,9 +1764,9 @@ export class App extends EventEmitter<TLEventMap> {
|
|||
}
|
||||
|
||||
/** Get shapes on a page. */
|
||||
getShapesInPage(pageId: TLPageId) {
|
||||
getShapeIdsInPage(pageId: TLPageId): Set<TLShapeId> {
|
||||
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<TLEventMap> {
|
|||
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<TLShapeId>
|
||||
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<TLEventMap> {
|
|||
// 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<TLEventMap> {
|
|||
// 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<TLEventMap> {
|
|||
* @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<TLEventMap> {
|
|||
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<TLEventMap> {
|
|||
// 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<TLEventMap> {
|
|||
|
||||
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<TLEventMap> {
|
|||
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<string>((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<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
|
||||
|
||||
|
@ -5842,7 +5931,7 @@ export class App extends EventEmitter<TLEventMap> {
|
|||
|
||||
// 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<TLEventMap> {
|
|||
return this
|
||||
}
|
||||
|
||||
getShapesAndDescendantsInOrder(ids: TLShapeId[]) {
|
||||
const idsToInclude: TLShapeId[] = []
|
||||
const visitedIds = new Set<string>()
|
||||
getShapeAndDescendantIds(ids: TLShapeId[]): Set<TLShapeId> {
|
||||
const idsToInclude = new Set<TLShapeId>()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -148,6 +148,10 @@ export class TLFrameUtil extends TLBoxUtil<TLFrameShape> {
|
|||
)
|
||||
}
|
||||
|
||||
providesBackgroundForChildren(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override canReceiveNewChildrenOfType = (_type: TLShape['type']) => {
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -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<TLHighlightShape> {
|
||||
static type = 'highlight'
|
||||
|
@ -103,59 +106,11 @@ export class TLHighlightUtil extends TLShapeUtil<TLHighlightShape> {
|
|||
}
|
||||
|
||||
render(shape: TLHighlightShape) {
|
||||
const forceSolid = useForceSolid()
|
||||
const strokeWidth = this.app.getStrokeWidth(shape.props.size)
|
||||
const allPointsFromSegments = getPointsFromSegments(shape.props.segments)
|
||||
return <HighlightRenderer app={this.app} shape={shape} opacity={OVERLAY_OPACITY} />
|
||||
}
|
||||
|
||||
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}>
|
||||
<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>
|
||||
)
|
||||
renderBackground(shape: TLHighlightShape) {
|
||||
return <HighlightRenderer app={this.app} shape={shape} />
|
||||
}
|
||||
|
||||
indicator(shape: TLHighlightShape) {
|
||||
|
@ -185,35 +140,11 @@ export class TLHighlightUtil extends TLShapeUtil<TLHighlightShape> {
|
|||
}
|
||||
|
||||
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<TLHighlightShape> = (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 (
|
||||
<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
|
||||
}
|
||||
|
|
|
@ -165,6 +165,14 @@ export abstract class TLShapeUtil<T extends TLUnknownShape = TLUnknownShape> {
|
|||
*/
|
||||
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<T extends TLUnknownShape = TLUnknownShape> {
|
|||
colors: TLExportColors
|
||||
): 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.
|
||||
*
|
||||
|
@ -375,6 +398,19 @@ export abstract class TLShapeUtil<T extends TLUnknownShape = TLUnknownShape> {
|
|||
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
|
||||
|
||||
/**
|
||||
|
|
|
@ -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<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(
|
||||
'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 (
|
||||
<div
|
||||
key={id}
|
||||
ref={rContainer}
|
||||
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 })
|
||||
}
|
||||
<>
|
||||
{util.renderBackground && (
|
||||
<div
|
||||
ref={backgroundContainerRef}
|
||||
className="tl-shape tl-shape-background"
|
||||
data-shape-type={shape.type}
|
||||
draggable={false}
|
||||
>
|
||||
<InnerShape shape={shape} util={util} />
|
||||
</OptionalErrorBoundary>
|
||||
{isCulled ? (
|
||||
<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
|
||||
)
|
||||
|
||||
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(
|
||||
function CulledShap<T extends TLShape>({ shape, util }: { shape: T; util: TLShapeUtil<T> }) {
|
||||
const bounds = util.bounds(shape)
|
||||
|
|
|
@ -1,14 +1,159 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
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\\">
|
||||
<rect x=\\"0\\" y=\\"0\\" width=\\"8\\" height=\\"8\\" fill=\\"white\\"></rect>
|
||||
<g strokelinecap=\\"round\\" stroke=\\"black\\">
|
||||
<line x1=\\"0.6666666666666666\\" y1=\\"2\\" x2=\\"2\\" y2=\\"0.6666666666666666\\"></line>
|
||||
<line x1=\\"3.333333333333333\\" y1=\\"4.666666666666666\\" x2=\\"4.666666666666666\\" y2=\\"3.333333333333333\\"></line>
|
||||
<line x1=\\"6\\" y1=\\"7.333333333333333\\" x2=\\"7.333333333333333\\" y2=\\"6\\"></line>
|
||||
</g>
|
||||
</mask><pattern id=\\"hash_pattern\\" width=\\"8\\" height=\\"8\\" patternUnits=\\"userSpaceOnUse\\">
|
||||
<rect x=\\"0\\" y=\\"0\\" width=\\"8\\" height=\\"8\\" fill=\\"\\" mask=\\"url(#hash_pattern_mask)\\"></rect>
|
||||
</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 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 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>"
|
||||
<wrapper>
|
||||
<svg
|
||||
direction="ltr"
|
||||
height="564"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
style="background-color: transparent;"
|
||||
viewBox="-32 -32 564 564"
|
||||
width="564"
|
||||
>
|
||||
<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>
|
||||
`;
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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<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(() => {
|
||||
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.frame ref="B" x={200} y={200} w={300} h={300}>
|
||||
<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.frame>,
|
||||
])
|
||||
|
||||
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([
|
||||
<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],
|
||||
])
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue