[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;
|
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)
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 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>"
|
>
|
||||||
|
<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')
|
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 () => {
|
||||||
|
|
|
@ -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],
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue