[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:
parent
627c84c2af
commit
7273eb3101
37 changed files with 573 additions and 519 deletions
|
@ -487,7 +487,7 @@ export function counterClockwiseAngleDist(a0: number, a1: number): number;
|
|||
export function createSessionStateSnapshotSignal(store: TLStore): Signal<null | TLSessionStateSnapshot>;
|
||||
|
||||
// @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)
|
||||
export function createTLUser(opts?: {
|
||||
|
@ -3246,6 +3246,7 @@ export interface TLStoreBaseOptions {
|
|||
assets?: Partial<TLAssetStore>;
|
||||
defaultName?: string;
|
||||
initialData?: SerializedStore<TLRecord>;
|
||||
multiplayerStatus?: null | Signal<'offline' | 'online'>;
|
||||
onEditorMount?: (editor: Editor) => (() => void) | void;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { Signal } from '@tldraw/state'
|
||||
import { HistoryEntry, MigrationSequence, SerializedStore, Store, StoreSchema } from '@tldraw/store'
|
||||
import {
|
||||
SchemaPropsInfo,
|
||||
|
@ -25,6 +26,9 @@ export interface TLStoreBaseOptions {
|
|||
|
||||
/** Called when the store is connected to an {@link Editor}. */
|
||||
onEditorMount?: (editor: Editor) => void | (() => void)
|
||||
|
||||
/** Is this store connected to a multiplayer sync server? */
|
||||
multiplayerStatus?: Signal<'online' | 'offline'> | null
|
||||
}
|
||||
|
||||
/** @public */
|
||||
|
@ -63,6 +67,7 @@ export function createTLStore({
|
|||
id,
|
||||
assets,
|
||||
onEditorMount,
|
||||
multiplayerStatus,
|
||||
...rest
|
||||
}: TLStoreOptions = {}): TLStore {
|
||||
const schema =
|
||||
|
@ -96,6 +101,7 @@ export function createTLStore({
|
|||
assert(editor instanceof Editor)
|
||||
onEditorMount?.(editor)
|
||||
},
|
||||
multiplayerStatus: multiplayerStatus ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
@ -163,7 +163,7 @@ export interface Signal<Value, Diff = unknown> {
|
|||
}
|
||||
|
||||
// @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
|
||||
export function transact<T>(fn: () => T): T;
|
||||
|
|
|
@ -45,7 +45,7 @@ export const ReactForwardRefSymbol = Symbol.for('react.forward_ref')
|
|||
*/
|
||||
export function track<T extends FunctionComponent<any>>(
|
||||
baseComponent: T
|
||||
): T extends React.MemoExoticComponent<any> ? T : React.MemoExoticComponent<T> {
|
||||
): React.NamedExoticComponent<React.ComponentProps<T>> {
|
||||
let compare = null
|
||||
const $$typeof = baseComponent['$$typeof' as keyof typeof baseComponent]
|
||||
if ($$typeof === ReactMemoSymbol) {
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
TLSyncClient,
|
||||
schema,
|
||||
} from '@tldraw/sync'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
Editor,
|
||||
Signal,
|
||||
|
@ -19,9 +19,11 @@ import {
|
|||
TLUserPreferences,
|
||||
computed,
|
||||
createPresenceStateDerivation,
|
||||
createTLStore,
|
||||
defaultUserPreferences,
|
||||
getUserPreferences,
|
||||
useTLStore,
|
||||
uniqueId,
|
||||
useRefState,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
|
||||
|
@ -35,20 +37,26 @@ export type RemoteTLStoreWithStatus = Exclude<
|
|||
|
||||
/** @public */
|
||||
export function useMultiplayerSync(opts: UseMultiplayerSyncOptions): RemoteTLStoreWithStatus {
|
||||
const [state, setState] = useState<{
|
||||
const [state, setState] = useRefState<{
|
||||
readyClient?: TLSyncClient<TLRecord, TLStore>
|
||||
error?: Error
|
||||
} | null>(null)
|
||||
const { uri, roomId = 'default', userPreferences: prefs, assets, onEditorMount } = opts
|
||||
|
||||
const store = useTLStore({ schema, assets, onEditorMount })
|
||||
const {
|
||||
uri,
|
||||
roomId = 'default',
|
||||
userPreferences: prefs,
|
||||
assets,
|
||||
onEditorMount,
|
||||
trackAnalyticsEvent: track,
|
||||
} = opts
|
||||
|
||||
const error: NonNullable<typeof state>['error'] = state?.error ?? undefined
|
||||
const track = opts.trackAnalyticsEvent
|
||||
|
||||
useEffect(() => {
|
||||
if (error) return
|
||||
|
||||
const storeId = uniqueId()
|
||||
|
||||
const userPreferences = computed<{ id: string; color: string; name: string }>(
|
||||
'userPreferences',
|
||||
() => {
|
||||
|
@ -65,7 +73,7 @@ export function useMultiplayerSync(opts: UseMultiplayerSyncOptions): RemoteTLSto
|
|||
// set sessionKey as a query param on the uri
|
||||
const withParams = new URL(uri)
|
||||
withParams.searchParams.set('sessionKey', TAB_ID)
|
||||
withParams.searchParams.set('storeId', store.id)
|
||||
withParams.searchParams.set('storeId', storeId)
|
||||
return withParams.toString()
|
||||
})
|
||||
|
||||
|
@ -81,6 +89,16 @@ export function useMultiplayerSync(opts: UseMultiplayerSyncOptions): RemoteTLSto
|
|||
|
||||
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({
|
||||
store,
|
||||
socket,
|
||||
|
@ -115,7 +133,7 @@ export function useMultiplayerSync(opts: UseMultiplayerSyncOptions): RemoteTLSto
|
|||
client.close()
|
||||
socket.close()
|
||||
}
|
||||
}, [prefs, roomId, store, uri, error, track])
|
||||
}, [assets, error, onEditorMount, prefs, roomId, setState, track, uri])
|
||||
|
||||
return useValue<RemoteTLStoreWithStatus>(
|
||||
'remote synced store',
|
||||
|
|
|
@ -30,7 +30,6 @@ import { IndexKey } from '@tldraw/editor';
|
|||
import { JsonObject } from '@tldraw/editor';
|
||||
import { JSX as JSX_2 } from 'react/jsx-runtime';
|
||||
import { LANGUAGES } from '@tldraw/editor';
|
||||
import { MemoExoticComponent } from 'react';
|
||||
import { MigrationFailureReason } from '@tldraw/editor';
|
||||
import { MigrationSequence } from '@tldraw/editor';
|
||||
import { NamedExoticComponent } from 'react';
|
||||
|
@ -333,6 +332,25 @@ export interface BreakPointProviderProps {
|
|||
// @internal (undocumented)
|
||||
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)
|
||||
export function CheckBoxToolbarItem(): JSX_2.Element;
|
||||
|
||||
|
@ -443,6 +461,9 @@ export const defaultShapeTools: (typeof ArrowShapeTool)[];
|
|||
// @public (undocumented)
|
||||
export const defaultShapeUtils: TLAnyShapeUtilConstructor[];
|
||||
|
||||
// @public (undocumented)
|
||||
export function DefaultSharePanel(): JSX_2.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export const DefaultStylePanel: NamedExoticComponent<TLUiStylePanelProps>;
|
||||
|
||||
|
@ -464,6 +485,9 @@ export interface DefaultToolbarProps {
|
|||
// @public (undocumented)
|
||||
export const defaultTools: (typeof EraserTool | typeof HandTool | typeof ZoomTool)[];
|
||||
|
||||
// @public (undocumented)
|
||||
export function DefaultTopPanel(): JSX_2.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export const DefaultZoomMenu: NamedExoticComponent<TLUiZoomMenuProps>;
|
||||
|
||||
|
@ -1264,7 +1288,7 @@ export interface PageItemInputProps {
|
|||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const PageItemSubmenu: MemoExoticComponent<({ index, listSize, item, onRename, }: PageItemSubmenuProps) => JSX_2.Element>;
|
||||
export const PageItemSubmenu: NamedExoticComponent<PageItemSubmenuProps>;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface PageItemSubmenuProps {
|
||||
|
@ -1293,6 +1317,15 @@ export function parseTldrawJsonFile({ json, schema, }: {
|
|||
// @public (undocumented)
|
||||
export function PasteMenuItem(): JSX_2.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export const PeopleMenu: NamedExoticComponent<PeopleMenuProps>;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface PeopleMenuProps {
|
||||
// (undocumented)
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export enum PORTRAIT_BREAKPOINT {
|
||||
// (undocumented)
|
||||
|
@ -1675,7 +1708,7 @@ export function TldrawScribble({ scribble, zoom, color, opacity, className }: TL
|
|||
export const TldrawSelectionBackground: ({ bounds, rotation }: TLSelectionBackgroundProps) => JSX_2.Element | null;
|
||||
|
||||
// @public (undocumented)
|
||||
export const TldrawSelectionForeground: MemoExoticComponent<({ bounds, rotation, }: TLSelectionForegroundProps) => JSX_2.Element | null>;
|
||||
export const TldrawSelectionForeground: NamedExoticComponent<TLSelectionForegroundProps>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function TldrawShapeIndicators(): JSX_2.Element | null;
|
||||
|
@ -1949,6 +1982,8 @@ export interface TLUiComponents {
|
|||
// (undocumented)
|
||||
ContextMenu?: ComponentType<TLUiContextMenuProps> | null;
|
||||
// (undocumented)
|
||||
CursorChatBubble?: ComponentType | null;
|
||||
// (undocumented)
|
||||
DebugMenu?: ComponentType | null;
|
||||
// (undocumented)
|
||||
DebugPanel?: ComponentType | null;
|
||||
|
@ -2174,6 +2209,8 @@ export interface TLUiEventMap {
|
|||
locale: string;
|
||||
};
|
||||
// (undocumented)
|
||||
'change-user-name': null;
|
||||
// (undocumented)
|
||||
'close-menu': {
|
||||
id: string;
|
||||
};
|
||||
|
@ -2264,6 +2301,8 @@ export interface TLUiEventMap {
|
|||
id: string;
|
||||
};
|
||||
// (undocumented)
|
||||
'set-color': null;
|
||||
// (undocumented)
|
||||
'set-style': {
|
||||
id: string;
|
||||
value: number | string;
|
||||
|
@ -2273,6 +2312,8 @@ export interface TLUiEventMap {
|
|||
operation: 'horizontal' | 'vertical';
|
||||
};
|
||||
// (undocumented)
|
||||
'start-following': null;
|
||||
// (undocumented)
|
||||
'stop-following': null;
|
||||
// (undocumented)
|
||||
'stretch-shapes': {
|
||||
|
|
|
@ -141,6 +141,8 @@ export {
|
|||
type TLUiQuickActionsProps,
|
||||
} from './lib/ui/components/QuickActions/DefaultQuickActions'
|
||||
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 {
|
||||
DefaultStylePanel,
|
||||
|
@ -196,6 +198,11 @@ export {
|
|||
useIsToolSelected,
|
||||
type ToolbarItemProps,
|
||||
} 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 {
|
||||
DefaultZoomMenu,
|
||||
type TLUiZoomMenuProps,
|
||||
|
|
|
@ -615,12 +615,17 @@
|
|||
height: fit-content;
|
||||
max-height: 100%;
|
||||
margin: 8px;
|
||||
margin-top: 4px;
|
||||
touch-action: auto;
|
||||
overscroll-behavior: none;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
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 {
|
||||
position: relative;
|
||||
|
@ -1579,3 +1584,193 @@
|
|||
flex: 1;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -109,6 +109,7 @@ const TldrawUiContent = React.memo(function TldrawUI() {
|
|||
NavigationPanel,
|
||||
HelperButtons,
|
||||
DebugPanel,
|
||||
CursorChatBubble,
|
||||
} = useTldrawUiComponents()
|
||||
|
||||
useKeyboardShortcuts()
|
||||
|
@ -164,6 +165,7 @@ const TldrawUiContent = React.memo(function TldrawUI() {
|
|||
<Dialogs />
|
||||
<ToastViewport />
|
||||
<FollowingIndicator />
|
||||
{CursorChatBubble && <CursorChatBubble />}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { useEditor, useValue } from '@tldraw/editor'
|
||||
import { useIsMultiplayer } from '../../hooks/useIsMultiplayer'
|
||||
import {
|
||||
ArrangeMenuSubmenu,
|
||||
ClipboardMenuGroup,
|
||||
ConversionsMenuGroup,
|
||||
CursorChatItem,
|
||||
EditMenuSubmenu,
|
||||
MoveToPageMenu,
|
||||
ReorderMenuSubmenu,
|
||||
|
@ -13,6 +15,7 @@ import { TldrawUiMenuGroup } from '../primitives/menus/TldrawUiMenuGroup'
|
|||
/** @public @react */
|
||||
export function DefaultContextMenuContent() {
|
||||
const editor = useEditor()
|
||||
const isMultiplayer = useIsMultiplayer()
|
||||
|
||||
const selectToolActive = useValue(
|
||||
'isSelectToolActive',
|
||||
|
@ -24,6 +27,7 @@ export function DefaultContextMenuContent() {
|
|||
|
||||
return (
|
||||
<>
|
||||
{isMultiplayer && <CursorChatItem />}
|
||||
<TldrawUiMenuGroup id="modify">
|
||||
<EditMenuSubmenu />
|
||||
<ArrangeMenuSubmenu />
|
||||
|
|
206
packages/tldraw/src/lib/ui/components/CursorChatBubble.tsx
Normal file
206
packages/tldraw/src/lib/ui/components/CursorChatBubble.tsx
Normal file
|
@ -0,0 +1,206 @@
|
|||
import { preventDefault, track, useEditor } from '@tldraw/editor'
|
||||
import {
|
||||
ChangeEvent,
|
||||
ClipboardEvent,
|
||||
KeyboardEvent,
|
||||
RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from '../hooks/useTranslation/useTranslation'
|
||||
|
||||
// todo:
|
||||
// - not cleaning up
|
||||
const CHAT_MESSAGE_TIMEOUT_CLOSING = 2000
|
||||
const CHAT_MESSAGE_TIMEOUT_CHATTING = 5000
|
||||
|
||||
export const CursorChatBubble = track(function CursorChatBubble() {
|
||||
const editor = useEditor()
|
||||
const { isChatting, chatMessage } = editor.getInstanceState()
|
||||
|
||||
const rTimeout = useRef<any>(-1)
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const closingUp = !isChatting && chatMessage
|
||||
if (closingUp || isChatting) {
|
||||
const duration = isChatting ? CHAT_MESSAGE_TIMEOUT_CHATTING : CHAT_MESSAGE_TIMEOUT_CLOSING
|
||||
rTimeout.current = editor.timers.setTimeout(() => {
|
||||
editor.updateInstanceState({ chatMessage: '', isChatting: false })
|
||||
setValue('')
|
||||
editor.focus()
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(rTimeout.current)
|
||||
}
|
||||
}, [editor, chatMessage, isChatting])
|
||||
|
||||
if (isChatting)
|
||||
return <CursorChatInput value={value} setValue={setValue} chatMessage={chatMessage} />
|
||||
|
||||
return chatMessage.trim() ? <NotEditingChatMessage chatMessage={chatMessage} /> : null
|
||||
})
|
||||
|
||||
function usePositionBubble(ref: RefObject<HTMLInputElement>) {
|
||||
const editor = useEditor()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const elm = ref.current
|
||||
if (!elm) return
|
||||
|
||||
const { x, y } = editor.inputs.currentScreenPoint
|
||||
ref.current?.style.setProperty('transform', `translate(${x}px, ${y}px)`)
|
||||
|
||||
// Positioning the chat bubble
|
||||
function positionChatBubble(e: PointerEvent) {
|
||||
const { minX, minY } = editor.getViewportScreenBounds()
|
||||
ref.current?.style.setProperty(
|
||||
'transform',
|
||||
`translate(${e.clientX - minX}px, ${e.clientY - minY}px)`
|
||||
)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', positionChatBubble)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', positionChatBubble)
|
||||
}
|
||||
}, [ref, editor])
|
||||
}
|
||||
|
||||
const NotEditingChatMessage = ({ chatMessage }: { chatMessage: string }) => {
|
||||
const editor = useEditor()
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
|
||||
usePositionBubble(ref)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="tl-cursor-chat tl-cursor-chat__bubble"
|
||||
style={{ backgroundColor: editor.user.getColor() }}
|
||||
>
|
||||
{chatMessage}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CursorChatInput = track(function CursorChatInput({
|
||||
chatMessage,
|
||||
value,
|
||||
setValue,
|
||||
}: {
|
||||
chatMessage: string
|
||||
value: string
|
||||
setValue: (value: string) => void
|
||||
}) {
|
||||
const editor = useEditor()
|
||||
const msg = useTranslation()
|
||||
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
const placeholder = chatMessage || msg('cursor-chat.type-to-chat')
|
||||
|
||||
usePositionBubble(ref)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const elm = ref.current
|
||||
if (!elm) return
|
||||
|
||||
const textMeasurement = editor.textMeasure.measureText(value || placeholder, {
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
fontStyle: 'normal',
|
||||
maxWidth: null,
|
||||
lineHeight: 1,
|
||||
padding: '6px',
|
||||
})
|
||||
|
||||
elm.style.setProperty('width', textMeasurement.w + 'px')
|
||||
}, [editor, value, placeholder])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Focus the input
|
||||
const raf = editor.timers.requestAnimationFrame(() => {
|
||||
ref.current?.focus()
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
const stopChatting = useCallback(() => {
|
||||
editor.updateInstanceState({ isChatting: false })
|
||||
editor.focus()
|
||||
}, [editor])
|
||||
|
||||
// Update the chat message as the user types
|
||||
const handleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = e.target
|
||||
setValue(value.slice(0, 64))
|
||||
editor.updateInstanceState({ chatMessage: value })
|
||||
},
|
||||
[editor, setValue]
|
||||
)
|
||||
|
||||
// Handle some keyboard shortcuts
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const elm = ref.current
|
||||
if (!elm) return
|
||||
|
||||
// get this from the element so that this hook doesn't depend on value
|
||||
const { value: currentValue } = elm
|
||||
|
||||
switch (e.key) {
|
||||
case 'Enter': {
|
||||
preventDefault(e)
|
||||
e.stopPropagation()
|
||||
|
||||
// If the user hasn't typed anything, stop chatting
|
||||
if (!currentValue) {
|
||||
stopChatting()
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, 'send' the message
|
||||
setValue('')
|
||||
break
|
||||
}
|
||||
case 'Escape': {
|
||||
preventDefault(e)
|
||||
e.stopPropagation()
|
||||
stopChatting()
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
[stopChatting, setValue]
|
||||
)
|
||||
|
||||
const handlePaste = useCallback((e: ClipboardEvent) => {
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={`tl-cursor-chat`}
|
||||
style={{ backgroundColor: editor.user.getColor() }}
|
||||
onBlur={stopChatting}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
/>
|
||||
)
|
||||
})
|
|
@ -1,4 +1,5 @@
|
|||
import { useActions } from '../../context/actions'
|
||||
import { useIsMultiplayer } from '../../hooks/useIsMultiplayer'
|
||||
import { useTools } from '../../hooks/useTools'
|
||||
import { TldrawUiMenuGroup } from '../primitives/menus/TldrawUiMenuGroup'
|
||||
import { TldrawUiMenuItem } from '../primitives/menus/TldrawUiMenuItem'
|
||||
|
@ -7,6 +8,7 @@ import { TldrawUiMenuItem } from '../primitives/menus/TldrawUiMenuItem'
|
|||
export function DefaultKeyboardShortcutsDialogContent() {
|
||||
const actions = useActions()
|
||||
const tools = useTools()
|
||||
const isMultiplayer = useIsMultiplayer()
|
||||
return (
|
||||
<>
|
||||
<TldrawUiMenuGroup label="shortcuts-dialog.tools" id="tools">
|
||||
|
@ -63,6 +65,11 @@ export function DefaultKeyboardShortcutsDialogContent() {
|
|||
<TldrawUiMenuItem {...actions['align-center-horizontal']} />
|
||||
<TldrawUiMenuItem {...actions['align-right']} />
|
||||
</TldrawUiMenuGroup>
|
||||
{isMultiplayer && (
|
||||
<TldrawUiMenuGroup label="shortcuts-dialog.collaboration" id="collaboration">
|
||||
<TldrawUiMenuItem {...actions['open-cursor-chat']} />
|
||||
</TldrawUiMenuGroup>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
import classNames from 'classnames'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from '../../hooks/useTranslation/useTranslation'
|
||||
import { TldrawUiIcon } from '../primitives/TldrawUiIcon'
|
||||
|
||||
/** @public @react */
|
||||
export function OfflineIndicator() {
|
||||
const msg = useTranslation()
|
||||
const rContainer = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<div className={classNames('tlui-offline-indicator')} ref={rContainer}>
|
||||
<div className={classNames('tlui-offline-indicator')}>
|
||||
{msg('status.offline')}
|
||||
<TldrawUiIcon aria-label="offline" icon="status-offline" small />
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
import { PeopleMenu } from './PeopleMenu'
|
||||
|
||||
/** @public @react */
|
||||
export function DefaultSharePanel() {
|
||||
return (
|
||||
<div className="tlui-share-zone" draggable={false}>
|
||||
<PeopleMenu />
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
import * as Popover from '@radix-ui/react-popover'
|
||||
import { track, useContainer, useEditor, usePeerIds, useValue } from '@tldraw/editor'
|
||||
import { ReactNode } from 'react'
|
||||
import { useMenuIsOpen } from '../../hooks/useMenuIsOpen'
|
||||
import { useTranslation } from '../../hooks/useTranslation/useTranslation'
|
||||
import { PeopleMenuAvatar } from './PeopleMenuAvatar'
|
||||
import { PeopleMenuItem } from './PeopleMenuItem'
|
||||
import { PeopleMenuMore } from './PeopleMenuMore'
|
||||
import { UserPresenceEditor } from './UserPresenceEditor'
|
||||
|
||||
/** @public */
|
||||
export interface PeopleMenuProps {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/** @public @react */
|
||||
export const PeopleMenu = track(function PeopleMenu({ children }: PeopleMenuProps) {
|
||||
const msg = useTranslation()
|
||||
|
||||
const container = useContainer()
|
||||
const editor = useEditor()
|
||||
|
||||
const userIds = usePeerIds()
|
||||
const userColor = useValue('user', () => editor.user.getColor(), [editor])
|
||||
const userName = useValue('user', () => editor.user.getName(), [editor])
|
||||
|
||||
const [isOpen, onOpenChange] = useMenuIsOpen('people menu')
|
||||
|
||||
return (
|
||||
<Popover.Root onOpenChange={onOpenChange} open={isOpen}>
|
||||
<Popover.Trigger dir="ltr" asChild>
|
||||
<button className="tlui-people-menu__avatars-button" title={msg('people-menu.title')}>
|
||||
{userIds.length > 5 && <PeopleMenuMore count={userIds.length - 5} />}
|
||||
<div className="tlui-people-menu__avatars">
|
||||
{userIds.slice(-5).map((userId) => (
|
||||
<PeopleMenuAvatar key={userId} userId={userId} />
|
||||
))}
|
||||
<div
|
||||
className="tlui-people-menu__avatar"
|
||||
style={{
|
||||
backgroundColor: userColor,
|
||||
}}
|
||||
>
|
||||
{userName === 'New User' ? '' : userName[0] ?? ''}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal container={container}>
|
||||
<Popover.Content
|
||||
dir="ltr"
|
||||
className="tlui-menu"
|
||||
align="end"
|
||||
side="bottom"
|
||||
sideOffset={2}
|
||||
alignOffset={-5}
|
||||
>
|
||||
<div className="tlui-people-menu__wrapper">
|
||||
<div className="tlui-people-menu__section">
|
||||
<UserPresenceEditor />
|
||||
</div>
|
||||
{userIds.length > 0 && (
|
||||
<div className="tlui-people-menu__section">
|
||||
{userIds.map((userId) => {
|
||||
return <PeopleMenuItem key={userId + '_presence'} userId={userId} />
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
})
|
|
@ -0,0 +1,18 @@
|
|||
import { usePresence } from '@tldraw/editor'
|
||||
|
||||
export function PeopleMenuAvatar({ userId }: { userId: string }) {
|
||||
const presence = usePresence(userId)
|
||||
|
||||
if (!presence) return null
|
||||
return (
|
||||
<div
|
||||
className="tlui-people-menu__avatar"
|
||||
key={userId}
|
||||
style={{
|
||||
backgroundColor: presence.color,
|
||||
}}
|
||||
>
|
||||
{presence.userName === 'New User' ? '' : presence.userName[0] ?? ''}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import { track, useEditor, usePresence } from '@tldraw/editor'
|
||||
import { useCallback } from 'react'
|
||||
import { useUiEvents } from '../../context/events'
|
||||
import { useTranslation } from '../../hooks/useTranslation/useTranslation'
|
||||
import { TldrawUiButton } from '../primitives/Button/TldrawUiButton'
|
||||
import { TldrawUiButtonIcon } from '../primitives/Button/TldrawUiButtonIcon'
|
||||
import { TldrawUiIcon } from '../primitives/TldrawUiIcon'
|
||||
|
||||
export const PeopleMenuItem = track(function PeopleMenuItem({ userId }: { userId: string }) {
|
||||
const editor = useEditor()
|
||||
const msg = useTranslation()
|
||||
const trackEvent = useUiEvents()
|
||||
|
||||
const presence = usePresence(userId)
|
||||
|
||||
const handleFollowClick = useCallback(() => {
|
||||
if (editor.getInstanceState().followingUserId === userId) {
|
||||
editor.stopFollowingUser()
|
||||
trackEvent('stop-following', { source: 'people-menu' })
|
||||
} else {
|
||||
editor.startFollowingUser(userId)
|
||||
trackEvent('start-following', { source: 'people-menu' })
|
||||
}
|
||||
}, [editor, userId, trackEvent])
|
||||
|
||||
const theyAreFollowingYou = presence?.followingUserId === editor.user.getId()
|
||||
const youAreFollowingThem = editor.getInstanceState().followingUserId === userId
|
||||
|
||||
if (!presence) return null
|
||||
|
||||
return (
|
||||
<div className="tlui-people-menu__item tlui-buttons__horizontal">
|
||||
<TldrawUiButton
|
||||
type="menu"
|
||||
className="tlui-people-menu__item__button"
|
||||
onClick={() => editor.zoomToUser(userId)}
|
||||
onDoubleClick={handleFollowClick}
|
||||
>
|
||||
<TldrawUiIcon icon="color" color={presence.color} />
|
||||
<div className="tlui-people-menu__name">{presence.userName ?? 'New User'}</div>
|
||||
</TldrawUiButton>
|
||||
<TldrawUiButton
|
||||
type="icon"
|
||||
className="tlui-people-menu__item__follow"
|
||||
title={
|
||||
theyAreFollowingYou
|
||||
? msg('people-menu.leading')
|
||||
: youAreFollowingThem
|
||||
? msg('people-menu.following')
|
||||
: msg('people-menu.follow')
|
||||
}
|
||||
onClick={handleFollowClick}
|
||||
disabled={theyAreFollowingYou}
|
||||
data-active={youAreFollowingThem || theyAreFollowingYou}
|
||||
>
|
||||
<TldrawUiButtonIcon
|
||||
icon={theyAreFollowingYou ? 'leading' : youAreFollowingThem ? 'following' : 'follow'}
|
||||
/>
|
||||
</TldrawUiButton>
|
||||
</div>
|
||||
)
|
||||
})
|
|
@ -0,0 +1,3 @@
|
|||
export function PeopleMenuMore({ count }: { count: number }) {
|
||||
return <div className="tlui-people-menu__more">{'+' + Math.abs(count)}</div>
|
||||
}
|
|
@ -0,0 +1,128 @@
|
|||
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 { useUiEvents } from '../../context/events'
|
||||
import { useTranslation } from '../../hooks/useTranslation/useTranslation'
|
||||
import { TldrawUiButton } from '../primitives/Button/TldrawUiButton'
|
||||
import { TldrawUiButtonIcon } from '../primitives/Button/TldrawUiButtonIcon'
|
||||
|
||||
export const UserPresenceColorPicker = track(function UserPresenceColorPicker() {
|
||||
const editor = useEditor()
|
||||
const container = useContainer()
|
||||
const msg = useTranslation()
|
||||
const trackEvent = useUiEvents()
|
||||
|
||||
const rPointing = useRef(false)
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||
setIsOpen(isOpen)
|
||||
}, [])
|
||||
|
||||
const value = editor.user.getColor()
|
||||
|
||||
const onValueChange = useCallback(
|
||||
(item: string) => {
|
||||
editor.user.updateUserPreferences({ color: item })
|
||||
trackEvent('set-color', { source: 'people-menu' })
|
||||
},
|
||||
[editor, trackEvent]
|
||||
)
|
||||
|
||||
const {
|
||||
handleButtonClick,
|
||||
handleButtonPointerDown,
|
||||
handleButtonPointerEnter,
|
||||
handleButtonPointerUp,
|
||||
} = React.useMemo(() => {
|
||||
const handlePointerUp = () => {
|
||||
rPointing.current = false
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
|
||||
const handleButtonClick = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const { id } = e.currentTarget.dataset
|
||||
if (!id) return
|
||||
if (value === id) return
|
||||
|
||||
onValueChange(id)
|
||||
}
|
||||
|
||||
const handleButtonPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const { id } = e.currentTarget.dataset
|
||||
if (!id) return
|
||||
|
||||
onValueChange(id)
|
||||
|
||||
rPointing.current = true
|
||||
window.addEventListener('pointerup', handlePointerUp) // see TLD-658
|
||||
}
|
||||
|
||||
const handleButtonPointerEnter = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (!rPointing.current) return
|
||||
|
||||
const { id } = e.currentTarget.dataset
|
||||
if (!id) return
|
||||
onValueChange(id)
|
||||
}
|
||||
|
||||
const handleButtonPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const { id } = e.currentTarget.dataset
|
||||
if (!id) return
|
||||
onValueChange(id)
|
||||
}
|
||||
|
||||
return {
|
||||
handleButtonClick,
|
||||
handleButtonPointerDown,
|
||||
handleButtonPointerEnter,
|
||||
handleButtonPointerUp,
|
||||
}
|
||||
}, [value, onValueChange])
|
||||
|
||||
return (
|
||||
<Popover.Root onOpenChange={handleOpenChange} open={isOpen}>
|
||||
<Popover.Trigger dir="ltr" asChild>
|
||||
<TldrawUiButton
|
||||
type="icon"
|
||||
className="tlui-people-menu__user__color"
|
||||
style={{ color: editor.user.getColor() }}
|
||||
title={msg('people-menu.change-color')}
|
||||
>
|
||||
<TldrawUiButtonIcon icon="color" />
|
||||
</TldrawUiButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal container={container}>
|
||||
<Popover.Content
|
||||
dir="ltr"
|
||||
className="tlui-menu tlui-people-menu__user__color-picker"
|
||||
align="start"
|
||||
side="left"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className={'tlui-buttons__grid'}>
|
||||
{USER_COLORS.map((item: string) => (
|
||||
<TldrawUiButton
|
||||
type="icon"
|
||||
key={item}
|
||||
data-id={item}
|
||||
data-testid={item}
|
||||
aria-label={item}
|
||||
data-state={value === item ? 'hinted' : undefined}
|
||||
title={item}
|
||||
className={'tlui-button-grid__button'}
|
||||
style={{ color: item }}
|
||||
onPointerEnter={handleButtonPointerEnter}
|
||||
onPointerDown={handleButtonPointerDown}
|
||||
onPointerUp={handleButtonPointerUp}
|
||||
onClick={handleButtonClick}
|
||||
>
|
||||
<TldrawUiButtonIcon icon="color" />
|
||||
</TldrawUiButton>
|
||||
))}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
})
|
|
@ -0,0 +1,80 @@
|
|||
import { useEditor, useValue } from '@tldraw/editor'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useUiEvents } from '../../context/events'
|
||||
import { useTranslation } from '../../hooks/useTranslation/useTranslation'
|
||||
import { TldrawUiButton } from '../primitives/Button/TldrawUiButton'
|
||||
import { TldrawUiButtonIcon } from '../primitives/Button/TldrawUiButtonIcon'
|
||||
import { TldrawUiInput } from '../primitives/TldrawUiInput'
|
||||
import { UserPresenceColorPicker } from './UserPresenceColorPicker'
|
||||
|
||||
export function UserPresenceEditor() {
|
||||
const editor = useEditor()
|
||||
const trackEvent = useUiEvents()
|
||||
const userName = useValue('userName', () => editor.user.getName(), [])
|
||||
const msg = useTranslation()
|
||||
|
||||
const rOriginalName = useRef(userName)
|
||||
const rCurrentName = useRef(userName)
|
||||
|
||||
// Whether the user is editing their name or not
|
||||
const [isEditingName, setIsEditingName] = useState(false)
|
||||
const toggleEditingName = useCallback(() => {
|
||||
setIsEditingName((s) => !s)
|
||||
}, [])
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(value: string) => {
|
||||
rCurrentName.current = value
|
||||
editor.user.updateUserPreferences({ name: value })
|
||||
},
|
||||
[editor]
|
||||
)
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
if (rOriginalName.current === rCurrentName.current) return
|
||||
trackEvent('change-user-name', { source: 'people-menu' })
|
||||
rOriginalName.current = rCurrentName.current
|
||||
}, [trackEvent])
|
||||
|
||||
return (
|
||||
<div className="tlui-people-menu__user">
|
||||
<UserPresenceColorPicker />
|
||||
{isEditingName ? (
|
||||
<TldrawUiInput
|
||||
className="tlui-people-menu__user__input"
|
||||
defaultValue={userName}
|
||||
onValueChange={handleValueChange}
|
||||
onComplete={toggleEditingName}
|
||||
onCancel={toggleEditingName}
|
||||
onBlur={handleBlur}
|
||||
shouldManuallyMaintainScrollPositionWhenFocused
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="tlui-people-menu__user__name"
|
||||
onDoubleClick={() => {
|
||||
if (!isEditingName) setIsEditingName(true)
|
||||
}}
|
||||
>
|
||||
{userName}
|
||||
</div>
|
||||
{userName === 'New User' ? (
|
||||
<div className="tlui-people-menu__user__label">{msg('people-menu.user')}</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<TldrawUiButton
|
||||
type="icon"
|
||||
className="tlui-people-menu__user__edit"
|
||||
data-testid="people-menu.change-name"
|
||||
title={msg('people-menu.change-name')}
|
||||
onClick={toggleEditingName}
|
||||
>
|
||||
<TldrawUiButtonIcon icon={isEditingName ? 'check' : 'edit'} />
|
||||
</TldrawUiButton>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -646,3 +646,19 @@ export function PrintItem() {
|
|||
])
|
||||
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']} />
|
||||
}
|
||||
|
|
|
@ -30,6 +30,7 @@ import { useCopyAs } from '../hooks/useCopyAs'
|
|||
import { useExportAs } from '../hooks/useExportAs'
|
||||
import { flattenShapesToImages } from '../hooks/useFlatten'
|
||||
import { useInsertMedia } from '../hooks/useInsertMedia'
|
||||
import { useIsMultiplayer } from '../hooks/useIsMultiplayer'
|
||||
import { usePrint } from '../hooks/usePrint'
|
||||
import { TLUiTranslationKey } from '../hooks/useTranslation/TLUiTranslationKey'
|
||||
import { useTranslation } from '../hooks/useTranslation/useTranslation'
|
||||
|
@ -84,6 +85,7 @@ function getExportName(editor: Editor, defaultName: string) {
|
|||
/** @internal */
|
||||
export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
||||
const editor = useEditor()
|
||||
const isMultiplayer = useIsMultiplayer()
|
||||
|
||||
const { addDialog, clearDialogs } = useDialogs()
|
||||
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)
|
||||
|
||||
if (overrides) {
|
||||
|
@ -1448,6 +1472,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|||
printSelectionOrPages,
|
||||
msg,
|
||||
defaultDocumentName,
|
||||
isMultiplayer,
|
||||
])
|
||||
|
||||
return <ActionsContext.Provider value={asActions(actions)}>{children}</ActionsContext.Provider>
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
DefaultContextMenu,
|
||||
TLUiContextMenuProps,
|
||||
} from '../components/ContextMenu/DefaultContextMenu'
|
||||
import { CursorChatBubble } from '../components/CursorChatBubble'
|
||||
import { DefaultDebugMenu } from '../components/DebugMenu/DefaultDebugMenu'
|
||||
import { DefaultDebugPanel } from '../components/DefaultDebugPanel'
|
||||
import { DefaultHelpMenu, TLUiHelpMenuProps } from '../components/HelpMenu/DefaultHelpMenu'
|
||||
|
@ -28,9 +29,12 @@ import {
|
|||
DefaultQuickActions,
|
||||
TLUiQuickActionsProps,
|
||||
} from '../components/QuickActions/DefaultQuickActions'
|
||||
import { DefaultSharePanel } from '../components/SharePanel/DefaultSharePanel'
|
||||
import { DefaultStylePanel, TLUiStylePanelProps } from '../components/StylePanel/DefaultStylePanel'
|
||||
import { DefaultToolbar } from '../components/Toolbar/DefaultToolbar'
|
||||
import { DefaultTopPanel } from '../components/TopPanel/DefaultTopPanel'
|
||||
import { DefaultZoomMenu, TLUiZoomMenuProps } from '../components/ZoomMenu/DefaultZoomMenu'
|
||||
import { useIsMultiplayer } from '../hooks/useIsMultiplayer'
|
||||
|
||||
/** @public */
|
||||
export interface TLUiComponents {
|
||||
|
@ -52,6 +56,7 @@ export interface TLUiComponents {
|
|||
MenuPanel?: ComponentType | null
|
||||
TopPanel?: ComponentType | null
|
||||
SharePanel?: ComponentType | null
|
||||
CursorChatBubble?: ComponentType | null
|
||||
}
|
||||
|
||||
const TldrawUiComponentsContext = createContext<TLUiComponents | null>(null)
|
||||
|
@ -68,6 +73,7 @@ export function TldrawUiComponentsProvider({
|
|||
children,
|
||||
}: TLUiComponentsProviderProps) {
|
||||
const _overrides = useShallowObjectIdentity(overrides)
|
||||
const isMultiplayer = useIsMultiplayer()
|
||||
|
||||
return (
|
||||
<TldrawUiComponentsContext.Provider
|
||||
|
@ -89,9 +95,12 @@ export function TldrawUiComponentsProvider({
|
|||
DebugPanel: DefaultDebugPanel,
|
||||
DebugMenu: DefaultDebugMenu,
|
||||
MenuPanel: DefaultMenuPanel,
|
||||
SharePanel: isMultiplayer ? DefaultSharePanel : null,
|
||||
CursorChatBubble: isMultiplayer ? CursorChatBubble : null,
|
||||
TopPanel: isMultiplayer ? DefaultTopPanel : null,
|
||||
..._overrides,
|
||||
}),
|
||||
[_overrides]
|
||||
[_overrides, isMultiplayer]
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
|
|
@ -94,7 +94,10 @@ export interface TLUiEventMap {
|
|||
'toggle-edge-scrolling': null
|
||||
'color-scheme': { value: string }
|
||||
'exit-pen-mode': null
|
||||
'start-following': null
|
||||
'stop-following': null
|
||||
'set-color': null
|
||||
'change-user-name': null
|
||||
'open-cursor-chat': null
|
||||
'zoom-tool': null
|
||||
'unlock-all': null
|
||||
|
|
20
packages/tldraw/src/lib/ui/hooks/useIsMultiplayer.ts
Normal file
20
packages/tldraw/src/lib/ui/hooks/useIsMultiplayer.ts
Normal 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]
|
||||
)
|
||||
}
|
|
@ -1499,6 +1499,8 @@ export interface TLStoreProps {
|
|||
assets: TLAssetStore;
|
||||
// (undocumented)
|
||||
defaultName: string;
|
||||
// (undocumented)
|
||||
multiplayerStatus: null | Signal<'offline' | 'online'>;
|
||||
onEditorMount: (editor: unknown) => (() => void) | void;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { Signal } from '@tldraw/state'
|
||||
import {
|
||||
SerializedStore,
|
||||
Store,
|
||||
|
@ -98,6 +99,7 @@ export interface TLStoreProps {
|
|||
* Called an {@link @tldraw/editor#Editor} connected to this store is mounted.
|
||||
*/
|
||||
onEditorMount: (editor: unknown) => void | (() => void)
|
||||
multiplayerStatus: Signal<'online' | 'offline'> | null
|
||||
}
|
||||
|
||||
/** @public */
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue