kopia lustrzana https://github.com/Tldraw/Tldraw
326 wiersze
9.3 KiB
TypeScript
326 wiersze
9.3 KiB
TypeScript
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<typeof setupWebGl>
|
|
shapeGeometryCache: ComputedCache<Float32Array | null, TLShape>
|
|
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<MinimapManager['_getColors']>
|
|
// 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
|
|
}
|
|
}
|
|
}
|