[3/5] Automatically enable multiplayer UI when using demo sync (#4119)

Adds a new `multiplayerStatus` store prop. This can either be `null`
(indicating this isn't a multiplayer store) or a signal containing
`online` or `offline` indicating that it is. We move a bunch of
previously dotcom specific UI into `tldraw` and use this new prop to
turn it on or off by default.

closes TLD-2611

### Change type

- [x] `improvement`
This commit is contained in:
alex 2024-07-10 16:46:09 +01:00 committed by GitHub
parent 627c84c2af
commit 7273eb3101
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 573 additions and 519 deletions

View file

@ -1,15 +1,14 @@
import { import {
ChangeEvent, ChangeEvent,
KeyboardEvent, KeyboardEvent,
ReactNode,
SetStateAction, SetStateAction,
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect,
useRef, useRef,
useState, useState,
} from 'react' } from 'react'
import { import {
CenteredTopPanelContainer,
OfflineIndicator, OfflineIndicator,
TLUiTranslationKey, TLUiTranslationKey,
TldrawUiButton, TldrawUiButton,
@ -38,12 +37,7 @@ interface NameState {
readonly isEditing: boolean readonly isEditing: boolean
} }
const MAX_TITLE_WIDTH_PX = 420
const BUTTON_WIDTH = 44 const BUTTON_WIDTH = 44
const STYLE_PANEL_WIDTH = 148
const MARGIN_BETWEEN_ZONES = 12
// the maximum amount the people menu will extend from the style panel
const SQUEEZE_FACTOR = 52
export const DocumentTopZone = track(function DocumentTopZone({ export const DocumentTopZone = track(function DocumentTopZone({
isOffline, isOffline,
@ -53,10 +47,10 @@ export const DocumentTopZone = track(function DocumentTopZone({
const isDocumentNameVisible = useBreakpoint() >= 4 const isDocumentNameVisible = useBreakpoint() >= 4
return ( return (
<DocumentTopZoneContainer> <CenteredTopPanelContainer ignoreRightWidth={BUTTON_WIDTH}>
{isDocumentNameVisible && <DocumentNameInner />} {isDocumentNameVisible && <DocumentNameInner />}
{isOffline && <OfflineIndicator />} {isOffline && <OfflineIndicator />}
</DocumentTopZoneContainer> </CenteredTopPanelContainer>
) )
}) })
@ -135,85 +129,6 @@ export const DocumentNameInner = track(function DocumentNameInner() {
) )
}) })
function DocumentTopZoneContainer({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null)
const breakpoint = useBreakpoint()
const updateLayout = useCallback(() => {
const element = ref.current
if (!element) return
const layoutTop = element.parentElement!.parentElement!
const leftPanel = layoutTop.querySelector('.tlui-layout__top__left')! as HTMLElement
const rightPanel = layoutTop.querySelector('.tlui-layout__top__right')! as HTMLElement
const totalWidth = layoutTop.offsetWidth
const leftWidth = leftPanel.offsetWidth
const rightWidth = rightPanel.offsetWidth
// Ignore button width
const selfWidth = element.offsetWidth - BUTTON_WIDTH
let xCoordIfCentered = (totalWidth - selfWidth) / 2
// Prevent subpixel bullsh
if (totalWidth % 2 !== 0) {
xCoordIfCentered -= 0.5
}
const xCoordIfLeftAligned = leftWidth + MARGIN_BETWEEN_ZONES
const left = element.offsetLeft
const maxWidth = Math.min(
totalWidth - rightWidth - leftWidth - 2 * MARGIN_BETWEEN_ZONES,
MAX_TITLE_WIDTH_PX
)
const xCoord = Math.max(xCoordIfCentered, xCoordIfLeftAligned) - left
// Squeeze the title if the right panel is too wide on small screens
if (rightPanel.offsetWidth > STYLE_PANEL_WIDTH && breakpoint <= 6) {
element.style.setProperty('max-width', maxWidth - SQUEEZE_FACTOR + 'px')
} else {
element.style.setProperty('max-width', maxWidth + 'px')
}
element.style.setProperty('transform', `translate(${xCoord}px, 0px)`)
}, [breakpoint])
useLayoutEffect(() => {
const element = ref.current
if (!element) return
const layoutTop = element.parentElement!.parentElement!
const leftPanel = layoutTop.querySelector('.tlui-layout__top__left')! as HTMLElement
const rightPanel = layoutTop.querySelector('.tlui-layout__top__right')! as HTMLElement
// Update layout when the things change
const observer = new ResizeObserver(updateLayout)
observer.observe(leftPanel)
observer.observe(rightPanel)
observer.observe(layoutTop)
observer.observe(element)
// Also update on first layout
updateLayout()
return () => {
observer.disconnect()
}
}, [updateLayout])
// Update after every render, too
useLayoutEffect(() => {
updateLayout()
})
return (
<div ref={ref} className="tlui-top-zone__container">
{children}
</div>
)
}
const DocumentNameEditor = track(function DocumentNameEditor({ const DocumentNameEditor = track(function DocumentNameEditor({
state, state,
setState, setState,

View file

@ -1,9 +1,7 @@
import { ROOM_OPEN_MODE, RoomOpenModeToPath, type RoomOpenMode } from '@tldraw/dotcom-shared' import { ROOM_OPEN_MODE, RoomOpenModeToPath, type RoomOpenMode } from '@tldraw/dotcom-shared'
import { useMultiplayerSync } from '@tldraw/sync-react' import { useMultiplayerSync } from '@tldraw/sync-react'
import { useCallback, useEffect } from 'react' import { useCallback } from 'react'
import { import {
DefaultContextMenu,
DefaultContextMenuContent,
DefaultHelpMenu, DefaultHelpMenu,
DefaultHelpMenuContent, DefaultHelpMenuContent,
DefaultKeyboardShortcutsDialog, DefaultKeyboardShortcutsDialog,
@ -13,48 +11,42 @@ import {
Editor, Editor,
ExportFileContentSubMenu, ExportFileContentSubMenu,
ExtrasGroup, ExtrasGroup,
PeopleMenu,
PreferencesGroup, PreferencesGroup,
TLComponents, TLComponents,
Tldraw, Tldraw,
TldrawUiButton,
TldrawUiButtonIcon,
TldrawUiButtonLabel,
TldrawUiMenuGroup, TldrawUiMenuGroup,
TldrawUiMenuItem, TldrawUiMenuItem,
ViewSubmenu, ViewSubmenu,
atom, assertExists,
useActions, useActions,
useEditor,
useTranslation,
useValue, useValue,
} from 'tldraw' } from 'tldraw'
import { UrlStateParams, useUrlState } from '../hooks/useUrlState' import { UrlStateParams, useUrlState } from '../hooks/useUrlState'
import { assetUrls } from '../utils/assetUrls' import { assetUrls } from '../utils/assetUrls'
import { MULTIPLAYER_SERVER } from '../utils/config' import { MULTIPLAYER_SERVER } from '../utils/config'
import { CursorChatMenuItem } from '../utils/context-menu/CursorChatMenuItem'
import { createAssetFromUrl } from '../utils/createAssetFromUrl' import { createAssetFromUrl } from '../utils/createAssetFromUrl'
import { multiplayerAssetStore } from '../utils/multiplayerAssetStore' import { multiplayerAssetStore } from '../utils/multiplayerAssetStore'
import { useSharing } from '../utils/sharing' import { useSharing } from '../utils/sharing'
import { CURSOR_CHAT_ACTION, useCursorChat } from '../utils/useCursorChat'
import { OPEN_FILE_ACTION, SAVE_FILE_COPY_ACTION, useFileSystem } from '../utils/useFileSystem' import { OPEN_FILE_ACTION, SAVE_FILE_COPY_ACTION, useFileSystem } from '../utils/useFileSystem'
import { useHandleUiEvents } from '../utils/useHandleUiEvent' import { useHandleUiEvents } from '../utils/useHandleUiEvent'
import { CursorChatBubble } from './CursorChatBubble'
import { DocumentTopZone } from './DocumentName/DocumentName' import { DocumentTopZone } from './DocumentName/DocumentName'
import { MultiplayerFileMenu } from './FileMenu' import { MultiplayerFileMenu } from './FileMenu'
import { Links } from './Links' import { Links } from './Links'
import { PeopleMenu } from './PeopleMenu/PeopleMenu'
import { ShareMenu } from './ShareMenu' import { ShareMenu } from './ShareMenu'
import { SneakyOnDropOverride } from './SneakyOnDropOverride' import { SneakyOnDropOverride } from './SneakyOnDropOverride'
import { StoreErrorScreen } from './StoreErrorScreen' import { StoreErrorScreen } from './StoreErrorScreen'
import { ThemeUpdater } from './ThemeUpdater/ThemeUpdater' import { ThemeUpdater } from './ThemeUpdater/ThemeUpdater'
const shittyOfflineAtom = atom('shitty offline atom', false)
const components: TLComponents = { const components: TLComponents = {
ErrorFallback: ({ error }) => { ErrorFallback: ({ error }) => {
throw error throw error
}, },
ContextMenu: (props) => (
<DefaultContextMenu {...props}>
<CursorChatMenuItem />
<DefaultContextMenuContent />
</DefaultContextMenu>
),
HelpMenu: () => ( HelpMenu: () => (
<DefaultHelpMenu> <DefaultHelpMenu>
<TldrawUiMenuGroup id="help"> <TldrawUiMenuGroup id="help">
@ -83,20 +75,41 @@ const components: TLComponents = {
<TldrawUiMenuItem {...actions[OPEN_FILE_ACTION]} /> <TldrawUiMenuItem {...actions[OPEN_FILE_ACTION]} />
</TldrawUiMenuGroup> </TldrawUiMenuGroup>
<DefaultKeyboardShortcutsDialogContent /> <DefaultKeyboardShortcutsDialogContent />
<TldrawUiMenuGroup label="shortcuts-dialog.collaboration" id="collaboration">
<TldrawUiMenuItem {...actions[CURSOR_CHAT_ACTION]} />
</TldrawUiMenuGroup>
</DefaultKeyboardShortcutsDialog> </DefaultKeyboardShortcutsDialog>
) )
}, },
TopPanel: () => { TopPanel: () => {
const isOffline = useValue('offline', () => shittyOfflineAtom.get(), []) const editor = useEditor()
const isOffline = useValue(
'offline',
() => {
const status = assertExists(
editor.store.props.multiplayerStatus,
'should be used with multiplayer store'
)
return status.get() === 'offline'
},
[]
)
return <DocumentTopZone isOffline={isOffline} /> return <DocumentTopZone isOffline={isOffline} />
}, },
SharePanel: () => { SharePanel: () => {
const editor = useEditor()
const msg = useTranslation()
return ( return (
<div className="tlui-share-zone" draggable={false}> <div className="tlui-share-zone" draggable={false}>
<PeopleMenu /> <PeopleMenu>
<div className="tlui-people-menu__section">
<TldrawUiButton
type="menu"
data-testid="people-menu.invite"
onClick={() => editor.addOpenMenu('share menu')}
>
<TldrawUiButtonLabel>{msg('people-menu.invite')}</TldrawUiButtonLabel>
<TldrawUiButtonIcon icon="plus" />
</TldrawUiButton>
</div>
</PeopleMenu>
<ShareMenu /> <ShareMenu />
</div> </div>
) )
@ -118,15 +131,8 @@ export function MultiplayerEditor({
assets: multiplayerAssetStore, assets: multiplayerAssetStore,
}) })
const isOffline =
storeWithStatus.status === 'synced-remote' && storeWithStatus.connectionStatus === 'offline'
useEffect(() => {
shittyOfflineAtom.set(isOffline)
}, [isOffline])
const sharingUiOverrides = useSharing() const sharingUiOverrides = useSharing()
const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true }) const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true })
const cursorChatOverrides = useCursorChat()
const isReadonly = const isReadonly =
roomOpenMode === ROOM_OPEN_MODE.READ_ONLY || roomOpenMode === ROOM_OPEN_MODE.READ_ONLY_LEGACY roomOpenMode === ROOM_OPEN_MODE.READ_ONLY || roomOpenMode === ROOM_OPEN_MODE.READ_ONLY_LEGACY
@ -154,14 +160,13 @@ export function MultiplayerEditor({
store={storeWithStatus} store={storeWithStatus}
assetUrls={assetUrls} assetUrls={assetUrls}
onMount={handleMount} onMount={handleMount}
overrides={[sharingUiOverrides, fileSystemUiOverrides, cursorChatOverrides]} overrides={[sharingUiOverrides, fileSystemUiOverrides]}
initialState={isReadonly ? 'hand' : 'select'} initialState={isReadonly ? 'hand' : 'select'}
onUiEvent={handleUiEvent} onUiEvent={handleUiEvent}
components={components} components={components}
inferDarkMode inferDarkMode
> >
<UrlStateSync /> <UrlStateSync />
<CursorChatBubble />
<SneakyOnDropOverride isMultiplayer /> <SneakyOnDropOverride isMultiplayer />
<ThemeUpdater /> <ThemeUpdater />
</Tldraw> </Tldraw>

View file

@ -1,86 +1,13 @@
import { useMultiplayerDemo } from '@tldraw/sync-react' import { useMultiplayerDemo } from '@tldraw/sync-react'
import { useCallback, useEffect } from 'react' import { Tldraw } from 'tldraw'
import { DefaultContextMenu, DefaultContextMenuContent, TLComponents, Tldraw, atom } from 'tldraw'
import { UrlStateParams, useUrlState } from '../hooks/useUrlState'
import { assetUrls } from '../utils/assetUrls' import { assetUrls } from '../utils/assetUrls'
import { CursorChatMenuItem } from '../utils/context-menu/CursorChatMenuItem'
import { useCursorChat } from '../utils/useCursorChat'
import { useFileSystem } from '../utils/useFileSystem'
import { useHandleUiEvents } from '../utils/useHandleUiEvent'
import { CursorChatBubble } from './CursorChatBubble'
import { PeopleMenu } from './PeopleMenu/PeopleMenu'
import { SneakyOnDropOverride } from './SneakyOnDropOverride'
import { StoreErrorScreen } from './StoreErrorScreen'
import { ThemeUpdater } from './ThemeUpdater/ThemeUpdater'
const shittyOfflineAtom = atom('shitty offline atom', false)
const components: TLComponents = {
ErrorFallback: ({ error }) => {
throw error
},
ContextMenu: (props) => (
<DefaultContextMenu {...props}>
<CursorChatMenuItem />
<DefaultContextMenuContent />
</DefaultContextMenu>
),
SharePanel: () => {
return (
<div className="tlui-share-zone" draggable={false}>
<PeopleMenu />
</div>
)
},
}
export function TemporaryBemoDevEditor({ slug }: { slug: string }) { export function TemporaryBemoDevEditor({ slug }: { slug: string }) {
const handleUiEvent = useHandleUiEvents() const store = useMultiplayerDemo({ host: 'http://127.0.0.1:8989', roomId: slug })
const storeWithStatus = useMultiplayerDemo({ host: 'http://127.0.0.1:8989', roomId: slug })
const isOffline =
storeWithStatus.status === 'synced-remote' && storeWithStatus.connectionStatus === 'offline'
useEffect(() => {
shittyOfflineAtom.set(isOffline)
}, [isOffline])
const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true })
const cursorChatOverrides = useCursorChat()
if (storeWithStatus.error) {
return <StoreErrorScreen error={storeWithStatus.error} />
}
return ( return (
<div className="tldraw__editor"> <div className="tldraw__editor">
<Tldraw <Tldraw store={store} assetUrls={assetUrls} inferDarkMode />
store={storeWithStatus}
assetUrls={assetUrls}
overrides={[fileSystemUiOverrides, cursorChatOverrides]}
onUiEvent={handleUiEvent}
components={components}
inferDarkMode
>
<UrlStateSync />
<CursorChatBubble />
<SneakyOnDropOverride isMultiplayer />
<ThemeUpdater />
</Tldraw>
</div> </div>
) )
} }
export function UrlStateSync() {
const syncViewport = useCallback((params: UrlStateParams) => {
window.history.replaceState(
{},
document.title,
window.location.pathname + `?v=${params.v}&p=${params.p}`
)
}, [])
useUrlState(syncViewport)
return null
}

View file

@ -1,16 +0,0 @@
import { TldrawUiMenuItem, useActions, useEditor, useValue } from 'tldraw'
import { CURSOR_CHAT_ACTION } from '../useCursorChat'
export function CursorChatMenuItem() {
const editor = useEditor()
const actions = useActions()
const shouldShow = useValue(
'show cursor chat',
() => editor.getCurrentToolId() === 'select' && !editor.getInstanceState().isCoarsePointer,
[editor]
)
if (!shouldShow) return null
return <TldrawUiMenuItem {...actions[CURSOR_CHAT_ACTION]} />
}

View file

@ -1,33 +0,0 @@
import { useMemo } from 'react'
import { TLUiOverrides } from 'tldraw'
import { useHandleUiEvents } from './useHandleUiEvent'
export const CURSOR_CHAT_ACTION = 'open-cursor-chat' as const
export function useCursorChat(): TLUiOverrides {
const handleUiEvent = useHandleUiEvents()
return useMemo(
(): TLUiOverrides => ({
actions(editor, actions) {
actions[CURSOR_CHAT_ACTION] = {
id: 'open-cursor-chat',
label: 'action.open-cursor-chat',
readonlyOk: true,
kbd: '/',
onSelect(source: any) {
handleUiEvent('open-cursor-chat', { source })
// Don't open cursor chat if we're on a touch device
if (editor.getInstanceState().isCoarsePointer) {
return
}
editor.updateInstanceState({ isChatting: true })
},
}
return actions
},
}),
[handleUiEvent]
)
}

View file

@ -4,17 +4,6 @@
margin-top: 4px; margin-top: 4px;
} }
.tlui-share-zone {
padding: 0px 0px 0px 0px;
display: flex;
flex-direction: row;
justify-content: flex-end;
z-index: var(--layer-panels);
align-items: center;
padding-top: 2px;
padding-right: 4px;
}
.tlui-share-zone__connection-status { .tlui-share-zone__connection-status {
width: 8px; width: 8px;
height: 100%; height: 100%;
@ -134,183 +123,6 @@
/* ------------------- People Menu ------------------- */ /* ------------------- People Menu ------------------- */
.tlui-people-menu__avatars-button {
display: flex;
align-items: center;
justify-content: flex-end;
background: none;
border: none;
cursor: pointer;
pointer-events: all;
border-radius: var(--radius-1);
padding-right: 1px;
height: 36px;
}
.tlui-people-menu__avatars {
display: flex;
flex-direction: row;
}
.tlui-people-menu__avatar {
height: 28px;
width: 28px;
border: 2px solid var(--color-background);
background-color: var(--color-low);
border-radius: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
font-size: 10px;
font-weight: bold;
color: var(--color-selected-contrast);
z-index: 2;
}
.tlui-people-menu__avatar:nth-of-type(n + 2) {
margin-left: -12px;
}
.tlui-people-menu__avatars-button[data-state='open'] {
opacity: 1;
}
@media (hover: hover) {
.tlui-people-menu__avatars-button:hover .tlui-people-menu__avatar {
border-color: var(--color-low);
}
}
.tlui-people-menu__more {
min-width: 0px;
font-size: 11px;
font-weight: 600;
color: var(--color-text-1);
font-family: inherit;
padding: 0px 4px;
letter-spacing: 1.5;
}
.tlui-people-menu__more::after {
border-radius: var(--radius-2);
inset: 0px;
}
.tlui-people-menu__wrapper {
position: relative;
display: flex;
flex-direction: column;
width: 220px;
height: fit-content;
max-height: 50vh;
}
.tlui-people-menu__section {
position: relative;
touch-action: auto;
flex-direction: column;
max-height: 100%;
overflow-x: hidden;
overflow-y: auto;
touch-action: auto;
}
.tlui-people-menu__section:not(:last-child) {
border-bottom: 1px solid var(--color-divider);
}
.tlui-people-menu__user {
display: flex;
justify-content: flex-start;
align-items: center;
}
.tlui-people-menu__user__color {
flex-shrink: 0;
}
.tlui-people-menu__user__name {
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--color-text-1);
max-width: 100%;
flex-grow: 1;
flex-shrink: 100;
}
.tlui-people-menu__user__label {
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--color-text-3);
flex-grow: 100;
flex-shrink: 0;
margin-left: 4px;
}
.tlui-people-menu__user__input {
flex-grow: 2;
height: 100%;
padding: 0px;
margin: 0px;
}
.tlui-people-menu__user > .tlui-input__wrapper {
width: auto;
display: flex;
align-items: auto;
flex-grow: 2;
gap: 8px;
height: 100%;
padding: 0px;
}
.tlui-people-menu__item {
display: flex;
justify-content: flex-start;
width: 100%;
}
.tlui-people-menu__item__button {
padding: 0 11px;
}
.tlui-people-menu__item > .tlui-button__menu {
width: auto;
display: flex;
align-items: auto;
justify-content: flex-start;
flex-grow: 2;
gap: 11px;
}
.tlui-people-menu__item__follow {
min-width: 44px;
}
.tlui-people-menu__item__follow[data-active='true'] .tlui-icon {
opacity: 1;
}
.tlui-people-menu__item__follow:focus-visible .tlui-icon {
opacity: 1;
}
@media (hover: hover) {
.tlui-people-menu__item__follow .tlui-icon {
opacity: 0;
}
.tlui-people-menu__item__follow:hover .tlui-icon {
opacity: 1;
}
}
.tlui-layout[data-breakpoint='0'] .tlui-offline-indicator { .tlui-layout[data-breakpoint='0'] .tlui-offline-indicator {
margin-top: 4px; margin-top: 4px;
} }

View file

@ -21,6 +21,7 @@ const components: Required<TLUiComponents> = {
SharePanel: null, SharePanel: null,
MenuPanel: null, MenuPanel: null,
TopPanel: null, TopPanel: null,
CursorChatBubble: null,
} }
export default function UiComponentsHiddenExample() { export default function UiComponentsHiddenExample() {

View file

@ -39,7 +39,7 @@
"refresh-assets": "lazy refresh-assets", "refresh-assets": "lazy refresh-assets",
"dev": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/examples' --filter='packages/tldraw' --filter='apps/{bemo-worker,images.tldraw.xyz}'", "dev": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/examples' --filter='packages/tldraw' --filter='apps/{bemo-worker,images.tldraw.xyz}'",
"dev-vscode": "code ./apps/vscode/extension && lazy run dev --filter='apps/vscode/{extension,editor}'", "dev-vscode": "code ./apps/vscode/extension && lazy run dev --filter='apps/vscode/{extension,editor}'",
"dev-app": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/{dotcom,dotcom-asset-upload,dotcom-worker,images.tldraw.xyz}' --filter='packages/tldraw'", "dev-app": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/{dotcom,dotcom-asset-upload,dotcom-worker,images.tldraw.xyz,bemo-worker}' --filter='packages/tldraw'",
"dev-docs": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/docs'", "dev-docs": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='apps/docs'",
"dev-huppy": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter 'apps/huppy'", "dev-huppy": "LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter 'apps/huppy'",
"build": "lazy build", "build": "lazy build",

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, onEditorMount, ...rest }?: TLStoreOptions): TLStore; export function createTLStore({ initialData, defaultName, id, assets, onEditorMount, multiplayerStatus, ...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>;
multiplayerStatus?: null | Signal<'offline' | 'online'>;
onEditorMount?: (editor: Editor) => (() => void) | void; onEditorMount?: (editor: Editor) => (() => void) | void;
} }

View file

@ -1,3 +1,4 @@
import { Signal } from '@tldraw/state'
import { HistoryEntry, MigrationSequence, SerializedStore, Store, StoreSchema } from '@tldraw/store' import { HistoryEntry, MigrationSequence, SerializedStore, Store, StoreSchema } from '@tldraw/store'
import { import {
SchemaPropsInfo, SchemaPropsInfo,
@ -25,6 +26,9 @@ export interface TLStoreBaseOptions {
/** Called when the store is connected to an {@link Editor}. */ /** Called when the store is connected to an {@link Editor}. */
onEditorMount?: (editor: Editor) => void | (() => void) onEditorMount?: (editor: Editor) => void | (() => void)
/** Is this store connected to a multiplayer sync server? */
multiplayerStatus?: Signal<'online' | 'offline'> | null
} }
/** @public */ /** @public */
@ -63,6 +67,7 @@ export function createTLStore({
id, id,
assets, assets,
onEditorMount, onEditorMount,
multiplayerStatus,
...rest ...rest
}: TLStoreOptions = {}): TLStore { }: TLStoreOptions = {}): TLStore {
const schema = const schema =
@ -96,6 +101,7 @@ export function createTLStore({
assert(editor instanceof Editor) assert(editor instanceof Editor)
onEditorMount?.(editor) onEditorMount?.(editor)
}, },
multiplayerStatus: multiplayerStatus ?? null,
}, },
}) })
} }

