kopia lustrzana https://github.com/Tldraw/Tldraw
Porównaj commity
22 Commity
942f52ab0d
...
eb671a48bd
Autor | SHA1 | Data |
---|---|---|
Mime Čuvalo | eb671a48bd | |
Mime Čuvalo | f7ecd803a4 | |
Mime Čuvalo | d96389418a | |
Mime Čuvalo | 237f6283f9 | |
Mitja Bezenšek | 5359aacc05 | |
Mitja Bezenšek | e94e706850 | |
Mitja Bezenšek | c1943be832 | |
Mitja Bezenšek | 90f1807a2c | |
Mitja Bezenšek | 4709274b2b | |
Mitja Bezenšek | 0e14c0e01e | |
Mitja Bezenšek | c71c774210 | |
Mitja Bezenšek | 741e97d6b7 | |
Mime Čuvalo | c9d944c5eb | |
alex | cce794e04b | |
dependabot[bot] | 4507ce6378 | |
Steve Ruiz | a6d2ab05d2 | |
Steve Ruiz | b5fab15c6d | |
Mitja Bezenšek | 42846e2969 | |
Mitja Bezenšek | 28aa0bb9ff | |
David Sheldrick | b5dfd81540 | |
Steve Ruiz | f6a2e352de | |
Mitja Bezenšek | 1fc68975e2 |
|
@ -2,7 +2,7 @@
|
|||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { RoomOpenMode } from '@tldraw/dotcom-shared'
|
||||
import { ROOM_OPEN_MODE, type RoomOpenMode } from '@tldraw/dotcom-shared'
|
||||
import {
|
||||
RoomSnapshot,
|
||||
TLServer,
|
||||
|
@ -90,22 +90,22 @@ export class TLDrawDurableObject extends TLServer {
|
|||
readonly router = Router()
|
||||
.get(
|
||||
'/r/:roomId',
|
||||
(req) => this.extractDocumentInfoFromRequest(req, RoomOpenMode.READ_WRITE),
|
||||
(req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_WRITE),
|
||||
(req) => this.onRequest(req)
|
||||
)
|
||||
.get(
|
||||
'/v/:roomId',
|
||||
(req) => this.extractDocumentInfoFromRequest(req, RoomOpenMode.READ_ONLY_LEGACY),
|
||||
(req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_ONLY_LEGACY),
|
||||
(req) => this.onRequest(req)
|
||||
)
|
||||
.get(
|
||||
'/ro/:roomId',
|
||||
(req) => this.extractDocumentInfoFromRequest(req, RoomOpenMode.READ_ONLY),
|
||||
(req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_ONLY),
|
||||
(req) => this.onRequest(req)
|
||||
)
|
||||
.post(
|
||||
'/r/:roomId/restore',
|
||||
(req) => this.extractDocumentInfoFromRequest(req, RoomOpenMode.READ_WRITE),
|
||||
(req) => this.extractDocumentInfoFromRequest(req, ROOM_OPEN_MODE.READ_WRITE),
|
||||
(req) => this.onRestore(req)
|
||||
)
|
||||
.all('*', () => new Response('Not found', { status: 404 }))
|
||||
|
|
|
@ -1,24 +1,22 @@
|
|||
import { SerializedSchema, SerializedStore } from '@tldraw/store'
|
||||
import { TLRecord } from '@tldraw/tlschema'
|
||||
import { CreateRoomRequestBody } from '@tldraw/dotcom-shared'
|
||||
import { RoomSnapshot, schema } from '@tldraw/tlsync'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { getR2KeyForRoom } from '../r2'
|
||||
import { Environment } from '../types'
|
||||
import { validateSnapshot } from '../utils/validateSnapshot'
|
||||
|
||||
type SnapshotRequestBody = {
|
||||
schema: SerializedSchema
|
||||
snapshot: SerializedStore<TLRecord>
|
||||
}
|
||||
import { isAllowedOrigin } from '../worker'
|
||||
|
||||
// Sets up a new room based on a provided snapshot, e.g. when a user clicks the "Share" buttons or the "Fork project" buttons.
|
||||
export async function createRoom(request: IRequest, env: Environment): Promise<Response> {
|
||||
// The data sent from the client will include the data for the new room
|
||||
const data = (await request.json()) as SnapshotRequestBody
|
||||
const data = (await request.json()) as CreateRoomRequestBody
|
||||
if (!isAllowedOrigin(data.origin)) {
|
||||
return Response.json({ error: true, message: 'Not allowed' }, { status: 406 })
|
||||
}
|
||||
|
||||
// There's a chance the data will be invalid, so we check it first
|
||||
const snapshotResult = validateSnapshot(data)
|
||||
const snapshotResult = validateSnapshot(data.snapshot)
|
||||
if (!snapshotResult.ok) {
|
||||
return Response.json({ error: true, message: snapshotResult.error }, { status: 400 })
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { SerializedSchema, SerializedStore } from '@tldraw/store'
|
||||
import { TLRecord } from '@tldraw/tlschema'
|
||||
import { CreateSnapshotRequestBody } from '@tldraw/dotcom-shared'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { Environment } from '../types'
|
||||
|
@ -7,12 +6,6 @@ import { createSupabaseClient, noSupabaseSorry } from '../utils/createSupabaseCl
|
|||
import { getSnapshotsTable } from '../utils/getSnapshotsTable'
|
||||
import { validateSnapshot } from '../utils/validateSnapshot'
|
||||
|
||||
type CreateSnapshotRequestBody = {
|
||||
schema: SerializedSchema
|
||||
snapshot: SerializedStore<TLRecord>
|
||||
parent_slug?: string | string[] | undefined
|
||||
}
|
||||
|
||||
export async function createRoomSnapshot(request: IRequest, env: Environment): Promise<Response> {
|
||||
const data = (await request.json()) as CreateSnapshotRequestBody
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { GetReadonlySlugResponseBody } from '@tldraw/dotcom-shared'
|
||||
import { lns } from '@tldraw/utils'
|
||||
import { IRequest } from 'itty-router'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { Environment } from '../types'
|
||||
|
||||
// Return a URL to a readonly version of the room
|
||||
|
@ -12,15 +13,18 @@ export async function getReadonlySlug(request: IRequest, env: Environment): Prom
|
|||
}
|
||||
|
||||
let slug = await env.SLUG_TO_READONLY_SLUG.get(roomId)
|
||||
let isLegacy = false
|
||||
|
||||
if (!slug) {
|
||||
slug = nanoid()
|
||||
await env.SLUG_TO_READONLY_SLUG.put(roomId, slug)
|
||||
await env.READONLY_SLUG_TO_SLUG.put(slug, roomId)
|
||||
// For all newly created rooms we add the readonly slug to the KV store.
|
||||
// If it does not exist there it means we are trying to get a slug for an old room.
|
||||
slug = lns(roomId)
|
||||
isLegacy = true
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
slug,
|
||||
})
|
||||
isLegacy,
|
||||
} satisfies GetReadonlySlugResponseBody)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import { RoomOpenMode } from '@tldraw/dotcom-shared'
|
||||
import { ROOM_OPEN_MODE, RoomOpenMode } from '@tldraw/dotcom-shared'
|
||||
import { exhaustiveSwitchError, lns } from '@tldraw/utils'
|
||||
import { Environment } from '../types'
|
||||
|
||||
export async function getSlug(env: Environment, slug: string | null, roomOpenMode: RoomOpenMode) {
|
||||
if (!slug) return null
|
||||
switch (roomOpenMode) {
|
||||
case RoomOpenMode.READ_WRITE:
|
||||
case ROOM_OPEN_MODE.READ_WRITE:
|
||||
return slug
|
||||
case RoomOpenMode.READ_ONLY:
|
||||
case ROOM_OPEN_MODE.READ_ONLY:
|
||||
return await env.READONLY_SLUG_TO_SLUG.get(slug)
|
||||
case RoomOpenMode.READ_ONLY_LEGACY:
|
||||
case ROOM_OPEN_MODE.READ_ONLY_LEGACY:
|
||||
return lns(slug)
|
||||
default:
|
||||
exhaustiveSwitchError(roomOpenMode)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/// <reference no-default-lib="true"/>
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
import { RoomOpenMode } from '@tldraw/dotcom-shared'
|
||||
import { ROOM_OPEN_MODE } from '@tldraw/dotcom-shared'
|
||||
import { Router, createCors } from 'itty-router'
|
||||
import { env } from 'process'
|
||||
import Toucan from 'toucan-js'
|
||||
|
@ -26,9 +26,9 @@ const router = Router()
|
|||
.post('/new-room', createRoom)
|
||||
.post('/snapshots', createRoomSnapshot)
|
||||
.get('/snapshot/:roomId', getRoomSnapshot)
|
||||
.get('/r/:roomId', (req, env) => joinExistingRoom(req, env, RoomOpenMode.READ_WRITE))
|
||||
.get('/v/:roomId', (req, env) => joinExistingRoom(req, env, RoomOpenMode.READ_ONLY_LEGACY))
|
||||
.get('/ro/:roomId', (req, env) => joinExistingRoom(req, env, RoomOpenMode.READ_ONLY))
|
||||
.get('/r/:roomId', (req, env) => joinExistingRoom(req, env, ROOM_OPEN_MODE.READ_WRITE))
|
||||
.get('/v/:roomId', (req, env) => joinExistingRoom(req, env, ROOM_OPEN_MODE.READ_ONLY_LEGACY))
|
||||
.get('/ro/:roomId', (req, env) => joinExistingRoom(req, env, ROOM_OPEN_MODE.READ_ONLY))
|
||||
.get('/r/:roomId/history', getRoomHistory)
|
||||
.get('/r/:roomId/history/:timestamp', getRoomHistorySnapshot)
|
||||
.get('/readonly-slug/:roomId', getReadonlySlug)
|
||||
|
@ -75,7 +75,7 @@ const Worker = {
|
|||
},
|
||||
}
|
||||
|
||||
function isAllowedOrigin(origin: string) {
|
||||
export function isAllowedOrigin(origin: string) {
|
||||
if (origin === 'http://localhost:3000') return true
|
||||
if (origin === 'http://localhost:5420') return true
|
||||
if (origin.endsWith('.tldraw.com')) return true
|
||||
|
|
|
@ -6,6 +6,10 @@ exports[`the_routes 1`] = `
|
|||
"reactRouterPattern": "/",
|
||||
"vercelRouterPattern": "^//?$",
|
||||
},
|
||||
{
|
||||
"reactRouterPattern": "/new",
|
||||
"vercelRouterPattern": "^/new/?$",
|
||||
},
|
||||
{
|
||||
"reactRouterPattern": "/r",
|
||||
"vercelRouterPattern": "^/r/?$",
|
||||
|
|
|
@ -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 { isInIframe } from '../utils/iFrame'
|
||||
import { trackAnalyticsEvent } from '../utils/trackAnalyticsEvent'
|
||||
|
@ -26,24 +27,26 @@ and we should show an annoying messsage.
|
|||
If we're not in an iframe, we don't need to do anything.
|
||||
*/
|
||||
|
||||
export enum ROOM_CONTEXT {
|
||||
PUBLIC_MULTIPLAYER = 'public-multiplayer',
|
||||
PUBLIC_READONLY = 'public-readonly',
|
||||
PUBLIC_SNAPSHOT = 'public-snapshot',
|
||||
HISTORY_SNAPSHOT = 'history-snapshot',
|
||||
HISTORY = 'history',
|
||||
LOCAL = 'local',
|
||||
}
|
||||
export const ROOM_CONTEXT = {
|
||||
PUBLIC_MULTIPLAYER: 'public-multiplayer',
|
||||
PUBLIC_READONLY: 'public-readonly',
|
||||
PUBLIC_SNAPSHOT: 'public-snapshot',
|
||||
HISTORY_SNAPSHOT: 'history-snapshot',
|
||||
HISTORY: 'history',
|
||||
LOCAL: 'local',
|
||||
} as const
|
||||
type $ROOM_CONTEXT = (typeof ROOM_CONTEXT)[keyof typeof ROOM_CONTEXT]
|
||||
|
||||
enum EMBEDDED_STATE {
|
||||
IFRAME_UNKNOWN = 'iframe-unknown',
|
||||
IFRAME_NOT_ALLOWED = 'iframe-not-allowed',
|
||||
NOT_IFRAME = 'not-iframe',
|
||||
IFRAME_OK = 'iframe-ok',
|
||||
}
|
||||
const EMBEDDED_STATE = {
|
||||
IFRAME_UNKNOWN: 'iframe-unknown',
|
||||
IFRAME_NOT_ALLOWED: 'iframe-not-allowed',
|
||||
NOT_IFRAME: 'not-iframe',
|
||||
IFRAME_OK: 'iframe-ok',
|
||||
} as const
|
||||
type $EMBEDDED_STATE = (typeof EMBEDDED_STATE)[keyof typeof EMBEDDED_STATE]
|
||||
|
||||
// Which routes do we allow to be embedded in tldraw.com itself?
|
||||
const WHITELIST_CONTEXT = [
|
||||
const WHITELIST_CONTEXT: $ROOM_CONTEXT[] = [
|
||||
ROOM_CONTEXT.PUBLIC_MULTIPLAYER,
|
||||
ROOM_CONTEXT.PUBLIC_READONLY,
|
||||
ROOM_CONTEXT.PUBLIC_SNAPSHOT,
|
||||
|
@ -57,10 +60,10 @@ export function IFrameProtector({
|
|||
children,
|
||||
}: {
|
||||
slug: string
|
||||
context: ROOM_CONTEXT
|
||||
context: $ROOM_CONTEXT
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [embeddedState, setEmbeddedState] = useState(
|
||||
const [embeddedState, setEmbeddedState] = useState<$EMBEDDED_STATE>(
|
||||
isInIframe() ? EMBEDDED_STATE.IFRAME_UNKNOWN : EMBEDDED_STATE.NOT_IFRAME
|
||||
)
|
||||
|
||||
|
@ -100,7 +103,15 @@ export function IFrameProtector({
|
|||
timeout = setTimeout(() => {
|
||||
setEmbeddedState(EMBEDDED_STATE.IFRAME_NOT_ALLOWED)
|
||||
const referrer = document.referrer
|
||||
trackAnalyticsEvent('connect_to_room_in_iframe', { slug, context, referrer })
|
||||
const ancestorOrigins = JSON.stringify(
|
||||
Object.values(window.location.ancestorOrigins || {})
|
||||
)
|
||||
trackAnalyticsEvent('connect_to_room_in_iframe', {
|
||||
slug,
|
||||
context,
|
||||
referrer,
|
||||
ancestorOrigins,
|
||||
})
|
||||
}, 1000)
|
||||
} else {
|
||||
// We don't allow iframe embeddings on other routes
|
||||
|
@ -125,7 +136,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"
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { RoomOpenMode, RoomOpenModeToPath } from '@tldraw/dotcom-shared'
|
||||
import { ROOM_OPEN_MODE, RoomOpenModeToPath, type RoomOpenMode } from '@tldraw/dotcom-shared'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import {
|
||||
DefaultContextMenu,
|
||||
|
@ -127,12 +127,14 @@ export function MultiplayerEditor({
|
|||
const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true })
|
||||
const cursorChatOverrides = useCursorChat()
|
||||
const isReadonly =
|
||||
roomOpenMode === RoomOpenMode.READ_ONLY || roomOpenMode === RoomOpenMode.READ_ONLY_LEGACY
|
||||
roomOpenMode === ROOM_OPEN_MODE.READ_ONLY || roomOpenMode === ROOM_OPEN_MODE.READ_ONLY_LEGACY
|
||||
|
||||
const handleMount = useCallback(
|
||||
(editor: Editor) => {
|
||||
;(window as any).app = editor
|
||||
;(window as any).editor = editor
|
||||
if (!isReadonly) {
|
||||
;(window as any).app = editor
|
||||
;(window as any).editor = editor
|
||||
}
|
||||
editor.updateInstanceState({
|
||||
isReadonly,
|
||||
})
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
import * as Popover from '@radix-ui/react-popover'
|
||||
import { RoomOpenMode, RoomOpenModeToPath } from '@tldraw/dotcom-shared'
|
||||
import {
|
||||
GetReadonlySlugResponseBody,
|
||||
ROOM_OPEN_MODE,
|
||||
RoomOpenModeToPath,
|
||||
} from '@tldraw/dotcom-shared'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import {
|
||||
TldrawUiMenuContextProvider,
|
||||
|
@ -16,11 +20,12 @@ import { createQRCodeImageDataString } from '../utils/qrcode'
|
|||
import { SHARE_PROJECT_ACTION, SHARE_SNAPSHOT_ACTION } from '../utils/sharing'
|
||||
import { ShareButton } from './ShareButton'
|
||||
|
||||
enum ShareCurrentState {
|
||||
OFFLINE = 'offline',
|
||||
SHARED_READ_WRITE = 'shared-read-write',
|
||||
SHARED_READ_ONLY = 'shared-read-only',
|
||||
}
|
||||
const SHARE_CURRENT_STATE = {
|
||||
OFFLINE: 'offline',
|
||||
SHARED_READ_WRITE: 'shared-read-write',
|
||||
SHARED_READ_ONLY: 'shared-read-only',
|
||||
} as const
|
||||
type ShareCurrentState = (typeof SHARE_CURRENT_STATE)[keyof typeof SHARE_CURRENT_STATE]
|
||||
|
||||
type ShareState = {
|
||||
state: ShareCurrentState
|
||||
|
@ -32,8 +37,8 @@ type ShareState = {
|
|||
|
||||
function isSharedReadonlyUrl(pathname: string) {
|
||||
return (
|
||||
pathname.startsWith(`/${RoomOpenModeToPath[RoomOpenMode.READ_ONLY]}/`) ||
|
||||
pathname.startsWith(`/${RoomOpenModeToPath[RoomOpenMode.READ_ONLY_LEGACY]}/`)
|
||||
pathname.startsWith(`/${RoomOpenModeToPath[ROOM_OPEN_MODE.READ_ONLY]}/`) ||
|
||||
pathname.startsWith(`/${RoomOpenModeToPath[ROOM_OPEN_MODE.READ_ONLY_LEGACY]}/`)
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -47,10 +52,10 @@ function getFreshShareState(): ShareState {
|
|||
|
||||
return {
|
||||
state: isSharedReadWrite
|
||||
? ShareCurrentState.SHARED_READ_WRITE
|
||||
? SHARE_CURRENT_STATE.SHARED_READ_WRITE
|
||||
: isSharedReadOnly
|
||||
? ShareCurrentState.SHARED_READ_ONLY
|
||||
: ShareCurrentState.OFFLINE,
|
||||
? SHARE_CURRENT_STATE.SHARED_READ_ONLY
|
||||
: SHARE_CURRENT_STATE.OFFLINE,
|
||||
url: window.location.href,
|
||||
readonlyUrl: isSharedReadOnly ? window.location.href : null,
|
||||
qrCodeDataUrl: '',
|
||||
|
@ -64,16 +69,17 @@ async function getReadonlyUrl() {
|
|||
if (isReadOnly) return window.location.href
|
||||
|
||||
const segments = pathname.split('/')
|
||||
segments[1] = RoomOpenModeToPath[RoomOpenMode.READ_ONLY]
|
||||
|
||||
const roomId = segments[2]
|
||||
const result = await fetch(`/api/readonly-slug/${roomId}`)
|
||||
if (!result.ok) return
|
||||
|
||||
const slug = (await result.json()).slug
|
||||
if (!slug) return
|
||||
const data = (await result.json()) as GetReadonlySlugResponseBody
|
||||
if (!data.slug) return
|
||||
|
||||
segments[2] = slug
|
||||
segments[1] =
|
||||
RoomOpenModeToPath[data.isLegacy ? ROOM_OPEN_MODE.READ_ONLY_LEGACY : ROOM_OPEN_MODE.READ_ONLY]
|
||||
segments[2] = data.slug
|
||||
const newPathname = segments.join('/')
|
||||
|
||||
return `${window.location.origin}${newPathname}${window.location.search}`
|
||||
|
@ -91,7 +97,7 @@ export const ShareMenu = React.memo(function ShareMenu() {
|
|||
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [isUploadingSnapshot, setIsUploadingSnapshot] = useState(false)
|
||||
const isReadOnlyLink = shareState.state === ShareCurrentState.SHARED_READ_ONLY
|
||||
const isReadOnlyLink = shareState.state === SHARE_CURRENT_STATE.SHARED_READ_ONLY
|
||||
const currentShareLinkUrl = isReadOnlyLink ? shareState.readonlyUrl : shareState.url
|
||||
const currentQrCodeUrl = isReadOnlyLink
|
||||
? shareState.readonlyQrCodeDataUrl
|
||||
|
@ -101,14 +107,14 @@ export const ShareMenu = React.memo(function ShareMenu() {
|
|||
const [didCopySnapshotLink, setDidCopySnapshotLink] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (shareState.state === ShareCurrentState.OFFLINE) {
|
||||
if (shareState.state === SHARE_CURRENT_STATE.OFFLINE) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const shareUrl = getShareUrl(window.location.href, false)
|
||||
if (!shareState.qrCodeDataUrl && shareState.state === ShareCurrentState.SHARED_READ_WRITE) {
|
||||
if (!shareState.qrCodeDataUrl && shareState.state === SHARE_CURRENT_STATE.SHARED_READ_WRITE) {
|
||||
// Fetch the QR code data URL
|
||||
createQRCodeImageDataString(shareUrl).then((dataUrl) => {
|
||||
if (!cancelled) {
|
||||
|
@ -157,8 +163,8 @@ export const ShareMenu = React.memo(function ShareMenu() {
|
|||
alignOffset={4}
|
||||
>
|
||||
<TldrawUiMenuContextProvider type="panel" sourceId="share-menu">
|
||||
{shareState.state === ShareCurrentState.SHARED_READ_WRITE ||
|
||||
shareState.state === ShareCurrentState.SHARED_READ_ONLY ? (
|
||||
{shareState.state === SHARE_CURRENT_STATE.SHARED_READ_WRITE ||
|
||||
shareState.state === SHARE_CURRENT_STATE.SHARED_READ_ONLY ? (
|
||||
<>
|
||||
<button
|
||||
className="tlui-share-zone__qr-code"
|
||||
|
@ -175,7 +181,7 @@ export const ShareMenu = React.memo(function ShareMenu() {
|
|||
/>
|
||||
|
||||
<TldrawUiMenuGroup id="copy">
|
||||
{shareState.state === ShareCurrentState.SHARED_READ_WRITE && (
|
||||
{shareState.state === SHARE_CURRENT_STATE.SHARED_READ_WRITE && (
|
||||
<TldrawUiMenuItem
|
||||
id="copy-to-clipboard"
|
||||
readonlyOk
|
||||
|
@ -242,7 +248,7 @@ export const ShareMenu = React.memo(function ShareMenu() {
|
|||
/>
|
||||
<p className="tlui-menu__group tlui-share-zone__details">
|
||||
{msg(
|
||||
shareState.state === ShareCurrentState.OFFLINE
|
||||
shareState.state === SHARE_CURRENT_STATE.OFFLINE
|
||||
? 'share-menu.offline-note'
|
||||
: isReadOnlyLink
|
||||
? 'share-menu.copy-readonly-link-note'
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { RoomOpenMode } from '@tldraw/dotcom-shared'
|
||||
import { ROOM_OPEN_MODE } from '@tldraw/dotcom-shared'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import '../../styles/globals.css'
|
||||
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
|
||||
|
@ -8,7 +8,7 @@ export function Component() {
|
|||
const id = useParams()['roomId'] as string
|
||||
return (
|
||||
<IFrameProtector slug={id} context={ROOM_CONTEXT.PUBLIC_MULTIPLAYER}>
|
||||
<MultiplayerEditor roomOpenMode={RoomOpenMode.READ_WRITE} roomSlug={id} />
|
||||
<MultiplayerEditor roomOpenMode={ROOM_OPEN_MODE.READ_WRITE} roomSlug={id} />
|
||||
</IFrameProtector>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { RoomOpenMode } from '@tldraw/dotcom-shared'
|
||||
import { ROOM_OPEN_MODE } from '@tldraw/dotcom-shared'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import '../../styles/globals.css'
|
||||
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
|
||||
|
@ -8,7 +8,7 @@ export function Component() {
|
|||
const id = useParams()['roomId'] as string
|
||||
return (
|
||||
<IFrameProtector slug={id} context={ROOM_CONTEXT.PUBLIC_READONLY}>
|
||||
<MultiplayerEditor roomOpenMode={RoomOpenMode.READ_ONLY_LEGACY} roomSlug={id} />
|
||||
<MultiplayerEditor roomOpenMode={ROOM_OPEN_MODE.READ_ONLY_LEGACY} roomSlug={id} />
|
||||
</IFrameProtector>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { RoomOpenMode } from '@tldraw/dotcom-shared'
|
||||
import { ROOM_OPEN_MODE } from '@tldraw/dotcom-shared'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import '../../styles/globals.css'
|
||||
import { IFrameProtector, ROOM_CONTEXT } from '../components/IFrameProtector'
|
||||
|
@ -8,7 +8,7 @@ export function Component() {
|
|||
const id = useParams()['roomId'] as string
|
||||
return (
|
||||
<IFrameProtector slug={id} context={ROOM_CONTEXT.PUBLIC_READONLY}>
|
||||
<MultiplayerEditor roomOpenMode={RoomOpenMode.READ_ONLY} roomSlug={id} />
|
||||
<MultiplayerEditor roomOpenMode={ROOM_OPEN_MODE.READ_ONLY} roomSlug={id} />
|
||||
</IFrameProtector>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -111,6 +111,7 @@ test('all React routes match', () => {
|
|||
test("non-react routes don't match", () => {
|
||||
// lil smoke test for basic patterns
|
||||
expect('/').toMatchAny(allvercelRouterPatterns)
|
||||
expect('/new').toMatchAny(allvercelRouterPatterns)
|
||||
expect('/r/whatever').toMatchAny(allvercelRouterPatterns)
|
||||
expect('/r/whatever/').toMatchAny(allvercelRouterPatterns)
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { captureException } from '@sentry/react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { useEffect } from 'react'
|
||||
import { createRoutesFromElements, Outlet, redirect, Route, useRouteError } from 'react-router-dom'
|
||||
import { createRoutesFromElements, Navigate, Outlet, Route, useRouteError } from 'react-router-dom'
|
||||
import { DefaultErrorFallback } from './components/DefaultErrorFallback/DefaultErrorFallback'
|
||||
import { ErrorPage } from './components/ErrorPage/ErrorPage'
|
||||
|
||||
|
@ -30,13 +29,8 @@ export const router = createRoutesFromElements(
|
|||
>
|
||||
<Route errorElement={<DefaultErrorFallback />}>
|
||||
<Route path="/" lazy={() => import('./pages/root')} />
|
||||
<Route
|
||||
path="/r"
|
||||
loader={() => {
|
||||
const id = 'v2' + nanoid()
|
||||
return redirect(`/r/${id}`)
|
||||
}}
|
||||
/>
|
||||
<Route path="/r" element={<Navigate to="/" />} />
|
||||
<Route path="/new" element={<Navigate to="/" />} />
|
||||
<Route path="/r/:roomId" lazy={() => import('./pages/public-multiplayer')} />
|
||||
<Route path="/r/:boardId/history" lazy={() => import('./pages/history')} />
|
||||
<Route
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
import {
|
||||
CreateRoomRequestBody,
|
||||
CreateSnapshotRequestBody,
|
||||
CreateSnapshotResponseBody,
|
||||
Snapshot,
|
||||
} from '@tldraw/dotcom-shared'
|
||||
import { useMemo } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
AssetRecordType,
|
||||
Editor,
|
||||
SerializedSchema,
|
||||
SerializedStore,
|
||||
TLAsset,
|
||||
TLAssetId,
|
||||
TLRecord,
|
||||
|
@ -33,27 +37,6 @@ export const FORK_PROJECT_ACTION = 'fork-project' as const
|
|||
const CREATE_SNAPSHOT_ENDPOINT = `/api/snapshots`
|
||||
const SNAPSHOT_UPLOAD_URL = `/api/new-room`
|
||||
|
||||
type SnapshotRequestBody = {
|
||||
schema: SerializedSchema
|
||||
snapshot: SerializedStore<TLRecord>
|
||||
}
|
||||
|
||||
type CreateSnapshotRequestBody = {
|
||||
schema: SerializedSchema
|
||||
snapshot: SerializedStore<TLRecord>
|
||||
parent_slug?: string | string[] | undefined
|
||||
}
|
||||
|
||||
type CreateSnapshotResponseBody =
|
||||
| {
|
||||
error: false
|
||||
roomId: string
|
||||
}
|
||||
| {
|
||||
error: true
|
||||
message: string
|
||||
}
|
||||
|
||||
async function getSnapshotLink(
|
||||
source: string,
|
||||
editor: Editor,
|
||||
|
@ -124,15 +107,24 @@ export function useSharing(): TLUiOverrides {
|
|||
const data = await getRoomData(editor, addToast, msg, uploadFileToAsset)
|
||||
if (!data) return
|
||||
|
||||
const topLevelUrl = new URL(
|
||||
window.location != window.parent.location
|
||||
? document.referrer
|
||||
: document.location.href
|
||||
)
|
||||
|
||||
const res = await fetch(SNAPSHOT_UPLOAD_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
schema: editor.store.schema.serialize(),
|
||||
snapshot: data,
|
||||
} satisfies SnapshotRequestBody),
|
||||
origin: topLevelUrl.origin,
|
||||
snapshot: {
|
||||
schema: editor.store.schema.serialize(),
|
||||
snapshot: data,
|
||||
} satisfies Snapshot,
|
||||
} satisfies CreateRoomRequestBody),
|
||||
})
|
||||
|
||||
const response = (await res.json()) as { error: boolean; slug?: string }
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import {
|
||||
ContextMenu,
|
||||
DefaultContextMenuContent,
|
||||
ErrorScreen,
|
||||
LoadingScreen,
|
||||
TldrawEditor,
|
||||
TldrawHandles,
|
||||
TldrawScribble,
|
||||
|
@ -10,7 +12,9 @@ import {
|
|||
defaultShapeTools,
|
||||
defaultShapeUtils,
|
||||
defaultTools,
|
||||
usePreloadAssets,
|
||||
} from 'tldraw'
|
||||
import { defaultEditorAssetUrls } from 'tldraw/src/lib/utils/static-assets/assetUrls'
|
||||
import 'tldraw/tldraw.css'
|
||||
|
||||
// There's a guide at the bottom of this file!
|
||||
|
@ -26,6 +30,16 @@ const defaultComponents = {
|
|||
|
||||
//[2]
|
||||
export default function ExplodedExample() {
|
||||
const assetLoading = usePreloadAssets(defaultEditorAssetUrls)
|
||||
|
||||
if (assetLoading.error) {
|
||||
return <ErrorScreen>Could not load assets.</ErrorScreen>
|
||||
}
|
||||
|
||||
if (!assetLoading.done) {
|
||||
return <LoadingScreen>Loading assets...</LoadingScreen>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tldraw__editor">
|
||||
<TldrawEditor
|
||||
|
|
|
@ -7,6 +7,13 @@
|
|||
"types": "./.tsbuild/index.d.ts",
|
||||
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
|
||||
"files": [],
|
||||
"dependencies": {
|
||||
"tldraw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18",
|
||||
"react-dom": "^18"
|
||||
},
|
||||
"scripts": {
|
||||
"test-ci": "lazy inherit",
|
||||
"test": "yarn run -T jest",
|
||||
|
|
|
@ -1 +1,8 @@
|
|||
export { RoomOpenMode, RoomOpenModeToPath } from './routes'
|
||||
export { ROOM_OPEN_MODE, RoomOpenModeToPath, type RoomOpenMode } from './routes'
|
||||
export type {
|
||||
CreateRoomRequestBody,
|
||||
CreateSnapshotRequestBody,
|
||||
CreateSnapshotResponseBody,
|
||||
GetReadonlySlugResponseBody,
|
||||
Snapshot,
|
||||
} from './types'
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
/** @public */
|
||||
export enum RoomOpenMode {
|
||||
READ_ONLY = 'readonly',
|
||||
READ_ONLY_LEGACY = 'readonly-legacy',
|
||||
READ_WRITE = 'read-write',
|
||||
}
|
||||
export const ROOM_OPEN_MODE = {
|
||||
READ_ONLY: 'readonly',
|
||||
READ_ONLY_LEGACY: 'readonly-legacy',
|
||||
READ_WRITE: 'read-write',
|
||||
} as const
|
||||
export type RoomOpenMode = (typeof ROOM_OPEN_MODE)[keyof typeof ROOM_OPEN_MODE]
|
||||
|
||||
/** @public */
|
||||
export const RoomOpenModeToPath: Record<RoomOpenMode, string> = {
|
||||
[RoomOpenMode.READ_ONLY]: 'ro',
|
||||
[RoomOpenMode.READ_ONLY_LEGACY]: 'v',
|
||||
[RoomOpenMode.READ_WRITE]: 'r',
|
||||
[ROOM_OPEN_MODE.READ_ONLY]: 'ro',
|
||||
[ROOM_OPEN_MODE.READ_ONLY_LEGACY]: 'v',
|
||||
[ROOM_OPEN_MODE.READ_WRITE]: 'r',
|
||||
}
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import { SerializedSchema, SerializedStore, TLRecord } from 'tldraw'
|
||||
|
||||
export type Snapshot = {
|
||||
schema: SerializedSchema
|
||||
snapshot: SerializedStore<TLRecord>
|
||||
}
|
||||
|
||||
export type CreateRoomRequestBody = {
|
||||
origin: string
|
||||
snapshot: Snapshot
|
||||
}
|
||||
|
||||
export type CreateSnapshotRequestBody = {
|
||||
schema: SerializedSchema
|
||||
snapshot: SerializedStore<TLRecord>
|
||||
parent_slug?: string | string[] | undefined
|
||||
}
|
||||
|
||||
export type CreateSnapshotResponseBody =
|
||||
| {
|
||||
error: false
|
||||
roomId: string
|
||||
}
|
||||
| {
|
||||
error: true
|
||||
message: string
|
||||
}
|
||||
|
||||
export type GetReadonlySlugResponseBody = { slug: string; isLegacy: boolean }
|
|
@ -6,5 +6,9 @@
|
|||
"outDir": "./.tsbuild",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"references": []
|
||||
"references": [
|
||||
{
|
||||
"path": "../tldraw"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -682,6 +682,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
|
||||
|
@ -693,6 +695,8 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
getCurrentPageId(): TLPageId;
|
||||
getCurrentPageRenderingShapesSorted(): TLShape[];
|
||||
getCurrentPageShapeIds(): Set<TLShapeId>;
|
||||
// @internal (undocumented)
|
||||
getCurrentPageShapeIdsSorted(): TLShapeId[];
|
||||
getCurrentPageShapes(): TLShape[];
|
||||
getCurrentPageShapesSorted(): TLShape[];
|
||||
getCurrentPageState(): TLInstancePageState;
|
||||
|
|
|
@ -10059,6 +10059,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",
|
||||
|
|
|
@ -2619,15 +2619,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
* @public
|
||||
*/
|
||||
animateToUser(userId: string): 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
|
||||
|
||||
|
@ -2883,6 +2875,45 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
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
|
||||
|
||||
|
@ -2894,9 +2925,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()
|
||||
|
||||
|
@ -2905,7 +2936,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
|
||||
}
|
||||
|
||||
|
@ -2924,7 +2955,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|||
|
||||
const moveTowardsUser = () => {
|
||||
// Stop following if we can't find the user
|
||||
const leaderPresence = [...leaderPresences.get()]
|
||||
const leaderPresence = [...leaderPresences]
|
||||
.sort((a, b) => {
|
||||
return a.lastActivityTimestamp - b.lastActivityTimestamp
|
||||
})
|
||||
|
@ -3281,6 +3312,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.
|
||||
*
|
||||
|
@ -3893,7 +3932,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()
|
||||
}
|
||||
|
||||
|
@ -4227,7 +4266,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) {
|
||||
|
@ -8159,7 +8198,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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -409,7 +409,7 @@ export const DefaultQuickActions: NamedExoticComponent<TLUiQuickActionsProps>;
|
|||
export function DefaultQuickActionsContent(): JSX_2.Element | undefined;
|
||||
|
||||
// @public (undocumented)
|
||||
export const defaultShapeTools: (typeof ArrowShapeTool | typeof DrawShapeTool | typeof FrameShapeTool | typeof GeoShapeTool | typeof LineShapeTool | typeof NoteShapeTool | typeof TextShapeTool)[];
|
||||
export const defaultShapeTools: (typeof ArrowShapeTool | typeof FrameShapeTool | typeof GeoShapeTool | typeof HighlightShapeTool | typeof LineShapeTool | typeof NoteShapeTool | typeof TextShapeTool)[];
|
||||
|
||||
// @public (undocumented)
|
||||
export const defaultShapeUtils: TLAnyShapeUtilConstructor[];
|
||||
|
@ -455,7 +455,7 @@ export function downsizeImage(blob: Blob, width: number, height: number, opts?:
|
|||
// @public (undocumented)
|
||||
export class DrawShapeTool extends StateNode {
|
||||
// (undocumented)
|
||||
static children: () => (typeof Drawing | typeof Idle_2)[];
|
||||
static children: () => (typeof Drawing | typeof Idle_3)[];
|
||||
// (undocumented)
|
||||
static id: string;
|
||||
// (undocumented)
|
||||
|
@ -678,7 +678,7 @@ export function FrameToolbarItem(): JSX_2.Element;
|
|||
// @public (undocumented)
|
||||
export class GeoShapeTool extends StateNode {
|
||||
// (undocumented)
|
||||
static children: () => (typeof Idle_3 | typeof Pointing_2)[];
|
||||
static children: () => (typeof Idle_2 | typeof Pointing_2)[];
|
||||
// (undocumented)
|
||||
static id: string;
|
||||
// (undocumented)
|
||||
|
@ -875,7 +875,7 @@ export function HexagonToolbarItem(): JSX_2.Element;
|
|||
// @public (undocumented)
|
||||
export class HighlightShapeTool extends StateNode {
|
||||
// (undocumented)
|
||||
static children: () => (typeof Drawing | typeof Idle_2)[];
|
||||
static children: () => (typeof Drawing | typeof Idle_3)[];
|
||||
// (undocumented)
|
||||
static id: string;
|
||||
// (undocumented)
|
||||
|
@ -2545,6 +2545,12 @@ export function useMenuIsOpen(id: string, cb?: (isOpen: boolean) => void): reado
|
|||
// @public (undocumented)
|
||||
export function useNativeClipboardEvents(): void;
|
||||
|
||||
// @public (undocumented)
|
||||
export function usePreloadAssets(assetUrls: TLEditorAssetUrls): {
|
||||
done: boolean;
|
||||
error: boolean;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export function useReadonly(): boolean;
|
||||
|
||||
|
|
|
@ -3829,15 +3829,6 @@
|
|||
"kind": "Content",
|
||||
"text": " | typeof "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "DrawShapeTool",
|
||||
"canonicalReference": "tldraw!DrawShapeTool:class"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": " | typeof "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "FrameShapeTool",
|
||||
|
@ -3856,6 +3847,15 @@
|
|||
"kind": "Content",
|
||||
"text": " | typeof "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "HighlightShapeTool",
|
||||
"canonicalReference": "tldraw!HighlightShapeTool:class"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": " | typeof "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "LineShapeTool",
|
||||
|
@ -4490,7 +4490,7 @@
|
|||
{
|
||||
"kind": "Reference",
|
||||
"text": "Idle",
|
||||
"canonicalReference": "tldraw!~Idle_2:class"
|
||||
"canonicalReference": "tldraw!~Idle_3:class"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
|
@ -7891,7 +7891,7 @@
|
|||
{
|
||||
"kind": "Reference",
|
||||
"text": "Idle",
|
||||
"canonicalReference": "tldraw!~Idle_3:class"
|
||||
"canonicalReference": "tldraw!~Idle_2:class"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
|
@ -9721,7 +9721,7 @@
|
|||
{
|
||||
"kind": "Reference",
|
||||
"text": "Idle",
|
||||
"canonicalReference": "tldraw!~Idle_2:class"
|
||||
"canonicalReference": "tldraw!~Idle_3:class"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
|
@ -27982,6 +27982,52 @@
|
|||
"parameters": [],
|
||||
"name": "useNativeClipboardEvents"
|
||||
},
|
||||
{
|
||||
"kind": "Function",
|
||||
"canonicalReference": "tldraw!usePreloadAssets:function(1)",
|
||||
"docComment": "/**\n * @public\n */\n",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "export declare function usePreloadAssets(assetUrls: "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "TLEditorAssetUrls",
|
||||
"canonicalReference": "tldraw!~TLEditorAssetUrls:type"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "): "
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "{\n done: boolean;\n error: boolean;\n}"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ";"
|
||||
}
|
||||
],
|
||||
"fileUrlPath": "packages/tldraw/src/lib/ui/hooks/usePreloadAssets.ts",
|
||||
"returnTypeTokenRange": {
|
||||
"startIndex": 3,
|
||||
"endIndex": 4
|
||||
},
|
||||
"releaseTag": "Public",
|
||||
"overloadIndex": 1,
|
||||
"parameters": [
|
||||
{
|
||||
"parameterName": "assetUrls",
|
||||
"parameterTypeTokenRange": {
|
||||
"startIndex": 1,
|
||||
"endIndex": 2
|
||||
},
|
||||
"isOptional": false
|
||||
}
|
||||
],
|
||||
"name": "usePreloadAssets"
|
||||
},
|
||||
{
|
||||
"kind": "Function",
|
||||
"canonicalReference": "tldraw!useReadonly:function(1)",
|
||||
|
|
|
@ -87,6 +87,7 @@ export { useExportAs } from './lib/ui/hooks/useExportAs'
|
|||
export { useKeyboardShortcuts } from './lib/ui/hooks/useKeyboardShortcuts'
|
||||
export { useLocalStorageState } from './lib/ui/hooks/useLocalStorageState'
|
||||
export { useMenuIsOpen } from './lib/ui/hooks/useMenuIsOpen'
|
||||
export { usePreloadAssets } from './lib/ui/hooks/usePreloadAssets'
|
||||
export { useReadonly } from './lib/ui/hooks/useReadonly'
|
||||
export { useRelevantStyles } from './lib/ui/hooks/useRelevantStyles'
|
||||
export {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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, { 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, { 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">
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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' })
|
||||
|
@ -648,9 +655,9 @@ export function useNativeClipboardEvents() {
|
|||
}
|
||||
}
|
||||
|
||||
const paste = (event: ClipboardEvent) => {
|
||||
const paste = (e: ClipboardEvent) => {
|
||||
if (disablingMiddleClickPaste) {
|
||||
event.stopPropagation()
|
||||
stopEventPropagation(e)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -660,8 +667,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) => {
|
||||
|
@ -671,6 +678,7 @@ export function useNativeClipboardEvents() {
|
|||
})
|
||||
}
|
||||
|
||||
preventDefault(e)
|
||||
trackEvent('paste', { source: 'kbd' })
|
||||
}
|
||||
|
||||
|
|
|
@ -56,6 +56,7 @@ function getTypefaces(assetUrls: TLEditorAssetUrls) {
|
|||
}
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function usePreloadAssets(assetUrls: TLEditorAssetUrls) {
|
||||
const typefaces = useMemo(() => getTypefaces(assetUrls), [assetUrls])
|
||||
|
||||
|
|
Plik diff jest za duży
Load Diff
Plik diff jest za duży
Load Diff
|
@ -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')
|
||||
})
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -6,7 +6,7 @@ import { execSync } from 'child_process'
|
|||
import { appendFileSync, existsSync, readdirSync, writeFileSync } from 'fs'
|
||||
import path, { join } from 'path'
|
||||
import { PassThrough } from 'stream'
|
||||
import tar from 'tar'
|
||||
import * as tar from 'tar'
|
||||
import { exec } from './lib/exec'
|
||||
import { makeEnv } from './lib/makeEnv'
|
||||
import { nicelog } from './lib/nicelog'
|
||||
|
@ -515,7 +515,7 @@ async function coalesceWithPreviousAssets(assetsDir: string) {
|
|||
// and it will mess up the inline source viewer on sentry errors.
|
||||
const out = tar.x({ cwd: assetsDir, 'keep-existing': true })
|
||||
for await (const chunk of Body?.transformToWebStream() as any as AsyncIterable<Uint8Array>) {
|
||||
out.write(chunk)
|
||||
out.write(Buffer.from(chunk.buffer))
|
||||
}
|
||||
out.end()
|
||||
}
|
||||
|
|
|
@ -18,12 +18,12 @@ async function hasPackageChanged(pkg: PackageDetails) {
|
|||
}
|
||||
const publishedTarballPath = `${dirPath}/published-package.tgz`
|
||||
writeFileSync(publishedTarballPath, Buffer.from(await res.arrayBuffer()))
|
||||
const publishedManifest = await getTarballManifest(publishedTarballPath)
|
||||
const publishedManifest = getTarballManifestSync(publishedTarballPath)
|
||||
|
||||
const localTarballPath = `${dirPath}/local-package.tgz`
|
||||
await exec('yarn', ['pack', '--out', localTarballPath], { pwd: pkg.dir })
|
||||
|
||||
const localManifest = await getTarballManifest(localTarballPath)
|
||||
const localManifest = getTarballManifestSync(localTarballPath)
|
||||
|
||||
return !manifestsAreEqual(publishedManifest, localManifest)
|
||||
} finally {
|
||||
|
@ -48,34 +48,25 @@ function manifestsAreEqual(a: Record<string, Buffer>, b: Record<string, Buffer>)
|
|||
return true
|
||||
}
|
||||
|
||||
function getTarballManifest(tarballPath: string): Promise<Record<string, Buffer>> {
|
||||
function getTarballManifestSync(tarballPath: string) {
|
||||
const manifest: Record<string, Buffer> = {}
|
||||
return new Promise((resolve, reject) =>
|
||||
tar.list(
|
||||
{
|
||||
// @ts-expect-error bad typings
|
||||
file: tarballPath,
|
||||
onentry: (entry) => {
|
||||
entry.on('data', (data) => {
|
||||
// we could hash these to reduce memory but it's probably fine
|
||||
const existing = manifest[entry.path]
|
||||
if (existing) {
|
||||
manifest[entry.path] = Buffer.concat([existing, data])
|
||||
} else {
|
||||
manifest[entry.path] = data
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
(err: any) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
tar.list({
|
||||
file: tarballPath,
|
||||
onentry: (entry) => {
|
||||
entry.on('data', (data) => {
|
||||
// we could hash these to reduce memory but it's probably fine
|
||||
const existing = manifest[entry.path]
|
||||
if (existing) {
|
||||
manifest[entry.path] = Buffer.concat([existing, data])
|
||||
} else {
|
||||
resolve(manifest)
|
||||
manifest[entry.path] = data
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
},
|
||||
sync: true,
|
||||
})
|
||||
|
||||
return manifest
|
||||
}
|
||||
|
||||
export async function didAnyPackageChange() {
|
||||
|
|
|
@ -32,7 +32,6 @@
|
|||
"@aws-sdk/lib-storage": "^3.440.0",
|
||||
"@types/is-ci": "^3.0.0",
|
||||
"@types/node": "~20.11",
|
||||
"@types/tar": "^6.1.11",
|
||||
"@typescript-eslint/utils": "^5.59.0",
|
||||
"ast-types": "^0.14.2",
|
||||
"cross-fetch": "^3.1.5",
|
||||
|
@ -59,7 +58,7 @@
|
|||
"@types/tmp": "^0.2.6",
|
||||
"ignore": "^5.2.4",
|
||||
"minimist": "^1.2.8",
|
||||
"tar": "^6.2.0",
|
||||
"tar": "^7.0.1",
|
||||
"tmp": "^0.2.3"
|
||||
}
|
||||
}
|
||||
|
|
131
yarn.lock
131
yarn.lock
|
@ -3680,6 +3680,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@isaacs/fs-minipass@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "@isaacs/fs-minipass@npm:4.0.0"
|
||||
dependencies:
|
||||
minipass: "npm:^7.0.4"
|
||||
checksum: 7444d7a3c9211c27494630e2bff8545e3494a1598624a4871ee7ef3a9e592a61fed3abd85d118f966673bd0b4401c266d45441f89c00c420e9d0cfbf1042dbd5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@istanbuljs/load-nyc-config@npm:^1.0.0":
|
||||
version: 1.1.0
|
||||
resolution: "@istanbuljs/load-nyc-config@npm:1.1.0"
|
||||
|
@ -7450,6 +7459,11 @@ __metadata:
|
|||
"@tldraw/dotcom-shared@workspace:*, @tldraw/dotcom-shared@workspace:packages/dotcom-shared":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@tldraw/dotcom-shared@workspace:packages/dotcom-shared"
|
||||
dependencies:
|
||||
tldraw: "workspace:*"
|
||||
peerDependencies:
|
||||
react: ^18
|
||||
react-dom: ^18
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
|
@ -7577,7 +7591,6 @@ __metadata:
|
|||
"@types/is-ci": "npm:^3.0.0"
|
||||
"@types/minimist": "npm:^1.2.5"
|
||||
"@types/node": "npm:~20.11"
|
||||
"@types/tar": "npm:^6.1.11"
|
||||
"@types/tmp": "npm:^0.2.6"
|
||||
"@typescript-eslint/utils": "npm:^5.59.0"
|
||||
ast-types: "npm:^0.14.2"
|
||||
|
@ -7596,7 +7609,7 @@ __metadata:
|
|||
rimraf: "npm:^4.4.0"
|
||||
semver: "npm:^7.3.8"
|
||||
svgo: "npm:^3.0.2"
|
||||
tar: "npm:^6.2.0"
|
||||
tar: "npm:^7.0.1"
|
||||
tmp: "npm:^0.2.3"
|
||||
typescript: "npm:^5.3.3"
|
||||
languageName: unknown
|
||||
|
@ -8441,16 +8454,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/tar@npm:^6.1.11":
|
||||
version: 6.1.11
|
||||
resolution: "@types/tar@npm:6.1.11"
|
||||
dependencies:
|
||||
"@types/node": "npm:*"
|
||||
minipass: "npm:^4.0.0"
|
||||
checksum: 0d54b8acbd7d2fc43bd1097eef5058604a6b0e3a394cf485038303ca3ef39ecb42451c7dc5a2b9b18420e137ef5b2c76ec504e94c2f45010b2c8e8c3a49d9de7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/testing-library__jest-dom@npm:^5.9.1":
|
||||
version: 5.14.9
|
||||
resolution: "@types/testing-library__jest-dom@npm:5.14.9"
|
||||
|
@ -10707,6 +10710,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"chownr@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "chownr@npm:3.0.0"
|
||||
checksum: b63cb1f73d171d140a2ed8154ee6566c8ab775d3196b0e03a2a94b5f6a0ce7777ee5685ca56849403c8d17bd457a6540672f9a60696a6137c7a409097495b82c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"chrome-trace-event@npm:^1.0.2":
|
||||
version: 1.0.3
|
||||
resolution: "chrome-trace-event@npm:1.0.3"
|
||||
|
@ -14653,18 +14663,18 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"glob@npm:^10.2.2, glob@npm:^10.3.10":
|
||||
version: 10.3.10
|
||||
resolution: "glob@npm:10.3.10"
|
||||
"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7":
|
||||
version: 10.3.12
|
||||
resolution: "glob@npm:10.3.12"
|
||||
dependencies:
|
||||
foreground-child: "npm:^3.1.0"
|
||||
jackspeak: "npm:^2.3.5"
|
||||
jackspeak: "npm:^2.3.6"
|
||||
minimatch: "npm:^9.0.1"
|
||||
minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
path-scurry: "npm:^1.10.1"
|
||||
minipass: "npm:^7.0.4"
|
||||
path-scurry: "npm:^1.10.2"
|
||||
bin:
|
||||
glob: dist/esm/bin.mjs
|
||||
checksum: 38bdb2c9ce75eb5ed168f309d4ed05b0798f640b637034800a6bf306f39d35409bf278b0eaaffaec07591085d3acb7184a201eae791468f0f617771c2486a6a8
|
||||
checksum: 9e8186abc22dc824b5dd86cefd8e6b5621a72d1be7f68bacc0fd681e8c162ec5546660a6ec0553d6a74757a585e655956c7f8f1a6d24570e8d865c307323d178
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -16283,7 +16293,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jackspeak@npm:^2.3.5":
|
||||
"jackspeak@npm:^2.3.6":
|
||||
version: 2.3.6
|
||||
resolution: "jackspeak@npm:2.3.6"
|
||||
dependencies:
|
||||
|
@ -17729,10 +17739,10 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lru-cache@npm:^10.0.0, lru-cache@npm:^10.0.1, lru-cache@npm:^9.1.1 || ^10.0.0":
|
||||
version: 10.1.0
|
||||
resolution: "lru-cache@npm:10.1.0"
|
||||
checksum: 207278d6fa711fb1f94a0835d4d4737441d2475302482a14785b10515e4c906a57ebf9f35bf060740c9560e91c7c1ad5a04fd7ed030972a9ba18bce2a228e95b
|
||||
"lru-cache@npm:^10.0.0, lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0":
|
||||
version: 10.2.0
|
||||
resolution: "lru-cache@npm:10.2.0"
|
||||
checksum: 502ec42c3309c0eae1ce41afca471f831c278566d45a5273a0c51102dee31e0e250a62fa9029c3370988df33a14188a38e682c16143b794de78668de3643e302
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -19125,7 +19135,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"minipass@npm:^4.0.0, minipass@npm:^4.2.4":
|
||||
"minipass@npm:^4.2.4":
|
||||
version: 4.2.8
|
||||
resolution: "minipass@npm:4.2.8"
|
||||
checksum: e148eb6dcb85c980234cad889139ef8ddf9d5bdac534f4f0268446c8792dd4c74f4502479be48de3c1cce2f6450f6da4d0d4a86405a8a12be04c1c36b339569a
|
||||
|
@ -19139,7 +19149,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3":
|
||||
"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4":
|
||||
version: 7.0.4
|
||||
resolution: "minipass@npm:7.0.4"
|
||||
checksum: e864bd02ceb5e0707696d58f7ce3a0b89233f0d686ef0d447a66db705c0846a8dc6f34865cd85256c1472ff623665f616b90b8ff58058b2ad996c5de747d2d18
|
||||
|
@ -19156,6 +19166,16 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"minizlib@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "minizlib@npm:3.0.1"
|
||||
dependencies:
|
||||
minipass: "npm:^7.0.4"
|
||||
rimraf: "npm:^5.0.5"
|
||||
checksum: 622cb85f51e5c206a080a62d20db0d7b4066f308cb6ce82a9644da112367c3416ae7062017e631eb7ac8588191cfa4a9a279b8651c399265202b298e98c4acef
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mkdirp-classic@npm:^0.5.2, mkdirp-classic@npm:^0.5.3":
|
||||
version: 0.5.3
|
||||
resolution: "mkdirp-classic@npm:0.5.3"
|
||||
|
@ -19172,6 +19192,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mkdirp@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "mkdirp@npm:3.0.1"
|
||||
bin:
|
||||
mkdirp: dist/cjs/src/bin.js
|
||||
checksum: 16fd79c28645759505914561e249b9a1f5fe3362279ad95487a4501e4467abeb714fd35b95307326b8fd03f3c7719065ef11a6f97b7285d7888306d1bd2232ba
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mlly@npm:^1.1.0, mlly@npm:^1.2.0":
|
||||
version: 1.5.0
|
||||
resolution: "mlly@npm:1.5.0"
|
||||
|
@ -20335,13 +20364,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"path-scurry@npm:^1.10.1, path-scurry@npm:^1.6.1":
|
||||
version: 1.10.1
|
||||
resolution: "path-scurry@npm:1.10.1"
|
||||
"path-scurry@npm:^1.10.2, path-scurry@npm:^1.6.1":
|
||||
version: 1.10.2
|
||||
resolution: "path-scurry@npm:1.10.2"
|
||||
dependencies:
|
||||
lru-cache: "npm:^9.1.1 || ^10.0.0"
|
||||
lru-cache: "npm:^10.2.0"
|
||||
minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
checksum: eebfb8304fef1d4f7e1486df987e4fd77413de4fce16508dea69fcf8eb318c09a6b15a7a2f4c22877cec1cb7ecbd3071d18ca9de79eeece0df874a00f1f0bdc8
|
||||
checksum: a2bbbe8dc284c49dd9be78ca25f3a8b89300e0acc24a77e6c74824d353ef50efbf163e64a69f4330b301afca42d0e2229be0560d6d616ac4e99d48b4062016b1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -22053,6 +22082,17 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rimraf@npm:^5.0.5":
|
||||
version: 5.0.5
|
||||
resolution: "rimraf@npm:5.0.5"
|
||||
dependencies:
|
||||
glob: "npm:^10.3.7"
|
||||
bin:
|
||||
rimraf: dist/esm/bin.mjs
|
||||
checksum: a612c7184f96258b7d1328c486b12ca7b60aa30e04229a08bbfa7e964486deb1e9a1b52d917809311bdc39a808a4055c0f950c0280fba194ba0a09e6f0d404f6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rollup-plugin-inject@npm:^3.0.0":
|
||||
version: 3.0.2
|
||||
resolution: "rollup-plugin-inject@npm:3.0.2"
|
||||
|
@ -23386,7 +23426,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tar@npm:^6.0.2, tar@npm:^6.1.11, tar@npm:^6.1.2, tar@npm:^6.2.0":
|
||||
"tar@npm:^6.0.2, tar@npm:^6.1.11, tar@npm:^6.1.2":
|
||||
version: 6.2.1
|
||||
resolution: "tar@npm:6.2.1"
|
||||
dependencies:
|
||||
|
@ -23400,6 +23440,20 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tar@npm:^7.0.1":
|
||||
version: 7.0.1
|
||||
resolution: "tar@npm:7.0.1"
|
||||
dependencies:
|
||||
"@isaacs/fs-minipass": "npm:^4.0.0"
|
||||
chownr: "npm:^3.0.0"
|
||||
minipass: "npm:^5.0.0"
|
||||
minizlib: "npm:^3.0.1"
|
||||
mkdirp: "npm:^3.0.1"
|
||||
yallist: "npm:^5.0.0"
|
||||
checksum: 6fd89ef8051d12975f66a2f3932a80479bdc6c9f3bcdf04b8b57784e942ed860708ccecf79bcbb30659b14ab52eef2095d2c3af377545ff9df30de28036671dc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"terminal-link@npm:^2.1.1":
|
||||
version: 2.1.1
|
||||
resolution: "terminal-link@npm:2.1.1"
|
||||
|
@ -24972,8 +25026,8 @@ __metadata:
|
|||
linkType: hard
|
||||
|
||||
"vite@npm:^5.0.0":
|
||||
version: 5.2.8
|
||||
resolution: "vite@npm:5.2.8"
|
||||
version: 5.2.9
|
||||
resolution: "vite@npm:5.2.9"
|
||||
dependencies:
|
||||
esbuild: "npm:^0.20.1"
|
||||
fsevents: "npm:~2.3.3"
|
||||
|
@ -25007,7 +25061,7 @@ __metadata:
|
|||
optional: true
|
||||
bin:
|
||||
vite: bin/vite.js
|
||||
checksum: caa40343c2c4e6d8e257fccb4c3029f62909c319a86063ce727ed550925c0a834460b0d1ca20c4d6c915f35302aa1052f6ec5193099a47ce21d74b9b817e69e1
|
||||
checksum: 26342c8dde540e4161fdad2c9c8f2f0e23567f051c7a40abb8e4796d6c4292fbd118ab7a4ac252515e78c4f99525b557731e6117287b2bccde0ea61d73bcff27
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
@ -25674,6 +25728,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"yallist@npm:^5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "yallist@npm:5.0.0"
|
||||
checksum: 1884d272d485845ad04759a255c71775db0fac56308764b4c77ea56a20d56679fad340213054c8c9c9c26fcfd4c4b2a90df993b7e0aaf3cdb73c618d1d1a802a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"yaml@npm:2.3.4, yaml@npm:^2.0.0, yaml@npm:^2.2.1, yaml@npm:^2.2.2, yaml@npm:^2.3.4":
|
||||
version: 2.3.4
|
||||
resolution: "yaml@npm:2.3.4"
|
||||
|
|
Ładowanie…
Reference in New Issue