Merge branch 'main' into camera-controls-api

pull/3282/head
Steve Ruiz 2024-04-21 13:39:02 +01:00
commit a6f3241c8f
46 zmienionych plików z 3413 dodań i 743 usunięć

Wyświetl plik

@ -19,7 +19,7 @@ body:
id: reproduction
attributes:
label: How can we reproduce the bug?
description: If you can make the bug happen again, please share the steps involved.
description: If you can make the bug happen again, please share the steps involved. You can [fork this CodeSandbox](https://codesandbox.io/p/sandbox/tldraw-example-n539u) to make a reproduction.
validations:
required: false
- type: dropdown

Wyświetl plik

@ -1,5 +1,6 @@
import { ReactNode, useEffect, useState, version } from 'react'
import { ReactNode, useEffect, useState } from 'react'
import { LoadingScreen } from 'tldraw'
import { version } from '../../version'
import { useUrl } from '../hooks/useUrl'
import { trackAnalyticsEvent } from '../utils/trackAnalyticsEvent'
@ -113,7 +114,7 @@ export function IFrameProtector({
<div className="tldraw__editor tl-container">
<div className="iframe-warning__container">
<a className="iframe-warning__link" href={url} target="_blank">
{'Visit this page on tldraw.com '}
{'Visit this page on tldraw.com'}
<svg
width="15"
height="15"

Wyświetl plik

@ -203,7 +203,6 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
text={text}
labelColor={theme[color].solid}
isSelected={isSelected}
disableTab
wrap
/>
</>

Wyświetl plik

@ -1,3 +1,13 @@
## 2.0.30
- Fixes a bug that prevented opening some files.
## 2.0.29
- Improved note shapes.
- Color improvements for both light and dark mode.
- Bug fixes and performance improvements.
## 2.0.28
- Fix an issue with panning the canvas.

Wyświetl plik

@ -1,7 +1,7 @@
{
"name": "tldraw-vscode",
"description": "The tldraw extension for VS Code.",
"version": "2.0.28",
"version": "2.0.30",
"private": true,
"author": {
"name": "tldraw Inc.",

Wyświetl plik

@ -11,6 +11,10 @@ import { TextDecoder, TextEncoder } from 'util'
global.TextEncoder = TextEncoder
global.TextDecoder = TextDecoder
Image.prototype.decode = async function () {
return true
}
function convertNumbersInObject(obj: any, roundToNearest: number) {
if (!obj) return obj
if (Array.isArray(obj)) {

Wyświetl plik

@ -698,6 +698,8 @@ export class Editor extends EventEmitter<TLEventMap> {
getCameraState(): "idle" | "moving";
getCanRedo(): boolean;
getCanUndo(): boolean;
getCollaborators(): TLInstancePresence[];
getCollaboratorsOnCurrentPage(): TLInstancePresence[];
getContainer: () => HTMLElement;
getContentFromCurrentPage(shapes: TLShape[] | TLShapeId[]): TLContent | undefined;
// @internal
@ -709,6 +711,8 @@ export class Editor extends EventEmitter<TLEventMap> {
getCurrentPageId(): TLPageId;
getCurrentPageRenderingShapesSorted(): TLShape[];
getCurrentPageShapeIds(): Set<TLShapeId>;
// @internal (undocumented)
getCurrentPageShapeIdsSorted(): TLShapeId[];
getCurrentPageShapes(): TLShape[];
getCurrentPageShapesSorted(): TLShape[];
getCurrentPageState(): TLInstancePageState;

Wyświetl plik

@ -10030,6 +10030,86 @@
"isAbstract": false,
"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",
"canonicalReference": "@tldraw/editor!Editor#getContainer:member",

Wyświetl plik

@ -2915,15 +2915,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
zoomToUser(userId: string, opts: TLCameraMoveOptions = { animation: { duration: 500 } }): this {
const presences = this.store.query.records('instance_presence', () => ({
userId: { eq: userId },
}))
const presence = [...presences.get()]
.sort((a, b) => {
return a.lastActivityTimestamp - b.lastActivityTimestamp
})
.pop()
const presence = this.getCollaborators().find((c) => c.userId === userId)
if (!presence) return this
@ -3186,6 +3178,45 @@ export class Editor extends EventEmitter<TLEventMap> {
const { x: cx, y: cy, z: cz = 1 } = this.getCamera()
return new Vec((point.x + cx) * cz, (point.y + cy) * cz, 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
@ -3202,9 +3233,9 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
startFollowingUser(userId: string): this {
const leaderPresences = this.store.query.records('instance_presence', () => ({
userId: { eq: userId },
}))
const leaderPresences = this._getCollaboratorsQuery()
.get()
.filter((p) => p.userId === userId)
const thisUserId = this.user.getId()
@ -3213,7 +3244,7 @@ export class Editor extends EventEmitter<TLEventMap> {
}
// 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
}
@ -3232,11 +3263,9 @@ export class Editor extends EventEmitter<TLEventMap> {
const moveTowardsUser = () =>
transact(() => {
// Stop following if we can't find the user
const leaderPresence = [...leaderPresences.get()]
.sort((a, b) => {
return a.lastActivityTimestamp - b.lastActivityTimestamp
})
.pop()
const leaderPresence = [...leaderPresences].sort(
(a, b) => b.lastActivityTimestamp - a.lastActivityTimestamp
)[0]
if (!leaderPresence) {
this.stopFollowingUser()
return
@ -3636,6 +3665,14 @@ export class Editor extends EventEmitter<TLEventMap> {
return this._currentPageShapeIds.get()
}
/**
* @internal
*/
@computed
getCurrentPageShapeIdsSorted() {
return Array.from(this.getCurrentPageShapeIds()).sort()
}
/**
* Get the ids of shapes on a page.
*
@ -4248,7 +4285,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
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()
}
@ -4582,7 +4619,7 @@ export class Editor extends EventEmitter<TLEventMap> {
@computed getCurrentPageBounds(): Box | undefined {
let commonBounds: Box | undefined
this.getCurrentPageShapeIds().forEach((shapeId) => {
this.getCurrentPageShapeIdsSorted().forEach((shapeId) => {
const bounds = this.getShapeMaskedPageBounds(shapeId)
if (!bounds) return
if (!commonBounds) {
@ -4911,28 +4948,11 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
@computed getCurrentPageShapesSorted(): TLShape[] {
const shapes = this.getCurrentPageShapes().sort(sortByIndex)
const parentChildMap = new Map<TLShapeId, TLShape[]>()
const result: TLShape[] = []
const topLevelShapes: TLShape[] = []
let shape: TLShape, parent: TLShape | undefined
for (let i = 0, n = shapes.length; i < n; i++) {
shape = shapes[i]
parent = this.getShape(shape.parentId)
if (parent) {
if (!parentChildMap.has(parent.id)) {
parentChildMap.set(parent.id, [])
}
parentChildMap.get(parent.id)!.push(shape)
} else {
// undefined if parent is a shape
topLevelShapes.push(shape)
}
}
const topLevelShapes = this.getSortedChildIdsForParent(this.getCurrentPageId())
for (let i = 0, n = topLevelShapes.length; i < n; i++) {
pushShapeWithDescendants(topLevelShapes[i], parentChildMap, result)
pushShapeWithDescendants(this, topLevelShapes[i], result)
}
return result
@ -8532,7 +8552,11 @@ export class Editor extends EventEmitter<TLEventMap> {
// it will be 0,0 when its actual screen position is equal
// to screenBounds.point. This is confusing!
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
@ -9220,16 +9244,12 @@ function applyPartialToShape<T extends TLShape>(prev: T, partial?: TLShapePartia
return next
}
function pushShapeWithDescendants(
shape: TLShape,
parentChildMap: Map<TLShapeId, TLShape[]>,
result: TLShape[]
): void {
function pushShapeWithDescendants(editor: Editor, id: TLShapeId, result: TLShape[]): void {
const shape = editor.getShape(id)
if (!shape) return
result.push(shape)
const children = parentChildMap.get(shape.id)
if (children) {
for (let i = 0, n = children.length; i < n; i++) {
pushShapeWithDescendants(children[i], parentChildMap, result)
}
const childIds = editor.getSortedChildIdsForParent(id)
for (let i = 0, n = childIds.length; i < n; i++) {
pushShapeWithDescendants(editor, childIds[i], result)
}
}

Wyświetl plik

@ -1,5 +1,5 @@
import { RESET_VALUE, computed, isUninitialized } from '@tldraw/state'
import { TLPageId, TLShapeId, isShape, isShapeId } from '@tldraw/tlschema'
import { computed, isUninitialized } from '@tldraw/state'
import { TLShapeId } from '@tldraw/tlschema'
import { Box } from '../../primitives/Box'
import { Editor } from '../Editor'
@ -21,15 +21,10 @@ function isShapeNotVisible(editor: Editor, id: TLShapeId, viewportPageBounds: Bo
*/
export const notVisibleShapes = (editor: Editor) => {
const isCullingOffScreenShapes = Number.isFinite(editor.renderingBoundsMargin)
const shapeHistory = editor.store.query.filterHistory('shape')
let lastPageId: TLPageId | null = null
let prevViewportPageBounds: Box
function fromScratch(editor: Editor): Set<TLShapeId> {
const shapes = editor.getCurrentPageShapeIds()
lastPageId = editor.getCurrentPageId()
const viewportPageBounds = editor.getViewportPageBounds()
prevViewportPageBounds = viewportPageBounds.clone()
const notVisibleShapes = new Set<TLShapeId>()
shapes.forEach((id) => {
if (isShapeNotVisible(editor, id, viewportPageBounds)) {
@ -38,68 +33,21 @@ export const notVisibleShapes = (editor: Editor) => {
})
return notVisibleShapes
}
return computed<Set<TLShapeId>>('getCulledShapes', (prevValue, lastComputedEpoch) => {
return computed<Set<TLShapeId>>('getCulledShapes', (prevValue) => {
if (!isCullingOffScreenShapes) return new Set<TLShapeId>()
if (isUninitialized(prevValue)) {
return fromScratch(editor)
}
const diff = shapeHistory.getDiffSince(lastComputedEpoch)
if (diff === RESET_VALUE) {
return fromScratch(editor)
}
const nextValue = fromScratch(editor)
const currentPageId = editor.getCurrentPageId()
if (lastPageId !== currentPageId) {
return fromScratch(editor)
}
const viewportPageBounds = editor.getViewportPageBounds()
if (!prevViewportPageBounds || !viewportPageBounds.equals(prevViewportPageBounds)) {
return fromScratch(editor)
}
let nextValue = null as null | Set<TLShapeId>
const addId = (id: TLShapeId) => {
// Already added
if (prevValue.has(id)) return
if (!nextValue) nextValue = new Set(prevValue)
nextValue.add(id)
}
const deleteId = (id: TLShapeId) => {
// No need to delete since it's not there
if (!prevValue.has(id)) return
if (!nextValue) nextValue = new Set(prevValue)
nextValue.delete(id)
}
for (const changes of diff) {
for (const record of Object.values(changes.added)) {
if (isShape(record)) {
const isCulled = isShapeNotVisible(editor, record.id, viewportPageBounds)
if (isCulled) {
addId(record.id)
}
}
}
for (const [_from, to] of Object.values(changes.updated)) {
if (isShape(to)) {
const isCulled = isShapeNotVisible(editor, to.id, viewportPageBounds)
if (isCulled) {
addId(to.id)
} else {
deleteId(to.id)
}
}
}
for (const id of Object.keys(changes.removed)) {
if (isShapeId(id)) {
deleteId(id)
}
if (prevValue.size !== nextValue.size) return nextValue
for (const prev of prevValue) {
if (!nextValue.has(prev)) {
return nextValue
}
}
return nextValue ?? prevValue
return prevValue
})
}

Wyświetl plik

@ -1,5 +1,4 @@
import { useComputed, useValue } from '@tldraw/state'
import { useMemo } from 'react'
import { uniq } from '../utils/uniq'
import { useEditor } from './useEditor'
@ -10,17 +9,12 @@ import { useEditor } from './useEditor'
*/
export function usePeerIds() {
const editor = useEditor()
const $presences = useMemo(() => {
return editor.store.query.records('instance_presence', () => ({
userId: { neq: editor.user.getId() },
}))
}, [editor])
const $userIds = useComputed(
'userIds',
() => uniq($presences.get().map((p) => p.userId)).sort(),
() => uniq(editor.getCollaborators().map((p) => p.userId)).sort(),
{ isEqual: (a, b) => a.join(',') === b.join?.(',') },
[$presences]
[editor]
)
return useValue($userIds)

Wyświetl plik

@ -1,6 +1,5 @@
import { useValue } from '@tldraw/state'
import { TLInstancePresence } from '@tldraw/tlschema'
import { useMemo } from 'react'
import { useEditor } from './useEditor'
// 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 {
const editor = useEditor()
const $presences = useMemo(() => {
return editor.store.query.records('instance_presence', () => ({
userId: { eq: userId },
}))
}, [editor, userId])
const latestPresence = useValue(
`latestPresence:${userId}`,
() => {
return $presences
.get()
.slice()
.sort((a, b) => b.lastActivityTimestamp - a.lastActivityTimestamp)[0]
return editor.getCollaborators().find((c) => c.userId === userId)
},
[]
[editor]
)
return latestPresence ?? null

Wyświetl plik

@ -39,12 +39,13 @@ export class Mat {
equals(m: Mat | MatModel) {
return (
this.a === m.a &&
this.b === m.b &&
this.c === m.c &&
this.d === m.d &&
this.e === m.e &&
this.f === m.f
this === m ||
(this.a === m.a &&
this.b === m.b &&
this.c === m.c &&
this.d === m.d &&
this.e === m.e &&
this.f === m.f)
)
}

Wyświetl plik

@ -4,7 +4,7 @@ import { HistoryBuffer } from './HistoryBuffer'
import { maybeCaptureParent, startCapturingParents, stopCapturingParents } from './capture'
import { GLOBAL_START_EPOCH } from './constants'
import { EMPTY_ARRAY, equals, haveParentsChanged, singleton } from './helpers'
import { getGlobalEpoch } from './transactions'
import { getGlobalEpoch, getIsReacting } from './transactions'
import { Child, ComputeDiff, RESET_VALUE, Signal } from './types'
import { logComputedGetterWarning } from './warnings'
@ -189,8 +189,15 @@ class __UNSAFE__Computed<Value, Diff = unknown> implements Computed<Value, Diff>
__unsafe__getWithoutCapture(ignoreErrors?: boolean): Value {
const isNew = this.lastChangedEpoch === GLOBAL_START_EPOCH
if (!isNew && (this.lastCheckedEpoch === getGlobalEpoch() || !haveParentsChanged(this))) {
this.lastCheckedEpoch = getGlobalEpoch()
const globalEpoch = getGlobalEpoch()
if (
!isNew &&
(this.lastCheckedEpoch === globalEpoch ||
(this.isActivelyListening && getIsReacting() && this.lastTraversedEpoch < globalEpoch) ||
!haveParentsChanged(this))
) {
this.lastCheckedEpoch = globalEpoch
if (this.error) {
if (!ignoreErrors) {
throw this.error.thrownValue

Wyświetl plik

@ -70,6 +70,10 @@ export function getGlobalEpoch() {
return inst.globalEpoch
}
export function getIsReacting() {
return inst.globalIsReacting
}
/**
* Collect all of the reactors that need to run for an atom and run them.
*

Wyświetl plik

@ -2507,9 +2507,7 @@ export function useDefaultHelpers(): {
export function useDialogs(): TLUiDialogsContextType;
// @public (undocumented)
export function useEditableText(id: TLShapeId, type: string, text: string, opts?: {
disableTab: boolean;
}): {
export function useEditableText(id: TLShapeId, type: string, text: string): {
handleBlur: () => void;
handleChange: (e: React_2.ChangeEvent<HTMLTextAreaElement>) => void;
handleDoubleClick: (e: any) => any;

Wyświetl plik

@ -15735,7 +15735,7 @@
},
{
"kind": "Content",
"text": ") => {\n id: import(\"@tldraw/editor\")."
"text": ") => {\n id: "
},
{
"kind": "Reference",
@ -15819,7 +15819,7 @@
},
{
"kind": "Content",
"text": ") => {\n id: import(\"@tldraw/editor\")."
"text": ") => {\n id: "
},
{
"kind": "Reference",
@ -15894,7 +15894,7 @@
},
{
"kind": "Content",
"text": ") => {\n id: import(\"@tldraw/editor\")."
"text": ") => {\n id: "
},
{
"kind": "Reference",
@ -15903,7 +15903,7 @@
},
{
"kind": "Content",
"text": ";\n props: {\n autoSize: boolean;\n scale?: undefined;\n };\n type: \"text\";\n } | {\n id: import(\"@tldraw/editor\")."
"text": ";\n props: {\n autoSize: boolean;\n scale?: undefined;\n };\n type: \"text\";\n } | {\n id: "
},
{
"kind": "Reference",
@ -27480,14 +27480,6 @@
"kind": "Content",
"text": "string"
},
{
"kind": "Content",
"text": ", opts?: "
},
{
"kind": "Content",
"text": "{\n disableTab: boolean;\n}"
},
{
"kind": "Content",
"text": "): "
@ -27575,8 +27567,8 @@
],
"fileUrlPath": "packages/tldraw/src/lib/shapes/shared/useEditableText.ts",
"returnTypeTokenRange": {
"startIndex": 9,
"endIndex": 26
"startIndex": 7,
"endIndex": 24
},
"releaseTag": "Public",
"overloadIndex": 1,
@ -27604,14 +27596,6 @@
"endIndex": 6
},
"isOptional": false
},
{
"parameterName": "opts",
"parameterTypeTokenRange": {
"startIndex": 7,
"endIndex": 8
},
"isOptional": true
}
],
"name": "useEditableText"

Wyświetl plik

@ -109,13 +109,10 @@ export function Tldraw(props: TldrawProps) {
)
const assets = useDefaultEditorAssetsWithOverrides(rest.assetUrls)
const { done: preloadingComplete, error: preloadingError } = usePreloadAssets(assets)
if (preloadingError) {
return <ErrorScreen>Could not load assets. Please refresh the page.</ErrorScreen>
}
if (!preloadingComplete) {
return <LoadingScreen>Loading assets...</LoadingScreen>
}

Wyświetl plik

@ -268,14 +268,16 @@ export function getArrowLabelPosition(editor: Editor, shape: TLArrowShape) {
const debugGeom: Geometry2d[] = []
const info = editor.getArrowInfo(shape)!
const hasStartBinding = shape.props.start.type === 'binding'
const hasEndBinding = shape.props.end.type === 'binding'
const hasStartArrowhead = info.start.arrowhead !== 'none'
const hasEndArrowhead = info.end.arrowhead !== 'none'
if (info.isStraight) {
const range = getStraightArrowLabelRange(editor, shape, info)
let clampedPosition = clamp(
shape.props.labelPosition,
hasStartArrowhead ? range.start : 0,
hasEndArrowhead ? range.end : 1
hasStartArrowhead || hasStartBinding ? range.start : 0,
hasEndArrowhead || hasEndBinding ? range.end : 1
)
// This makes the position snap in the middle.
clampedPosition = clampedPosition >= 0.48 && clampedPosition <= 0.52 ? 0.5 : clampedPosition
@ -285,8 +287,8 @@ export function getArrowLabelPosition(editor: Editor, shape: TLArrowShape) {
if (range.dbg) debugGeom.push(...range.dbg)
let clampedPosition = clamp(
shape.props.labelPosition,
hasStartArrowhead ? range.start : 0,
hasEndArrowhead ? range.end : 1
hasStartArrowhead || hasStartBinding ? range.start : 0,
hasEndArrowhead || hasEndBinding ? range.end : 1
)
// This makes the position snap in the middle.
clampedPosition = clampedPosition >= 0.48 && clampedPosition <= 0.52 ? 0.5 : clampedPosition

Wyświetl plik

@ -35,7 +35,6 @@ export const ArrowTextLabel = React.memo(function ArrowTextLabel({
labelColor={theme[labelColor].solid}
textWidth={width}
isSelected={isSelected}
disableTab
style={{
transform: `translate(${position.x}px, ${position.y}px)`,
}}

Wyświetl plik

@ -97,7 +97,7 @@ export class Drawing extends StateNode {
this.mergeNextPoint = false
}
this.updateShapes()
this.updateDrawingShape()
}
}
@ -115,7 +115,7 @@ export class Drawing extends StateNode {
}
}
}
this.updateShapes()
this.updateDrawingShape()
}
override onKeyUp: TLEventHandlers['onKeyUp'] = (info) => {
@ -137,7 +137,7 @@ export class Drawing extends StateNode {
}
}
this.updateShapes()
this.updateDrawingShape()
}
override onExit? = () => {
@ -281,7 +281,7 @@ export class Drawing extends StateNode {
this.initialShape = this.editor.getShape<DrawableShape>(id)
}
private updateShapes() {
private updateDrawingShape() {
const { initialShape } = this
const { inputs } = this.editor

Wyświetl plik

@ -402,7 +402,6 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
</SVGContainer>
{showHtmlContainer && (
<HTMLContainer
id={shape.id}
style={{
overflow: 'hidden',
width: shape.props.w,
@ -421,7 +420,6 @@ export class GeoShapeUtil extends BaseBoxShapeUtil<TLGeoShape> {
text={text}
isSelected={isSelected}
labelColor={theme[props.labelColor].solid}
disableTab
wrap
/>
</HTMLContainer>

Wyświetl plik

@ -190,7 +190,6 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
isNote
isSelected={isSelected}
labelColor={theme[color].note.text}
disableTab
wrap
onKeyDown={handleKeyDown}
/>

Wyświetl plik

@ -27,7 +27,6 @@ type TextLabelProps = {
bounds?: Box
isNote?: boolean
isSelected: boolean
disableTab?: boolean
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
classNamePrefix?: string
style?: React.CSSProperties
@ -51,15 +50,13 @@ export const TextLabel = React.memo(function TextLabel({
onKeyDown: handleKeyDownCustom,
classNamePrefix,
style,
disableTab = false,
textWidth,
textHeight,
}: TextLabelProps) {
const { rInput, isEmpty, isEditing, isEditingAnything, ...editableTextRest } = useEditableText(
id,
type,
text,
{ disableTab }
text
)
const [initialText, setInitialText] = useState(text)

Wyświetl plik

@ -1,12 +1,4 @@
import {
Vec,
VecLike,
assert,
average,
precise,
shortAngleDist,
toDomPrecision,
} from '@tldraw/editor'
import { Vec, VecLike, assert, average, precise, toDomPrecision } from '@tldraw/editor'
import { getStrokeOutlineTracks } from './getStrokeOutlinePoints'
import { getStrokePoints } from './getStrokePoints'
import { setStrokePointRadii } from './setStrokePointRadii'
@ -36,17 +28,20 @@ function partitionAtElbows(points: StrokePoint[]): StrokePoint[][] {
const result: StrokePoint[][] = []
let currentPartition: StrokePoint[] = [points[0]]
for (let i = 1; i < points.length - 1; i++) {
const prevPoint = points[i - 1]
const thisPoint = points[i]
const nextPoint = points[i + 1]
const prevAngle = Vec.Angle(prevPoint.point, thisPoint.point)
const nextAngle = Vec.Angle(thisPoint.point, nextPoint.point)
// acuteness is a normalized representation of how acute the angle is.
// 1 is an infinitely thin wedge
// 0 is a straight line
const acuteness = Math.abs(shortAngleDist(prevAngle, nextAngle)) / Math.PI
if (acuteness > 0.8) {
let prevV = Vec.Sub(points[1].point, points[0].point).uni()
let nextV: Vec
let dpr: number
let prevPoint: StrokePoint, thisPoint: StrokePoint, nextPoint: StrokePoint
for (let i = 1, n = points.length; i < n - 1; i++) {
prevPoint = points[i - 1]
thisPoint = points[i]
nextPoint = points[i + 1]
nextV = Vec.Sub(nextPoint.point, thisPoint.point).uni()
dpr = Vec.Dpr(prevV, nextV)
prevV = nextV
if (dpr < -0.8) {
// always treat such acute angles as elbows
// and use the extended .input point as the elbow point for swooshiness in fast zaggy lines
const elbowPoint = {
@ -59,19 +54,20 @@ function partitionAtElbows(points: StrokePoint[]): StrokePoint[][] {
continue
}
currentPartition.push(thisPoint)
if (acuteness < 0.25) {
// this is not an elbow, bail out
if (dpr > 0.7) {
// Not an elbow
continue
}
// so now we have a reasonably acute angle but it might not be an elbow if it's far
// away from it's neighbors
const avgRadius = (prevPoint.radius + thisPoint.radius + nextPoint.radius) / 3
const incomingNormalizedDist = Vec.Dist(prevPoint.point, thisPoint.point) / avgRadius
const outgoingNormalizedDist = Vec.Dist(thisPoint.point, nextPoint.point) / avgRadius
// angular dist is a normalized representation of how far away the point is from it's neighbors
// away from it's neighbors, angular dist is a normalized representation of how far away the point is from it's neighbors
// (normalized by the radius)
const angularDist = incomingNormalizedDist + outgoingNormalizedDist
if (angularDist < 1.5) {
if (
(Vec.Dist2(prevPoint.point, thisPoint.point) + Vec.Dist2(thisPoint.point, nextPoint.point)) /
((prevPoint.radius + thisPoint.radius + nextPoint.radius) / 3) ** 2 <
1.5
) {
// if this point is kinda close to its neighbors and it has a reasonably
// acute angle, it's probably a hard elbow
currentPartition.push(thisPoint)
@ -89,11 +85,13 @@ function partitionAtElbows(points: StrokePoint[]): StrokePoint[][] {
function cleanUpPartition(partition: StrokePoint[]) {
// clean up start of partition (remove points that are too close to the start)
const startPoint = partition[0]
let nextPoint: StrokePoint
while (partition.length > 2) {
const nextPoint = partition[1]
const dist = Vec.Dist(startPoint.point, nextPoint.point)
const avgRadius = (startPoint.radius + nextPoint.radius) / 2
if (dist < avgRadius * 0.5) {
nextPoint = partition[1]
if (
Vec.Dist2(startPoint.point, nextPoint.point) <
(((startPoint.radius + nextPoint.radius) / 2) * 0.5) ** 2
) {
partition.splice(1, 1)
} else {
break
@ -101,11 +99,13 @@ function cleanUpPartition(partition: StrokePoint[]) {
}
// clean up end of partition in the same fashion
const endPoint = partition[partition.length - 1]
let prevPoint: StrokePoint
while (partition.length > 2) {
const prevPoint = partition[partition.length - 2]
const dist = Vec.Dist(endPoint.point, prevPoint.point)
const avgRadius = (endPoint.radius + prevPoint.radius) / 2
if (dist < avgRadius * 0.5) {
prevPoint = partition[partition.length - 2]
if (
Vec.Dist2(endPoint.point, prevPoint.point) <
(((endPoint.radius + prevPoint.radius) / 2) * 0.5) ** 2
) {
partition.splice(partition.length - 2, 1)
} else {
break
@ -115,13 +115,14 @@ function cleanUpPartition(partition: StrokePoint[]) {
if (partition.length > 1) {
partition[0] = {
...partition[0],
vector: Vec.FromAngle(Vec.Angle(partition[1].point, partition[0].point)),
vector: Vec.Sub(partition[0].point, partition[1].point).uni(),
}
partition[partition.length - 1] = {
...partition[partition.length - 1],
vector: Vec.FromAngle(
Vec.Angle(partition[partition.length - 1].point, partition[partition.length - 2].point)
),
vector: Vec.Sub(
partition[partition.length - 2].point,
partition[partition.length - 1].point
).uni(),
}
}
return partition

Wyświetl plik

@ -2,7 +2,6 @@ import {
TLShapeId,
TLUnknownShape,
getPointerInfo,
preventDefault,
stopEventPropagation,
useEditor,
useValue,
@ -11,31 +10,14 @@ import React, { useCallback, useEffect, useRef } from 'react'
import { INDENT, TextHelpers } from './TextHelpers'
/** @public */
export function useEditableText(
id: TLShapeId,
type: string,
text: string,
opts = { disableTab: false } as { disableTab: boolean }
) {
export function useEditableText(id: TLShapeId, type: string, text: string) {
const editor = useEditor()
const rInput = useRef<HTMLTextAreaElement>(null)
const isEditing = useValue(
'isEditing',
() => {
return editor.getEditingShapeId() === id
},
[editor]
)
const isEditingAnything = useValue(
'isEditingAnything',
() => {
return editor.getEditingShapeId() !== null
},
[editor]
)
const rSelectionRanges = useRef<Range[] | null>()
const isEditing = useValue('isEditing', () => editor.getEditingShapeId() === id, [editor])
const isEditingAnything = useValue('isEditingAnything', () => !!editor.getEditingShapeId(), [
editor,
])
useEffect(() => {
function selectAllIfEditing({ shapeId }: { shapeId: TLShapeId }) {
@ -52,14 +34,13 @@ export function useEditableText(
}
})
}
editor.on('select-all-text', selectAllIfEditing)
return () => {
editor.off('select-all-text', selectAllIfEditing)
}
}, [editor, id])
const rSelectionRanges = useRef<Range[] | null>()
useEffect(() => {
if (!isEditing) return
@ -69,10 +50,18 @@ export function useEditableText(
// Focus if we're not already focused
if (document.activeElement !== elm) {
elm.focus()
// On mobile etc, just select all the text when we start focusing
if (editor.getInstanceState().isCoarsePointer) {
elm.select()
}
} else {
// This fixes iOS not showing the cursor sometimes. This "shakes" the cursor
// awake.
if (editor.environment.isSafari) {
elm.blur()
elm.focus()
}
}
// When the selection changes, save the selection ranges
@ -103,12 +92,14 @@ export function useEditableText(
requestAnimationFrame(() => {
const elm = rInput.current
const editingShapeId = editor.getEditingShapeId()
// Did we move to a different shape?
if (editingShapeId) {
// important! these ^v are two different things
// is that shape OUR shape?
if (elm && editingShapeId === id) {
elm.focus()
if (ranges && ranges.length) {
const selection = window.getSelection()
if (selection) {
@ -134,20 +125,9 @@ export function useEditableText(
}
break
}
case 'Tab': {
if (!opts.disableTab) {
preventDefault(e)
if (e.shiftKey) {
TextHelpers.unindent(e.currentTarget)
} else {
TextHelpers.indent(e.currentTarget)
}
}
break
}
}
},
[editor, id, opts.disableTab]
[editor, id]
)
// When the text changes, update the text value.
@ -198,8 +178,6 @@ export function useEditableText(
[editor, id, isEditing]
)
const handleDoubleClick = stopEventPropagation
return {
rInput,
handleFocus: noop,
@ -207,7 +185,7 @@ export function useEditableText(
handleKeyDown,
handleChange,
handleInputPointerDown,
handleDoubleClick,
handleDoubleClick: stopEventPropagation,
isEmpty: text.trim().length === 0,
isEditing,
isEditingAnything,

Wyświetl plik

@ -7,18 +7,22 @@ import {
SvgExportContext,
TLOnEditEndHandler,
TLOnResizeHandler,
TLShapeId,
TLShapeUtilFlag,
TLTextShape,
Vec,
WeakMapCache,
getDefaultColorTheme,
preventDefault,
textShapeMigrations,
textShapeProps,
toDomPrecision,
useEditor,
} from '@tldraw/editor'
import { useCallback } from 'react'
import { useDefaultColorTheme } from '../shared/ShapeFill'
import { SvgTextLabel } from '../shared/SvgTextLabel'
import { TextHelpers } from '../shared/TextHelpers'
import { TextLabel } from '../shared/TextLabel'
import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from '../shared/default-shape-constants'
import { getFontDefForExport } from '../shared/defaultStyleDefs'
@ -73,6 +77,7 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
const { width, height } = this.getMinDimensions(shape)
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
const theme = useDefaultColorTheme()
const handleKeyDown = useTextShapeKeydownHandler(id)
return (
<TextLabel
@ -94,6 +99,7 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
transformOrigin: 'top left',
}}
wrap
onKeyDown={handleKeyDown}
/>
)
}
@ -332,3 +338,32 @@ function getTextSize(editor: Editor, props: TLTextShape['props']) {
height: Math.max(fontSize, result.h),
}
}
function useTextShapeKeydownHandler(id: TLShapeId) {
const editor = useEditor()
return useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (editor.getEditingShapeId() !== id) return
switch (e.key) {
case 'Enter': {
if (e.ctrlKey || e.metaKey) {
editor.complete()
}
break
}
case 'Tab': {
preventDefault(e)
if (e.shiftKey) {
TextHelpers.unindent(e.currentTarget)
} else {
TextHelpers.indent(e.currentTarget)
}
break
}
}
},
[editor, id]
)
}

Wyświetl plik

@ -92,8 +92,8 @@ export class PointingArrowLabel extends StateNode {
let nextLabelPosition
if (info.isStraight) {
// straight arrows
const lineLength = Vec.Dist2(info.start.point, info.end.point)
const segmentLength = Vec.Dist2(info.end.point, nearestPoint)
const lineLength = Vec.Dist(info.start.point, info.end.point)
const segmentLength = Vec.Dist(info.end.point, nearestPoint)
nextLabelPosition = 1 - segmentLength / lineLength
} else {
const { _center, measure, angleEnd, angleStart } = groupGeometry.children[0] as Arc2d

Wyświetl plik

@ -1,5 +1,5 @@
import { useEditor } from '@tldraw/editor'
import { useEffect, useState } from 'react'
import { useEditor, useQuickReactor } from '@tldraw/editor'
import { useRef, useState } from 'react'
import { useActions } from '../../context/actions'
import { TldrawUiMenuItem } from '../primitives/menus/TldrawUiMenuItem'
@ -9,33 +9,25 @@ export function BackToContent() {
const actions = useActions()
const [showBackToContent, setShowBackToContent] = useState(false)
const rIsShowing = useRef(false)
useEffect(() => {
let showBackToContentPrev = false
const interval = setInterval(() => {
const renderingShapes = editor.getRenderingShapes()
const renderingBounds = editor.getRenderingBounds()
// Rendering shapes includes all the shapes in the current page.
// We have to filter them down to just the shapes that are inside the renderingBounds.
const visibleShapes = renderingShapes.filter((s) => {
const maskedPageBounds = editor.getShapeMaskedPageBounds(s.id)
return maskedPageBounds && renderingBounds.includes(maskedPageBounds)
})
const showBackToContentNow =
visibleShapes.length === 0 && editor.getCurrentPageShapes().length > 0
useQuickReactor(
'toggle showback to content',
() => {
const showBackToContentPrev = rIsShowing.current
const shapeIds = editor.getCurrentPageShapeIds()
let showBackToContentNow = false
if (shapeIds.size) {
showBackToContentNow = shapeIds.size === editor.getCulledShapes().size
}
if (showBackToContentPrev !== showBackToContentNow) {
setShowBackToContent(showBackToContentNow)
showBackToContentPrev = showBackToContentNow
rIsShowing.current = showBackToContentNow
}
}, 1000)
return () => {
clearInterval(interval)
}
}, [editor])
},
[editor]
)
if (!showBackToContent) return null

Wyświetl plik

@ -1,18 +1,13 @@
import {
ANIMATION_MEDIUM_MS,
Box,
TLPointerEventInfo,
TLShapeId,
Vec,
getPointerInfo,
intersectPolygonPolygon,
normalizeWheel,
releasePointerCapture,
setPointerCapture,
useComputed,
useEditor,
useIsDarkMode,
useQuickReactor,
} from '@tldraw/editor'
import * as React from 'react'
import { MinimapManager } from './MinimapManager'
@ -24,67 +19,78 @@ export function DefaultMinimap() {
const rCanvas = React.useRef<HTMLCanvasElement>(null!)
const rPointing = React.useRef(false)
const isDarkMode = useIsDarkMode()
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])
const minimapRef = React.useRef<MinimapManager>()
React.useEffect(() => {
// Must check after render
const raf = requestAnimationFrame(() => {
minimap.updateColors()
minimap.render()
})
return () => {
cancelAnimationFrame(raf)
}
}, [editor, minimap, isDarkMode])
const minimap = new MinimapManager(editor, rCanvas.current)
minimapRef.current = minimap
return minimapRef.current.close
}, [editor])
const onDoubleClick = React.useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
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)
minimap.originPageCenter.setTo(editor.getViewportPageBounds().center)
minimapRef.current.originPagePoint.setTo(clampedPoint)
minimapRef.current.originPageCenter.setTo(editor.getViewportPageBounds().center)
editor.centerOnPoint(point, { animation: { duration: ANIMATION_MEDIUM_MS } })
},
[editor, minimap]
[editor]
)
const onPointerDown = React.useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => {
if (!minimapRef.current) return
const elm = e.currentTarget
setPointerCapture(elm, e)
if (!editor.getCurrentPageShapeIds().size) return
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()
minimap.isInViewport = _vpPageBounds.containsPoint(clampedPoint)
minimapRef.current.isInViewport = _vpPageBounds.containsPoint(clampedPoint)
if (minimap.isInViewport) {
minimap.originPagePoint.setTo(clampedPoint)
minimap.originPageCenter.setTo(_vpPageBounds.center)
if (minimapRef.current.isInViewport) {
minimapRef.current.originPagePoint.setTo(clampedPoint)
minimapRef.current.originPageCenter.setTo(_vpPageBounds.center)
} else {
const delta = Vec.Sub(_vpPageBounds.center, _vpPageBounds.point)
const pagePoint = Vec.Add(point, delta)
minimap.originPagePoint.setTo(pagePoint)
minimap.originPageCenter.setTo(point)
minimapRef.current.originPagePoint.setTo(pagePoint)
minimapRef.current.originPageCenter.setTo(point)
editor.centerOnPoint(point, { animation: { duration: ANIMATION_MEDIUM_MS } })
}
@ -98,16 +104,24 @@ export function DefaultMinimap() {
document.body.addEventListener('pointerup', release)
},
[editor, minimap]
[editor]
)
const onPointerMove = React.useCallback(
(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 (minimap.isInViewport) {
const delta = minimap.originPagePoint.clone().sub(minimap.originPageCenter)
if (minimapRef.current.isInViewport) {
const delta = minimapRef.current.originPagePoint
.clone()
.sub(minimapRef.current.originPageCenter)
editor.centerOnPoint(Vec.Sub(point, delta))
return
}
@ -115,7 +129,7 @@ export function DefaultMinimap() {
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)
@ -130,7 +144,7 @@ export function DefaultMinimap() {
editor.dispatch(info)
},
[editor, minimap]
[editor]
)
const onWheel = React.useCallback(
@ -150,73 +164,16 @@ export function DefaultMinimap() {
[editor]
)
// Update the minimap's dpr when the dpr changes
useQuickReactor(
'update when dpr changes',
() => {
const dpr = devicePixelRatio.get()
minimap.setDpr(dpr)
const isDarkMode = useIsDarkMode()
const canvas = rCanvas.current as HTMLCanvasElement
const rect = canvas.getBoundingClientRect()
const width = rect.width * dpr
const height = rect.height * dpr
// These must happen in order
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)
}
})
minimap.pageBounds = allShapeBounds
minimap.collaborators = presences.get()
minimap.render()
},
[editor, minimap]
)
React.useEffect(() => {
// need to wait a tick for next theme css to be applied
// otherwise the minimap will render with the wrong colors
setTimeout(() => {
minimapRef.current?.updateColors()
minimapRef.current?.render()
})
}, [isDarkMode])
return (
<div className="tlui-minimap">

Wyświetl plik

@ -1,114 +1,159 @@
import {
Box,
ComputedCache,
Editor,
PI2,
TLInstancePresence,
TLShapeId,
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 {
constructor(public editor: Editor) {}
dpr = 1
colors = {
shapeFill: 'rgba(144, 144, 144, .1)',
selectFill: '#2f80ed',
viewportFill: 'rgba(144, 144, 144, .1)',
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))
}
id = uniqueId()
cvs: HTMLCanvasElement | null = null
pageBounds: (Box & { id: TLShapeId })[] = []
collaborators: TLInstancePresence[] = []
private _getColors() {
const style = getComputedStyle(this.editor.getContainer())
canvasScreenBounds = new Box()
canvasPageBounds = new Box()
return {
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()
contentScreenBounds = new Box()
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
debug = false
/** Get the canvas's true bounds converted to page bounds. */
@computed getCanvasPageBounds() {
const canvasScreenBounds = this.getCanvasScreenBounds()
const contentPageBounds = this.getContentPageBounds()
setDpr(dpr: number) {
this.dpr = +dpr.toFixed(2)
}
const aspectRatio = canvasScreenBounds.width / canvasScreenBounds.height
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
let targetWidth = contentPageBounds.width
let targetHeight = targetWidth / aspectRatio
if (targetHeight < contentPageBounds.height) {
targetHeight = contentPageBounds.height
targetWidth = targetHeight * aspectRatio
}
contentScreenBounds.set(x, y, w, h)
const box = new Box(0, 0, targetWidth, targetHeight)
box.center = contentPageBounds.center
return box
}
/** 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
@computed getCanvasPageBoundsArray() {
const { x, y, w, h } = this.getCanvasPageBounds()
return new Float32Array([x, y, w, h])
}
getScreenPoint = (x: number, y: number) => {
const { canvasScreenBounds } = this
getPagePoint = (clientX: number, clientY: number) => {
const canvasPageBounds = this.getCanvasPageBounds()
const canvasScreenBounds = this.getCanvasScreenBounds()
const screenX = (x - canvasScreenBounds.minX) * this.dpr
const screenY = (y - canvasScreenBounds.minY) * this.dpr
// first offset the canvas position
let x = clientX - canvasScreenBounds.x
let y = clientY - canvasScreenBounds.y
return { x: screenX, y: screenY }
}
// then multiply by the ratio between the page and screen bounds
x *= canvasPageBounds.width / canvasScreenBounds.width
y *= canvasPageBounds.height / canvasScreenBounds.height
getPagePoint = (x: number, y: number) => {
const { contentPageBounds, contentScreenBounds, canvasPageBounds } = this
// then add the canvas page bounds' offset
x += canvasPageBounds.minX
y += canvasPageBounds.minY
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
)
return new Vec(x, y, 1)
}
minimapScreenPointToPagePoint = (
@ -123,13 +168,13 @@ export class MinimapManager {
let { x: px, y: py } = this.getPagePoint(x, y)
if (clampToBounds) {
const shapesPageBounds = this.editor.getCurrentPageBounds()
const shapesPageBounds = this.editor.getCurrentPageBounds() ?? new Box()
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 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))
@ -171,209 +216,110 @@ export class MinimapManager {
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 = () => {
const { cvs, pageBounds } = this
this.updateCanvasPageBounds()
// make sure we update when dark mode switches
const context = this.gl.context
const canvasSize = this.getCanvasSize()
const { editor, canvasScreenBounds, canvasPageBounds, contentPageBounds, contentScreenBounds } =
this
const { width: cw, height: ch } = canvasScreenBounds
this.gl.setCanvasPageBounds(this.getCanvasPageBoundsArray())
const selectedShapeIds = new Set(editor.getSelectedShapeIds())
const viewportPageBounds = editor.getViewportPageBounds()
this.elem.width = canvasSize.x
this.elem.height = canvasSize.y
context.viewport(0, 0, canvasSize.x, canvasSize.y)
if (!cvs || !pageBounds) {
return
// 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)
}
const ctx = cvs.getContext('2d')!
context.clear(context.COLOR_BUFFER_BIT)
if (!ctx) {
throw new Error('Minimap (shapes): Could not get context')
}
const selectedShapes = new Set(this.editor.getSelectedShapeIds())
ctx.resetTransform()
ctx.globalAlpha = 1
ctx.clearRect(0, 0, cw, ch)
const colors = this.colors
let selectedShapeOffset = 0
let unselectedShapeOffset = 0
// Transform canvas
const ids = this.editor.getCurrentPageShapeIdsSorted()
const sx = contentScreenBounds.width / contentPageBounds.width
const sy = contentScreenBounds.height / contentPageBounds.height
for (let i = 0, len = ids.length; i < len; i++) {
const shapeId = ids[i]
const geometry = this.shapeGeometryCache.get(shapeId)
if (!geometry) continue
ctx.translate((cw - contentScreenBounds.width) / 2, (ch - contentScreenBounds.height) / 2)
ctx.scale(sx, sy)
ctx.translate(-contentPageBounds.minX, -contentPageBounds.minY)
const len = geometry.length
// 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()
if (selectedShapes.has(shapeId)) {
appendVertices(this.gl.selectedShapes, selectedShapeOffset, geometry)
selectedShapeOffset += len
} else {
appendVertices(this.gl.unselectedShapes, unselectedShapeOffset, geometry)
unselectedShapeOffset += len
}
}
// 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)
}
}
this.drawViewport()
this.drawShapes(this.gl.unselectedShapes, unselectedShapeOffset, colors.shapeFill)
this.drawShapes(this.gl.selectedShapes, selectedShapeOffset, colors.selectFill)
this.drawCollaborators()
}
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)
private drawShapes(stuff: BufferStuff, len: number, color: Float32Array) {
this.gl.prepareTriangles(stuff, len)
this.gl.setFillColor(color)
this.gl.drawTriangles(len)
}
static sharpRect(
ctx: CanvasRenderingContext2D | Path2D,
x: number,
y: number,
width: number,
height: number,
_rx?: number,
_ry?: number
) {
ctx.rect(x, y, width, height)
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
}
}
}

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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
}

Wyświetl plik

@ -1,4 +1,4 @@
import { createContext, useContext } from 'react'
import { createContext, useContext, useEffect } from 'react'
import { TLUiAssetUrls } from '../assetUrls'
/** @internal */
@ -14,6 +14,19 @@ export function AssetUrlsProvider({
assetUrls: TLUiAssetUrls
children: React.ReactNode
}) {
useEffect(() => {
for (const src of Object.values(assetUrls.icons)) {
const image = new Image()
image.src = src
image.decode()
}
for (const src of Object.values(assetUrls.embedIcons)) {
const image = new Image()
image.src = src
image.decode()
}
}, [assetUrls])
return <AssetUrlsContext.Provider value={assetUrls}>{children}</AssetUrlsContext.Provider>
}

Wyświetl plik

@ -9,6 +9,8 @@ import {
TLTextShape,
VecLike,
isNonNull,
preventDefault,
stopEventPropagation,
uniq,
useEditor,
useValue,
@ -615,24 +617,29 @@ export function useNativeClipboardEvents() {
useEffect(() => {
if (!appIsFocused) return
const copy = () => {
const copy = (e: ClipboardEvent) => {
if (
editor.getSelectedShapeIds().length === 0 ||
editor.getEditingShapeId() !== null ||
disallowClipboardEvents(editor)
)
) {
return
}
preventDefault(e)
handleNativeOrMenuCopy(editor)
trackEvent('copy', { source: 'kbd' })
}
function cut() {
function cut(e: ClipboardEvent) {
if (
editor.getSelectedShapeIds().length === 0 ||
editor.getEditingShapeId() !== null ||
disallowClipboardEvents(editor)
)
) {
return
}
preventDefault(e)
handleNativeOrMenuCopy(editor)
editor.deleteShapes(editor.getSelectedShapeIds())
trackEvent('cut', { source: 'kbd' })
@ -649,9 +656,9 @@ export function useNativeClipboardEvents() {
}
}
const paste = (event: ClipboardEvent) => {
const paste = (e: ClipboardEvent) => {
if (disablingMiddleClickPaste) {
event.stopPropagation()
stopEventPropagation(e)
return
}
@ -661,8 +668,8 @@ export function useNativeClipboardEvents() {
if (editor.getEditingShapeId() !== null || disallowClipboardEvents(editor)) return
// First try to use the clipboard data on the event
if (event.clipboardData && !editor.inputs.shiftKey) {
handlePasteFromEventClipboardData(editor, event.clipboardData)
if (e.clipboardData && !editor.inputs.shiftKey) {
handlePasteFromEventClipboardData(editor, e.clipboardData)
} else {
// Or else use the clipboard API
navigator.clipboard.read().then((clipboardItems) => {
@ -672,6 +679,7 @@ export function useNativeClipboardEvents() {
})
}
preventDefault(e)
trackEvent('paste', { source: 'kbd' })
}

Wyświetl plik

@ -1,38 +0,0 @@
import { useEffect, useState } from 'react'
import { useAssetUrls } from '../context/asset-urls'
import { iconTypes } from '../icon-types'
/** @internal */
export function usePreloadIcons(): boolean {
const [isLoaded, setIsLoaded] = useState<boolean>(false)
const assetUrls = useAssetUrls()
useEffect(() => {
let cancelled = false
async function loadImages() {
// Run through all of the icons and load them. It doesn't matter
// if any of the images don't load; though we expect that they would.
// Instead, we just want to make sure that the browser has cached
// all of the icons it can so that they're available when we need them.
await Promise.allSettled(
iconTypes.map((icon) => {
const image = new Image()
image.src = assetUrls.icons[icon]
return image.decode()
})
)
if (cancelled) return
setIsLoaded(true)
}
loadImages()
return () => {
cancelled = true
}
}, [isLoaded, assetUrls])
return isLoaded
}

Wyświetl plik

@ -62,7 +62,7 @@ const schemaV2 = T.object<SerializedSchemaV2>({
const tldrawFileValidator: T.Validator<TldrawFile> = T.object({
tldrawFileFormatVersion: T.nonZeroInteger,
schema: T.union('schemaVersion', {
schema: T.numberUnion('schemaVersion', {
1: schemaV1,
2: schemaV2,
}),

Wyświetl plik

@ -1,5 +1,6 @@
import { TLDrawShape, TLHighlightShape, last } from '@tldraw/editor'
import { TestEditor } from './TestEditor'
import { TEST_DRAW_SHAPE_SCREEN_POINTS } from './drawing.data'
jest.useFakeTimers()
@ -260,3 +261,22 @@ for (const toolType of ['draw', 'highlight'] as const) {
})
})
}
it('Draws a bunch', () => {
editor.setCurrentTool('draw').setCamera({ x: 0, y: 0, z: 1 })
const [first, ...rest] = TEST_DRAW_SHAPE_SCREEN_POINTS
editor.pointerMove(first.x, first.y).pointerDown()
for (const point of rest) {
editor.pointerMove(point.x, point.y)
}
editor.pointerUp()
editor.selectAll()
const shape = { ...editor.getLastCreatedShape() }
// @ts-expect-error
delete shape.id
expect(shape).toMatchSnapshot('draw shape')
})

Wyświetl plik

@ -136,3 +136,56 @@ it('correctly calculates the culled shapes when adding and deleting shapes', ()
const culledShapeFromScratch = editor.getCulledShapes()
expect(culledShapesIncremental).toEqual(culledShapeFromScratch)
})
it('works for shapes that are outside of the viewport, but are then moved inside it', () => {
const box1Id = createShapeId()
const box2Id = createShapeId()
const arrowId = createShapeId()
editor.createShapes([
{
id: box1Id,
props: { w: 100, h: 100, geo: 'rectangle' },
type: 'geo',
x: -500,
y: 0,
},
{
id: box2Id,
type: 'geo',
x: -1000,
y: 200,
props: { w: 100, h: 100, geo: 'rectangle' },
},
{
id: arrowId,
type: 'arrow',
props: {
start: {
type: 'binding',
isExact: true,
boundShapeId: box1Id,
normalizedAnchor: { x: 0.5, y: 0.5 },
isPrecise: false,
},
end: {
type: 'binding',
isExact: true,
boundShapeId: box2Id,
normalizedAnchor: { x: 0.5, y: 0.5 },
isPrecise: false,
},
},
},
])
expect(editor.getCulledShapes()).toEqual(new Set([box1Id, box2Id, arrowId]))
// Move box1 and box2 inside the viewport
editor.updateShapes([
{ id: box1Id, type: 'geo', x: 100 },
{ id: box2Id, type: 'geo', x: 200 },
])
// Arrow should also not be culled
expect(editor.getCulledShapes()).toEqual(new Set())
})

Wyświetl plik

@ -34,15 +34,17 @@ export function measureAverageDuration(
const start = performance.now()
const result = originalMethod.apply(this, args)
const end = performance.now()
const value = averages.get(descriptor.value)!
const length = end - start
const total = value.total + length
const count = value.count + 1
averages.set(descriptor.value, { total, count })
// eslint-disable-next-line no-console
console.log(
`${propertyKey} took ${(end - start).toFixed(2)}ms | average ${(total / count).toFixed(2)}ms`
)
if (length !== 0) {
const value = averages.get(descriptor.value)!
const total = value.total + length
const count = value.count + 1
averages.set(descriptor.value, { total, count })
// eslint-disable-next-line no-console
console.log(
`${propertyKey} took ${(end - start).toFixed(2)}ms | average ${(total / count).toFixed(2)}ms`
)
}
return result
}
averages.set(descriptor.value, { total: 0, count: 0 })

Wyświetl plik

@ -83,6 +83,9 @@ function nullable<T>(validator: Validatable<T>): Validator<null | T>;
// @public
const number: Validator<number>;
// @internal (undocumented)
function numberUnion<Key extends string, Config extends UnionValidatorConfig<Key, Config>>(key: Key, config: Config): UnionValidator<Key, Config>;
// @public
function object<Shape extends object>(config: {
readonly [K in keyof Shape]: Validatable<Shape[K]>;
@ -134,6 +137,7 @@ declare namespace T {
jsonDict,
dict,
union,
numberUnion,
model,
setEnum,
optional,
@ -178,7 +182,7 @@ function union<Key extends string, Config extends UnionValidatorConfig<Key, Conf
// @public (undocumented)
export class UnionValidator<Key extends string, Config extends UnionValidatorConfig<Key, Config>, UnknownValue = never> extends Validator<TypeOf<Config[keyof Config]> | UnknownValue> {
constructor(key: Key, config: Config, unknownValueValidation: (value: object, variant: string) => UnknownValue);
constructor(key: Key, config: Config, unknownValueValidation: (value: object, variant: string) => UnknownValue, useNumberKeys: boolean);
// (undocumented)
validateUnknownVariants<Unknown>(unknownValueValidation: (value: object, variant: string) => Unknown): UnionValidator<Key, Config, Unknown>;
}

Wyświetl plik

@ -3027,6 +3027,14 @@
"kind": "Content",
"text": "(value: object, variant: string) => UnknownValue"
},
{
"kind": "Content",
"text": ", useNumberKeys: "
},
{
"kind": "Content",
"text": "boolean"
},
{
"kind": "Content",
"text": ");"
@ -3059,6 +3067,14 @@
"endIndex": 6
},
"isOptional": false
},
{
"parameterName": "useNumberKeys",
"parameterTypeTokenRange": {
"startIndex": 7,
"endIndex": 8
},
"isOptional": false
}
]
},
@ -4260,6 +4276,14 @@
"kind": "Content",
"text": "(value: object, variant: string) => UnknownValue"
},
{
"kind": "Content",
"text": ", useNumberKeys: "
},
{
"kind": "Content",
"text": "boolean"
},
{
"kind": "Content",
"text": ");"
@ -4292,6 +4316,14 @@
"endIndex": 6
},
"isOptional": false
},
{
"parameterName": "useNumberKeys",
"parameterTypeTokenRange": {
"startIndex": 7,
"endIndex": 8
},
"isOptional": false
}
]
},

Wyświetl plik

@ -394,7 +394,8 @@ export class UnionValidator<
constructor(
private readonly key: Key,
private readonly config: Config,
private readonly unknownValueValidation: (value: object, variant: string) => UnknownValue
private readonly unknownValueValidation: (value: object, variant: string) => UnknownValue,
private readonly useNumberKeys: boolean
) {
super(
(input) => {
@ -442,11 +443,13 @@ export class UnionValidator<
matchingSchema: Validatable<any> | undefined
variant: string
} {
const variant = getOwnProperty(object, this.key) as keyof Config | undefined
if (typeof variant !== 'string') {
const variant = getOwnProperty(object, this.key) as string & keyof Config
if (!this.useNumberKeys && typeof variant !== 'string') {
throw new ValidationError(
`Expected a string for key "${this.key}", got ${typeToString(variant)}`
)
} else if (this.useNumberKeys && !Number.isFinite(Number(variant))) {
throw new ValidationError(`Expected a number for key "${this.key}", got "${variant as any}"`)
}
const matchingSchema = hasOwnProperty(this.config, variant) ? this.config[variant] : undefined
@ -456,7 +459,7 @@ export class UnionValidator<
validateUnknownVariants<Unknown>(
unknownValueValidation: (value: object, variant: string) => Unknown
): UnionValidator<Key, Config, Unknown> {
return new UnionValidator(this.key, this.config, unknownValueValidation)
return new UnionValidator(this.key, this.config, unknownValueValidation, this.useNumberKeys)
}
}
@ -829,14 +832,41 @@ export function union<Key extends string, Config extends UnionValidatorConfig<Ke
key: Key,
config: Config
): UnionValidator<Key, Config> {
return new UnionValidator(key, config, (unknownValue, unknownVariant) => {
throw new ValidationError(
`Expected one of ${Object.keys(config)
.map((key) => JSON.stringify(key))
.join(' or ')}, got ${JSON.stringify(unknownVariant)}`,
[key]
)
})
return new UnionValidator(
key,
config,
(unknownValue, unknownVariant) => {
throw new ValidationError(
`Expected one of ${Object.keys(config)
.map((key) => JSON.stringify(key))
.join(' or ')}, got ${JSON.stringify(unknownVariant)}`,
[key]
)
},
false
)
}
/**
* @internal
*/
export function numberUnion<Key extends string, Config extends UnionValidatorConfig<Key, Config>>(
key: Key,
config: Config
): UnionValidator<Key, Config> {
return new UnionValidator(
key,
config,
(unknownValue, unknownVariant) => {
throw new ValidationError(
`Expected one of ${Object.keys(config)
.map((key) => JSON.stringify(key))
.join(' or ')}, got ${JSON.stringify(unknownVariant)}`,
[key]
)
},
true
)
}
/**