View file

@ -163,7 +163,7 @@ export interface Signal<Value, Diff = unknown> {
} }
// @public // @public
export function track<T extends FunctionComponent<any>>(baseComponent: T): T extends React_2.MemoExoticComponent<any> ? T : React_2.MemoExoticComponent<T>; export function track<T extends FunctionComponent<any>>(baseComponent: T): React_2.NamedExoticComponent<React_2.ComponentProps<T>>;
// @public // @public
export function transact<T>(fn: () => T): T; export function transact<T>(fn: () => T): T;

View file

@ -45,7 +45,7 @@ export const ReactForwardRefSymbol = Symbol.for('react.forward_ref')
*/ */
export function track<T extends FunctionComponent<any>>( export function track<T extends FunctionComponent<any>>(
baseComponent: T baseComponent: T
): T extends React.MemoExoticComponent<any> ? T : React.MemoExoticComponent<T> { ): React.NamedExoticComponent<React.ComponentProps<T>> {
let compare = null let compare = null
const $$typeof = baseComponent['$$typeof' as keyof typeof baseComponent] const $$typeof = baseComponent['$$typeof' as keyof typeof baseComponent]
if ($$typeof === ReactMemoSymbol) { if ($$typeof === ReactMemoSymbol) {

View file

@ -7,7 +7,7 @@ import {
TLSyncClient, TLSyncClient,
schema, schema,
} from '@tldraw/sync' } from '@tldraw/sync'
import { useEffect, useState } from 'react' import { useEffect } from 'react'
import { import {
Editor, Editor,
Signal, Signal,
@ -19,9 +19,11 @@ import {
TLUserPreferences, TLUserPreferences,
computed, computed,
createPresenceStateDerivation, createPresenceStateDerivation,
createTLStore,
defaultUserPreferences, defaultUserPreferences,
getUserPreferences, getUserPreferences,
useTLStore, uniqueId,
useRefState,
useValue, useValue,
} from 'tldraw' } from 'tldraw'
@ -35,20 +37,26 @@ export type RemoteTLStoreWithStatus = Exclude<
/** @public */ /** @public */
export function useMultiplayerSync(opts: UseMultiplayerSyncOptions): RemoteTLStoreWithStatus { export function useMultiplayerSync(opts: UseMultiplayerSyncOptions): RemoteTLStoreWithStatus {
const [state, setState] = useState<{ const [state, setState] = useRefState<{
readyClient?: TLSyncClient<TLRecord, TLStore> readyClient?: TLSyncClient<TLRecord, TLStore>
error?: Error error?: Error
} | null>(null) } | null>(null)
const { uri, roomId = 'default', userPreferences: prefs, assets, onEditorMount } = opts const {
uri,
const store = useTLStore({ schema, assets, onEditorMount }) roomId = 'default',
userPreferences: prefs,
assets,
onEditorMount,
trackAnalyticsEvent: track,
} = opts
const error: NonNullable<typeof state>['error'] = state?.error ?? undefined const error: NonNullable<typeof state>['error'] = state?.error ?? undefined
const track = opts.trackAnalyticsEvent
useEffect(() => { useEffect(() => {
if (error) return if (error) return
const storeId = uniqueId()
const userPreferences = computed<{ id: string; color: string; name: string }>( const userPreferences = computed<{ id: string; color: string; name: string }>(
'userPreferences', 'userPreferences',
() => { () => {
@ -65,7 +73,7 @@ export function useMultiplayerSync(opts: UseMultiplayerSyncOptions): RemoteTLSto
// set sessionKey as a query param on the uri // set sessionKey as a query param on the uri
const withParams = new URL(uri) const withParams = new URL(uri)
withParams.searchParams.set('sessionKey', TAB_ID) withParams.searchParams.set('sessionKey', TAB_ID)
withParams.searchParams.set('storeId', store.id) withParams.searchParams.set('storeId', storeId)
return withParams.toString() return withParams.toString()
}) })
@ -81,6 +89,16 @@ export function useMultiplayerSync(opts: UseMultiplayerSyncOptions): RemoteTLSto
let didCancel = false let didCancel = false
const store = createTLStore({
id: storeId,
schema,
assets,
onEditorMount,
multiplayerStatus: computed('multiplayer status', () =>
socket.connectionStatus === 'error' ? 'offline' : socket.connectionStatus
),
})
const client = new TLSyncClient({ const client = new TLSyncClient({
store, store,
socket, socket,
@ -115,7 +133,7 @@ export function useMultiplayerSync(opts: UseMultiplayerSyncOptions): RemoteTLSto
client.close() client.close()
socket.close() socket.close()
} }
}, [prefs, roomId, store, uri, error, track]) }, [assets, error, onEditorMount, prefs, roomId, setState, track, uri])
return useValue<RemoteTLStoreWithStatus>( return useValue<RemoteTLStoreWithStatus>(
'remote synced store', 'remote synced store',

View file

@ -30,7 +30,6 @@ import { IndexKey } from '@tldraw/editor';
import { JsonObject } from '@tldraw/editor'; import { JsonObject } from '@tldraw/editor';
import { JSX as JSX_2 } from 'react/jsx-runtime'; import { JSX as JSX_2 } from 'react/jsx-runtime';
import { LANGUAGES } from '@tldraw/editor'; import { LANGUAGES } from '@tldraw/editor';
import { MemoExoticComponent } from 'react';
import { MigrationFailureReason } from '@tldraw/editor'; import { MigrationFailureReason } from '@tldraw/editor';
import { MigrationSequence } from '@tldraw/editor'; import { MigrationSequence } from '@tldraw/editor';
import { NamedExoticComponent } from 'react'; import { NamedExoticComponent } from 'react';
@ -333,6 +332,25 @@ export interface BreakPointProviderProps {
// @internal (undocumented) // @internal (undocumented)
export function buildFromV1Document(editor: Editor, _document: unknown): void; export function buildFromV1Document(editor: Editor, _document: unknown): void;
// @public (undocumented)
export function CenteredTopPanelContainer({ maxWidth, ignoreRightWidth, stylePanelWidth, marginBetweenZones, squeezeAmount, children, }: CenteredTopPanelContainerProps): JSX_2.Element;
// @public (undocumented)
export interface CenteredTopPanelContainerProps {
// (undocumented)
children: ReactNode;
// (undocumented)
ignoreRightWidth?: number;
// (undocumented)
marginBetweenZones?: number;
// (undocumented)
maxWidth?: number;
// (undocumented)
squeezeAmount?: number;
// (undocumented)
stylePanelWidth?: number;
}
// @public (undocumented) // @public (undocumented)
export function CheckBoxToolbarItem(): JSX_2.Element; export function CheckBoxToolbarItem(): JSX_2.Element;
@ -443,6 +461,9 @@ export const defaultShapeTools: (typeof ArrowShapeTool)[];
// @public (undocumented) // @public (undocumented)
export const defaultShapeUtils: TLAnyShapeUtilConstructor[]; export const defaultShapeUtils: TLAnyShapeUtilConstructor[];
// @public (undocumented)
export function DefaultSharePanel(): JSX_2.Element;
// @public (undocumented) // @public (undocumented)
export const DefaultStylePanel: NamedExoticComponent<TLUiStylePanelProps>; export const DefaultStylePanel: NamedExoticComponent<TLUiStylePanelProps>;
@ -464,6 +485,9 @@ export interface DefaultToolbarProps {
// @public (undocumented) // @public (undocumented)
export const defaultTools: (typeof EraserTool | typeof HandTool | typeof ZoomTool)[]; export const defaultTools: (typeof EraserTool | typeof HandTool | typeof ZoomTool)[];
// @public (undocumented)
export function DefaultTopPanel(): JSX_2.Element;
// @public (undocumented) // @public (undocumented)
export const DefaultZoomMenu: NamedExoticComponent<TLUiZoomMenuProps>; export const DefaultZoomMenu: NamedExoticComponent<TLUiZoomMenuProps>;
@ -1264,7 +1288,7 @@ export interface PageItemInputProps {
} }
// @public (undocumented) // @public (undocumented)
export const PageItemSubmenu: MemoExoticComponent<({ index, listSize, item, onRename, }: PageItemSubmenuProps) => JSX_2.Element>; export const PageItemSubmenu: NamedExoticComponent<PageItemSubmenuProps>;
// @public (undocumented) // @public (undocumented)
export interface PageItemSubmenuProps { export interface PageItemSubmenuProps {
@ -1293,6 +1317,15 @@ export function parseTldrawJsonFile({ json, schema, }: {
// @public (undocumented) // @public (undocumented)
export function PasteMenuItem(): JSX_2.Element; export function PasteMenuItem(): JSX_2.Element;
// @public (undocumented)
export const PeopleMenu: NamedExoticComponent<PeopleMenuProps>;
// @public (undocumented)
export interface PeopleMenuProps {
// (undocumented)
children?: ReactNode;
}
// @public (undocumented) // @public (undocumented)
export enum PORTRAIT_BREAKPOINT { export enum PORTRAIT_BREAKPOINT {
// (undocumented) // (undocumented)
@ -1675,7 +1708,7 @@ export function TldrawScribble({ scribble, zoom, color, opacity, className }: TL
export const TldrawSelectionBackground: ({ bounds, rotation }: TLSelectionBackgroundProps) => JSX_2.Element | null; export const TldrawSelectionBackground: ({ bounds, rotation }: TLSelectionBackgroundProps) => JSX_2.Element | null;
// @public (undocumented) // @public (undocumented)
export const TldrawSelectionForeground: MemoExoticComponent<({ bounds, rotation, }: TLSelectionForegroundProps) => JSX_2.Element | null>; export const TldrawSelectionForeground: NamedExoticComponent<TLSelectionForegroundProps>;
// @public (undocumented) // @public (undocumented)
export function TldrawShapeIndicators(): JSX_2.Element | null; export function TldrawShapeIndicators(): JSX_2.Element | null;
@ -1949,6 +1982,8 @@ export interface TLUiComponents {
// (undocumented) // (undocumented)
ContextMenu?: ComponentType<TLUiContextMenuProps> | null; ContextMenu?: ComponentType<TLUiContextMenuProps> | null;
// (undocumented) // (undocumented)
CursorChatBubble?: ComponentType | null;
// (undocumented)
DebugMenu?: ComponentType | null; DebugMenu?: ComponentType | null;
// (undocumented) // (undocumented)
DebugPanel?: ComponentType | null; DebugPanel?: ComponentType | null;
@ -2174,6 +2209,8 @@ export interface TLUiEventMap {
locale: string; locale: string;
}; };
// (undocumented) // (undocumented)
'change-user-name': null;
// (undocumented)
'close-menu': { 'close-menu': {
id: string; id: string;
}; };
@ -2264,6 +2301,8 @@ export interface TLUiEventMap {
id: string; id: string;
}; };
// (undocumented) // (undocumented)
'set-color': null;
// (undocumented)
'set-style': { 'set-style': {
id: string; id: string;
value: number | string; value: number | string;
@ -2273,6 +2312,8 @@ export interface TLUiEventMap {
operation: 'horizontal' | 'vertical'; operation: 'horizontal' | 'vertical';
}; };
// (undocumented) // (undocumented)
'start-following': null;
// (undocumented)
'stop-following': null; 'stop-following': null;
// (undocumented) // (undocumented)
'stretch-shapes': { 'stretch-shapes': {

View file

@ -141,6 +141,8 @@ export {
type TLUiQuickActionsProps, type TLUiQuickActionsProps,
} from './lib/ui/components/QuickActions/DefaultQuickActions' } from './lib/ui/components/QuickActions/DefaultQuickActions'
export { DefaultQuickActionsContent } from './lib/ui/components/QuickActions/DefaultQuickActionsContent' export { DefaultQuickActionsContent } from './lib/ui/components/QuickActions/DefaultQuickActionsContent'
export { DefaultSharePanel } from './lib/ui/components/SharePanel/DefaultSharePanel'
export { PeopleMenu, type PeopleMenuProps } from './lib/ui/components/SharePanel/PeopleMenu'
export { Spinner } from './lib/ui/components/Spinner' export { Spinner } from './lib/ui/components/Spinner'
export { export {
DefaultStylePanel, DefaultStylePanel,
@ -196,6 +198,11 @@ export {
useIsToolSelected, useIsToolSelected,
type ToolbarItemProps, type ToolbarItemProps,
} from './lib/ui/components/Toolbar/DefaultToolbarContent' } from './lib/ui/components/Toolbar/DefaultToolbarContent'
export {
CenteredTopPanelContainer,
type CenteredTopPanelContainerProps,
} from './lib/ui/components/TopPanel/CenteredTopPanelContainer'
export { DefaultTopPanel } from './lib/ui/components/TopPanel/DefaultTopPanel'
export { export {
DefaultZoomMenu, DefaultZoomMenu,
type TLUiZoomMenuProps, type TLUiZoomMenuProps,

View file

@ -615,12 +615,17 @@
height: fit-content; height: fit-content;
max-height: 100%; max-height: 100%;
margin: 8px; margin: 8px;
margin-top: 4px;
touch-action: auto; touch-action: auto;
overscroll-behavior: none; overscroll-behavior: none;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
color: var(--color-text); color: var(--color-text);
} }
/* if the style panel is the only child (ie no share menu), increase the margin */
.tlui-style-panel__wrapper:only-child {
margin-top: 8px;
}
.tlui-style-panel { .tlui-style-panel {
position: relative; position: relative;
@ -1579,3 +1584,193 @@
flex: 1; flex: 1;
font-size: 12px; font-size: 12px;
} }
/* ------------------- Da share zone ------------------ */
.tlui-share-zone {
padding: 0px 0px 0px 0px;
display: flex;
flex-direction: row;
justify-content: flex-end;
z-index: var(--layer-panels);
align-items: center;
padding-top: 2px;
padding-right: 4px;
}
/* ------------------- People Menu ------------------- */
.tlui-people-menu__avatars-button {
display: flex;
align-items: center;
justify-content: flex-end;
background: none;
border: none;
cursor: pointer;
pointer-events: all;
border-radius: var(--radius-1);
padding-right: 1px;
height: 36px;
}
.tlui-people-menu__avatars {
display: flex;
flex-direction: row;
}
.tlui-people-menu__avatar {
height: 28px;
width: 28px;
border: 2px solid var(--color-background);
background-color: var(--color-low);
border-radius: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
font-size: 10px;
font-weight: bold;
color: var(--color-selected-contrast);
z-index: 2;
}
.tlui-people-menu__avatar:nth-of-type(n + 2) {
margin-left: -12px;
}
.tlui-people-menu__avatars-button[data-state='open'] {
opacity: 1;
}
@media (hover: hover) {
.tlui-people-menu__avatars-button:hover .tlui-people-menu__avatar {
border-color: var(--color-low);
}
}
.tlui-people-menu__more {
min-width: 0px;
font-size: 11px;
font-weight: 600;
color: var(--color-text-1);
font-family: inherit;
padding: 0px 4px;
letter-spacing: 1.5;
}
.tlui-people-menu__more::after {
border-radius: var(--radius-2);
inset: 0px;
}
.tlui-people-menu__wrapper {
position: relative;
display: flex;
flex-direction: column;
width: 220px;
height: fit-content;
max-height: 50vh;
}
.tlui-people-menu__section {
position: relative;
touch-action: auto;
flex-direction: column;
max-height: 100%;
overflow-x: hidden;
overflow-y: auto;
touch-action: auto;
}
.tlui-people-menu__section:not(:last-child) {
border-bottom: 1px solid var(--color-divider);
}
.tlui-people-menu__user {
display: flex;
justify-content: flex-start;
align-items: center;
}
.tlui-people-menu__user__color {
flex-shrink: 0;
}
.tlui-people-menu__user__name {
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--color-text-1);
max-width: 100%;
flex-grow: 1;
flex-shrink: 100;
}
.tlui-people-menu__user__label {
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--color-text-3);
flex-grow: 100;
flex-shrink: 0;
margin-left: 4px;
}
.tlui-people-menu__user__input {
flex-grow: 2;
height: 100%;
padding: 0px;
margin: 0px;
}
.tlui-people-menu__user > .tlui-input__wrapper {
width: auto;
display: flex;
align-items: auto;
flex-grow: 2;
gap: 8px;
height: 100%;
padding: 0px;
}
.tlui-people-menu__item {
display: flex;
justify-content: flex-start;
width: 100%;
}
.tlui-people-menu__item__button {
padding: 0 11px;
}
.tlui-people-menu__item > .tlui-button__menu {
width: auto;
display: flex;
align-items: auto;
justify-content: flex-start;
flex-grow: 2;
gap: 11px;
}
.tlui-people-menu__item__follow {
min-width: 44px;
}
.tlui-people-menu__item__follow[data-active='true'] .tlui-icon {
opacity: 1;
}
.tlui-people-menu__item__follow:focus-visible .tlui-icon {
opacity: 1;
}
@media (hover: hover) {
.tlui-people-menu__item__follow .tlui-icon {
opacity: 0;
}
.tlui-people-menu__item__follow:hover .tlui-icon {
opacity: 1;
}
}

View file

@ -109,6 +109,7 @@ const TldrawUiContent = React.memo(function TldrawUI() {
NavigationPanel, NavigationPanel,
HelperButtons, HelperButtons,
DebugPanel, DebugPanel,
CursorChatBubble,
} = useTldrawUiComponents() } = useTldrawUiComponents()
useKeyboardShortcuts() useKeyboardShortcuts()
@ -164,6 +165,7 @@ const TldrawUiContent = React.memo(function TldrawUI() {
<Dialogs /> <Dialogs />
<ToastViewport /> <ToastViewport />
<FollowingIndicator /> <FollowingIndicator />
{CursorChatBubble && <CursorChatBubble />}
</div> </div>
) )
}) })

View file

@ -1,8 +1,10 @@
import { useEditor, useValue } from '@tldraw/editor' import { useEditor, useValue } from '@tldraw/editor'
import { useIsMultiplayer } from '../../hooks/useIsMultiplayer'
import { import {
ArrangeMenuSubmenu, ArrangeMenuSubmenu,
ClipboardMenuGroup, ClipboardMenuGroup,
ConversionsMenuGroup, ConversionsMenuGroup,
CursorChatItem,
EditMenuSubmenu, EditMenuSubmenu,
MoveToPageMenu, MoveToPageMenu,
ReorderMenuSubmenu, ReorderMenuSubmenu,
@ -13,6 +15,7 @@ import { TldrawUiMenuGroup } from '../primitives/menus/TldrawUiMenuGroup'
/** @public @react */ /** @public @react */
export function DefaultContextMenuContent() { export function DefaultContextMenuContent() {
const editor = useEditor() const editor = useEditor()
const isMultiplayer = useIsMultiplayer()
const selectToolActive = useValue( const selectToolActive = useValue(
'isSelectToolActive', 'isSelectToolActive',
@ -24,6 +27,7 @@ export function DefaultContextMenuContent() {
return ( return (
<> <>
{isMultiplayer && <CursorChatItem />}
<TldrawUiMenuGroup id="modify"> <TldrawUiMenuGroup id="modify">
<EditMenuSubmenu /> <EditMenuSubmenu />
<ArrangeMenuSubmenu /> <ArrangeMenuSubmenu />

View file

@ -1,3 +1,4 @@
import { preventDefault, track, useEditor } from '@tldraw/editor'
import { import {
ChangeEvent, ChangeEvent,
ClipboardEvent, ClipboardEvent,
@ -9,7 +10,7 @@ import {
useRef, useRef,
useState, useState,
} from 'react' } from 'react'
import { preventDefault, track, useEditor, useTranslation } from 'tldraw' import { useTranslation } from '../hooks/useTranslation/useTranslation'
// todo: // todo:
// - not cleaning up // - not cleaning up
@ -27,7 +28,7 @@ export const CursorChatBubble = track(function CursorChatBubble() {
const closingUp = !isChatting && chatMessage const closingUp = !isChatting && chatMessage
if (closingUp || isChatting) { if (closingUp || isChatting) {
const duration = isChatting ? CHAT_MESSAGE_TIMEOUT_CHATTING : CHAT_MESSAGE_TIMEOUT_CLOSING const duration = isChatting ? CHAT_MESSAGE_TIMEOUT_CHATTING : CHAT_MESSAGE_TIMEOUT_CLOSING
rTimeout.current = setTimeout(() => { rTimeout.current = editor.timers.setTimeout(() => {
editor.updateInstanceState({ chatMessage: '', isChatting: false }) editor.updateInstanceState({ chatMessage: '', isChatting: false })
setValue('') setValue('')
editor.focus() editor.focus()
@ -125,7 +126,7 @@ const CursorChatInput = track(function CursorChatInput({
useLayoutEffect(() => { useLayoutEffect(() => {
// Focus the input // Focus the input
const raf = requestAnimationFrame(() => { const raf = editor.timers.requestAnimationFrame(() => {
ref.current?.focus() ref.current?.focus()
}) })

View file

@ -1,4 +1,5 @@
import { useActions } from '../../context/actions' import { useActions } from '../../context/actions'
import { useIsMultiplayer } from '../../hooks/useIsMultiplayer'
import { useTools } from '../../hooks/useTools' import { useTools } from '../../hooks/useTools'
import { TldrawUiMenuGroup } from '../primitives/menus/TldrawUiMenuGroup' import { TldrawUiMenuGroup } from '../primitives/menus/TldrawUiMenuGroup'
import { TldrawUiMenuItem } from '../primitives/menus/TldrawUiMenuItem' import { TldrawUiMenuItem } from '../primitives/menus/TldrawUiMenuItem'
@ -7,6 +8,7 @@ import { TldrawUiMenuItem } from '../primitives/menus/TldrawUiMenuItem'
export function DefaultKeyboardShortcutsDialogContent() { export function DefaultKeyboardShortcutsDialogContent() {
const actions = useActions() const actions = useActions()
const tools = useTools() const tools = useTools()
const isMultiplayer = useIsMultiplayer()
return ( return (
<> <>
<TldrawUiMenuGroup label="shortcuts-dialog.tools" id="tools"> <TldrawUiMenuGroup label="shortcuts-dialog.tools" id="tools">
@ -63,6 +65,11 @@ export function DefaultKeyboardShortcutsDialogContent() {
<TldrawUiMenuItem {...actions['align-center-horizontal']} /> <TldrawUiMenuItem {...actions['align-center-horizontal']} />
<TldrawUiMenuItem {...actions['align-right']} /> <TldrawUiMenuItem {...actions['align-right']} />
</TldrawUiMenuGroup> </TldrawUiMenuGroup>
{isMultiplayer && (
<TldrawUiMenuGroup label="shortcuts-dialog.collaboration" id="collaboration">
<TldrawUiMenuItem {...actions['open-cursor-chat']} />
</TldrawUiMenuGroup>
)}
</> </>
) )
} }

View file

@ -1,15 +1,13 @@
import classNames from 'classnames' import classNames from 'classnames'
import { useRef } from 'react'
import { useTranslation } from '../../hooks/useTranslation/useTranslation' import { useTranslation } from '../../hooks/useTranslation/useTranslation'
import { TldrawUiIcon } from '../primitives/TldrawUiIcon' import { TldrawUiIcon } from '../primitives/TldrawUiIcon'
/** @public @react */ /** @public @react */
export function OfflineIndicator() { export function OfflineIndicator() {
const msg = useTranslation() const msg = useTranslation()
const rContainer = useRef<HTMLDivElement>(null)
return ( return (
<div className={classNames('tlui-offline-indicator')} ref={rContainer}> <div className={classNames('tlui-offline-indicator')}>
{msg('status.offline')} {msg('status.offline')}
<TldrawUiIcon aria-label="offline" icon="status-offline" small /> <TldrawUiIcon aria-label="offline" icon="status-offline" small />
</div> </div>

View file

@ -0,0 +1,10 @@
import { PeopleMenu } from './PeopleMenu'
/** @public @react */
export function DefaultSharePanel() {
return (
<div className="tlui-share-zone" draggable={false}>
<PeopleMenu />
</div>
)
}

View file

@ -1,27 +1,20 @@
import * as Popover from '@radix-ui/react-popover' import * as Popover from '@radix-ui/react-popover'
import { import { track, useContainer, useEditor, usePeerIds, useValue } from '@tldraw/editor'
TldrawUiButton, import { ReactNode } from 'react'
TldrawUiButtonIcon, import { useMenuIsOpen } from '../../hooks/useMenuIsOpen'
TldrawUiButtonLabel, import { useTranslation } from '../../hooks/useTranslation/useTranslation'
track,
useContainer,
useEditor,
useMenuIsOpen,
usePeerIds,
useTranslation,
useValue,
} from 'tldraw'
import { PeopleMenuAvatar } from './PeopleMenuAvatar' import { PeopleMenuAvatar } from './PeopleMenuAvatar'
import { PeopleMenuItem } from './PeopleMenuItem' import { PeopleMenuItem } from './PeopleMenuItem'
import { PeopleMenuMore } from './PeopleMenuMore' import { PeopleMenuMore } from './PeopleMenuMore'
import { UserPresenceEditor } from './UserPresenceEditor' import { UserPresenceEditor } from './UserPresenceEditor'
/** @public */ /** @public */
export const PeopleMenu = track(function PeopleMenu({ export interface PeopleMenuProps {
hideShareMenu, children?: ReactNode
}: { }
hideShareMenu?: boolean
}) { /** @public @react */
export const PeopleMenu = track(function PeopleMenu({ children }: PeopleMenuProps) {
const msg = useTranslation() const msg = useTranslation()
const container = useContainer() const container = useContainer()
@ -73,18 +66,7 @@ export const PeopleMenu = track(function PeopleMenu({
})} })}
</div> </div>
)} )}
{!hideShareMenu && ( {children}
<div className="tlui-people-menu__section">
<TldrawUiButton
type="menu"
data-testid="people-menu.invite"
onClick={() => editor.addOpenMenu('share menu')}
>
<TldrawUiButtonLabel>{msg('people-menu.invite')}</TldrawUiButtonLabel>
<TldrawUiButtonIcon icon="plus" />
</TldrawUiButton>
</div>
)}
</div> </div>
</Popover.Content> </Popover.Content>
</Popover.Portal> </Popover.Portal>

View file

@ -1,4 +1,4 @@
import { usePresence } from 'tldraw' import { usePresence } from '@tldraw/editor'
export function PeopleMenuAvatar({ userId }: { userId: string }) { export function PeopleMenuAvatar({ userId }: { userId: string }) {
const presence = usePresence(userId) const presence = usePresence(userId)

View file

@ -1,15 +1,10 @@
import { track, useEditor, usePresence } from '@tldraw/editor'
import { useCallback } from 'react' import { useCallback } from 'react'
import { import { useUiEvents } from '../../context/events'
TldrawUiButton, import { useTranslation } from '../../hooks/useTranslation/useTranslation'
TldrawUiButtonIcon, import { TldrawUiButton } from '../primitives/Button/TldrawUiButton'
TldrawUiIcon, import { TldrawUiButtonIcon } from '../primitives/Button/TldrawUiButtonIcon'
track, import { TldrawUiIcon } from '../primitives/TldrawUiIcon'
useEditor,
usePresence,
useTranslation,
useUiEvents,
} from 'tldraw'
import { UI_OVERRIDE_TODO_EVENT } from '../../utils/useHandleUiEvent'
export const PeopleMenuItem = track(function PeopleMenuItem({ userId }: { userId: string }) { export const PeopleMenuItem = track(function PeopleMenuItem({ userId }: { userId: string }) {
const editor = useEditor() const editor = useEditor()
@ -24,7 +19,7 @@ export const PeopleMenuItem = track(function PeopleMenuItem({ userId }: { userId
trackEvent('stop-following', { source: 'people-menu' }) trackEvent('stop-following', { source: 'people-menu' })
} else { } else {
editor.startFollowingUser(userId) editor.startFollowingUser(userId)
trackEvent('start-following' as UI_OVERRIDE_TODO_EVENT, { source: 'people-menu' }) trackEvent('start-following', { source: 'people-menu' })
} }
}, [editor, userId, trackEvent]) }, [editor, userId, trackEvent])

View file

@ -1,16 +1,10 @@
import * as Popover from '@radix-ui/react-popover' import * as Popover from '@radix-ui/react-popover'
import { USER_COLORS, track, useContainer, useEditor } from '@tldraw/editor'
import React, { useCallback, useRef, useState } from 'react' import React, { useCallback, useRef, useState } from 'react'
import { import { useUiEvents } from '../../context/events'
TldrawUiButton, import { useTranslation } from '../../hooks/useTranslation/useTranslation'
TldrawUiButtonIcon, import { TldrawUiButton } from '../primitives/Button/TldrawUiButton'
USER_COLORS, import { TldrawUiButtonIcon } from '../primitives/Button/TldrawUiButtonIcon'
track,
useContainer,
useEditor,
useTranslation,
useUiEvents,
} from 'tldraw'
import { UI_OVERRIDE_TODO_EVENT } from '../../utils/useHandleUiEvent'
export const UserPresenceColorPicker = track(function UserPresenceColorPicker() { export const UserPresenceColorPicker = track(function UserPresenceColorPicker() {
const editor = useEditor() const editor = useEditor()
@ -30,7 +24,7 @@ export const UserPresenceColorPicker = track(function UserPresenceColorPicker()
const onValueChange = useCallback( const onValueChange = useCallback(
(item: string) => { (item: string) => {
editor.user.updateUserPreferences({ color: item }) editor.user.updateUserPreferences({ color: item })
trackEvent('set-color' as UI_OVERRIDE_TODO_EVENT, { source: 'people-menu' }) trackEvent('set-color', { source: 'people-menu' })
}, },
[editor, trackEvent] [editor, trackEvent]
) )

View file

@ -1,14 +1,10 @@
import { useEditor, useValue } from '@tldraw/editor'
import { useCallback, useRef, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { import { useUiEvents } from '../../context/events'
TldrawUiButton, import { useTranslation } from '../../hooks/useTranslation/useTranslation'
TldrawUiButtonIcon, import { TldrawUiButton } from '../primitives/Button/TldrawUiButton'
TldrawUiInput, import { TldrawUiButtonIcon } from '../primitives/Button/TldrawUiButtonIcon'
useEditor, import { TldrawUiInput } from '../primitives/TldrawUiInput'
useTranslation,
useUiEvents,
useValue,
} from 'tldraw'
import { UI_OVERRIDE_TODO_EVENT } from '../../utils/useHandleUiEvent'
import { UserPresenceColorPicker } from './UserPresenceColorPicker' import { UserPresenceColorPicker } from './UserPresenceColorPicker'
export function UserPresenceEditor() { export function UserPresenceEditor() {
@ -36,7 +32,7 @@ export function UserPresenceEditor() {
const handleBlur = useCallback(() => { const handleBlur = useCallback(() => {
if (rOriginalName.current === rCurrentName.current) return if (rOriginalName.current === rCurrentName.current) return
trackEvent('change-user-name' as UI_OVERRIDE_TODO_EVENT, { source: 'people-menu' }) trackEvent('change-user-name', { source: 'people-menu' })
rOriginalName.current = rCurrentName.current rOriginalName.current = rCurrentName.current
}, [trackEvent]) }, [trackEvent])

View file

@ -0,0 +1,99 @@
import { ReactNode, useCallback, useLayoutEffect, useRef } from 'react'
import { useBreakpoint } from '../../context/breakpoints'
/** @public */
export interface CenteredTopPanelContainerProps {
children: ReactNode
maxWidth?: number
ignoreRightWidth?: number
stylePanelWidth?: number
marginBetweenZones?: number
squeezeAmount?: number
}
/** @public @react */
export function CenteredTopPanelContainer({
maxWidth = 420,
ignoreRightWidth = 0,
stylePanelWidth = 148,
marginBetweenZones = 12,
squeezeAmount = 52,
children,
}: CenteredTopPanelContainerProps) {
const ref = useRef<HTMLDivElement>(null)
const breakpoint = useBreakpoint()
const updateLayout = useCallback(() => {
const element = ref.current
if (!element) return
const layoutTop = element.parentElement!.parentElement!
const leftPanel = layoutTop.querySelector('.tlui-layout__top__left')! as HTMLElement
const rightPanel = layoutTop.querySelector('.tlui-layout__top__right')! as HTMLElement
const totalWidth = layoutTop.offsetWidth
const leftWidth = leftPanel.offsetWidth
const rightWidth = rightPanel.offsetWidth
// Ignore button width
const selfWidth = element.offsetWidth - ignoreRightWidth
let xCoordIfCentered = (totalWidth - selfWidth) / 2
// Prevent subpixel bullsh
if (totalWidth % 2 !== 0) {
xCoordIfCentered -= 0.5
}
const xCoordIfLeftAligned = leftWidth + marginBetweenZones
const left = element.offsetLeft
const maxWidthProperty = Math.min(
totalWidth - rightWidth - leftWidth - 2 * marginBetweenZones,
maxWidth
)
const xCoord = Math.max(xCoordIfCentered, xCoordIfLeftAligned) - left
// Squeeze the title if the right panel is too wide on small screens
if (rightPanel.offsetWidth > stylePanelWidth && breakpoint <= 6) {
element.style.setProperty('max-width', maxWidthProperty - squeezeAmount + 'px')
} else {
element.style.setProperty('max-width', maxWidthProperty + 'px')
}
element.style.setProperty('transform', `translate(${xCoord}px, 0px)`)
}, [breakpoint, ignoreRightWidth, marginBetweenZones, maxWidth, squeezeAmount, stylePanelWidth])
useLayoutEffect(() => {
const element = ref.current
if (!element) return
const layoutTop = element.parentElement!.parentElement!
const leftPanel = layoutTop.querySelector('.tlui-layout__top__left')! as HTMLElement
const rightPanel = layoutTop.querySelector('.tlui-layout__top__right')! as HTMLElement
// Update layout when the things change
const observer = new ResizeObserver(updateLayout)
observer.observe(leftPanel)
observer.observe(rightPanel)
observer.observe(layoutTop)
observer.observe(element)
// Also update on first layout
updateLayout()
return () => {
observer.disconnect()
}
}, [updateLayout])
// Update after every render, too
useLayoutEffect(() => {
updateLayout()
})
return (
<div ref={ref} className="tlui-top-panel__container">
{children}
</div>
)
}

View file

@ -0,0 +1,10 @@
import { useMultiplayerStatus } from '../../hooks/useIsMultiplayer'
import { OfflineIndicator } from '../OfflineIndicator/OfflineIndicator'
import { CenteredTopPanelContainer } from './CenteredTopPanelContainer'
/** @public @react */
export function DefaultTopPanel() {
const isOffline = useMultiplayerStatus() === 'offline'
return <CenteredTopPanelContainer>{isOffline && <OfflineIndicator />}</CenteredTopPanelContainer>
}

View file

@ -646,3 +646,19 @@ export function PrintItem() {
]) ])
return <TldrawUiMenuItem {...actions['print']} disabled={emptyPage} /> return <TldrawUiMenuItem {...actions['print']} disabled={emptyPage} />
} }
/* ---------------------- Multiplayer --------------------- */
/** @public @react */
export function CursorChatItem() {
const editor = useEditor()
const actions = useActions()
const shouldShow = useValue(
'show cursor chat',
() => editor.getCurrentToolId() === 'select' && !editor.getInstanceState().isCoarsePointer,
[editor]
)
if (!shouldShow) return null
return <TldrawUiMenuItem {...actions['open-cursor-chat']} />
}

View file

@ -30,6 +30,7 @@ import { useCopyAs } from '../hooks/useCopyAs'
import { useExportAs } from '../hooks/useExportAs' import { useExportAs } from '../hooks/useExportAs'
import { flattenShapesToImages } from '../hooks/useFlatten' import { flattenShapesToImages } from '../hooks/useFlatten'
import { useInsertMedia } from '../hooks/useInsertMedia' import { useInsertMedia } from '../hooks/useInsertMedia'
import { useIsMultiplayer } from '../hooks/useIsMultiplayer'
import { usePrint } from '../hooks/usePrint' import { usePrint } from '../hooks/usePrint'
import { TLUiTranslationKey } from '../hooks/useTranslation/TLUiTranslationKey' import { TLUiTranslationKey } from '../hooks/useTranslation/TLUiTranslationKey'
import { useTranslation } from '../hooks/useTranslation/useTranslation' import { useTranslation } from '../hooks/useTranslation/useTranslation'
@ -84,6 +85,7 @@ function getExportName(editor: Editor, defaultName: string) {
/** @internal */ /** @internal */
export function ActionsProvider({ overrides, children }: ActionsProviderProps) { export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
const editor = useEditor() const editor = useEditor()
const isMultiplayer = useIsMultiplayer()
const { addDialog, clearDialogs } = useDialogs() const { addDialog, clearDialogs } = useDialogs()
const { clearToasts, addToast } = useToasts() const { clearToasts, addToast } = useToasts()
@ -1424,6 +1426,28 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
}, },
] ]
if (isMultiplayer) {
actionItems.push({
id: 'open-cursor-chat',
label: 'action.open-cursor-chat',
readonlyOk: true,
kbd: '/',
onSelect(source: any) {
trackEvent('open-cursor-chat', { source })
// Don't open cursor chat if we're on a touch device
if (editor.getInstanceState().isCoarsePointer) {
return
}
// wait a frame before opening as otherwise the open context menu will close it
editor.timers.requestAnimationFrame(() => {
editor.updateInstanceState({ isChatting: true })
})
},
})
}
const actions = makeActions(actionItems) const actions = makeActions(actionItems)
if (overrides) { if (overrides) {
@ -1448,6 +1472,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
printSelectionOrPages, printSelectionOrPages,
msg, msg,
defaultDocumentName, defaultDocumentName,
isMultiplayer,
]) ])
return <ActionsContext.Provider value={asActions(actions)}>{children}</ActionsContext.Provider> return <ActionsContext.Provider value={asActions(actions)}>{children}</ActionsContext.Provider>

View file

@ -8,6 +8,7 @@ import {
DefaultContextMenu, DefaultContextMenu,
TLUiContextMenuProps, TLUiContextMenuProps,
} from '../components/ContextMenu/DefaultContextMenu' } from '../components/ContextMenu/DefaultContextMenu'
import { CursorChatBubble } from '../components/CursorChatBubble'
import { DefaultDebugMenu } from '../components/DebugMenu/DefaultDebugMenu' import { DefaultDebugMenu } from '../components/DebugMenu/DefaultDebugMenu'
import { DefaultDebugPanel } from '../components/DefaultDebugPanel' import { DefaultDebugPanel } from '../components/DefaultDebugPanel'
import { DefaultHelpMenu, TLUiHelpMenuProps } from '../components/HelpMenu/DefaultHelpMenu' import { DefaultHelpMenu, TLUiHelpMenuProps } from '../components/HelpMenu/DefaultHelpMenu'
@ -28,9 +29,12 @@ import {
DefaultQuickActions, DefaultQuickActions,
TLUiQuickActionsProps, TLUiQuickActionsProps,
} from '../components/QuickActions/DefaultQuickActions' } from '../components/QuickActions/DefaultQuickActions'
import { DefaultSharePanel } from '../components/SharePanel/DefaultSharePanel'
import { DefaultStylePanel, TLUiStylePanelProps } from '../components/StylePanel/DefaultStylePanel' import { DefaultStylePanel, TLUiStylePanelProps } from '../components/StylePanel/DefaultStylePanel'
import { DefaultToolbar } from '../components/Toolbar/DefaultToolbar' import { DefaultToolbar } from '../components/Toolbar/DefaultToolbar'
import { DefaultTopPanel } from '../components/TopPanel/DefaultTopPanel'
import { DefaultZoomMenu, TLUiZoomMenuProps } from '../components/ZoomMenu/DefaultZoomMenu' import { DefaultZoomMenu, TLUiZoomMenuProps } from '../components/ZoomMenu/DefaultZoomMenu'
import { useIsMultiplayer } from '../hooks/useIsMultiplayer'
/** @public */ /** @public */
export interface TLUiComponents { export interface TLUiComponents {
@ -52,6 +56,7 @@ export interface TLUiComponents {
MenuPanel?: ComponentType | null MenuPanel?: ComponentType | null
TopPanel?: ComponentType | null TopPanel?: ComponentType | null
SharePanel?: ComponentType | null SharePanel?: ComponentType | null
CursorChatBubble?: ComponentType | null
} }
const TldrawUiComponentsContext = createContext<TLUiComponents | null>(null) const TldrawUiComponentsContext = createContext<TLUiComponents | null>(null)
@ -68,6 +73,7 @@ export function TldrawUiComponentsProvider({
children, children,
}: TLUiComponentsProviderProps) { }: TLUiComponentsProviderProps) {
const _overrides = useShallowObjectIdentity(overrides) const _overrides = useShallowObjectIdentity(overrides)
const isMultiplayer = useIsMultiplayer()
return ( return (
<TldrawUiComponentsContext.Provider <TldrawUiComponentsContext.Provider
@ -89,9 +95,12 @@ export function TldrawUiComponentsProvider({
DebugPanel: DefaultDebugPanel, DebugPanel: DefaultDebugPanel,
DebugMenu: DefaultDebugMenu, DebugMenu: DefaultDebugMenu,
MenuPanel: DefaultMenuPanel, MenuPanel: DefaultMenuPanel,
SharePanel: isMultiplayer ? DefaultSharePanel : null,
CursorChatBubble: isMultiplayer ? CursorChatBubble : null,
TopPanel: isMultiplayer ? DefaultTopPanel : null,
..._overrides, ..._overrides,
}), }),
[_overrides] [_overrides, isMultiplayer]
)} )}
> >
{children} {children}

View file

@ -94,7 +94,10 @@ export interface TLUiEventMap {
'toggle-edge-scrolling': null 'toggle-edge-scrolling': null
'color-scheme': { value: string } 'color-scheme': { value: string }
'exit-pen-mode': null 'exit-pen-mode': null
'start-following': null
'stop-following': null 'stop-following': null
'set-color': null
'change-user-name': null
'open-cursor-chat': null 'open-cursor-chat': null
'zoom-tool': null 'zoom-tool': null
'unlock-all': null 'unlock-all': null

View file

@ -0,0 +1,20 @@
import { useEditor, useValue } from '@tldraw/editor'
export function useIsMultiplayer() {
const editor = useEditor()
return !!editor.store.props.multiplayerStatus
}
export function useMultiplayerStatus() {
const editor = useEditor()
return useValue(
'multiplayerStatus',
() => {
if (!editor.store.props.multiplayerStatus) {
return null
}
return editor.store.props.multiplayerStatus.get()
},
[editor]
)
}

View file

@ -1499,6 +1499,8 @@ export interface TLStoreProps {
assets: TLAssetStore; assets: TLAssetStore;
// (undocumented) // (undocumented)
defaultName: string; defaultName: string;
// (undocumented)
multiplayerStatus: null | Signal<'offline' | 'online'>;
onEditorMount: (editor: unknown) => (() => void) | void; onEditorMount: (editor: unknown) => (() => void) | void;
} }

View file

@ -1,3 +1,4 @@
import { Signal } from '@tldraw/state'
import { import {
SerializedStore, SerializedStore,
Store, Store,
@ -98,6 +99,7 @@ export interface TLStoreProps {
* Called an {@link @tldraw/editor#Editor} connected to this store is mounted. * Called an {@link @tldraw/editor#Editor} connected to this store is mounted.
*/ */
onEditorMount: (editor: unknown) => void | (() => void) onEditorMount: (editor: unknown) => void | (() => void)
multiplayerStatus: Signal<'online' | 'offline'> | null
} }
/** @public */ /** @public */