import { Box, ComputedCache, Editor, TLShape, Vec, atom, clamp, computed, react, uniqueId, } from '@tldraw/editor' import { getRgba } from './getRgba' import { BufferStuff, appendVertices, setupWebGl } from './minimap-webgl-setup' import { pie, rectangle, roundedRectangle } from './minimap-webgl-shapes' export class MinimapManager { disposables = [] as (() => void)[] close = () => this.disposables.forEach((d) => d()) gl: ReturnType shapeGeometryCache: ComputedCache constructor( public editor: Editor, public readonly elem: HTMLCanvasElement ) { this.gl = setupWebGl(elem) this.shapeGeometryCache = editor.store.createComputedCache('webgl-geometry', (r: TLShape) => { const bounds = editor.getShapeMaskedPageBounds(r.id) if (!bounds) return null const arr = new Float32Array(12) rectangle(arr, 0, bounds.x, bounds.y, bounds.w, bounds.h) return arr }) this.colors = this._getColors() this.disposables.push(this._listenForCanvasResize(), react('minimap render', this.render)) } private _getColors() { const style = getComputedStyle(this.editor.getContainer()) return { shapeFill: getRgba(style.getPropertyValue('--color-text-3').trim()), selectFill: getRgba(style.getPropertyValue('--color-selected').trim()), viewportFill: getRgba(style.getPropertyValue('--color-muted-1').trim()), } } private colors: ReturnType // this should be called after dark/light mode changes have propagated to the dom updateColors() { this.colors = this._getColors() } readonly id = uniqueId() @computed getDpr() { return this.editor.getInstanceState().devicePixelRatio } @computed getContentPageBounds() { const viewportPageBounds = this.editor.getViewportPageBounds() const commonShapeBounds = this.editor.getCurrentPageBounds() return commonShapeBounds ? Box.Expand(commonShapeBounds, viewportPageBounds) : viewportPageBounds } @computed getContentScreenBounds() { const contentPageBounds = this.getContentPageBounds() const topLeft = this.editor.pageToScreen(contentPageBounds.point) const bottomRight = this.editor.pageToScreen( new Vec(contentPageBounds.maxX, contentPageBounds.maxY) ) return new Box(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y) } private _getCanvasBoundingRect() { const { x, y, width, height } = this.elem.getBoundingClientRect() return new Box(x, y, width, height) } private readonly canvasBoundingClientRect = atom('canvasBoundingClientRect', new Box()) getCanvasScreenBounds() { return this.canvasBoundingClientRect.get() } private _listenForCanvasResize() { const observer = new ResizeObserver(() => { const rect = this._getCanvasBoundingRect() this.canvasBoundingClientRect.set(rect) }) observer.observe(this.elem) return () => observer.disconnect() } @computed getCanvasSize() { const rect = this.canvasBoundingClientRect.get() const dpr = this.getDpr() return new Vec(rect.width * dpr, rect.height * dpr) } @computed getCanvasClientPosition() { return this.canvasBoundingClientRect.get().point } originPagePoint = new Vec() originPageCenter = new Vec() isInViewport = false /** Get the canvas's true bounds converted to page bounds. */ @computed getCanvasPageBounds() { const canvasScreenBounds = this.getCanvasScreenBounds() const contentPageBounds = this.getContentPageBounds() const aspectRatio = canvasScreenBounds.width / canvasScreenBounds.height let targetWidth = contentPageBounds.width let targetHeight = targetWidth / aspectRatio if (targetHeight < contentPageBounds.height) { targetHeight = contentPageBounds.height targetWidth = targetHeight * aspectRatio } const box = new Box(0, 0, targetWidth, targetHeight) box.center = contentPageBounds.center return box } @computed getCanvasPageBoundsArray() { const { x, y, w, h } = this.getCanvasPageBounds() return new Float32Array([x, y, w, h]) } getPagePoint = (clientX: number, clientY: number) => { const canvasPageBounds = this.getCanvasPageBounds() const canvasScreenBounds = this.getCanvasScreenBounds() // first offset the canvas position let x = clientX - canvasScreenBounds.x let y = clientY - canvasScreenBounds.y // then multiply by the ratio between the page and screen bounds x *= canvasPageBounds.width / canvasScreenBounds.width y *= canvasPageBounds.height / canvasScreenBounds.height // then add the canvas page bounds' offset x += canvasPageBounds.minX y += canvasPageBounds.minY return new Vec(x, y, 1) } minimapScreenPointToPagePoint = ( x: number, y: number, shiftKey = false, clampToBounds = false ) => { const { editor } = this const viewportPageBounds = editor.getViewportPageBounds() let { x: px, y: py } = this.getPagePoint(x, y) if (clampToBounds) { const shapesPageBounds = this.editor.getCurrentPageBounds() ?? new Box() const vpPageBounds = viewportPageBounds const minX = shapesPageBounds.minX - vpPageBounds.width / 2 const maxX = shapesPageBounds.maxX + vpPageBounds.width / 2 const minY = shapesPageBounds.minY - vpPageBounds.height / 2 const maxY = shapesPageBounds.maxY + vpPageBounds.height / 2 const lx = Math.max(0, minX + vpPageBounds.width - px) const rx = Math.max(0, -(maxX - vpPageBounds.width - px)) const ly = Math.max(0, minY + vpPageBounds.height - py) const ry = Math.max(0, -(maxY - vpPageBounds.height - py)) const ql = Math.max(0, lx - rx) const qr = Math.max(0, rx - lx) const qt = Math.max(0, ly - ry) const qb = Math.max(0, ry - ly) if (ql && ql > qr) { px += ql / 2 } else if (qr) { px -= qr / 2 } if (qt && qt > qb) { py += qt / 2 } else if (qb) { py -= qb / 2 } px = clamp(px, minX, maxX) py = clamp(py, minY, maxY) } if (shiftKey) { const { originPagePoint } = this const dx = Math.abs(px - originPagePoint.x) const dy = Math.abs(py - originPagePoint.y) if (dx > dy) { py = originPagePoint.y } else { px = originPagePoint.x } } return new Vec(px, py) } render = () => { // make sure we update when dark mode switches const context = this.gl.context const canvasSize = this.getCanvasSize() this.gl.setCanvasPageBounds(this.getCanvasPageBoundsArray()) this.elem.width = canvasSize.x this.elem.height = canvasSize.y context.viewport(0, 0, canvasSize.x, canvasSize.y) // this affects which color transparent shapes are blended with // during rendering. If we were to invert this any shapes narrower // than 1 px in screen space would have much lower contrast. e.g. // draw shapes on a large canvas. if (this.editor.user.getIsDarkMode()) { context.clearColor(1, 1, 1, 0) } else { context.clearColor(0, 0, 0, 0) } context.clear(context.COLOR_BUFFER_BIT) const selectedShapes = new Set(this.editor.getSelectedShapeIds()) const colors = this.colors let selectedShapeOffset = 0 let unselectedShapeOffset = 0 const ids = this.editor.getCurrentPageShapeIdsSorted() for (let i = 0, len = ids.length; i < len; i++) { const shapeId = ids[i] const geometry = this.shapeGeometryCache.get(shapeId) if (!geometry) continue const len = geometry.length if (selectedShapes.has(shapeId)) { appendVertices(this.gl.selectedShapes, selectedShapeOffset, geometry) selectedShapeOffset += len } else { appendVertices(this.gl.unselectedShapes, unselectedShapeOffset, geometry) unselectedShapeOffset += len } } this.drawViewport() this.drawShapes(this.gl.unselectedShapes, unselectedShapeOffset, colors.shapeFill) this.drawShapes(this.gl.selectedShapes, selectedShapeOffset, colors.selectFill) this.drawCollaborators() } private drawShapes(stuff: BufferStuff, len: number, color: Float32Array) { this.gl.prepareTriangles(stuff, len) this.gl.setFillColor(color) this.gl.drawTriangles(len) } private drawViewport() { const viewport = this.editor.getViewportPageBounds() const zoom = this.getCanvasPageBounds().width / this.getCanvasScreenBounds().width const len = roundedRectangle(this.gl.viewport.vertices, viewport, 4 * zoom) this.gl.prepareTriangles(this.gl.viewport, len) this.gl.setFillColor(this.colors.viewportFill) this.gl.drawTriangles(len) } drawCollaborators() { const collaborators = this.editor.getCollaboratorsOnCurrentPage() if (!collaborators.length) return const zoom = this.getCanvasPageBounds().width / this.getCanvasScreenBounds().width // just draw a little circle for each collaborator const numSegmentsPerCircle = 20 const dataSizePerCircle = numSegmentsPerCircle * 6 const totalSize = dataSizePerCircle * collaborators.length // expand vertex array if needed if (this.gl.collaborators.vertices.length < totalSize) { this.gl.collaborators.vertices = new Float32Array(totalSize) } const vertices = this.gl.collaborators.vertices let offset = 0 for (const { cursor } of collaborators) { pie(vertices, { center: Vec.From(cursor), radius: 2 * zoom, offset, numArcSegments: numSegmentsPerCircle, }) offset += dataSizePerCircle } this.gl.prepareTriangles(this.gl.collaborators, totalSize) offset = 0 for (const { color } of collaborators) { this.gl.setFillColor(getRgba(color)) this.gl.context.drawArrays(this.gl.context.TRIANGLES, offset / 2, dataSizePerCircle / 2) offset += dataSizePerCircle } } }