import { Box, Editor, PI2, TLInstancePresence, TLShapeId, Vec, clamp, throttle, uniqueId, } from '@tldraw/editor' export class MinimapManager { constructor(public editor: Editor) {} dpr = 1 colors = { shapeFill: 'rgba(144, 144, 144, .1)', selectFill: '#2f80ed', viewportFill: 'rgba(144, 144, 144, .1)', } id = uniqueId() cvs: HTMLCanvasElement | null = null pageBounds: (Box & { id: TLShapeId })[] = [] collaborators: TLInstancePresence[] = [] canvasScreenBounds = new Box() canvasPageBounds = new Box() contentPageBounds = new Box() contentScreenBounds = new Box() originPagePoint = new Vec() originPageCenter = new Vec() isInViewport = false debug = false setDpr(dpr: number) { this.dpr = +dpr.toFixed(2) } updateContentScreenBounds = () => { const { contentScreenBounds, contentPageBounds: content, canvasScreenBounds: canvas } = this let { x, y, w, h } = contentScreenBounds if (content.w > content.h) { const sh = canvas.w / (content.w / content.h) if (sh > canvas.h) { x = (canvas.w - canvas.w * (canvas.h / sh)) / 2 y = 0 w = canvas.w * (canvas.h / sh) h = canvas.h } else { x = 0 y = (canvas.h - sh) / 2 w = canvas.w h = sh } } else if (content.w < content.h) { const sw = canvas.h / (content.h / content.w) x = (canvas.w - sw) / 2 y = 0 w = sw h = canvas.h } else { x = canvas.h / 2 y = 0 w = canvas.h h = canvas.h } contentScreenBounds.set(x, y, w, h) } /** Get the canvas's true bounds converted to page bounds. */ updateCanvasPageBounds = () => { const { canvasPageBounds, canvasScreenBounds, contentPageBounds, contentScreenBounds } = this canvasPageBounds.set( 0, 0, contentPageBounds.width / (contentScreenBounds.width / canvasScreenBounds.width), contentPageBounds.height / (contentScreenBounds.height / canvasScreenBounds.height) ) canvasPageBounds.center = contentPageBounds.center } getScreenPoint = (x: number, y: number) => { const { canvasScreenBounds } = this const screenX = (x - canvasScreenBounds.minX) * this.dpr const screenY = (y - canvasScreenBounds.minY) * this.dpr return { x: screenX, y: screenY } } getPagePoint = (x: number, y: number) => { const { contentPageBounds, contentScreenBounds, canvasPageBounds } = this const { x: screenX, y: screenY } = this.getScreenPoint(x, y) return new Vec( canvasPageBounds.minX + (screenX * contentPageBounds.width) / contentScreenBounds.width, canvasPageBounds.minY + (screenY * contentPageBounds.height) / contentScreenBounds.height, 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() const vpPageBounds = viewportPageBounds const minX = (shapesPageBounds?.minX ?? 0) - vpPageBounds.width / 2 const maxX = (shapesPageBounds?.maxX ?? 0) + vpPageBounds.width / 2 const minY = (shapesPageBounds?.minY ?? 0) - vpPageBounds.height / 2 const maxY = (shapesPageBounds?.maxY ?? 0) + 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) } updateColors = () => { const style = getComputedStyle(this.editor.getContainer()) this.colors = { shapeFill: style.getPropertyValue('--color-text-3').trim(), selectFill: style.getPropertyValue('--color-selected').trim(), viewportFill: style.getPropertyValue('--color-muted-1').trim(), } } render = throttle(() => { const { cvs, pageBounds } = this this.updateCanvasPageBounds() const { editor, canvasScreenBounds, canvasPageBounds, contentPageBounds, contentScreenBounds } = this const { width: cw, height: ch } = canvasScreenBounds const selectedShapeIds = new Set(editor.getSelectedShapeIds()) const viewportPageBounds = editor.getViewportPageBounds() if (!cvs || !pageBounds) { return } const ctx = cvs.getContext('2d')! if (!ctx) { throw new Error('Minimap (shapes): Could not get context') } ctx.resetTransform() ctx.globalAlpha = 1 ctx.clearRect(0, 0, cw, ch) // Transform canvas const sx = contentScreenBounds.width / contentPageBounds.width const sy = contentScreenBounds.height / contentPageBounds.height ctx.translate((cw - contentScreenBounds.width) / 2, (ch - contentScreenBounds.height) / 2) ctx.scale(sx, sy) ctx.translate(-contentPageBounds.minX, -contentPageBounds.minY) // shapes const shapesPath = new Path2D() const selectedPath = new Path2D() const { shapeFill, selectFill, viewportFill } = this.colors // When there are many shapes, don't draw rounded rectangles; // consider using the shape's size instead. let pb: Box & { id: TLShapeId } for (let i = 0, n = pageBounds.length; i < n; i++) { pb = pageBounds[i] ;(selectedShapeIds.has(pb.id) ? selectedPath : shapesPath).rect( pb.minX, pb.minY, pb.width, pb.height ) } // Fill the shapes paths ctx.fillStyle = shapeFill ctx.fill(shapesPath) // Fill the selected paths ctx.fillStyle = selectFill ctx.fill(selectedPath) if (this.debug) { // Page bounds const commonBounds = Box.Common(pageBounds) const { minX, minY, width, height } = commonBounds ctx.strokeStyle = 'green' ctx.lineWidth = 2 / sx ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy) } // Brush { const { brush } = editor.getInstanceState() if (brush) { const { x, y, w, h } = brush ctx.beginPath() MinimapManager.sharpRect(ctx, x, y, w, h) ctx.closePath() ctx.fillStyle = viewportFill ctx.fill() } } // Viewport { const { minX, minY, width, height } = viewportPageBounds ctx.beginPath() const rx = 12 / sx const ry = 12 / sx MinimapManager.roundedRect( ctx, minX, minY, width, height, Math.min(width / 4, rx), Math.min(height / 4, ry) ) ctx.closePath() ctx.fillStyle = viewportFill ctx.fill() if (this.debug) { ctx.strokeStyle = 'orange' ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy) } } // Show collaborator cursors // Padding for canvas bounds edges const px = 2.5 / sx const py = 2.5 / sy const currentPageId = editor.getCurrentPageId() let collaborator: TLInstancePresence for (let i = 0; i < this.collaborators.length; i++) { collaborator = this.collaborators[i] if (collaborator.currentPageId !== currentPageId) { continue } ctx.beginPath() ctx.ellipse( clamp(collaborator.cursor.x, canvasPageBounds.minX + px, canvasPageBounds.maxX - px), clamp(collaborator.cursor.y, canvasPageBounds.minY + py, canvasPageBounds.maxY - py), 5 / sx, 5 / sy, 0, 0, PI2 ) ctx.fillStyle = collaborator.color ctx.fill() } if (this.debug) { ctx.lineWidth = 2 / sx { // Minimap Bounds const { minX, minY, width, height } = contentPageBounds ctx.strokeStyle = 'red' ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy) } { // Canvas Bounds const { minX, minY, width, height } = canvasPageBounds ctx.strokeStyle = 'blue' ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy) } } }, 32) static roundedRect( ctx: CanvasRenderingContext2D | Path2D, x: number, y: number, width: number, height: number, rx: number, ry: number ) { if (rx < 1 && ry < 1) { ctx.rect(x, y, width, height) return } ctx.moveTo(x + rx, y) ctx.lineTo(x + width - rx, y) ctx.quadraticCurveTo(x + width, y, x + width, y + ry) ctx.lineTo(x + width, y + height - ry) ctx.quadraticCurveTo(x + width, y + height, x + width - rx, y + height) ctx.lineTo(x + rx, y + height) ctx.quadraticCurveTo(x, y + height, x, y + height - ry) ctx.lineTo(x, y + ry) ctx.quadraticCurveTo(x, y, x + rx, y) } static sharpRect( ctx: CanvasRenderingContext2D | Path2D, x: number, y: number, width: number, height: number, _rx?: number, _ry?: number ) { ctx.rect(x, y, width, height) } }