WebGL Minimap (#3510)
This PR replaces our current minimap implementation with one that uses WebGL ### Change Type <!-- ❗ Please select a 'Scope' label ❗️ --> - [x] `sdk` — Changes the tldraw SDK - [ ] `dotcom` — Changes the tldraw.com web app - [ ] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [ ] `internal` — Does not affect user-facing stuff <!-- ❗ Please select a 'Type' label ❗️ --> - [ ] `bugfix` — Bug fix - [ ] `feature` — New feature - [x] `improvement` — Improving existing features - [ ] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know ### Test Plan 1. Add a step-by-step description of how to test your PR here. 2. - [ ] Unit Tests - [ ] End to end tests ### Release Notes - Add a brief release note for your PR here. --------- Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
parent
f6a2e352de
commit
b5dfd81540
11 changed files with 743 additions and 420 deletions
|
@ -682,6 +682,8 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
getCameraState(): "idle" | "moving";
|
getCameraState(): "idle" | "moving";
|
||||||
getCanRedo(): boolean;
|
getCanRedo(): boolean;
|
||||||
getCanUndo(): boolean;
|
getCanUndo(): boolean;
|
||||||
|
getCollaborators(): TLInstancePresence[];
|
||||||
|
getCollaboratorsOnCurrentPage(): TLInstancePresence[];
|
||||||
getContainer: () => HTMLElement;
|
getContainer: () => HTMLElement;
|
||||||
getContentFromCurrentPage(shapes: TLShape[] | TLShapeId[]): TLContent | undefined;
|
getContentFromCurrentPage(shapes: TLShape[] | TLShapeId[]): TLContent | undefined;
|
||||||
// @internal
|
// @internal
|
||||||
|
@ -693,6 +695,8 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
getCurrentPageId(): TLPageId;
|
getCurrentPageId(): TLPageId;
|
||||||
getCurrentPageRenderingShapesSorted(): TLShape[];
|
getCurrentPageRenderingShapesSorted(): TLShape[];
|
||||||
getCurrentPageShapeIds(): Set<TLShapeId>;
|
getCurrentPageShapeIds(): Set<TLShapeId>;
|
||||||
|
// @internal (undocumented)
|
||||||
|
getCurrentPageShapeIdsSorted(): TLShapeId[];
|
||||||
getCurrentPageShapes(): TLShape[];
|
getCurrentPageShapes(): TLShape[];
|
||||||
getCurrentPageShapesSorted(): TLShape[];
|
getCurrentPageShapesSorted(): TLShape[];
|
||||||
getCurrentPageState(): TLInstancePageState;
|
getCurrentPageState(): TLInstancePageState;
|
||||||
|
|
|
@ -10059,6 +10059,86 @@
|
||||||
"isAbstract": false,
|
"isAbstract": false,
|
||||||
"name": "getCanUndo"
|
"name": "getCanUndo"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"kind": "Method",
|
||||||
|
"canonicalReference": "@tldraw/editor!Editor#getCollaborators:member(1)",
|
||||||
|
"docComment": "/**\n * Returns a list of presence records for all peer collaborators. This will return the latest presence record for each connected user.\n *\n * @public\n */\n",
|
||||||
|
"excerptTokens": [
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "getCollaborators(): "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "import(\"@tldraw/tlschema\")."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Reference",
|
||||||
|
"text": "TLInstancePresence",
|
||||||
|
"canonicalReference": "@tldraw/tlschema!TLInstancePresence:interface"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "[]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": ";"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isStatic": false,
|
||||||
|
"returnTypeTokenRange": {
|
||||||
|
"startIndex": 1,
|
||||||
|
"endIndex": 4
|
||||||
|
},
|
||||||
|
"releaseTag": "Public",
|
||||||
|
"isProtected": false,
|
||||||
|
"overloadIndex": 1,
|
||||||
|
"parameters": [],
|
||||||
|
"isOptional": false,
|
||||||
|
"isAbstract": false,
|
||||||
|
"name": "getCollaborators"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Method",
|
||||||
|
"canonicalReference": "@tldraw/editor!Editor#getCollaboratorsOnCurrentPage:member(1)",
|
||||||
|
"docComment": "/**\n * Returns a list of presence records for all peer collaborators on the current page. This will return the latest presence record for each connected user.\n *\n * @public\n */\n",
|
||||||
|
"excerptTokens": [
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "getCollaboratorsOnCurrentPage(): "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "import(\"@tldraw/tlschema\")."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Reference",
|
||||||
|
"text": "TLInstancePresence",
|
||||||
|
"canonicalReference": "@tldraw/tlschema!TLInstancePresence:interface"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": "[]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Content",
|
||||||
|
"text": ";"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isStatic": false,
|
||||||
|
"returnTypeTokenRange": {
|
||||||
|
"startIndex": 1,
|
||||||
|
"endIndex": 4
|
||||||
|
},
|
||||||
|
"releaseTag": "Public",
|
||||||
|
"isProtected": false,
|
||||||
|
"overloadIndex": 1,
|
||||||
|
"parameters": [],
|
||||||
|
"isOptional": false,
|
||||||
|
"isAbstract": false,
|
||||||
|
"name": "getCollaboratorsOnCurrentPage"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"kind": "Property",
|
"kind": "Property",
|
||||||
"canonicalReference": "@tldraw/editor!Editor#getContainer:member",
|
"canonicalReference": "@tldraw/editor!Editor#getContainer:member",
|
||||||
|
|
|
@ -2619,15 +2619,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
animateToUser(userId: string): this {
|
animateToUser(userId: string): this {
|
||||||
const presences = this.store.query.records('instance_presence', () => ({
|
const presence = this.getCollaborators().find((c) => c.userId === userId)
|
||||||
userId: { eq: userId },
|
|
||||||
}))
|
|
||||||
|
|
||||||
const presence = [...presences.get()]
|
|
||||||
.sort((a, b) => {
|
|
||||||
return a.lastActivityTimestamp - b.lastActivityTimestamp
|
|
||||||
})
|
|
||||||
.pop()
|
|
||||||
|
|
||||||
if (!presence) return this
|
if (!presence) return this
|
||||||
|
|
||||||
|
@ -2883,6 +2875,45 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
z: point.z ?? 0.5,
|
z: point.z ?? 0.5,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Collaborators
|
||||||
|
|
||||||
|
@computed
|
||||||
|
private _getCollaboratorsQuery() {
|
||||||
|
return this.store.query.records('instance_presence', () => ({
|
||||||
|
userId: { neq: this.user.getId() },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of presence records for all peer collaborators.
|
||||||
|
* This will return the latest presence record for each connected user.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
@computed
|
||||||
|
getCollaborators() {
|
||||||
|
const allPresenceRecords = this._getCollaboratorsQuery().get()
|
||||||
|
if (!allPresenceRecords.length) return EMPTY_ARRAY
|
||||||
|
const userIds = [...new Set(allPresenceRecords.map((c) => c.userId))].sort()
|
||||||
|
return userIds.map((id) => {
|
||||||
|
const latestPresence = allPresenceRecords
|
||||||
|
.filter((c) => c.userId === id)
|
||||||
|
.sort((a, b) => b.lastActivityTimestamp - a.lastActivityTimestamp)[0]
|
||||||
|
return latestPresence
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of presence records for all peer collaborators on the current page.
|
||||||
|
* This will return the latest presence record for each connected user.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
@computed
|
||||||
|
getCollaboratorsOnCurrentPage() {
|
||||||
|
const currentPageId = this.getCurrentPageId()
|
||||||
|
return this.getCollaborators().filter((c) => c.currentPageId === currentPageId)
|
||||||
|
}
|
||||||
|
|
||||||
// Following
|
// Following
|
||||||
|
|
||||||
|
@ -2894,9 +2925,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
startFollowingUser(userId: string): this {
|
startFollowingUser(userId: string): this {
|
||||||
const leaderPresences = this.store.query.records('instance_presence', () => ({
|
const leaderPresences = this._getCollaboratorsQuery()
|
||||||
userId: { eq: userId },
|
.get()
|
||||||
}))
|
.filter((p) => p.userId === userId)
|
||||||
|
|
||||||
const thisUserId = this.user.getId()
|
const thisUserId = this.user.getId()
|
||||||
|
|
||||||
|
@ -2905,7 +2936,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the leader is following us, then we can't follow them
|
// If the leader is following us, then we can't follow them
|
||||||
if (leaderPresences.get().some((p) => p.followingUserId === thisUserId)) {
|
if (leaderPresences.some((p) => p.followingUserId === thisUserId)) {
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2924,7 +2955,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
|
|
||||||
const moveTowardsUser = () => {
|
const moveTowardsUser = () => {
|
||||||
// Stop following if we can't find the user
|
// Stop following if we can't find the user
|
||||||
const leaderPresence = [...leaderPresences.get()]
|
const leaderPresence = [...leaderPresences]
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
return a.lastActivityTimestamp - b.lastActivityTimestamp
|
return a.lastActivityTimestamp - b.lastActivityTimestamp
|
||||||
})
|
})
|
||||||
|
@ -3281,6 +3312,14 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
return this._currentPageShapeIds.get()
|
return this._currentPageShapeIds.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
@computed
|
||||||
|
getCurrentPageShapeIdsSorted() {
|
||||||
|
return Array.from(this.getCurrentPageShapeIds()).sort()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the ids of shapes on a page.
|
* Get the ids of shapes on a page.
|
||||||
*
|
*
|
||||||
|
@ -3893,7 +3932,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
getShapePageTransform(shape: TLShape | TLShapeId): Mat {
|
getShapePageTransform(shape: TLShape | TLShapeId): Mat {
|
||||||
const id = typeof shape === 'string' ? shape : this.getShape(shape)!.id
|
const id = typeof shape === 'string' ? shape : shape.id
|
||||||
return this._getShapePageTransformCache().get(id) ?? Mat.Identity()
|
return this._getShapePageTransformCache().get(id) ?? Mat.Identity()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4227,7 +4266,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
@computed getCurrentPageBounds(): Box | undefined {
|
@computed getCurrentPageBounds(): Box | undefined {
|
||||||
let commonBounds: Box | undefined
|
let commonBounds: Box | undefined
|
||||||
|
|
||||||
this.getCurrentPageShapeIds().forEach((shapeId) => {
|
this.getCurrentPageShapeIdsSorted().forEach((shapeId) => {
|
||||||
const bounds = this.getShapeMaskedPageBounds(shapeId)
|
const bounds = this.getShapeMaskedPageBounds(shapeId)
|
||||||
if (!bounds) return
|
if (!bounds) return
|
||||||
if (!commonBounds) {
|
if (!commonBounds) {
|
||||||
|
@ -8159,7 +8198,11 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
// it will be 0,0 when its actual screen position is equal
|
// it will be 0,0 when its actual screen position is equal
|
||||||
// to screenBounds.point. This is confusing!
|
// to screenBounds.point. This is confusing!
|
||||||
currentScreenPoint.set(sx, sy)
|
currentScreenPoint.set(sx, sy)
|
||||||
currentPagePoint.set(sx / cz - cx, sy / cz - cy, sz)
|
const nx = sx / cz - cx
|
||||||
|
const ny = sy / cz - cy
|
||||||
|
if (isFinite(nx) && isFinite(ny)) {
|
||||||
|
currentPagePoint.set(nx, ny, sz)
|
||||||
|
}
|
||||||
|
|
||||||
this.inputs.isPen = info.type === 'pointer' && info.isPen
|
this.inputs.isPen = info.type === 'pointer' && info.isPen
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { useComputed, useValue } from '@tldraw/state'
|
import { useComputed, useValue } from '@tldraw/state'
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { uniq } from '../utils/uniq'
|
import { uniq } from '../utils/uniq'
|
||||||
import { useEditor } from './useEditor'
|
import { useEditor } from './useEditor'
|
||||||
|
|
||||||
|
@ -10,17 +9,12 @@ import { useEditor } from './useEditor'
|
||||||
*/
|
*/
|
||||||
export function usePeerIds() {
|
export function usePeerIds() {
|
||||||
const editor = useEditor()
|
const editor = useEditor()
|
||||||
const $presences = useMemo(() => {
|
|
||||||
return editor.store.query.records('instance_presence', () => ({
|
|
||||||
userId: { neq: editor.user.getId() },
|
|
||||||
}))
|
|
||||||
}, [editor])
|
|
||||||
|
|
||||||
const $userIds = useComputed(
|
const $userIds = useComputed(
|
||||||
'userIds',
|
'userIds',
|
||||||
() => uniq($presences.get().map((p) => p.userId)).sort(),
|
() => uniq(editor.getCollaborators().map((p) => p.userId)).sort(),
|
||||||
{ isEqual: (a, b) => a.join(',') === b.join?.(',') },
|
{ isEqual: (a, b) => a.join(',') === b.join?.(',') },
|
||||||
[$presences]
|
[editor]
|
||||||
)
|
)
|
||||||
|
|
||||||
return useValue($userIds)
|
return useValue($userIds)
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { useValue } from '@tldraw/state'
|
import { useValue } from '@tldraw/state'
|
||||||
import { TLInstancePresence } from '@tldraw/tlschema'
|
import { TLInstancePresence } from '@tldraw/tlschema'
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { useEditor } from './useEditor'
|
import { useEditor } from './useEditor'
|
||||||
|
|
||||||
// TODO: maybe move this to a computed property on the App class?
|
// TODO: maybe move this to a computed property on the App class?
|
||||||
|
@ -11,21 +10,12 @@ import { useEditor } from './useEditor'
|
||||||
export function usePresence(userId: string): TLInstancePresence | null {
|
export function usePresence(userId: string): TLInstancePresence | null {
|
||||||
const editor = useEditor()
|
const editor = useEditor()
|
||||||
|
|
||||||
const $presences = useMemo(() => {
|
|
||||||
return editor.store.query.records('instance_presence', () => ({
|
|
||||||
userId: { eq: userId },
|
|
||||||
}))
|
|
||||||
}, [editor, userId])
|
|
||||||
|
|
||||||
const latestPresence = useValue(
|
const latestPresence = useValue(
|
||||||
`latestPresence:${userId}`,
|
`latestPresence:${userId}`,
|
||||||
() => {
|
() => {
|
||||||
return $presences
|
return editor.getCollaborators().find((c) => c.userId === userId)
|
||||||
.get()
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => b.lastActivityTimestamp - a.lastActivityTimestamp)[0]
|
|
||||||
},
|
},
|
||||||
[]
|
[editor]
|
||||||
)
|
)
|
||||||
|
|
||||||
return latestPresence ?? null
|
return latestPresence ?? null
|
||||||
|
|
|
@ -39,12 +39,13 @@ export class Mat {
|
||||||
|
|
||||||
equals(m: Mat | MatModel) {
|
equals(m: Mat | MatModel) {
|
||||||
return (
|
return (
|
||||||
this.a === m.a &&
|
this === m ||
|
||||||
|
(this.a === m.a &&
|
||||||
this.b === m.b &&
|
this.b === m.b &&
|
||||||
this.c === m.c &&
|
this.c === m.c &&
|
||||||
this.d === m.d &&
|
this.d === m.d &&
|
||||||
this.e === m.e &&
|
this.e === m.e &&
|
||||||
this.f === m.f
|
this.f === m.f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,18 +1,13 @@
|
||||||
import {
|
import {
|
||||||
ANIMATION_MEDIUM_MS,
|
ANIMATION_MEDIUM_MS,
|
||||||
Box,
|
|
||||||
TLPointerEventInfo,
|
TLPointerEventInfo,
|
||||||
TLShapeId,
|
|
||||||
Vec,
|
Vec,
|
||||||
getPointerInfo,
|
getPointerInfo,
|
||||||
intersectPolygonPolygon,
|
|
||||||
normalizeWheel,
|
normalizeWheel,
|
||||||
releasePointerCapture,
|
releasePointerCapture,
|
||||||
setPointerCapture,
|
setPointerCapture,
|
||||||
useComputed,
|
|
||||||
useEditor,
|
useEditor,
|
||||||
useIsDarkMode,
|
useIsDarkMode,
|
||||||
useQuickReactor,
|
|
||||||
} from '@tldraw/editor'
|
} from '@tldraw/editor'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { MinimapManager } from './MinimapManager'
|
import { MinimapManager } from './MinimapManager'
|
||||||
|
@ -24,67 +19,78 @@ export function DefaultMinimap() {
|
||||||
const rCanvas = React.useRef<HTMLCanvasElement>(null!)
|
const rCanvas = React.useRef<HTMLCanvasElement>(null!)
|
||||||
const rPointing = React.useRef(false)
|
const rPointing = React.useRef(false)
|
||||||
|
|
||||||
const isDarkMode = useIsDarkMode()
|
const minimapRef = React.useRef<MinimapManager>()
|
||||||
const devicePixelRatio = useComputed('dpr', () => editor.getInstanceState().devicePixelRatio, [
|
|
||||||
editor,
|
|
||||||
])
|
|
||||||
const presences = React.useMemo(() => editor.store.query.records('instance_presence'), [editor])
|
|
||||||
|
|
||||||
const minimap = React.useMemo(() => new MinimapManager(editor), [editor])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// Must check after render
|
const minimap = new MinimapManager(editor, rCanvas.current)
|
||||||
const raf = requestAnimationFrame(() => {
|
minimapRef.current = minimap
|
||||||
minimap.updateColors()
|
return minimapRef.current.close
|
||||||
minimap.render()
|
}, [editor])
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(raf)
|
|
||||||
}
|
|
||||||
}, [editor, minimap, isDarkMode])
|
|
||||||
|
|
||||||
const onDoubleClick = React.useCallback(
|
const onDoubleClick = React.useCallback(
|
||||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
if (!editor.getCurrentPageShapeIds().size) return
|
if (!editor.getCurrentPageShapeIds().size) return
|
||||||
|
if (!minimapRef.current) return
|
||||||
|
|
||||||
const point = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, false)
|
const point = minimapRef.current.minimapScreenPointToPagePoint(
|
||||||
|
e.clientX,
|
||||||
|
e.clientY,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
const clampedPoint = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, true)
|
const clampedPoint = minimapRef.current.minimapScreenPointToPagePoint(
|
||||||
|
e.clientX,
|
||||||
|
e.clientY,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
minimap.originPagePoint.setTo(clampedPoint)
|
minimapRef.current.originPagePoint.setTo(clampedPoint)
|
||||||
minimap.originPageCenter.setTo(editor.getViewportPageBounds().center)
|
minimapRef.current.originPageCenter.setTo(editor.getViewportPageBounds().center)
|
||||||
|
|
||||||
editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS })
|
editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS })
|
||||||
},
|
},
|
||||||
[editor, minimap]
|
[editor]
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPointerDown = React.useCallback(
|
const onPointerDown = React.useCallback(
|
||||||
(e: React.PointerEvent<HTMLCanvasElement>) => {
|
(e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!minimapRef.current) return
|
||||||
const elm = e.currentTarget
|
const elm = e.currentTarget
|
||||||
setPointerCapture(elm, e)
|
setPointerCapture(elm, e)
|
||||||
if (!editor.getCurrentPageShapeIds().size) return
|
if (!editor.getCurrentPageShapeIds().size) return
|
||||||
|
|
||||||
rPointing.current = true
|
rPointing.current = true
|
||||||
|
|
||||||
minimap.isInViewport = false
|
minimapRef.current.isInViewport = false
|
||||||
|
|
||||||
const point = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, false)
|
const point = minimapRef.current.minimapScreenPointToPagePoint(
|
||||||
|
e.clientX,
|
||||||
|
e.clientY,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
const clampedPoint = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, false, true)
|
const clampedPoint = minimapRef.current.minimapScreenPointToPagePoint(
|
||||||
|
e.clientX,
|
||||||
|
e.clientY,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
const _vpPageBounds = editor.getViewportPageBounds()
|
const _vpPageBounds = editor.getViewportPageBounds()
|
||||||
|
|
||||||
minimap.isInViewport = _vpPageBounds.containsPoint(clampedPoint)
|
minimapRef.current.isInViewport = _vpPageBounds.containsPoint(clampedPoint)
|
||||||
|
|
||||||
if (minimap.isInViewport) {
|
if (minimapRef.current.isInViewport) {
|
||||||
minimap.originPagePoint.setTo(clampedPoint)
|
minimapRef.current.originPagePoint.setTo(clampedPoint)
|
||||||
minimap.originPageCenter.setTo(_vpPageBounds.center)
|
minimapRef.current.originPageCenter.setTo(_vpPageBounds.center)
|
||||||
} else {
|
} else {
|
||||||
const delta = Vec.Sub(_vpPageBounds.center, _vpPageBounds.point)
|
const delta = Vec.Sub(_vpPageBounds.center, _vpPageBounds.point)
|
||||||
const pagePoint = Vec.Add(point, delta)
|
const pagePoint = Vec.Add(point, delta)
|
||||||
minimap.originPagePoint.setTo(pagePoint)
|
minimapRef.current.originPagePoint.setTo(pagePoint)
|
||||||
minimap.originPageCenter.setTo(point)
|
minimapRef.current.originPageCenter.setTo(point)
|
||||||
editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS })
|
editor.centerOnPoint(point, { duration: ANIMATION_MEDIUM_MS })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,16 +104,24 @@ export function DefaultMinimap() {
|
||||||
|
|
||||||
document.body.addEventListener('pointerup', release)
|
document.body.addEventListener('pointerup', release)
|
||||||
},
|
},
|
||||||
[editor, minimap]
|
[editor]
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPointerMove = React.useCallback(
|
const onPointerMove = React.useCallback(
|
||||||
(e: React.PointerEvent<HTMLCanvasElement>) => {
|
(e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
const point = minimap.minimapScreenPointToPagePoint(e.clientX, e.clientY, e.shiftKey, true)
|
if (!minimapRef.current) return
|
||||||
|
const point = minimapRef.current.minimapScreenPointToPagePoint(
|
||||||
|
e.clientX,
|
||||||
|
e.clientY,
|
||||||
|
e.shiftKey,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
if (rPointing.current) {
|
if (rPointing.current) {
|
||||||
if (minimap.isInViewport) {
|
if (minimapRef.current.isInViewport) {
|
||||||
const delta = minimap.originPagePoint.clone().sub(minimap.originPageCenter)
|
const delta = minimapRef.current.originPagePoint
|
||||||
|
.clone()
|
||||||
|
.sub(minimapRef.current.originPageCenter)
|
||||||
editor.centerOnPoint(Vec.Sub(point, delta))
|
editor.centerOnPoint(Vec.Sub(point, delta))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -115,7 +129,7 @@ export function DefaultMinimap() {
|
||||||
editor.centerOnPoint(point)
|
editor.centerOnPoint(point)
|
||||||
}
|
}
|
||||||
|
|
||||||
const pagePoint = minimap.getPagePoint(e.clientX, e.clientY)
|
const pagePoint = minimapRef.current.getPagePoint(e.clientX, e.clientY)
|
||||||
|
|
||||||
const screenPoint = editor.pageToScreen(pagePoint)
|
const screenPoint = editor.pageToScreen(pagePoint)
|
||||||
|
|
||||||
|
@ -130,7 +144,7 @@ export function DefaultMinimap() {
|
||||||
|
|
||||||
editor.dispatch(info)
|
editor.dispatch(info)
|
||||||
},
|
},
|
||||||
[editor, minimap]
|
[editor]
|
||||||
)
|
)
|
||||||
|
|
||||||
const onWheel = React.useCallback(
|
const onWheel = React.useCallback(
|
||||||
|
@ -150,73 +164,16 @@ export function DefaultMinimap() {
|
||||||
[editor]
|
[editor]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update the minimap's dpr when the dpr changes
|
const isDarkMode = useIsDarkMode()
|
||||||
useQuickReactor(
|
|
||||||
'update when dpr changes',
|
|
||||||
() => {
|
|
||||||
const dpr = devicePixelRatio.get()
|
|
||||||
minimap.setDpr(dpr)
|
|
||||||
|
|
||||||
const canvas = rCanvas.current as HTMLCanvasElement
|
React.useEffect(() => {
|
||||||
const rect = canvas.getBoundingClientRect()
|
// need to wait a tick for next theme css to be applied
|
||||||
const width = rect.width * dpr
|
// otherwise the minimap will render with the wrong colors
|
||||||
const height = rect.height * dpr
|
setTimeout(() => {
|
||||||
|
minimapRef.current?.updateColors()
|
||||||
// These must happen in order
|
minimapRef.current?.render()
|
||||||
canvas.width = width
|
|
||||||
canvas.height = height
|
|
||||||
minimap.canvasScreenBounds.set(rect.x, rect.y, width, height)
|
|
||||||
|
|
||||||
minimap.cvs = rCanvas.current
|
|
||||||
},
|
|
||||||
[devicePixelRatio, minimap]
|
|
||||||
)
|
|
||||||
|
|
||||||
useQuickReactor(
|
|
||||||
'minimap render when pagebounds or collaborators changes',
|
|
||||||
() => {
|
|
||||||
const shapeIdsOnCurrentPage = editor.getCurrentPageShapeIds()
|
|
||||||
const commonBoundsOfAllShapesOnCurrentPage = editor.getCurrentPageBounds()
|
|
||||||
const viewportPageBounds = editor.getViewportPageBounds()
|
|
||||||
|
|
||||||
const _dpr = devicePixelRatio.get() // dereference
|
|
||||||
|
|
||||||
minimap.contentPageBounds = commonBoundsOfAllShapesOnCurrentPage
|
|
||||||
? Box.Expand(commonBoundsOfAllShapesOnCurrentPage, viewportPageBounds)
|
|
||||||
: viewportPageBounds
|
|
||||||
|
|
||||||
minimap.updateContentScreenBounds()
|
|
||||||
|
|
||||||
// All shape bounds
|
|
||||||
|
|
||||||
const allShapeBounds = [] as (Box & { id: TLShapeId })[]
|
|
||||||
|
|
||||||
shapeIdsOnCurrentPage.forEach((id) => {
|
|
||||||
let pageBounds = editor.getShapePageBounds(id) as Box & { id: TLShapeId }
|
|
||||||
if (!pageBounds) return
|
|
||||||
|
|
||||||
const pageMask = editor.getShapeMask(id)
|
|
||||||
|
|
||||||
if (pageMask) {
|
|
||||||
const intersection = intersectPolygonPolygon(pageMask, pageBounds.corners)
|
|
||||||
if (!intersection) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pageBounds = Box.FromPoints(intersection) as Box & { id: TLShapeId }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pageBounds) {
|
|
||||||
pageBounds.id = id // kinda dirty but we want to include the id here
|
|
||||||
allShapeBounds.push(pageBounds)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
}, [isDarkMode])
|
||||||
minimap.pageBounds = allShapeBounds
|
|
||||||
minimap.collaborators = presences.get()
|
|
||||||
minimap.render()
|
|
||||||
},
|
|
||||||
[editor, minimap]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tlui-minimap">
|
<div className="tlui-minimap">
|
||||||
|
|
|
@ -1,114 +1,159 @@
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
|
ComputedCache,
|
||||||
Editor,
|
Editor,
|
||||||
PI2,
|
TLShape,
|
||||||
TLInstancePresence,
|
|
||||||
TLShapeId,
|
|
||||||
Vec,
|
Vec,
|
||||||
|
atom,
|
||||||
clamp,
|
clamp,
|
||||||
|
computed,
|
||||||
|
react,
|
||||||
uniqueId,
|
uniqueId,
|
||||||
} from '@tldraw/editor'
|
} 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 {
|
export class MinimapManager {
|
||||||
constructor(public editor: Editor) {}
|
disposables = [] as (() => void)[]
|
||||||
|
close = () => this.disposables.forEach((d) => d())
|
||||||
dpr = 1
|
gl: ReturnType<typeof setupWebGl>
|
||||||
|
shapeGeometryCache: ComputedCache<Float32Array | null, TLShape>
|
||||||
colors = {
|
constructor(
|
||||||
shapeFill: 'rgba(144, 144, 144, .1)',
|
public editor: Editor,
|
||||||
selectFill: '#2f80ed',
|
public readonly elem: HTMLCanvasElement
|
||||||
viewportFill: 'rgba(144, 144, 144, .1)',
|
) {
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
id = uniqueId()
|
private _getColors() {
|
||||||
cvs: HTMLCanvasElement | null = null
|
const style = getComputedStyle(this.editor.getContainer())
|
||||||
pageBounds: (Box & { id: TLShapeId })[] = []
|
|
||||||
collaborators: TLInstancePresence[] = []
|
|
||||||
|
|
||||||
canvasScreenBounds = new Box()
|
return {
|
||||||
canvasPageBounds = new Box()
|
shapeFill: getRgba(style.getPropertyValue('--color-text-3').trim()),
|
||||||
|
selectFill: getRgba(style.getPropertyValue('--color-selected').trim()),
|
||||||
|
viewportFill: getRgba(style.getPropertyValue('--color-muted-1').trim()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
contentPageBounds = new Box()
|
private colors: ReturnType<MinimapManager['_getColors']>
|
||||||
contentScreenBounds = new Box()
|
// 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()
|
originPagePoint = new Vec()
|
||||||
originPageCenter = new Vec()
|
originPageCenter = new Vec()
|
||||||
|
|
||||||
isInViewport = false
|
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. */
|
/** Get the canvas's true bounds converted to page bounds. */
|
||||||
updateCanvasPageBounds = () => {
|
@computed getCanvasPageBounds() {
|
||||||
const { canvasPageBounds, canvasScreenBounds, contentPageBounds, contentScreenBounds } = this
|
const canvasScreenBounds = this.getCanvasScreenBounds()
|
||||||
|
const contentPageBounds = this.getContentPageBounds()
|
||||||
|
|
||||||
canvasPageBounds.set(
|
const aspectRatio = canvasScreenBounds.width / canvasScreenBounds.height
|
||||||
0,
|
|
||||||
0,
|
|
||||||
contentPageBounds.width / (contentScreenBounds.width / canvasScreenBounds.width),
|
|
||||||
contentPageBounds.height / (contentScreenBounds.height / canvasScreenBounds.height)
|
|
||||||
)
|
|
||||||
|
|
||||||
canvasPageBounds.center = contentPageBounds.center
|
let targetWidth = contentPageBounds.width
|
||||||
|
let targetHeight = targetWidth / aspectRatio
|
||||||
|
if (targetHeight < contentPageBounds.height) {
|
||||||
|
targetHeight = contentPageBounds.height
|
||||||
|
targetWidth = targetHeight * aspectRatio
|
||||||
}
|
}
|
||||||
|
|
||||||
getScreenPoint = (x: number, y: number) => {
|
const box = new Box(0, 0, targetWidth, targetHeight)
|
||||||
const { canvasScreenBounds } = this
|
box.center = contentPageBounds.center
|
||||||
|
return box
|
||||||
const screenX = (x - canvasScreenBounds.minX) * this.dpr
|
|
||||||
const screenY = (y - canvasScreenBounds.minY) * this.dpr
|
|
||||||
|
|
||||||
return { x: screenX, y: screenY }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getPagePoint = (x: number, y: number) => {
|
@computed getCanvasPageBoundsArray() {
|
||||||
const { contentPageBounds, contentScreenBounds, canvasPageBounds } = this
|
const { x, y, w, h } = this.getCanvasPageBounds()
|
||||||
|
return new Float32Array([x, y, w, h])
|
||||||
|
}
|
||||||
|
|
||||||
const { x: screenX, y: screenY } = this.getScreenPoint(x, y)
|
getPagePoint = (clientX: number, clientY: number) => {
|
||||||
|
const canvasPageBounds = this.getCanvasPageBounds()
|
||||||
|
const canvasScreenBounds = this.getCanvasScreenBounds()
|
||||||
|
|
||||||
return new Vec(
|
// first offset the canvas position
|
||||||
canvasPageBounds.minX + (screenX * contentPageBounds.width) / contentScreenBounds.width,
|
let x = clientX - canvasScreenBounds.x
|
||||||
canvasPageBounds.minY + (screenY * contentPageBounds.height) / contentScreenBounds.height,
|
let y = clientY - canvasScreenBounds.y
|
||||||
1
|
|
||||||
)
|
// 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 = (
|
minimapScreenPointToPagePoint = (
|
||||||
|
@ -123,13 +168,13 @@ export class MinimapManager {
|
||||||
let { x: px, y: py } = this.getPagePoint(x, y)
|
let { x: px, y: py } = this.getPagePoint(x, y)
|
||||||
|
|
||||||
if (clampToBounds) {
|
if (clampToBounds) {
|
||||||
const shapesPageBounds = this.editor.getCurrentPageBounds()
|
const shapesPageBounds = this.editor.getCurrentPageBounds() ?? new Box()
|
||||||
const vpPageBounds = viewportPageBounds
|
const vpPageBounds = viewportPageBounds
|
||||||
|
|
||||||
const minX = (shapesPageBounds?.minX ?? 0) - vpPageBounds.width / 2
|
const minX = shapesPageBounds.minX - vpPageBounds.width / 2
|
||||||
const maxX = (shapesPageBounds?.maxX ?? 0) + vpPageBounds.width / 2
|
const maxX = shapesPageBounds.maxX + vpPageBounds.width / 2
|
||||||
const minY = (shapesPageBounds?.minY ?? 0) - vpPageBounds.height / 2
|
const minY = shapesPageBounds.minY - vpPageBounds.height / 2
|
||||||
const maxY = (shapesPageBounds?.maxY ?? 0) + vpPageBounds.height / 2
|
const maxY = shapesPageBounds.maxY + vpPageBounds.height / 2
|
||||||
|
|
||||||
const lx = Math.max(0, minX + vpPageBounds.width - px)
|
const lx = Math.max(0, minX + vpPageBounds.width - px)
|
||||||
const rx = Math.max(0, -(maxX - vpPageBounds.width - px))
|
const rx = Math.max(0, -(maxX - vpPageBounds.width - px))
|
||||||
|
@ -171,209 +216,110 @@ export class MinimapManager {
|
||||||
return new Vec(px, py)
|
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 = () => {
|
render = () => {
|
||||||
const { cvs, pageBounds } = this
|
// make sure we update when dark mode switches
|
||||||
this.updateCanvasPageBounds()
|
const context = this.gl.context
|
||||||
|
const canvasSize = this.getCanvasSize()
|
||||||
|
|
||||||
const { editor, canvasScreenBounds, canvasPageBounds, contentPageBounds, contentScreenBounds } =
|
this.gl.setCanvasPageBounds(this.getCanvasPageBoundsArray())
|
||||||
this
|
|
||||||
const { width: cw, height: ch } = canvasScreenBounds
|
|
||||||
|
|
||||||
const selectedShapeIds = new Set(editor.getSelectedShapeIds())
|
this.elem.width = canvasSize.x
|
||||||
const viewportPageBounds = editor.getViewportPageBounds()
|
this.elem.height = canvasSize.y
|
||||||
|
context.viewport(0, 0, canvasSize.x, canvasSize.y)
|
||||||
|
|
||||||
if (!cvs || !pageBounds) {
|
// this affects which color transparent shapes are blended with
|
||||||
return
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = cvs.getContext('2d')!
|
context.clear(context.COLOR_BUFFER_BIT)
|
||||||
|
|
||||||
if (!ctx) {
|
const selectedShapes = new Set(this.editor.getSelectedShapeIds())
|
||||||
throw new Error('Minimap (shapes): Could not get context')
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.resetTransform()
|
const colors = this.colors
|
||||||
ctx.globalAlpha = 1
|
let selectedShapeOffset = 0
|
||||||
ctx.clearRect(0, 0, cw, ch)
|
let unselectedShapeOffset = 0
|
||||||
|
|
||||||
// Transform canvas
|
const ids = this.editor.getCurrentPageShapeIdsSorted()
|
||||||
|
|
||||||
const sx = contentScreenBounds.width / contentPageBounds.width
|
for (let i = 0, len = ids.length; i < len; i++) {
|
||||||
const sy = contentScreenBounds.height / contentPageBounds.height
|
const shapeId = ids[i]
|
||||||
|
const geometry = this.shapeGeometryCache.get(shapeId)
|
||||||
|
if (!geometry) continue
|
||||||
|
|
||||||
ctx.translate((cw - contentScreenBounds.width) / 2, (ch - contentScreenBounds.height) / 2)
|
const len = geometry.length
|
||||||
ctx.scale(sx, sy)
|
|
||||||
ctx.translate(-contentPageBounds.minX, -contentPageBounds.minY)
|
|
||||||
|
|
||||||
// shapes
|
if (selectedShapes.has(shapeId)) {
|
||||||
const shapesPath = new Path2D()
|
appendVertices(this.gl.selectedShapes, selectedShapeOffset, geometry)
|
||||||
const selectedPath = new Path2D()
|
selectedShapeOffset += len
|
||||||
|
} else {
|
||||||
const { shapeFill, selectFill, viewportFill } = this.colors
|
appendVertices(this.gl.unselectedShapes, unselectedShapeOffset, geometry)
|
||||||
|
unselectedShapeOffset += len
|
||||||
// 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
|
this.drawViewport()
|
||||||
{
|
this.drawShapes(this.gl.unselectedShapes, unselectedShapeOffset, colors.shapeFill)
|
||||||
const { minX, minY, width, height } = viewportPageBounds
|
this.drawShapes(this.gl.selectedShapes, selectedShapeOffset, colors.selectFill)
|
||||||
|
this.drawCollaborators()
|
||||||
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
|
private drawShapes(stuff: BufferStuff, len: number, color: Float32Array) {
|
||||||
|
this.gl.prepareTriangles(stuff, len)
|
||||||
// Padding for canvas bounds edges
|
this.gl.setFillColor(color)
|
||||||
const px = 2.5 / sx
|
this.gl.drawTriangles(len)
|
||||||
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()
|
private drawViewport() {
|
||||||
ctx.ellipse(
|
const viewport = this.editor.getViewportPageBounds()
|
||||||
clamp(collaborator.cursor.x, canvasPageBounds.minX + px, canvasPageBounds.maxX - px),
|
const zoom = this.getCanvasPageBounds().width / this.getCanvasScreenBounds().width
|
||||||
clamp(collaborator.cursor.y, canvasPageBounds.minY + py, canvasPageBounds.maxY - py),
|
const len = roundedRectangle(this.gl.viewport.vertices, viewport, 4 * zoom)
|
||||||
5 / sx,
|
|
||||||
5 / sy,
|
this.gl.prepareTriangles(this.gl.viewport, len)
|
||||||
0,
|
this.gl.setFillColor(this.colors.viewportFill)
|
||||||
0,
|
this.gl.drawTriangles(len)
|
||||||
PI2
|
|
||||||
)
|
|
||||||
ctx.fillStyle = collaborator.color
|
|
||||||
ctx.fill()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.debug) {
|
drawCollaborators() {
|
||||||
ctx.lineWidth = 2 / sx
|
const collaborators = this.editor.getCollaboratorsOnCurrentPage()
|
||||||
|
if (!collaborators.length) return
|
||||||
|
|
||||||
{
|
const zoom = this.getCanvasPageBounds().width / this.getCanvasScreenBounds().width
|
||||||
// Minimap Bounds
|
|
||||||
const { minX, minY, width, height } = contentPageBounds
|
// just draw a little circle for each collaborator
|
||||||
ctx.strokeStyle = 'red'
|
const numSegmentsPerCircle = 20
|
||||||
ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy)
|
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
|
||||||
// Canvas Bounds
|
let offset = 0
|
||||||
const { minX, minY, width, height } = canvasPageBounds
|
for (const { cursor } of collaborators) {
|
||||||
ctx.strokeStyle = 'blue'
|
pie(vertices, {
|
||||||
ctx.strokeRect(minX + 1 / sx, minY + 1 / sy, width - 2 / sx, height - 2 / sy)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
16
packages/tldraw/src/lib/ui/components/Minimap/getRgba.ts
Normal file
16
packages/tldraw/src/lib/ui/components/Minimap/getRgba.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
const memo = {} as Record<string, Float32Array>
|
||||||
|
|
||||||
|
export function getRgba(colorString: string) {
|
||||||
|
if (memo[colorString]) {
|
||||||
|
return memo[colorString]
|
||||||
|
}
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
context!.fillStyle = colorString
|
||||||
|
context!.fillRect(0, 0, 1, 1)
|
||||||
|
const [r, g, b, a] = context!.getImageData(0, 0, 1, 1).data
|
||||||
|
const result = new Float32Array([r / 255, g / 255, b / 255, a / 255])
|
||||||
|
|
||||||
|
memo[colorString] = result
|
||||||
|
return result
|
||||||
|
}
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { roundedRectangleDataSize } from './minimap-webgl-shapes'
|
||||||
|
|
||||||
|
export function setupWebGl(canvas: HTMLCanvasElement | null) {
|
||||||
|
if (!canvas) throw new Error('Canvas element not found')
|
||||||
|
|
||||||
|
const context = canvas.getContext('webgl2', {
|
||||||
|
premultipliedAlpha: false,
|
||||||
|
})
|
||||||
|
if (!context) throw new Error('Failed to get webgl2 context')
|
||||||
|
|
||||||
|
const vertexShaderSourceCode = `#version 300 es
|
||||||
|
precision mediump float;
|
||||||
|
|
||||||
|
in vec2 shapeVertexPosition;
|
||||||
|
|
||||||
|
uniform vec4 canvasPageBounds;
|
||||||
|
|
||||||
|
// taken (with thanks) from
|
||||||
|
// https://webglfundamentals.org/webgl/lessons/webgl-2d-matrices.html
|
||||||
|
void main() {
|
||||||
|
// convert the position from pixels to 0.0 to 1.0
|
||||||
|
vec2 zeroToOne = (shapeVertexPosition - canvasPageBounds.xy) / canvasPageBounds.zw;
|
||||||
|
|
||||||
|
// convert from 0->1 to 0->2
|
||||||
|
vec2 zeroToTwo = zeroToOne * 2.0;
|
||||||
|
|
||||||
|
// convert from 0->2 to -1->+1 (clipspace)
|
||||||
|
vec2 clipSpace = zeroToTwo - 1.0;
|
||||||
|
|
||||||
|
gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
|
||||||
|
}`
|
||||||
|
|
||||||
|
const vertexShader = context.createShader(context.VERTEX_SHADER)
|
||||||
|
if (!vertexShader) {
|
||||||
|
throw new Error('Failed to create vertex shader')
|
||||||
|
}
|
||||||
|
context.shaderSource(vertexShader, vertexShaderSourceCode)
|
||||||
|
context.compileShader(vertexShader)
|
||||||
|
if (!context.getShaderParameter(vertexShader, context.COMPILE_STATUS)) {
|
||||||
|
throw new Error('Failed to compile vertex shader')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fragmentShaderSourceCode = `#version 300 es
|
||||||
|
precision mediump float;
|
||||||
|
|
||||||
|
uniform vec4 fillColor;
|
||||||
|
out vec4 outputColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
outputColor = fillColor;
|
||||||
|
}`
|
||||||
|
|
||||||
|
const fragmentShader = context.createShader(context.FRAGMENT_SHADER)
|
||||||
|
if (!fragmentShader) {
|
||||||
|
throw new Error('Failed to create fragment shader')
|
||||||
|
}
|
||||||
|
context.shaderSource(fragmentShader, fragmentShaderSourceCode)
|
||||||
|
context.compileShader(fragmentShader)
|
||||||
|
if (!context.getShaderParameter(fragmentShader, context.COMPILE_STATUS)) {
|
||||||
|
throw new Error('Failed to compile fragment shader')
|
||||||
|
}
|
||||||
|
|
||||||
|
const program = context.createProgram()
|
||||||
|
if (!program) {
|
||||||
|
throw new Error('Failed to create program')
|
||||||
|
}
|
||||||
|
context.attachShader(program, vertexShader)
|
||||||
|
context.attachShader(program, fragmentShader)
|
||||||
|
context.linkProgram(program)
|
||||||
|
if (!context.getProgramParameter(program, context.LINK_STATUS)) {
|
||||||
|
throw new Error('Failed to link program')
|
||||||
|
}
|
||||||
|
context.useProgram(program)
|
||||||
|
|
||||||
|
const shapeVertexPositionAttributeLocation = context.getAttribLocation(
|
||||||
|
program,
|
||||||
|
'shapeVertexPosition'
|
||||||
|
)
|
||||||
|
if (shapeVertexPositionAttributeLocation < 0) {
|
||||||
|
throw new Error('Failed to get shapeVertexPosition attribute location')
|
||||||
|
}
|
||||||
|
context.enableVertexAttribArray(shapeVertexPositionAttributeLocation)
|
||||||
|
|
||||||
|
const canvasPageBoundsLocation = context.getUniformLocation(program, 'canvasPageBounds')
|
||||||
|
const fillColorLocation = context.getUniformLocation(program, 'fillColor')
|
||||||
|
|
||||||
|
const selectedShapesBuffer = context.createBuffer()
|
||||||
|
if (!selectedShapesBuffer) throw new Error('Failed to create buffer')
|
||||||
|
|
||||||
|
const unselectedShapesBuffer = context.createBuffer()
|
||||||
|
if (!unselectedShapesBuffer) throw new Error('Failed to create buffer')
|
||||||
|
|
||||||
|
return {
|
||||||
|
context,
|
||||||
|
selectedShapes: allocateBuffer(context, 1024),
|
||||||
|
unselectedShapes: allocateBuffer(context, 4096),
|
||||||
|
viewport: allocateBuffer(context, roundedRectangleDataSize),
|
||||||
|
collaborators: allocateBuffer(context, 1024),
|
||||||
|
|
||||||
|
prepareTriangles(stuff: BufferStuff, len: number) {
|
||||||
|
context.bindBuffer(context.ARRAY_BUFFER, stuff.buffer)
|
||||||
|
context.bufferData(context.ARRAY_BUFFER, stuff.vertices, context.STATIC_DRAW, 0, len)
|
||||||
|
context.enableVertexAttribArray(shapeVertexPositionAttributeLocation)
|
||||||
|
context.vertexAttribPointer(
|
||||||
|
shapeVertexPositionAttributeLocation,
|
||||||
|
2,
|
||||||
|
context.FLOAT,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
drawTriangles(len: number) {
|
||||||
|
context.drawArrays(context.TRIANGLES, 0, len / 2)
|
||||||
|
},
|
||||||
|
|
||||||
|
setFillColor(color: Float32Array) {
|
||||||
|
context.uniform4fv(fillColorLocation, color)
|
||||||
|
},
|
||||||
|
|
||||||
|
setCanvasPageBounds(bounds: Float32Array) {
|
||||||
|
context.uniform4fv(canvasPageBoundsLocation, bounds)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BufferStuff = ReturnType<typeof allocateBuffer>
|
||||||
|
|
||||||
|
function allocateBuffer(context: WebGL2RenderingContext, size: number) {
|
||||||
|
const buffer = context.createBuffer()
|
||||||
|
if (!buffer) throw new Error('Failed to create buffer')
|
||||||
|
return { buffer, vertices: new Float32Array(size) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendVertices(bufferStuff: BufferStuff, offset: number, data: Float32Array) {
|
||||||
|
let len = bufferStuff.vertices.length
|
||||||
|
while (len < offset + data.length) {
|
||||||
|
len *= 2
|
||||||
|
}
|
||||||
|
if (len != bufferStuff.vertices.length) {
|
||||||
|
const newVertices = new Float32Array(len)
|
||||||
|
newVertices.set(bufferStuff.vertices)
|
||||||
|
bufferStuff.vertices = newVertices
|
||||||
|
}
|
||||||
|
|
||||||
|
bufferStuff.vertices.set(data, offset)
|
||||||
|
}
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { Box, HALF_PI, PI, PI2, Vec } from '@tldraw/editor'
|
||||||
|
|
||||||
|
export const numArcSegmentsPerCorner = 10
|
||||||
|
|
||||||
|
export const roundedRectangleDataSize =
|
||||||
|
// num triangles in corners
|
||||||
|
4 * 6 * numArcSegmentsPerCorner +
|
||||||
|
// num triangles in center rect
|
||||||
|
12 +
|
||||||
|
// num triangles in outer rects
|
||||||
|
4 * 12
|
||||||
|
|
||||||
|
export function pie(
|
||||||
|
array: Float32Array,
|
||||||
|
{
|
||||||
|
center,
|
||||||
|
radius,
|
||||||
|
numArcSegments = 20,
|
||||||
|
startAngle = 0,
|
||||||
|
endAngle = PI2,
|
||||||
|
offset = 0,
|
||||||
|
}: {
|
||||||
|
center: Vec
|
||||||
|
radius: number
|
||||||
|
numArcSegments?: number
|
||||||
|
startAngle?: number
|
||||||
|
endAngle?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const angle = (endAngle - startAngle) / numArcSegments
|
||||||
|
let i = offset
|
||||||
|
for (let a = startAngle; a < endAngle; a += angle) {
|
||||||
|
array[i++] = center.x
|
||||||
|
array[i++] = center.y
|
||||||
|
array[i++] = center.x + Math.cos(a) * radius
|
||||||
|
array[i++] = center.y + Math.sin(a) * radius
|
||||||
|
array[i++] = center.x + Math.cos(a + angle) * radius
|
||||||
|
array[i++] = center.y + Math.sin(a + angle) * radius
|
||||||
|
}
|
||||||
|
return array
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal **/
|
||||||
|
export function rectangle(
|
||||||
|
array: Float32Array,
|
||||||
|
offset: number,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
w: number,
|
||||||
|
h: number
|
||||||
|
) {
|
||||||
|
array[offset++] = x
|
||||||
|
array[offset++] = y
|
||||||
|
array[offset++] = x
|
||||||
|
array[offset++] = y + h
|
||||||
|
array[offset++] = x + w
|
||||||
|
array[offset++] = y
|
||||||
|
|
||||||
|
array[offset++] = x + w
|
||||||
|
array[offset++] = y
|
||||||
|
array[offset++] = x
|
||||||
|
array[offset++] = y + h
|
||||||
|
array[offset++] = x + w
|
||||||
|
array[offset++] = y + h
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roundedRectangle(data: Float32Array, box: Box, radius: number): number {
|
||||||
|
const numArcSegments = numArcSegmentsPerCorner
|
||||||
|
radius = Math.min(radius, Math.min(box.w, box.h) / 2)
|
||||||
|
// first draw the inner box
|
||||||
|
const innerBox = Box.ExpandBy(box, -radius)
|
||||||
|
if (innerBox.w <= 0 || innerBox.h <= 0) {
|
||||||
|
// just draw a circle
|
||||||
|
pie(data, { center: box.center, radius: radius, numArcSegments: numArcSegmentsPerCorner * 4 })
|
||||||
|
return numArcSegmentsPerCorner * 4 * 6
|
||||||
|
}
|
||||||
|
let offset = 0
|
||||||
|
// draw center rect first
|
||||||
|
rectangle(data, offset, innerBox.minX, innerBox.minY, innerBox.w, innerBox.h)
|
||||||
|
offset += 12
|
||||||
|
// then top rect
|
||||||
|
rectangle(data, offset, innerBox.minX, box.minY, innerBox.w, radius)
|
||||||
|
offset += 12
|
||||||
|
// then right rect
|
||||||
|
rectangle(data, offset, innerBox.maxX, innerBox.minY, radius, innerBox.h)
|
||||||
|
offset += 12
|
||||||
|
// then bottom rect
|
||||||
|
rectangle(data, offset, innerBox.minX, innerBox.maxY, innerBox.w, radius)
|
||||||
|
offset += 12
|
||||||
|
// then left rect
|
||||||
|
rectangle(data, offset, box.minX, innerBox.minY, radius, innerBox.h)
|
||||||
|
offset += 12
|
||||||
|
|
||||||
|
// draw the corners
|
||||||
|
|
||||||
|
// top left
|
||||||
|
pie(data, {
|
||||||
|
numArcSegments,
|
||||||
|
offset,
|
||||||
|
center: innerBox.point,
|
||||||
|
radius,
|
||||||
|
startAngle: PI,
|
||||||
|
endAngle: PI * 1.5,
|
||||||
|
})
|
||||||
|
|
||||||
|
offset += numArcSegments * 6
|
||||||
|
|
||||||
|
// top right
|
||||||
|
pie(data, {
|
||||||
|
numArcSegments,
|
||||||
|
offset,
|
||||||
|
center: Vec.Add(innerBox.point, new Vec(innerBox.w, 0)),
|
||||||
|
radius,
|
||||||
|
startAngle: PI * 1.5,
|
||||||
|
endAngle: PI2,
|
||||||
|
})
|
||||||
|
|
||||||
|
offset += numArcSegments * 6
|
||||||
|
|
||||||
|
// bottom right
|
||||||
|
pie(data, {
|
||||||
|
numArcSegments,
|
||||||
|
offset,
|
||||||
|
center: Vec.Add(innerBox.point, innerBox.size),
|
||||||
|
radius,
|
||||||
|
startAngle: 0,
|
||||||
|
endAngle: HALF_PI,
|
||||||
|
})
|
||||||
|
|
||||||
|
offset += numArcSegments * 6
|
||||||
|
|
||||||
|
// bottom left
|
||||||
|
pie(data, {
|
||||||
|
numArcSegments,
|
||||||
|
offset,
|
||||||
|
center: Vec.Add(innerBox.point, new Vec(0, innerBox.h)),
|
||||||
|
radius,
|
||||||
|
startAngle: HALF_PI,
|
||||||
|
endAngle: PI,
|
||||||
|
})
|
||||||
|
|
||||||
|
return roundedRectangleDataSize
|
||||||
|
}
|
Loading…
Reference in a new issue