| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430 |
- import Vec from '@tldraw/vec'
- import type { TLAsset, TLBinding, TLEventMap } from '../../types'
- import { BoundsUtils, isNonNullable, uniqueId } from '../../utils'
- import type { TLShape, TLShapeModel } from '../shapes'
- import type { TLApp } from '../TLApp'
- export class TLApi<S extends TLShape = TLShape, K extends TLEventMap = TLEventMap> {
- private app: TLApp<S, K>
- constructor(app: TLApp<S, K>) {
- this.app = app
- }
- editShape = (shape: S | undefined): this => {
- if (!shape?.props.isLocked)
- this.app.transition('select').selectedTool.transition('editingShape', { shape })
- return this
- }
- /**
- * Set the hovered shape.
- *
- * @param shape The new hovered shape or shape id.
- */
- hoverShape = (shape: string | S | undefined): this => {
- this.app.setHoveredShape(shape)
- return this
- }
- /**
- * Create one or more shapes on the current page.
- *
- * @param shapes The new shape instances or serialized shapes.
- */
- createShapes = (...shapes: S[] | TLShapeModel[]): this => {
- this.app.createShapes(shapes)
- return this
- }
- /**
- * Update one or more shapes on the current page.
- *
- * @param shapes The serialized shape changes to apply.
- */
- updateShapes = <T extends S>(...shapes: ({ id: string } & Partial<T['props']>)[]): this => {
- this.app.updateShapes(shapes)
- return this
- }
- /**
- * Delete one or more shapes from the current page.
- *
- * @param shapes The shapes or shape ids to delete.
- */
- deleteShapes = (...shapes: S[] | string[]): this => {
- this.app.deleteShapes(shapes.length ? shapes : this.app.selectedShapesArray)
- return this
- }
- /**
- * Select one or more shapes on the current page.
- *
- * @param shapes The shapes or shape ids to select.
- */
- selectShapes = (...shapes: S[] | string[]): this => {
- this.app.setSelectedShapes(shapes)
- return this
- }
- /**
- * Deselect one or more selected shapes on the current page.
- *
- * @param ids The shapes or shape ids to deselect.
- */
- deselectShapes = (...shapes: S[] | string[]): this => {
- const ids =
- typeof shapes[0] === 'string' ? (shapes as string[]) : (shapes as S[]).map(shape => shape.id)
- this.app.setSelectedShapes(
- this.app.selectedShapesArray.filter(shape => !ids.includes(shape.id))
- )
- return this
- }
- flipHorizontal = (...shapes: S[] | string[]): this => {
- this.app.flipHorizontal(shapes)
- return this
- }
- flipVertical = (...shapes: S[] | string[]): this => {
- this.app.flipVertical(shapes)
- return this
- }
- /** Select all shapes on the current page. */
- selectAll = (): this => {
- this.app.setSelectedShapes(
- this.app.currentPage.shapes.filter(s => !this.app.shapesInGroups().includes(s))
- )
- return this
- }
- /** Deselect all shapes on the current page. */
- deselectAll = (): this => {
- this.app.setSelectedShapes([])
- return this
- }
- /** Zoom the camera in. */
- zoomIn = (): this => {
- this.app.viewport.zoomIn()
- return this
- }
- /** Zoom the camera out. */
- zoomOut = (): this => {
- this.app.viewport.zoomOut()
- return this
- }
- /** Reset the camera to 100%. */
- resetZoom = (): this => {
- this.app.viewport.resetZoom()
- return this
- }
- /** Zoom to fit all of the current page's shapes in the viewport. */
- zoomToFit = (): this => {
- const { shapes } = this.app.currentPage
- if (shapes.length === 0) return this
- const commonBounds = BoundsUtils.getCommonBounds(shapes.map(shape => shape.bounds))
- this.app.viewport.zoomToBounds(commonBounds)
- return this
- }
- cameraToCenter = (): this => {
- const { shapes } = this.app.currentPage
- if (shapes.length === 0) return this
- // Viewport should be focused to existing shapes
- const commonBounds = BoundsUtils.getCommonBounds(shapes.map(shape => shape.bounds))
- this.app.viewport.update({
- point: Vec.add(Vec.neg(BoundsUtils.getBoundsCenter(commonBounds)), [
- this.app.viewport.currentView.width / 2,
- this.app.viewport.currentView.height / 2,
- ]),
- })
- return this
- }
- /** Zoom to fit the current selection in the viewport. */
- zoomToSelection = (): this => {
- const { selectionBounds } = this.app
- if (!selectionBounds) return this
- this.app.viewport.zoomToBounds(selectionBounds)
- return this
- }
- resetZoomToCursor = (): this => {
- const viewport = this.app.viewport
- viewport.animateCamera({
- zoom: 1,
- point: Vec.sub(this.app.inputs.originScreenPoint, this.app.inputs.originPoint),
- })
- return this
- }
- toggleGrid = (): this => {
- const { settings } = this.app
- settings.update({ showGrid: !settings.showGrid })
- return this
- }
- setColor = (color: string): this => {
- const { settings } = this.app
- settings.update({ color: color })
- this.app.selectedShapesArray.forEach(s => {
- s.update({ fill: color, stroke: color })
- })
- this.app.persist()
- return this
- }
- setScaleLevel = (scaleLevel: string): this => {
- const { settings } = this.app
- settings.update({ scaleLevel })
- this.app.selectedShapes.forEach(shape => {
- shape.setScaleLevel(scaleLevel)
- })
- this.app.persist()
- return this
- }
- save = () => {
- this.app.save()
- return this
- }
- saveAs = () => {
- this.app.save()
- return this
- }
- undo = () => {
- this.app.undo()
- return this
- }
- redo = () => {
- this.app.redo()
- return this
- }
- persist = () => {
- this.app.persist()
- return this
- }
- createNewLineBinding = (source: S | string, target: S | string) => {
- return this.app.createNewLineBinding(source, target)
- }
- /** Clone shapes with given context */
- cloneShapes = ({
- shapes,
- assets,
- bindings,
- point = [0, 0],
- }: {
- shapes: TLShapeModel[]
- point: number[]
- // assets & bindings are the context for creating shapes
- assets: TLAsset[]
- bindings: Record<string, TLBinding>
- }) => {
- const commonBounds = BoundsUtils.getCommonBounds(
- shapes
- .filter(s => s.type !== 'group')
- .map(shape => ({
- minX: shape.point?.[0] ?? point[0],
- minY: shape.point?.[1] ?? point[1],
- width: shape.size?.[0] ?? 4,
- height: shape.size?.[1] ?? 4,
- maxX: (shape.point?.[0] ?? point[0]) + (shape.size?.[0] ?? 4),
- maxY: (shape.point?.[1] ?? point[1]) + (shape.size?.[1] ?? 4),
- }))
- )
- const clonedShapes = shapes.map(shape => {
- return {
- ...shape,
- id: uniqueId(),
- point: [
- point[0] + shape.point![0] - commonBounds.minX,
- point[1] + shape.point![1] - commonBounds.minY,
- ],
- }
- })
- clonedShapes.forEach(s => {
- if (s.children && s.children?.length > 0) {
- s.children = s.children
- .map(oldId => clonedShapes[shapes.findIndex(s => s.id === oldId)]?.id)
- .filter(isNonNullable)
- }
- })
- const clonedBindings: TLBinding[] = []
- // Try to rebinding the shapes with the given bindings
- clonedShapes
- .flatMap(s => Object.values(s.handles ?? {}))
- .forEach(handle => {
- if (!handle.bindingId) {
- return
- }
- // try to bind the new shape
- const binding = bindings[handle.bindingId]
- if (binding) {
- // if the copied binding from/to is in the source
- const oldFromIdx = shapes.findIndex(s => s.id === binding.fromId)
- const oldToIdx = shapes.findIndex(s => s.id === binding.toId)
- if (binding && oldFromIdx !== -1 && oldToIdx !== -1) {
- const newBinding: TLBinding = {
- ...binding,
- id: uniqueId(),
- fromId: clonedShapes[oldFromIdx].id,
- toId: clonedShapes[oldToIdx].id,
- }
- clonedBindings.push(newBinding)
- handle.bindingId = newBinding.id
- } else {
- handle.bindingId = undefined
- }
- } else {
- console.warn('binding not found', handle.bindingId)
- }
- })
- const clonedAssets = assets.filter(asset => {
- // do we need to create new asset id?
- return clonedShapes.some(shape => shape.assetId === asset.id)
- })
- return {
- shapes: clonedShapes,
- assets: clonedAssets,
- bindings: clonedBindings,
- }
- }
- getClonedShapesFromTldrString = (text: string, point: number[]) => {
- const safeParseJson = (json: string) => {
- try {
- return JSON.parse(json)
- } catch {
- return null
- }
- }
- const getWhiteboardsTldrFromText = (text: string) => {
- const innerText = text.match(/<whiteboard-tldr>(.*)<\/whiteboard-tldr>/)?.[1]
- if (innerText) {
- return safeParseJson(innerText)
- }
- }
- try {
- const data = getWhiteboardsTldrFromText(text)
- if (!data) return null
- const { shapes, bindings, assets } = data
- return this.cloneShapes({
- shapes,
- bindings,
- assets,
- point,
- })
- } catch (err) {
- console.log(err)
- }
- return null
- }
- cloneShapesIntoCurrentPage = (opts: {
- shapes: TLShapeModel[]
- point: number[]
- // assets & bindings are the context for creating shapes
- assets: TLAsset[]
- bindings: Record<string, TLBinding>
- }) => {
- const data = this.cloneShapes(opts)
- if (data) {
- this.addClonedShapes(data)
- }
- return this
- }
- addClonedShapes = (opts: ReturnType<TLApi['cloneShapes']>) => {
- const { shapes, assets, bindings } = opts
- if (assets.length > 0) {
- this.app.createAssets(assets)
- }
- if (shapes.length > 0) {
- this.app.createShapes(shapes)
- }
- this.app.currentPage.updateBindings(Object.fromEntries(bindings.map(b => [b.id, b])))
- this.app.selectedTool.transition('idle') // clears possible editing states
- return this
- }
- addClonedShapesFromTldrString = (text: string, point: number[]) => {
- const data = this.getClonedShapesFromTldrString(text, point)
- if (data) {
- this.addClonedShapes(data)
- }
- return this
- }
- doGroup = (shapes: S[] = this.app.allSelectedShapesArray) => {
- if (this.app.readOnly) return
- const selectedGroups: S[] = [
- ...shapes.filter(s => s.type === 'group'),
- ...shapes.map(s => this.app.getParentGroup(s)),
- ].filter(isNonNullable)
- // not using this.app.removeShapes because it also remove shapes in the group
- this.app.currentPage.removeShapes(...selectedGroups)
- // group all shapes
- const selectedShapes = shapes.filter(s => s.type !== 'group')
- if (selectedShapes.length > 1) {
- const ShapeGroup = this.app.getShapeClass('group')
- const group = new ShapeGroup({
- id: uniqueId(),
- type: ShapeGroup.id,
- parentId: this.app.currentPage.id,
- children: selectedShapes.map(s => s.id),
- })
- this.app.currentPage.addShapes(group)
- this.app.setSelectedShapes([group])
- // the shapes in the group should also be moved to the bottom of the array (to be on top on the canvas)
- this.app.bringForward(selectedShapes)
- }
- this.app.persist()
- }
- unGroup = (shapes: S[] = this.app.allSelectedShapesArray) => {
- if (this.app.readOnly) return
- const selectedGroups: S[] = [
- ...shapes.filter(s => s.type === 'group'),
- ...shapes.map(s => this.app.getParentGroup(s)),
- ].filter(isNonNullable)
- const shapesInGroups = this.app.shapesInGroups(selectedGroups)
- if (selectedGroups.length > 0) {
- // not using this.app.removeShapes because it also remove shapes in the group
- this.app.currentPage.removeShapes(...selectedGroups)
- this.app.persist()
- this.app.setSelectedShapes(shapesInGroups)
- }
- }
- }
|