[2/4] Rename sync hooks, add bookmarks to demo (#4094)

Adds a new `onEditorMount` callback to the store, allowing store
creators to do things like registering bookmark handlers. We use this in
the new demo hook.

This also renames `useRemoteSyncClient` to `useMultiplayerSync`, and
`useRemoteSyncDemo` to `useMultiplayerDemo`.

Closes TLD-2601

### Change type

- [x] `api`
This commit is contained in:
alex 2024-07-10 14:15:44 +01:00 committed by GitHub
parent 965bc10997
commit 627c84c2af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 127 additions and 39 deletions

View file

@ -1,5 +1,5 @@
main = "src/worker.ts" main = "src/worker.ts"
compatibility_date = "2024-06-25" compatibility_date = "2024-06-20"
upload_source_maps = true upload_source_maps = true
[dev] [dev]

View file

@ -1,5 +1,5 @@
import { ROOM_OPEN_MODE, RoomOpenModeToPath, type RoomOpenMode } from '@tldraw/dotcom-shared' import { ROOM_OPEN_MODE, RoomOpenModeToPath, type RoomOpenMode } from '@tldraw/dotcom-shared'
import { useRemoteSyncClient } from '@tldraw/sync-react' import { useMultiplayerSync } from '@tldraw/sync-react'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import { import {
DefaultContextMenu, DefaultContextMenu,
@ -112,7 +112,7 @@ export function MultiplayerEditor({
}) { }) {
const handleUiEvent = useHandleUiEvents() const handleUiEvent = useHandleUiEvents()
const storeWithStatus = useRemoteSyncClient({ const storeWithStatus = useMultiplayerSync({
uri: `${MULTIPLAYER_SERVER}/${RoomOpenModeToPath[roomOpenMode]}/${roomSlug}`, uri: `${MULTIPLAYER_SERVER}/${RoomOpenModeToPath[roomOpenMode]}/${roomSlug}`,
roomId: roomSlug, roomId: roomSlug,
assets: multiplayerAssetStore, assets: multiplayerAssetStore,

View file

@ -1,4 +1,4 @@
import { useDemoRemoteSyncClient } from '@tldraw/sync-react' import { useMultiplayerDemo } from '@tldraw/sync-react'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import { DefaultContextMenu, DefaultContextMenuContent, TLComponents, Tldraw, atom } from 'tldraw' import { DefaultContextMenu, DefaultContextMenuContent, TLComponents, Tldraw, atom } from 'tldraw'
import { UrlStateParams, useUrlState } from '../hooks/useUrlState' import { UrlStateParams, useUrlState } from '../hooks/useUrlState'
@ -37,7 +37,7 @@ const components: TLComponents = {
export function TemporaryBemoDevEditor({ slug }: { slug: string }) { export function TemporaryBemoDevEditor({ slug }: { slug: string }) {
const handleUiEvent = useHandleUiEvents() const handleUiEvent = useHandleUiEvents()
const storeWithStatus = useDemoRemoteSyncClient({ host: 'http://127.0.0.1:8989', roomId: slug }) const storeWithStatus = useMultiplayerDemo({ host: 'http://127.0.0.1:8989', roomId: slug })
const isOffline = const isOffline =
storeWithStatus.status === 'synced-remote' && storeWithStatus.connectionStatus === 'offline' storeWithStatus.status === 'synced-remote' && storeWithStatus.connectionStatus === 'offline'
@ -48,14 +48,6 @@ export function TemporaryBemoDevEditor({ slug }: { slug: string }) {
const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true }) const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true })
const cursorChatOverrides = useCursorChat() const cursorChatOverrides = useCursorChat()
// TODO: handle bookmarks
// const handleMount = useCallback(
// (editor: Editor) => {
// editor.registerExternalAssetHandler('url', createAssetFromUrl)
// },
// []
// )
if (storeWithStatus.error) { if (storeWithStatus.error) {
return <StoreErrorScreen error={storeWithStatus.error} /> return <StoreErrorScreen error={storeWithStatus.error} />
} }

View file

@ -487,7 +487,7 @@ export function counterClockwiseAngleDist(a0: number, a1: number): number;
export function createSessionStateSnapshotSignal(store: TLStore): Signal<null | TLSessionStateSnapshot>; export function createSessionStateSnapshotSignal(store: TLStore): Signal<null | TLSessionStateSnapshot>;
// @public // @public
export function createTLStore({ initialData, defaultName, id, assets, ...rest }?: TLStoreOptions): TLStore; export function createTLStore({ initialData, defaultName, id, assets, onEditorMount, ...rest }?: TLStoreOptions): TLStore;
// @public (undocumented) // @public (undocumented)
export function createTLUser(opts?: { export function createTLUser(opts?: {
@ -3246,6 +3246,7 @@ export interface TLStoreBaseOptions {
assets?: Partial<TLAssetStore>; assets?: Partial<TLAssetStore>;
defaultName?: string; defaultName?: string;
initialData?: SerializedStore<TLRecord>; initialData?: SerializedStore<TLRecord>;
onEditorMount?: (editor: Editor) => (() => void) | void;
} }
// @public (undocumented) // @public (undocumented)
@ -3422,6 +3423,9 @@ export function useLocalStore(options: {
snapshot?: TLEditorSnapshot | TLStoreSnapshot; snapshot?: TLEditorSnapshot | TLStoreSnapshot;
} & TLStoreOptions): TLStoreWithStatus; } & TLStoreOptions): TLStoreWithStatus;
// @internal (undocumented)
export function useOnMount(onMount?: TLOnMountHandler): void;
// @internal (undocumented) // @internal (undocumented)
export function usePeerIds(): string[]; export function usePeerIds(): string[];

View file

@ -36,6 +36,7 @@ export {
ErrorScreen, ErrorScreen,
LoadingScreen, LoadingScreen,
TldrawEditor, TldrawEditor,
useOnMount,
type LoadingScreenProps, type LoadingScreenProps,
type TLOnMountHandler, type TLOnMountHandler,
type TldrawEditorBaseProps, type TldrawEditorBaseProps,

View file

@ -450,7 +450,15 @@ function Layout({ children, onMount }: { children: ReactNode; onMount?: TLOnMoun
useCursor() useCursor()
useDarkMode() useDarkMode()
useForceUpdate() useForceUpdate()
useOnMount(onMount) useOnMount((editor) => {
const teardownStore = editor.store.props.onEditorMount(editor)
const teardownCallback = onMount?.(editor)
return () => {
teardownStore?.()
teardownCallback?.()
}
})
return children return children
} }
@ -474,7 +482,8 @@ export function ErrorScreen({ children }: LoadingScreenProps) {
return <div className="tl-loading">{children}</div> return <div className="tl-loading">{children}</div>
} }
function useOnMount(onMount?: TLOnMountHandler) { /** @internal */
export function useOnMount(onMount?: TLOnMountHandler) {
const editor = useEditor() const editor = useEditor()
const onMountEvent = useEvent((editor: Editor) => { const onMountEvent = useEvent((editor: Editor) => {

View file

@ -7,7 +7,8 @@ import {
TLStoreProps, TLStoreProps,
createTLSchema, createTLSchema,
} from '@tldraw/tlschema' } from '@tldraw/tlschema'
import { FileHelpers } from '@tldraw/utils' import { FileHelpers, assert } from '@tldraw/utils'
import { Editor } from '../editor/Editor'
import { TLAnyBindingUtilConstructor, checkBindings } from './defaultBindings' import { TLAnyBindingUtilConstructor, checkBindings } from './defaultBindings'
import { TLAnyShapeUtilConstructor, checkShapesAndAddCore } from './defaultShapes' import { TLAnyShapeUtilConstructor, checkShapesAndAddCore } from './defaultShapes'
@ -21,6 +22,9 @@ export interface TLStoreBaseOptions {
/** How should this store upload & resolve assets? */ /** How should this store upload & resolve assets? */
assets?: Partial<TLAssetStore> assets?: Partial<TLAssetStore>
/** Called when the store is connected to an {@link Editor}. */
onEditorMount?: (editor: Editor) => void | (() => void)
} }
/** @public */ /** @public */
@ -58,6 +62,7 @@ export function createTLStore({
defaultName = '', defaultName = '',
id, id,
assets, assets,
onEditorMount,
...rest ...rest
}: TLStoreOptions = {}): TLStore { }: TLStoreOptions = {}): TLStore {
const schema = const schema =
@ -87,6 +92,10 @@ export function createTLStore({
...defaultAssetStore, ...defaultAssetStore,
...assets, ...assets,
}, },
onEditorMount: (editor) => {
assert(editor instanceof Editor)
onEditorMount?.(editor)
},
}, },
}) })
} }

View file

@ -1,2 +1,6 @@
export { useDemoRemoteSyncClient, type UseDemoSyncClientConfig } from './useDemoSyncClient' export {
export { useRemoteSyncClient, type RemoteTLStoreWithStatus } from './useRemoteSyncClient' useMultiplayerSync,
type RemoteTLStoreWithStatus,
type UseMultiplayerSyncOptions,
} from './useMultiplayerSync'
export { useMultiplayerDemo, type UseMultiplayerDemoOptions } from './useMutliplayerDemo'

View file

@ -9,6 +9,7 @@ import {
} from '@tldraw/sync' } from '@tldraw/sync'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
Editor,
Signal, Signal,
TAB_ID, TAB_ID,
TLAssetStore, TLAssetStore,
@ -33,14 +34,14 @@ export type RemoteTLStoreWithStatus = Exclude<
> >
/** @public */ /** @public */
export function useRemoteSyncClient(opts: UseSyncClientConfig): RemoteTLStoreWithStatus { export function useMultiplayerSync(opts: UseMultiplayerSyncOptions): RemoteTLStoreWithStatus {
const [state, setState] = useState<{ const [state, setState] = useState<{
readyClient?: TLSyncClient<TLRecord, TLStore> readyClient?: TLSyncClient<TLRecord, TLStore>
error?: Error error?: Error
} | null>(null) } | null>(null)
const { uri, roomId = 'default', userPreferences: prefs, assets } = opts const { uri, roomId = 'default', userPreferences: prefs, assets, onEditorMount } = opts
const store = useTLStore({ schema, assets }) const store = useTLStore({ schema, assets, onEditorMount })
const error: NonNullable<typeof state>['error'] = state?.error ?? undefined const error: NonNullable<typeof state>['error'] = state?.error ?? undefined
const track = opts.trackAnalyticsEvent const track = opts.trackAnalyticsEvent
@ -134,11 +135,12 @@ export function useRemoteSyncClient(opts: UseSyncClientConfig): RemoteTLStoreWit
} }
/** @public */ /** @public */
export interface UseSyncClientConfig { export interface UseMultiplayerSyncOptions {
uri: string uri: string
roomId?: string roomId?: string
userPreferences?: Signal<TLUserPreferences> userPreferences?: Signal<TLUserPreferences>
/* @internal */ /* @internal */
trackAnalyticsEvent?(name: string, data: { [key: string]: any }): void trackAnalyticsEvent?(name: string, data: { [key: string]: any }): void
assets?: Partial<TLAssetStore> assets?: Partial<TLAssetStore>
onEditorMount?: (editor: Editor) => void
} }

View file

@ -1,9 +1,19 @@
import { useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { MediaHelpers, Signal, TLAssetStore, TLUserPreferences, uniqueId } from 'tldraw' import {
import { RemoteTLStoreWithStatus, useRemoteSyncClient } from './useRemoteSyncClient' AssetRecordType,
Editor,
MediaHelpers,
Signal,
TLAsset,
TLAssetStore,
TLUserPreferences,
getHashForString,
uniqueId,
} from 'tldraw'
import { RemoteTLStoreWithStatus, useMultiplayerSync } from './useMultiplayerSync'
/** @public */ /** @public */
export interface UseDemoSyncClientConfig { export interface UseMultiplayerDemoOptions {
roomId: string roomId: string
userPreferences?: Signal<TLUserPreferences> userPreferences?: Signal<TLUserPreferences>
/** @internal */ /** @internal */
@ -29,18 +39,26 @@ function getEnv(cb: () => string | undefined): string | undefined {
const DEMO_WORKER = getEnv(() => process.env.DEMO_WORKER) ?? 'https://demo.tldraw.xyz' const DEMO_WORKER = getEnv(() => process.env.DEMO_WORKER) ?? 'https://demo.tldraw.xyz'
const IMAGE_WORKER = getEnv(() => process.env.IMAGE_WORKER) ?? 'https://images.tldraw.xyz' const IMAGE_WORKER = getEnv(() => process.env.IMAGE_WORKER) ?? 'https://images.tldraw.xyz'
export function useDemoRemoteSyncClient({ export function useMultiplayerDemo({
roomId, roomId,
userPreferences, userPreferences,
host = DEMO_WORKER, host = DEMO_WORKER,
}: UseDemoSyncClientConfig): RemoteTLStoreWithStatus { }: UseMultiplayerDemoOptions): RemoteTLStoreWithStatus {
const assets = useMemo(() => createDemoAssetStore(host), [host]) const assets = useMemo(() => createDemoAssetStore(host), [host])
return useRemoteSyncClient({ return useMultiplayerSync({
uri: `${host}/connect/${roomId}`, uri: `${host}/connect/${roomId}`,
roomId, roomId,
userPreferences, userPreferences,
assets, assets,
onEditorMount: useCallback(
(editor: Editor) => {
editor.registerExternalAssetHandler('url', async ({ url }) => {
return await createAssetFromUrlUsingDemoServer(host, url)
})
},
[host]
),
}) })
} }
@ -116,3 +134,49 @@ function createDemoAssetStore(host: string): TLAssetStore {
}, },
} }
} }
async function createAssetFromUrlUsingDemoServer(host: string, url: string): Promise<TLAsset> {
const urlHash = getHashForString(url)
try {
// First, try to get the meta data from our endpoint
const fetchUrl = new URL(`${host}/bookmarks/unfurl`)
fetchUrl.searchParams.set('url', url)
const meta = (await (await fetch(fetchUrl)).json()) as {
description?: string
image?: string
favicon?: string
title?: string
} | null
return {
id: AssetRecordType.createId(urlHash),
typeName: 'asset',
type: 'bookmark',
props: {
src: url,
description: meta?.description ?? '',
image: meta?.image ?? '',
favicon: meta?.favicon ?? '',
title: meta?.title ?? '',
},
meta: {},
}
} catch (error) {
// Otherwise, fallback to a blank bookmark
console.error(error)
return {
id: AssetRecordType.createId(urlHash),
typeName: 'asset',
type: 'bookmark',
props: {
src: url,
description: '',
image: '',
favicon: '',
title: '',
},
meta: {},
}
}
}

View file

@ -2,7 +2,6 @@ import {
DEFAULT_SUPPORTED_IMAGE_TYPES, DEFAULT_SUPPORTED_IMAGE_TYPES,
DEFAULT_SUPPORT_VIDEO_TYPES, DEFAULT_SUPPORT_VIDEO_TYPES,
DefaultSpinner, DefaultSpinner,
Editor,
ErrorScreen, ErrorScreen,
LoadingScreen, LoadingScreen,
TLEditorComponents, TLEditorComponents,
@ -12,11 +11,11 @@ import {
TldrawEditorStoreProps, TldrawEditorStoreProps,
useEditor, useEditor,
useEditorComponents, useEditorComponents,
useEvent, useOnMount,
useShallowArrayIdentity, useShallowArrayIdentity,
useShallowObjectIdentity, useShallowObjectIdentity,
} from '@tldraw/editor' } from '@tldraw/editor'
import { useLayoutEffect, useMemo } from 'react' import { useMemo } from 'react'
import { TldrawHandles } from './canvas/TldrawHandles' import { TldrawHandles } from './canvas/TldrawHandles'
import { TldrawScribble } from './canvas/TldrawScribble' import { TldrawScribble } from './canvas/TldrawScribble'
import { TldrawSelectionBackground } from './canvas/TldrawSelectionBackground' import { TldrawSelectionBackground } from './canvas/TldrawSelectionBackground'
@ -148,7 +147,7 @@ function InsideOfEditorAndUiContext({
const toasts = useToasts() const toasts = useToasts()
const msg = useTranslation() const msg = useTranslation()
const onMountEvent = useEvent((editor: Editor) => { useOnMount(() => {
const unsubs: (void | (() => void) | undefined)[] = [] const unsubs: (void | (() => void) | undefined)[] = []
unsubs.push(...registerDefaultSideEffects(editor)) unsubs.push(...registerDefaultSideEffects(editor))
@ -168,7 +167,10 @@ function InsideOfEditorAndUiContext({
} }
) )
// ...then we run the onMount prop, which may override the above // ...then we call the store's on mount which may override them...
unsubs.push(editor.store.props.onEditorMount(editor))
// ...then we run the user's onMount prop, which may override things again.
unsubs.push(onMount?.(editor)) unsubs.push(onMount?.(editor))
return () => { return () => {
@ -176,10 +178,6 @@ function InsideOfEditorAndUiContext({
} }
}) })
useLayoutEffect(() => {
if (editor) return onMountEvent?.(editor)
}, [editor, onMountEvent])
const { Canvas } = useEditorComponents() const { Canvas } = useEditorComponents()
const { ContextMenu } = useTldrawUiComponents() const { ContextMenu } = useTldrawUiComponents()

View file

@ -1499,6 +1499,7 @@ export interface TLStoreProps {
assets: TLAssetStore; assets: TLAssetStore;
// (undocumented) // (undocumented)
defaultName: string; defaultName: string;
onEditorMount: (editor: unknown) => (() => void) | void;
} }
// @public (undocumented) // @public (undocumented)

View file

@ -94,6 +94,10 @@ export interface TLAssetStore {
export interface TLStoreProps { export interface TLStoreProps {
defaultName: string defaultName: string
assets: TLAssetStore assets: TLAssetStore
/**
* Called an {@link @tldraw/editor#Editor} connected to this store is mounted.
*/
onEditorMount: (editor: unknown) => void | (() => void)
} }
/** @public */ /** @public */