/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { TLBounds, TLTransformInfo, Utils, TLPageState } from '@tldraw/core'; import { TDSnapshot, ShapesWithProp, TDShape, TDBinding, TDPage, TldrawCommand, TldrawPatch, TDShapeType, ArrowShape, TDHandle, TDExportType, BINDING_DISTANCE, } from '@toeverything/components/board-types'; import { Vec } from '@tldraw/vec'; import type { TDShapeUtil } from '@toeverything/components/board-shapes'; import { getShapeUtil, getTrianglePoints, } from '@toeverything/components/board-shapes'; import { deepCopy } from './manager/deep-copy'; import { intersectRayBounds, intersectRayEllipse, intersectRayLineSegment, } from '@tldraw/intersect'; const isDev = process.env['NODE_ENV'] === 'development'; export class TLDR { // eslint-disable-next-line @typescript-eslint/no-explicit-any static get_shape_util(type: T['type']): TDShapeUtil; // eslint-disable-next-line @typescript-eslint/no-explicit-any static get_shape_util(shape: T): TDShapeUtil; static get_shape_util(shape: T | T['type']) { return getShapeUtil(shape); } static get_selected_shapes(data: TDSnapshot, pageId: string) { const page = TLDR.get_page(data, pageId); const selectedIds = TLDR.get_selected_ids(data, pageId); return selectedIds.map(id => page.shapes[id]); } static screen_to_world(data: TDSnapshot, point: number[]) { const camera = TLDR.get_page_state( data, data.appState.currentPageId ).camera; return Vec.sub(Vec.div(point, camera.zoom), camera.point); } static get_camera_zoom(zoom: number) { return Utils.clamp(zoom, 0.1, 5); } static get_page(data: TDSnapshot, pageId: string): TDPage { return data.document.pages[pageId]; } static get_page_state(data: TDSnapshot, pageId: string): TLPageState { return data.document.pageStates[pageId]; } static get_selected_ids(data: TDSnapshot, pageId: string): string[] { return TLDR.get_page_state(data, pageId).selectedIds; } static get_shapes(data: TDSnapshot, pageId: string): TDShape[] { return Object.values(TLDR.get_page(data, pageId).shapes); } static get_camera(data: TDSnapshot, pageId: string): TLPageState['camera'] { return TLDR.get_page_state(data, pageId).camera; } static get_shape( data: TDSnapshot, shapeId: string, pageId: string ): T { return TLDR.get_page(data, pageId).shapes[shapeId] as T; } static get_center(shape: T) { return TLDR.get_shape_util(shape).getCenter(shape); } static get_bounds(shape: T) { return TLDR.get_shape_util(shape).getBounds(shape); } static get_rotated_bounds(shape: T) { return TLDR.get_shape_util(shape).getRotatedBounds(shape); } static get_selected_bounds(data: TDSnapshot): TLBounds { return Utils.getCommonBounds( TLDR.get_selected_shapes(data, data.appState.currentPageId).map( shape => TLDR.get_shape_util(shape).getBounds(shape) ) ); } static get_parent_id(data: TDSnapshot, id: string, pageId: string) { return TLDR.get_shape(data, id, pageId).parentId; } // static getPointedId(data: TDSnapshot, id: string, pageId: string): string { // const page = TLDR.getPage(data, pageId) // const pageState = TLDR.getPageState(data, data.appState.currentPageId) // const shape = TLDR.getShape(data, id, pageId) // if (!shape) return id // return shape.parentId === pageState.currentParentId || shape.parentId === page.id // ? id // : TLDR.getPointedId(data, shape.parentId, pageId) // } // static getDrilledPointedId(data: TDSnapshot, id: string, pageId: string): string { // const shape = TLDR.getShape(data, id, pageId) // const { currentPageId } = data.appState // const { currentParentId, pointedId } = TLDR.getPageState(data, data.appState.currentPageId) // return shape.parentId === currentPageId || // shape.parentId === pointedId || // shape.parentId === currentParentId // ? id // : TLDR.getDrilledPointedId(data, shape.parentId, pageId) // } // static getTopParentId(data: TDSnapshot, id: string, pageId: string): string { // const page = TLDR.getPage(data, pageId) // const pageState = TLDR.getPageState(data, pageId) // const shape = TLDR.getShape(data, id, pageId) // if (shape.parentId === shape.id) { // throw Error(`Shape has the same id as its parent! ${shape.id}`) // } // return shape.parentId === page.id || shape.parentId === pageState.currentParentId // ? id // : TLDR.getTopParentId(data, shape.parentId, pageId) // } // Get an array of a shape id and its descendant shapes' ids static get_document_branch( data: TDSnapshot, id: string, pageId: string ): string[] { const shape = TLDR.get_shape(data, id, pageId); if (shape.children === undefined) return [id]; return [ id, ...shape.children.flatMap(childId => TLDR.get_document_branch(data, childId, pageId) ), ]; } // Get a deep array of unproxied shapes and their descendants static get_selected_branch_snapshot( data: TDSnapshot, pageId: string, fn: (shape: TDShape) => K ): ({ id: string } & K)[]; static get_selected_branch_snapshot( data: TDSnapshot, pageId: string ): TDShape[]; static get_selected_branch_snapshot( data: TDSnapshot, pageId: string, fn?: (shape: TDShape) => K ): (TDShape | K)[] { const page = TLDR.get_page(data, pageId); const copies = TLDR.get_selected_ids(data, pageId) .flatMap(id => TLDR.get_document_branch(data, id, pageId).map( id => page.shapes[id] ) ) .filter(shape => !shape.isLocked) .map(Utils.deepClone); if (fn !== undefined) { return copies.map(shape => ({ id: shape.id, ...fn(shape) })); } return copies; } // Get a shallow array of unproxied shapes static get_selected_shape_snapshot( data: TDSnapshot, pageId: string ): TDShape[]; static get_selected_shape_snapshot( data: TDSnapshot, pageId: string, fn?: (shape: TDShape) => K ): ({ id: string } & K)[]; static get_selected_shape_snapshot( data: TDSnapshot, pageId: string, fn?: (shape: TDShape) => K ): (TDShape | K)[] { const copies = TLDR.get_selected_shapes(data, pageId) .filter(shape => !shape.isLocked) .map(Utils.deepClone); if (fn !== undefined) { return copies.map(shape => ({ id: shape.id, ...fn(shape) })); } return copies; } // For a given array of shape ids, an array of all other shapes that may be affected by a mutation to it. // Use this to decide which shapes to clone as before / after for a command. static get_all_effected_shape_ids( data: TDSnapshot, ids: string[], pageId: string ): string[] { const page = TLDR.get_page(data, pageId); const visited = new Set(ids); ids.forEach(id => { const shape = page.shapes[id]; // Add descendant shapes function collectDescendants(shape: TDShape): void { if (shape.children === undefined) return; shape.children .filter(childId => !visited.has(childId)) .forEach(childId => { visited.add(childId); collectDescendants(page.shapes[childId]); }); } collectDescendants(shape); // Add asecendant shapes function collectAscendants(shape: TDShape): void { const parentId = shape.parentId; if (parentId === page.id) return; if (visited.has(parentId)) return; visited.add(parentId); collectAscendants(page.shapes[parentId]); } collectAscendants(shape); // Add bindings that are to or from any of the visited shapes (this does not have to be recursive) visited.forEach(id => { Object.values(page.bindings) .filter( binding => binding.fromId === id || binding.toId === id ) .forEach(binding => visited.add( binding.fromId === id ? binding.toId : binding.fromId ) ); }); }); // Return the unique array of visited shapes return Array.from(visited.values()); } static get_linked_shape_ids( data: TDSnapshot, pageId: string, direction: 'center' | 'left' | 'right', includeArrows = true ) { const selectedIds = TLDR.get_selected_ids(data, pageId); const page = TLDR.get_page(data, pageId); const linkedIds = new Set(selectedIds); const checkedIds = new Set(); const idsToCheck = [...selectedIds]; const arrows = new Set( Object.values(page.shapes).filter(shape => { return ( shape.type === TDShapeType.Arrow && (shape.handles.start.bindingId || shape.handles?.end.bindingId) ); }) as ArrowShape[] ); while (idsToCheck.length) { const id = idsToCheck.pop(); if (!(id && arrows.size)) break; if (checkedIds.has(id)) continue; checkedIds.add(id); arrows.forEach(arrow => { const { handles: { start: { bindingId: startBindingId }, end: { bindingId: endBindingId }, }, } = arrow; const startBinding = startBindingId ? page.bindings[startBindingId] : null; const endBinding = endBindingId ? page.bindings[endBindingId] : null; let hit = false; if (startBinding && startBinding.toId === id) { if (direction === 'center') { hit = true; } else if (arrow.decorations?.start && endBinding) { // The arrow is pointing to this shape at its start hit = direction === 'left'; } else { // The arrow is pointing away from this shape hit = direction === 'right'; } if (hit) { // This arrow is bound to this shape if (includeArrows) linkedIds.add(arrow.id); linkedIds.add(id); if (endBinding) { linkedIds.add(endBinding.toId); idsToCheck.push(endBinding.toId); } } } else if (endBinding && endBinding.toId === id) { // This arrow is bound to this shape at its end if (direction === 'center') { hit = true; } else if (arrow.decorations?.end && startBinding) { // The arrow is pointing to this shape hit = direction === 'left'; } else { // The arrow is pointing away from this shape hit = direction === 'right'; } if (hit) { if (includeArrows) linkedIds.add(arrow.id); linkedIds.add(id); if (startBinding) { linkedIds.add(startBinding.toId); idsToCheck.push(startBinding.toId); } } } if ( (!startBinding || linkedIds.has(startBinding.toId)) && (!endBinding || linkedIds.has(endBinding.toId)) ) { arrows.delete(arrow); } }); } return Array.from(linkedIds.values()); } static get_child_index_above( data: TDSnapshot, id: string, pageId: string ): number { const page = data.document.pages[pageId]; const shape = page.shapes[id]; let siblings: TDShape[]; if (shape.parentId === page.id) { siblings = Object.values(page.shapes) .filter(shape => shape.parentId === page.id) .sort((a, b) => a.childIndex - b.childIndex); } else { const parent = page.shapes[shape.parentId]; if (!parent.children) throw Error('No children in parent!'); siblings = parent.children .map(childId => page.shapes[childId]) .sort((a, b) => a.childIndex - b.childIndex); } const index = siblings.indexOf(shape); const nextSibling = siblings[index + 1]; if (!nextSibling) return shape.childIndex + 1; return nextSibling.childIndex; } /* -------------------------------------------------- */ /* Mutations */ /* -------------------------------------------------- */ static get_before_shape( shape: T, change: Partial ): Partial { return Object.fromEntries( Object.keys(change).map(k => [k, shape[k as keyof T]]) ) as Partial; } static mutate_shapes( data: TDSnapshot, ids: string[], fn: (shape: T, i: number) => Partial | void, pageId: string ): { before: Record>; after: Record>; data: TDSnapshot; } { const beforeShapes: Record> = {}; const afterShapes: Record> = {}; ids.forEach((id, i) => { const shape = TLDR.get_shape(data, id, pageId); if (shape.isLocked) return; const change = fn(shape, i); if (change) { beforeShapes[id] = TLDR.get_before_shape(shape, change); afterShapes[id] = change; } }); const dataWithMutations = Utils.deepMerge(data, { document: { pages: { [data.appState.currentPageId]: { shapes: afterShapes, }, }, }, }); return { before: beforeShapes, after: afterShapes, data: dataWithMutations, }; } static create_shapes( data: TDSnapshot, shapes: TDShape[], pageId: string ): TldrawCommand { const before: TldrawPatch = { document: { pages: { [pageId]: { shapes: { ...Object.fromEntries( shapes.flatMap(shape => { const results: [ string, Partial | undefined ][] = [[shape.id, undefined]]; // If the shape is a child of another shape, also save that shape if (shape.parentId !== pageId) { const parent = TLDR.get_shape( data, shape.parentId, pageId ); if (!parent.children) throw Error( 'No children in parent!' ); results.push([ parent.id, { children: parent.children }, ]); } return results; }) ), }, }, }, }, }; const after: TldrawPatch = { document: { pages: { [pageId]: { shapes: { shapes: { ...Object.fromEntries( shapes.flatMap(shape => { const results: [ string, Partial | undefined ][] = [[shape.id, shape]]; // If the shape is a child of a different shape, update its parent if (shape.parentId !== pageId) { const parent = TLDR.get_shape( data, shape.parentId, pageId ); if (!parent.children) throw Error( 'No children in parent!' ); results.push([ parent.id, { children: [ ...parent.children, shape.id, ], }, ]); } return results; }) ), }, }, }, }, }, }; return { before, after, }; } static delete_shapes( data: TDSnapshot, shapes: TDShape[] | string[], pageId?: string ): TldrawCommand { pageId = pageId ? pageId : data.appState.currentPageId; const page = TLDR.get_page(data, pageId); const shapeIds = typeof shapes[0] === 'string' ? (shapes as string[]) : (shapes as TDShape[]).map(shape => shape.id); const before: TldrawPatch = { document: { pages: { [pageId]: { shapes: { // These are the shapes that we're going to delete ...Object.fromEntries( shapeIds.flatMap(id => { const shape = page.shapes[id]; const results: [ string, Partial | undefined ][] = [[shape.id, shape]]; // If the shape is a child of another shape, also add that shape if (shape.parentId !== pageId) { const parent = page.shapes[shape.parentId]; if (!parent.children) throw Error( 'No children in parent!' ); results.push([ parent.id, { children: parent.children }, ]); } return results; }) ), }, bindings: { // These are the bindings that we're going to delete ...Object.fromEntries( Object.values(page.bindings) .filter(binding => { return ( shapeIds.includes(binding.fromId) || shapeIds.includes(binding.toId) ); }) .map(binding => { return [binding.id, binding]; }) ), }, }, }, }, }; const after: TldrawPatch = { document: { pages: { [pageId]: { shapes: { ...Object.fromEntries( shapeIds.flatMap(id => { const shape = page.shapes[id]; const results: [ string, Partial | undefined ][] = [[shape.id, undefined]]; // If the shape is a child of a different shape, update its parent if (shape.parentId !== page.id) { const parent = page.shapes[shape.parentId]; if (!parent.children) throw Error( 'No children in parent!' ); results.push([ parent.id, { children: parent.children.filter( id => id !== shape.id ), }, ]); } return results; }) ), }, }, }, }, }; return { before, after, }; } static on_session_complete(shape: T) { const delta = TLDR.get_shape_util(shape).onSessionComplete?.(shape); if (!delta) return shape; return { ...shape, ...delta }; } static on_children_change( data: TDSnapshot, shape: T, pageId: string ) { if (!shape.children) return; const delta = TLDR.get_shape_util(shape).onChildrenChange?.( shape, shape.children.map(id => TLDR.get_shape(data, id, pageId)) ); if (!delta) return shape; return { ...shape, ...delta }; } static update_arrow_bindings(page: TDPage, arrowShape: ArrowShape) { const result = { start: deepCopy(arrowShape.handles.start), end: deepCopy(arrowShape.handles.end), }; type HandleInfo = { handle: TDHandle; point: number[]; // in page space } & ( | { isBound: false; } | { isBound: true; hasDecoration: boolean; binding: TDBinding; util: TDShapeUtil; target: TDShape; bounds: TLBounds; expandedBounds: TLBounds; intersectBounds: TLBounds; center: number[]; } ); let start: HandleInfo = { isBound: false, handle: arrowShape.handles.start, point: Vec.add(arrowShape.handles.start.point, arrowShape.point), }; let end: HandleInfo = { isBound: false, handle: arrowShape.handles.end, point: Vec.add(arrowShape.handles.end.point, arrowShape.point), }; if (arrowShape.handles.start.bindingId) { const hasDecoration = arrowShape.decorations?.start !== undefined; const handle = arrowShape.handles.start; const binding = page.bindings[arrowShape.handles.start.bindingId]; if (!binding) throw Error( "Could not find a binding to match the start handle's bindingId" ); const target = page.shapes[binding.toId]; const util = TLDR.get_shape_util(target); const bounds = util.getBounds(target); const expandedBounds = util.getExpandedBounds(target); const intersectBounds = hasDecoration ? Utils.expandBounds(bounds, binding.distance) : bounds; const { minX, minY, width, height } = expandedBounds; const anchorPoint = Vec.add( [minX, minY], Vec.mulV( [width, height], Vec.rotWith(binding.point, [0.5, 0.5], target.rotation || 0) ) ); start = { isBound: true, hasDecoration, binding, handle, point: anchorPoint, util, target, bounds, expandedBounds, intersectBounds, center: util.getCenter(target), }; } if (arrowShape.handles.end.bindingId) { const hasDecoration = arrowShape.decorations?.end !== undefined; const handle = arrowShape.handles.end; const binding = page.bindings[arrowShape.handles.end.bindingId]; if (!binding) throw Error( "Could not find a binding to match the end handle's bindingId" ); const target = page.shapes[binding.toId]; const util = TLDR.get_shape_util(target); const bounds = util.getBounds(target); const expandedBounds = util.getExpandedBounds(target); const intersectBounds = hasDecoration ? Utils.expandBounds(bounds, binding.distance) : bounds; const { minX, minY, width, height } = expandedBounds; const anchorPoint = Vec.add( [minX, minY], Vec.mulV( [width, height], Vec.rotWith(binding.point, [0.5, 0.5], target.rotation || 0) ) ); end = { isBound: true, hasDecoration, binding, handle, point: anchorPoint, util, target, bounds, expandedBounds, intersectBounds, center: util.getCenter(target), }; } for (const ID of ['end', 'start'] as const) { const A = ID === 'start' ? start : end; const B = ID === 'start' ? end : start; if (A.isBound) { if (!A.binding.distance) { // If the binding distance is zero, then the arrow is bound to a specific point // in the target shape. The resulting handle should be exactly at that point. result[ID].point = Vec.sub(A.point, arrowShape.point); } else { // We'll need to figure out the handle's true point based on some intersections // between the opposite handle point and this handle point. This is different // for each type of shape. const direction = Vec.uni(Vec.sub(A.point, B.point)); switch (A.target.type) { case TDShapeType.Ellipse: { const hits = intersectRayEllipse( B.point, direction, A.center, A.target.radius[0] + (A.hasDecoration ? A.binding.distance : 0), A.target.radius[1] + (A.hasDecoration ? A.binding.distance : 0), A.target.rotation || 0 ).points.sort( (a, b) => Vec.dist(a, B.point) - Vec.dist(b, B.point) ); if (hits[0] !== undefined) { result[ID].point = Vec.toFixed( Vec.sub(hits[0], arrowShape.point) ); } break; } case TDShapeType.Triangle: { const targetPoint = A.target.point; const points = getTrianglePoints( A.target.size, A.hasDecoration ? BINDING_DISTANCE : 0, A.target.rotation ).map(pt => Vec.add(pt, targetPoint)); const hits = Utils.pointsToLineSegments( points, true ) .map(([p0, p1]) => intersectRayLineSegment( B.point, direction, p0, p1 ) ) .filter( intersection => intersection.didIntersect ) .flatMap(intersection => intersection.points) .sort( (a, b) => Vec.dist(a, B.point) - Vec.dist(b, B.point) ); if (hits[0] !== undefined) { result[ID].point = Vec.toFixed( Vec.sub(hits[0], arrowShape.point) ); } break; } default: { const hits = intersectRayBounds( B.point, direction, A.intersectBounds, A.target.rotation ) .filter(int => int.didIntersect) .map(int => int.points[0]) .sort( (a, b) => Vec.dist(a, B.point) - Vec.dist(b, B.point) ); if (!hits[0]) continue; let bHit: number[] | undefined = undefined; if (B.isBound) { const bHits = intersectRayBounds( B.point, direction, B.intersectBounds, B.target.rotation ) .filter(int => int.didIntersect) .map(int => int.points[0]) .sort( (a, b) => Vec.dist(a, B.point) - Vec.dist(b, B.point) ); bHit = bHits[0]; } if ( B.isBound && (hits.length < 2 || (bHit && hits[0] && Math.ceil(Vec.dist(hits[0], bHit)) < BINDING_DISTANCE * 2.5) || Utils.boundsContain( A.expandedBounds, B.expandedBounds ) || Utils.boundsCollide( A.expandedBounds, B.expandedBounds )) ) { // If the other handle is bound, and if we need to fallback to the short arrow method... const shortArrowDirection = Vec.uni( Vec.sub(B.point, A.point) ); const shortArrowHits = intersectRayBounds( A.point, shortArrowDirection, A.bounds, A.target.rotation ) .filter(int => int.didIntersect) .map(int => int.points[0]); if (!shortArrowHits[0]) continue; result[ID].point = Vec.toFixed( Vec.sub(shortArrowHits[0], arrowShape.point) ); result[ID === 'start' ? 'end' : 'start'].point = Vec.toFixed( Vec.add( Vec.sub( shortArrowHits[0], arrowShape.point ), Vec.mul( shortArrowDirection, Math.min( Vec.dist( shortArrowHits[0], B.point ), BINDING_DISTANCE * 2.5 * (Utils.boundsContain( B.bounds, A.intersectBounds ) ? -1 : 1) ) ) ) ); } else if ( !B.isBound && ((hits[0] && Vec.dist(hits[0], B.point) < BINDING_DISTANCE * 2.5) || Utils.pointInBounds( B.point, A.intersectBounds )) ) { // Short arrow time! const shortArrowDirection = Vec.uni( Vec.sub(A.center, B.point) ); return TLDR.get_shape_util( arrowShape ).onHandleChange?.(arrowShape, { [ID]: { ...arrowShape.handles[ID], point: Vec.toFixed( Vec.add( Vec.sub( B.point, arrowShape.point ), Vec.mul( shortArrowDirection, BINDING_DISTANCE * 2.5 ) ) ), }, }); } else if (hits[0]) { result[ID].point = Vec.toFixed( Vec.sub(hits[0], arrowShape.point) ); } } } } } } return TLDR.get_shape_util(arrowShape).onHandleChange?.( arrowShape, result ); } static transform( shape: T, bounds: TLBounds, info: TLTransformInfo ) { const delta = TLDR.get_shape_util(shape).transform(shape, bounds, info); if (!delta) return shape; return { ...shape, ...delta }; } static transform_single( shape: T, bounds: TLBounds, info: TLTransformInfo ) { const delta = TLDR.get_shape_util(shape).transformSingle( shape, bounds, info ); if (!delta) return shape; return { ...shape, ...delta }; } /** * Rotate a shape around an origin point. * @param shape a shape. * @param center the shape's center in page space. * @param origin the page point to rotate around. * @param rotation the amount to rotate the shape. */ static get_rotated_shape_mutation( shape: T, // in page space center: number[], // in page space origin: number[], // in page space (probably the center of common bounds) delta: number // The shape's rotation delta ): Partial | void { // The shape's center relative to the shape's point const relativeCenter = Vec.sub(center, shape.point); // Rotate the center around the origin const rotatedCenter = Vec.rotWith(center, origin, delta); // Get the top left point relative to the rotated center const nextPoint = Vec.toFixed(Vec.sub(rotatedCenter, relativeCenter)); // If the shape has handles, we need to rotate the handles instead // of rotating the shape. Shapes with handles should never be rotated, // because that makes a lot of other things incredible difficult. if (shape.handles !== undefined) { const change = this.get_shape_util(shape).onHandleChange?.( // Base the change on a shape with the next point { ...shape, point: nextPoint }, Object.fromEntries( Object.entries(shape.handles).map(([handleId, handle]) => { // Rotate each handle's point around the shape's center // (in relative shape space, as the handle's point will be). const point = Vec.toFixed( Vec.rotWith(handle.point, relativeCenter, delta) ); return [handleId, { ...handle, point }]; }) ) as T['handles'] ); return change; } // If the shape has no handles, move the shape to the new point // and set the rotation. // Clamp the next rotation between 0 and PI2 const nextRotation = Utils.clampRadians((shape.rotation || 0) + delta); return { point: nextPoint, rotation: nextRotation, } as Partial; } /* -------------------------------------------------- */ /* Parents */ /* -------------------------------------------------- */ static update_parents( data: TDSnapshot, pageId: string, changedShapeIds: string[] ): void { const page = TLDR.get_page(data, pageId); if (changedShapeIds.length === 0) return; const { shapes } = TLDR.get_page(data, pageId); const parentToUpdateIds = Array.from( new Set(changedShapeIds.map(id => shapes[id].parentId).values()) ).filter(id => id !== page.id); for (const parentId of parentToUpdateIds) { const parent = shapes[parentId]; if (!parent.children) { throw Error( 'A shape is parented to a shape without a children array.' ); } TLDR.on_children_change(data, parent, pageId); } TLDR.update_parents(data, pageId, parentToUpdateIds); } /* -------------------------------------------------- */ /* Bindings */ /* -------------------------------------------------- */ static get_binding( data: TDSnapshot, id: string, pageId: string ): TDBinding { return TLDR.get_page(data, pageId).bindings[id]; } static get_bindings(data: TDSnapshot, pageId: string): TDBinding[] { const page = TLDR.get_page(data, pageId); return Object.values(page.bindings); } static get_bindable_shape_ids(data: TDSnapshot) { return TLDR.get_shapes(data, data.appState.currentPageId) .filter(shape => TLDR.get_shape_util(shape).canBind) .sort((a, b) => b.childIndex - a.childIndex) .map(shape => shape.id); } static get_bindings_with_shape_ids( data: TDSnapshot, ids: string[], pageId: string ): TDBinding[] { return Array.from( new Set( TLDR.get_bindings(data, pageId).filter(binding => { return ( ids.includes(binding.toId) || ids.includes(binding.fromId) ); }) ).values() ); } static get_related_bindings( data: TDSnapshot, ids: string[], pageId: string ): TDBinding[] { const changedShapeIds = new Set(ids); const page = TLDR.get_page(data, pageId); // Find all bindings that we need to update const bindingsArr = Object.values(page.bindings); // Start with bindings that are directly bound to our changed shapes const bindingsToUpdate = new Set( bindingsArr.filter( binding => changedShapeIds.has(binding.toId) || changedShapeIds.has(binding.fromId) ) ); // Next, look for other bindings that effect the same shapes let prevSize = bindingsToUpdate.size; let delta = -1; while (delta !== 0) { bindingsToUpdate.forEach(binding => { const fromId = binding.fromId; for (const otherBinding of bindingsArr) { if (otherBinding.fromId === fromId) { bindingsToUpdate.add(otherBinding); } if (otherBinding.toId === fromId) { bindingsToUpdate.add(otherBinding); } } }); // Continue until we stop finding new bindings to update delta = bindingsToUpdate.size - prevSize; prevSize = bindingsToUpdate.size; } return Array.from(bindingsToUpdate.values()); } static copy_string_to_clipboard = (string: string) => { try { if (navigator.clipboard) { navigator.clipboard.write([ new ClipboardItem({ 'text/plain': new Blob([string], { type: 'text/plain', }), }), ]); } } catch (e) { const textarea = document.createElement('textarea'); textarea.setAttribute('position', 'fixed'); textarea.setAttribute('top', '0'); textarea.setAttribute('readonly', 'true'); textarea.setAttribute('contenteditable', 'true'); textarea.style.position = 'fixed'; textarea.value = string; document.body.appendChild(textarea); textarea.focus(); textarea.select(); try { const range = document.createRange(); range.selectNodeContents(textarea); const sel = window.getSelection(); if (sel) { sel.removeAllRanges(); sel.addRange(range); textarea.setSelectionRange(0, textarea.value.length); } } catch (err) { console.error(err); // Could not copy to clipboard } finally { document.body.removeChild(textarea); } } }; /* -------------------------------------------------- */ /* Groups */ /* -------------------------------------------------- */ static flatten_shape = (data: TDSnapshot, shape: TDShape): TDShape[] => { return [ shape, ...(shape.children ?? []) .map(childId => TLDR.get_shape(data, childId, data.appState.currentPageId) ) .sort((a, b) => a.childIndex - b.childIndex) .flatMap(shape => TLDR.flatten_shape(data, shape)), ]; }; static flatten_page = (data: TDSnapshot, pageId: string): TDShape[] => { return Object.values(data.document.pages[pageId].shapes) .sort((a, b) => a.childIndex - b.childIndex) .reduce( (acc, shape) => [...acc, ...TLDR.flatten_shape(data, shape)], [] ); }; static get_top_child_index = (data: TDSnapshot, pageId: string): number => { const shapes = TLDR.get_shapes(data, pageId); return shapes.length === 0 ? 1 : shapes .filter(shape => shape.parentId === pageId) .sort((a, b) => b.childIndex - a.childIndex)[0].childIndex + 1; }; /* -------------------------------------------------- */ /* Text */ /* -------------------------------------------------- */ static fix_new_lines = /\r?\n|\r/g; static normalize_text(text: string) { return text .replace(TLDR.fix_new_lines, '\n') .split('\n') .map(x => x || ' ') .join('\n'); } /* -------------------------------------------------- */ /* Assertions */ /* -------------------------------------------------- */ static assert_shape_has_property

( shape: TDShape, prop: P ): asserts shape is ShapesWithProp

{ if (shape[prop] === undefined) { throw new Error(); } } static warn(e: any) { if (isDev) { console.warn(e); } } static error(e: any) { if (isDev) { console.error(e); } } /* -------------------------------------------------- */ /* Export */ /* -------------------------------------------------- */ static get_svg_string(svg: SVGElement, scale = 1) { const clone = svg.cloneNode(true) as SVGGraphicsElement; svg.setAttribute('width', +svg.getAttribute('width')! * scale + ''); svg.setAttribute('height', +svg.getAttribute('height')! * scale + ''); return new XMLSerializer() .serializeToString(clone) .replaceAll(' ', '') .replaceAll(/((\s|")[0-9]*\.[0-9]{2})([0-9]*)(\b|"|\))/g, '$1'); } static get_svg_as_data_url(svg: SVGElement, scale = 1) { const svgString = TLDR.get_svg_string(svg, scale); const base64svg = window.btoa(unescape(svgString)); return `data:image/svg+xml;base64,${base64svg}`; } static async get_image_for_svg( svg: SVGElement, type: Exclude = TDExportType.PNG, opts = {} as Partial<{ scale: number; quality: number; }> ) { const { scale = 2, quality = 1 } = opts; const svgString = TLDR.get_svg_string(svg, scale); const width = +svg.getAttribute('width')!; const height = +svg.getAttribute('height')!; if (!svgString) return; const canvas = await new Promise(resolve => { const image = new Image(); image.crossOrigin = 'anonymous'; const base64svg = window.btoa( unescape(encodeURIComponent(svgString)) ); const dataUrl = `data:image/svg+xml;base64,${base64svg}`; image.onload = () => { const canvas = document.createElement( 'canvas' ) as HTMLCanvasElement; const context = canvas.getContext('2d')!; canvas.width = width; canvas.height = height; context.drawImage(image, 0, 0, width, height); URL.revokeObjectURL(dataUrl); resolve(canvas); }; image.onerror = () => { console.warn('Could not convert that SVG to an image.'); }; image.src = dataUrl; }); const blob = await new Promise(resolve => canvas.toBlob(blob => resolve(blob!), 'image/' + type, quality) ); return blob; } }