Composable custom UI (#2796)

This PR refactors our menu systems and provides an interface to hide or
replace individual user interface elements.

# Background

Previously, we've had two types of overrides:
- "schema" overrides that would allow insertion or replacement of items
in the different menus
- "component" overrides that would replace components in the editor's
user interface

This PR is an attempt to unify the two and to provide for additional
cases where the "schema-based" user interface had begun to break down.

# Approach

This PR makes no attempt to change the `actions` or `tools`
overrides—the current system seems to be correct for those because they
are not reactive. The challenge with the other ui schemas is that they
_are_ reactive, and thus the overrides both need to a) be fed in from
outside of the editor as props, and b) react to changes from the editor,
which is an impossible situation.

The new approach is to use React to declare menu items. (Surprise!) 

```tsx
function CustomHelpMenuContent() {
	return (
		<>
			<DefaultHelpMenuContent />
			<TldrawUiMenuGroup id="custom stuff">
				<TldrawUiMenuItem
					id="about"
					label="Like my posts"
					icon="external-link"
					readonlyOk
					onSelect={() => {
						window.open('https://x.com/tldraw', '_blank')
					}}
				/>
			</TldrawUiMenuGroup>
		</>
	)
}

const components: TLComponents = {
	HelpMenuContent: CustomHelpMenuContent,
}

export default function CustomHelpMenuContentExample() {
	return (
		<div className="tldraw__editor">
			<Tldraw components={components} />
		</div>
	)
}
```

We use a `components` prop with the combined editor and ui components.

- [ ] Create a "layout" component?
- [ ] Make UI components more isolated? If possible, they shouldn't
depend on styles outside of themselves, so that they can be used in
other layouts. Maybe we wait on this because I'm feeling a slippery
slope toward presumptions about configurability.
- [ ] OTOH maybe we go hard and consider these things as separate
components, even packages, with their own interfaces for customizability
/ configurability, just go all the way with it, and see what that looks
like.

# Pros

Top line: you can customize tldraw's user interface in a MUCH more
granular / powerful way than before.

It solves a case where menu items could not be made stateful from
outside of the editor context, and provides the option to do things in
the menus that we couldn't allow previously with the "schema-based"
approach.

It also may (who knows) be more performant because we can locate the
state inside of the components for individual buttons and groups,
instead of all at the top level above the "schema". Because items /
groups decide their own state, we don't have to have big checks on how
many items are selected, or whether we have a flippable state. Items and
groups themselves are allowed to re-build as part of the regular React
lifecycle. Menus aren't constantly being rebuilt, if that were ever an
issue.

Menu items can be shared between different menu types. We'll are
sometimes able to re-use items between, for example, the menu and the
context menu and the actions menu.

Our overrides no longer mutate anything, so there's less weird searching
and finding.

# Cons

This approach can make customization menu contents significantly more
complex, as an end user would need to re-declare most of a menu in order
to make any change to it. Luckily a user can add things to the top or
bottom of the context menu fairly easily. (And who knows, folks may
actually want to do deep customization, and this allows for it.)

It's more code. We are shipping more react components, basically one for
each menu item / group.

Currently this PR does not export the subcomponents, i.e. menu items. If
we do want to export these, then heaven help us, it's going to be a
_lot_ of exports.

# Progress 

- [x] Context menu
- [x] Main menu
- [x] Zoom menu
- [x] Help menu
- [x] Actions menu
- [x] Keyboard shortcuts menu
- [x] Quick actions in main menu? (new)
- [x] Helper buttons? (new)
- [x] Debug Menu

And potentially
- [x] Toolbar
- [x] Style menu
- [ ] Share zone
- [x] Navigation zone
- [ ] Other zones

### Change Type

- [x] `major` — Breaking change

### Test Plan

1. use the context menu
2. use the custom context menu example
3. use cursor chat in the context menu

- [x] Unit Tests
- [ ] End to end tests

### Release Notes

- Add a brief release note for your PR here.
This commit is contained in:
Steve Ruiz 2024-02-15 12:10:09 +00:00 committed by GitHub
parent 5faac660bc
commit ac0259a6af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
189 changed files with 10501 additions and 7418 deletions

View file

@ -19,3 +19,10 @@ apps/example/www/index.css
apps/docs/.next apps/docs/.next
packages/tldraw/tldraw.css packages/tldraw/tldraw.css
**/dist-cjs/**/*
**/dist-esm/**/*
**/*.js.map
**/*.api.json
apps/docs/utils/vector-db
packages/**/api

View file

@ -87,7 +87,6 @@ const myOverrides: TLUiOverrides = {
icon: 'color', icon: 'color',
label: 'tools.card', label: 'tools.card',
kbd: 'c', kbd: 'c',
readonlyOk: false,
onSelect: () => { onSelect: () => {
// Whatever you want to happen when the tool is selected // Whatever you want to happen when the tool is selected
editor.setCurrentTool('card') editor.setCurrentTool('card')

View file

@ -155,33 +155,35 @@ The [Tldraw](?) component combines two lower-level components: [TldrawEditor](?)
### Customize the default components ### Customize the default components
You can customize the appearance of the tldraw editor using the [Tldraw](?) (or [TldrawEditor](?) component's `components` prop. You can customize the appearance of the tldraw editor and ui using the [Tldraw](?) (or [TldrawEditor](?)) component's `components` prop.
```tsx ```tsx
<Tldraw
components={{ const components: TLComponents = {
Background: YourCustomBackground, Background: YourCustomBackground,
SvgDefs: YourCustomSvgDefs, SvgDefs: YourCustomSvgDefs,
Brush: YourCustomBrush, Brush: YourCustomBrush,
ZoomBrush: YourCustomBrush, ZoomBrush: YourCustomBrush,
CollaboratorBrush: YourCustomBrush, CollaboratorBrush: YourCustomBrush,
Cursor: YourCustomCursor, Cursor: YourCustomCursor,
CollaboratorCursor: YourCustomCursor, CollaboratorCursor: YourCustomCursor,
CollaboratorHint: YourCustomCollaboratorHint, CollaboratorHint: YourCustomCollaboratorHint,
CollaboratorShapeIndicator: YourCustomdicator, CollaboratorShapeIndicator: YourCustomdicator,
Grid: YourCustomGrid, Grid: YourCustomGrid,
Scribble: YourCustomScribble, Scribble: YourCustomScribble,
SnapLine: YourCustomSnapLine, SnapLine: YourCustomSnapLine,
Handles: YourCustomHandles, Handles: YourCustomHandles,
Handle: YourCustomHandle, Handle: YourCustomHandle,
CollaboratorScribble: YourCustomScribble, CollaboratorScribble: YourCustomScribble,
ErrorFallback: YourCustomErrorFallback, ErrorFallback: YourCustomErrorFallback,
ShapeErrorFallback: YourCustomShapeErrorFallback, ShapeErrorFallback: YourCustomShapeErrorFallback,
ShapeIndicatorErrorFallback: YourCustomShapeIndicatorErrorFallback, ShapeIndicatorErrorFallback: YourCustomShapeIndicatorErrorFallback,
Spinner: YourCustomSpinner, Spinner: YourCustomSpinner,
SelectionBackground: YourCustomSelectionBackground, SelectionBackground: YourCustomSelectionBackground,
SelectionForeground: YourCustomSelectionForeground, SelectionForeground: YourCustomSelectionForeground,
HoveredShapeIndicator: YourCustomHoveredShapeIndicator, HoveredShapeIndicator: YourCustomHoveredShapeIndicator,
}} // ...
/> }
<Tldraw components={components}/>
``` ```

View file

@ -1,5 +1,14 @@
import * as Popover from '@radix-ui/react-popover' import * as Popover from '@radix-ui/react-popover'
import { Button, useActions, useContainer, useEditor, useTranslation } from '@tldraw/tldraw' import {
TldrawUiMenuContextProvider,
TldrawUiMenuGroup,
TldrawUiMenuItem,
unwrapLabel,
useActions,
useContainer,
useEditor,
useTranslation,
} from '@tldraw/tldraw'
import React, { useState } from 'react' import React, { useState } from 'react'
import { useShareMenuIsOpen } from '../hooks/useShareMenuOpen' import { useShareMenuIsOpen } from '../hooks/useShareMenuOpen'
import { SHARE_PROJECT_ACTION, SHARE_SNAPSHOT_ACTION } from '../utils/sharing' import { SHARE_PROJECT_ACTION, SHARE_SNAPSHOT_ACTION } from '../utils/sharing'
@ -33,50 +42,39 @@ export const ExportMenu = React.memo(function ExportMenu() {
side="bottom" side="bottom"
sideOffset={6} sideOffset={6}
> >
<div className="tlui-menu__group"> <TldrawUiMenuContextProvider type="panel" sourceId="export-menu">
<Button <TldrawUiMenuGroup id="share">
type="menu" <TldrawUiMenuItem {...shareProject} />
label={shareProject.label} <p className="tlui-menu__group tlui-share-zone__details">
icon={'share-1'} {msg('share-menu.fork-note')}
onClick={() => { </p>
shareProject.onSelect('export-menu') </TldrawUiMenuGroup>
}} <TldrawUiMenuGroup id="snapshot">
/> <TldrawUiMenuItem
<p className="tlui-menu__group tlui-share-zone__details"> id="copy-to-clipboard"
{msg('share-menu.fork-note')} readonlyOk
</p> icon={didCopySnapshotLink ? 'clipboard-copied' : 'clipboard-copy'}
</div> label={unwrapLabel(shareSnapshot.label)}
<div className="tlui-menu__group"> onSelect={async () => {
<Button setIsUploadingSnapshot(true)
type="menu" await shareSnapshot.onSelect('share-menu')
icon={didCopySnapshotLink ? 'clipboard-copied' : 'clipboard-copy'} setIsUploadingSnapshot(false)
label={shareSnapshot.label!} setDidCopySnapshotLink(true)
onClick={async () => { setTimeout(() => setDidCopySnapshotLink(false), 1000)
setIsUploadingSnapshot(true) }}
await shareSnapshot.onSelect('share-menu') spinner={isUploadingSnapshot}
setIsUploadingSnapshot(false) />
setDidCopySnapshotLink(true) <p className="tlui-menu__group tlui-share-zone__details">
setTimeout(() => setDidCopySnapshotLink(false), 1000) {msg('share-menu.snapshot-link-note')}
}} </p>
spinner={isUploadingSnapshot} </TldrawUiMenuGroup>
/> <TldrawUiMenuGroup id="save">
<p className="tlui-menu__group tlui-share-zone__details"> <TldrawUiMenuItem {...saveFileCopyAction} />
{msg('share-menu.snapshot-link-note')} <p className="tlui-menu__group tlui-share-zone__details">
</p> {msg('share-menu.save-note')}
</div> </p>
<div className="tlui-menu__group"> </TldrawUiMenuGroup>
<Button </TldrawUiMenuContextProvider>
type="menu"
label={saveFileCopyAction.label}
icon={'share-2'}
onClick={() => {
saveFileCopyAction.onSelect('export-menu')
}}
/>
<p className="tlui-menu__group tlui-share-zone__details">
{msg('share-menu.save-note')}
</p>
</div>
</Popover.Content> </Popover.Content>
</Popover.Portal> </Popover.Portal>
</Popover.Root> </Popover.Root>

View file

@ -0,0 +1,45 @@
import {
TldrawUiMenuGroup,
TldrawUiMenuItem,
TldrawUiMenuSubmenu,
useActions,
} from '@tldraw/tldraw'
import {
FORK_PROJECT_ACTION,
LEAVE_SHARED_PROJECT_ACTION,
SHARE_PROJECT_ACTION,
} from '../utils/sharing'
import { NEW_PROJECT_ACTION, OPEN_FILE_ACTION, SAVE_FILE_COPY_ACTION } from '../utils/useFileSystem'
export function LocalFileMenu() {
const actions = useActions()
return (
<TldrawUiMenuSubmenu id="file" label="menu.file">
<TldrawUiMenuGroup id="file-actions">
<TldrawUiMenuItem {...actions[SAVE_FILE_COPY_ACTION]} />
<TldrawUiMenuItem {...actions[OPEN_FILE_ACTION]} />
<TldrawUiMenuItem {...actions[NEW_PROJECT_ACTION]} />
</TldrawUiMenuGroup>
<TldrawUiMenuGroup id="share">
<TldrawUiMenuItem {...actions[SHARE_PROJECT_ACTION]} />
</TldrawUiMenuGroup>
</TldrawUiMenuSubmenu>
)
}
export function MultiplayerFileMenu() {
const actions = useActions()
return (
<TldrawUiMenuSubmenu id="file" label="menu.file">
<TldrawUiMenuGroup id="file-actions">
<TldrawUiMenuItem {...actions[SAVE_FILE_COPY_ACTION]} />
</TldrawUiMenuGroup>
<TldrawUiMenuGroup id="share">
<TldrawUiMenuItem {...actions[FORK_PROJECT_ACTION]} />
<TldrawUiMenuItem {...actions[LEAVE_SHARED_PROJECT_ACTION]} />
</TldrawUiMenuGroup>
</TldrawUiMenuSubmenu>
)
}

View file

@ -0,0 +1,45 @@
import { TldrawUiMenuGroup, TldrawUiMenuItem } from '@tldraw/tldraw'
import { openUrl } from '../utils/url'
export function Links() {
return (
<TldrawUiMenuGroup id="links">
<TldrawUiMenuItem
id="github"
label="help-menu.github"
readonlyOk
icon="github"
onSelect={() => {
openUrl('https://github.com/tldraw/tldraw')
}}
/>
<TldrawUiMenuItem
id="twitter"
label="help-menu.twitter"
icon="twitter"
readonlyOk
onSelect={() => {
openUrl('https://twitter.com/tldraw')
}}
/>
<TldrawUiMenuItem
id="discord"
label="help-menu.discord"
icon="discord"
readonlyOk
onSelect={() => {
openUrl('https://discord.gg/SBBEVCA4PG')
}}
/>
<TldrawUiMenuItem
id="about"
label="help-menu.about"
icon="external-link"
readonlyOk
onSelect={() => {
openUrl('https://tldraw.dev')
}}
/>
</TldrawUiMenuGroup>
)
}

View file

@ -1,21 +1,77 @@
import { Editor, Tldraw } from '@tldraw/tldraw' import {
DefaultDebugMenu,
DefaultDebugMenuContent,
DefaultHelpMenu,
DefaultHelpMenuContent,
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
DefaultMainMenu,
DefaultMainMenuContent,
Editor,
TLComponents,
Tldraw,
TldrawUiMenuGroup,
TldrawUiMenuItem,
useActions,
} from '@tldraw/tldraw'
import { useCallback } from 'react' import { useCallback } from 'react'
import { assetUrls } from '../utils/assetUrls' import { assetUrls } from '../utils/assetUrls'
import { createAssetFromUrl } from '../utils/createAssetFromUrl' import { createAssetFromUrl } from '../utils/createAssetFromUrl'
import { linksUiOverrides } from '../utils/links'
import { DebugMenuItems } from '../utils/migration/DebugMenuItems' import { DebugMenuItems } from '../utils/migration/DebugMenuItems'
import { LocalMigration } from '../utils/migration/LocalMigration' import { LocalMigration } from '../utils/migration/LocalMigration'
import { SCRATCH_PERSISTENCE_KEY } from '../utils/scratch-persistence-key' import { SCRATCH_PERSISTENCE_KEY } from '../utils/scratch-persistence-key'
import { useSharing } from '../utils/sharing' import { useSharing } from '../utils/sharing'
import { 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 { LocalFileMenu } from './FileMenu'
import { Links } from './Links'
import { ShareMenu } from './ShareMenu' import { ShareMenu } from './ShareMenu'
import { SneakyOnDropOverride } from './SneakyOnDropOverride' import { SneakyOnDropOverride } from './SneakyOnDropOverride'
import { ThemeUpdater } from './ThemeUpdater/ThemeUpdater' import { ThemeUpdater } from './ThemeUpdater/ThemeUpdater'
const components: TLComponents = {
ErrorFallback: ({ error }) => {
throw error
},
HelpMenu: () => (
<DefaultHelpMenu>
<TldrawUiMenuGroup id="help">
<DefaultHelpMenuContent />
</TldrawUiMenuGroup>
<Links />
</DefaultHelpMenu>
),
MainMenu: () => (
<DefaultMainMenu>
<LocalFileMenu />
<DefaultMainMenuContent />
</DefaultMainMenu>
),
KeyboardShortcutsDialog: (props) => {
const actions = useActions()
return (
<DefaultKeyboardShortcutsDialog {...props}>
<TldrawUiMenuGroup id="shortcuts-dialog.file">
<TldrawUiMenuItem {...actions[SAVE_FILE_COPY_ACTION]} />
<TldrawUiMenuItem {...actions[OPEN_FILE_ACTION]} />
</TldrawUiMenuGroup>
<DefaultKeyboardShortcutsDialogContent />
</DefaultKeyboardShortcutsDialog>
)
},
DebugMenu: () => {
return (
<DefaultDebugMenu>
<DefaultDebugMenuContent />
<DebugMenuItems />
</DefaultDebugMenu>
)
},
}
export function LocalEditor() { export function LocalEditor() {
const handleUiEvent = useHandleUiEvents() const handleUiEvent = useHandleUiEvents()
const sharingUiOverrides = useSharing({ isMultiplayer: false }) const sharingUiOverrides = useSharing()
const fileSystemUiOverrides = useFileSystem({ isMultiplayer: false }) const fileSystemUiOverrides = useFileSystem({ isMultiplayer: false })
const handleMount = useCallback((editor: Editor) => { const handleMount = useCallback((editor: Editor) => {
@ -29,19 +85,14 @@ export function LocalEditor() {
persistenceKey={SCRATCH_PERSISTENCE_KEY} persistenceKey={SCRATCH_PERSISTENCE_KEY}
onMount={handleMount} onMount={handleMount}
autoFocus autoFocus
overrides={[sharingUiOverrides, fileSystemUiOverrides, linksUiOverrides]} overrides={[sharingUiOverrides, fileSystemUiOverrides]}
onUiEvent={handleUiEvent} onUiEvent={handleUiEvent}
components={{ components={components}
ErrorFallback: ({ error }) => {
throw error
},
}}
shareZone={ shareZone={
<div className="tlui-share-zone" draggable={false}> <div className="tlui-share-zone" draggable={false}>
<ShareMenu /> <ShareMenu />
</div> </div>
} }
renderDebugMenuItems={() => <DebugMenuItems />}
inferDarkMode inferDarkMode
> >
<LocalMigration /> <LocalMigration />

View file

@ -1,25 +1,85 @@
import { Editor, OfflineIndicator, Tldraw, lns } from '@tldraw/tldraw' import {
DefaultContextMenu,
DefaultContextMenuContent,
DefaultHelpMenu,
DefaultHelpMenuContent,
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
DefaultMainMenu,
DefaultMainMenuContent,
Editor,
OfflineIndicator,
TLComponents,
Tldraw,
TldrawUiMenuGroup,
TldrawUiMenuItem,
lns,
useActions,
} from '@tldraw/tldraw'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import { useRemoteSyncClient } from '../hooks/useRemoteSyncClient' import { useRemoteSyncClient } from '../hooks/useRemoteSyncClient'
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 { createAssetFromFile } from '../utils/createAssetFromFile' import { createAssetFromFile } from '../utils/createAssetFromFile'
import { createAssetFromUrl } from '../utils/createAssetFromUrl' import { createAssetFromUrl } from '../utils/createAssetFromUrl'
import { linksUiOverrides } from '../utils/links'
import { useSharing } from '../utils/sharing' import { useSharing } from '../utils/sharing'
import { trackAnalyticsEvent } from '../utils/trackAnalyticsEvent' import { trackAnalyticsEvent } from '../utils/trackAnalyticsEvent'
import { useCursorChat } from '../utils/useCursorChat' import { CURSOR_CHAT_ACTION, useCursorChat } from '../utils/useCursorChat'
import { 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 { CursorChatBubble } from './CursorChatBubble'
import { EmbeddedInIFrameWarning } from './EmbeddedInIFrameWarning' import { EmbeddedInIFrameWarning } from './EmbeddedInIFrameWarning'
import { MultiplayerFileMenu } from './FileMenu'
import { Links } from './Links'
import { PeopleMenu } from './PeopleMenu/PeopleMenu' 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 components: TLComponents = {
ErrorFallback: ({ error }) => {
throw error
},
ContextMenu: (props) => (
<DefaultContextMenu {...props}>
<CursorChatMenuItem />
<DefaultContextMenuContent />
</DefaultContextMenu>
),
HelpMenu: () => (
<DefaultHelpMenu>
<TldrawUiMenuGroup id="help">
<DefaultHelpMenuContent />
</TldrawUiMenuGroup>
<Links />
</DefaultHelpMenu>
),
MainMenu: () => (
<DefaultMainMenu>
<MultiplayerFileMenu />
<DefaultMainMenuContent />
</DefaultMainMenu>
),
KeyboardShortcutsDialog: (props) => {
const actions = useActions()
return (
<DefaultKeyboardShortcutsDialog {...props}>
<TldrawUiMenuGroup id="shortcuts-dialog.file">
<TldrawUiMenuItem {...actions[SAVE_FILE_COPY_ACTION]} />
<TldrawUiMenuItem {...actions[OPEN_FILE_ACTION]} />
</TldrawUiMenuGroup>
<DefaultKeyboardShortcutsDialogContent />
<TldrawUiMenuGroup id="shortcuts-dialog.collaboration">
<TldrawUiMenuItem {...actions[CURSOR_CHAT_ACTION]} />
</TldrawUiMenuGroup>
</DefaultKeyboardShortcutsDialog>
)
},
}
export function MultiplayerEditor({ export function MultiplayerEditor({
isReadOnly, isReadOnly,
roomSlug, roomSlug,
@ -37,7 +97,7 @@ export function MultiplayerEditor({
}) })
const isEmbedded = useIsEmbedded(roomSlug) const isEmbedded = useIsEmbedded(roomSlug)
const sharingUiOverrides = useSharing({ isMultiplayer: true }) const sharingUiOverrides = useSharing()
const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true }) const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true })
const cursorChatOverrides = useCursorChat() const cursorChatOverrides = useCursorChat()
@ -67,19 +127,10 @@ export function MultiplayerEditor({
store={storeWithStatus} store={storeWithStatus}
assetUrls={assetUrls} assetUrls={assetUrls}
onMount={handleMount} onMount={handleMount}
overrides={[sharingUiOverrides, fileSystemUiOverrides, cursorChatOverrides]}
initialState={isReadOnly ? 'hand' : 'select'} initialState={isReadOnly ? 'hand' : 'select'}
overrides={[
sharingUiOverrides,
fileSystemUiOverrides,
linksUiOverrides,
cursorChatOverrides,
]}
onUiEvent={handleUiEvent} onUiEvent={handleUiEvent}
components={{ components={components}
ErrorFallback: ({ error }) => {
throw error
},
}}
topZone={isOffline && <OfflineIndicator />} topZone={isOffline && <OfflineIndicator />}
shareZone={ shareZone={
<div className="tlui-share-zone" draggable={false}> <div className="tlui-share-zone" draggable={false}>

View file

@ -1,5 +1,14 @@
import * as Popover from '@radix-ui/react-popover' import * as Popover from '@radix-ui/react-popover'
import { Button, lns, useActions, useContainer, useTranslation } from '@tldraw/tldraw' import {
TldrawUiMenuContextProvider,
TldrawUiMenuGroup,
TldrawUiMenuItem,
lns,
unwrapLabel,
useActions,
useContainer,
useTranslation,
} from '@tldraw/tldraw'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useShareMenuIsOpen } from '../hooks/useShareMenuOpen' import { useShareMenuIsOpen } from '../hooks/useShareMenuOpen'
import { createQRCodeImageDataString } from '../utils/qrcode' import { createQRCodeImageDataString } from '../utils/qrcode'
@ -105,114 +114,118 @@ export const ShareMenu = React.memo(function ShareMenu() {
sideOffset={2} sideOffset={2}
alignOffset={4} alignOffset={4}
> >
{shareState.state === 'shared' || shareState.state === 'readonly' ? ( <TldrawUiMenuContextProvider type="panel" sourceId="share-menu">
<> {shareState.state === 'shared' || shareState.state === 'readonly' ? (
<button <>
className="tlui-share-zone__qr-code" <button
style={{ backgroundImage: `url(${currentQrCodeUrl})` }} className="tlui-share-zone__qr-code"
title={msg( style={{ backgroundImage: `url(${currentQrCodeUrl})` }}
isReadOnlyLink ? 'share-menu.copy-readonly-link' : 'share-menu.copy-link' title={msg(
)} isReadOnlyLink ? 'share-menu.copy-readonly-link' : 'share-menu.copy-link'
onClick={() => { )}
setDidCopy(true)
setTimeout(() => setDidCopy(false), 1000)
navigator.clipboard.writeText(currentShareLinkUrl)
}}
/>
<div className="tlui-menu__group">
<Button
type="menu"
icon={didCopy ? 'clipboard-copied' : 'clipboard-copy'}
label={isReadOnlyLink ? 'share-menu.copy-readonly-link' : 'share-menu.copy-link'}
onClick={() => { onClick={() => {
setDidCopy(true) setDidCopy(true)
setTimeout(() => setDidCopy(false), 750) setTimeout(() => setDidCopy(false), 1000)
navigator.clipboard.writeText(currentShareLinkUrl) navigator.clipboard.writeText(currentShareLinkUrl)
}} }}
/> />
{shareState.state === 'shared' && ( <TldrawUiMenuGroup id="copy">
<Button <TldrawUiMenuItem
type="menu" id="copy-to-clipboard"
label="share-menu.readonly-link" readonlyOk
icon={isReadOnlyLink ? 'check' : 'checkbox-empty'} icon={didCopy ? 'clipboard-copied' : 'clipboard-copy'}
onClick={async () => { label={
setIsReadOnlyLink(() => !isReadOnlyLink) isReadOnlyLink ? 'share-menu.copy-readonly-link' : 'share-menu.copy-link'
}
onSelect={() => {
setDidCopy(true)
setTimeout(() => setDidCopy(false), 750)
navigator.clipboard.writeText(currentShareLinkUrl)
}} }}
/> />
)} {shareState.state === 'shared' && (
<p className="tlui-menu__group tlui-share-zone__details"> <TldrawUiMenuItem
{msg( id="toggle-read-only"
isReadOnlyLink label="share-menu.readonly-link"
? 'share-menu.copy-readonly-link-note' icon={isReadOnlyLink ? 'check' : 'checkbox-empty'}
: 'share-menu.copy-link-note' onSelect={async () => {
setIsReadOnlyLink(() => !isReadOnlyLink)
}}
/>
)} )}
</p> <p className="tlui-menu__group tlui-share-zone__details">
</div> {msg(
isReadOnlyLink
<div className="tlui-menu__group">
<Button
type="menu"
icon={didCopySnapshotLink ? 'clipboard-copied' : 'clipboard-copy'}
label={shareSnapshot.label!}
onClick={async () => {
setIsUploadingSnapshot(true)
await shareSnapshot.onSelect('share-menu')
setIsUploadingSnapshot(false)
setDidCopySnapshotLink(true)
setTimeout(() => setDidCopySnapshotLink(false), 1000)
}}
spinner={isUploadingSnapshot}
/>
<p className="tlui-menu__group tlui-share-zone__details">
{msg('share-menu.snapshot-link-note')}
</p>
</div>
</>
) : (
<>
<div className="tlui-menu__group">
<Button
type="menu"
label="share-menu.share-project"
icon="share-1"
onClick={async () => {
if (isUploading) return
setIsUploading(true)
await shareProject.onSelect('menu')
setIsUploading(false)
}}
spinner={isUploading}
/>
<p className="tlui-menu__group tlui-share-zone__details">
{msg(
shareState.state === 'offline'
? 'share-menu.offline-note'
: isReadOnlyLink
? 'share-menu.copy-readonly-link-note' ? 'share-menu.copy-readonly-link-note'
: 'share-menu.copy-link-note' : 'share-menu.copy-link-note'
)} )}
</p> </p>
</div> </TldrawUiMenuGroup>
<div className="tlui-menu__group">
<Button <TldrawUiMenuGroup id="snapshot">
type="menu" <TldrawUiMenuItem
icon={didCopySnapshotLink ? 'clipboard-copied' : 'clipboard-copy'} {...shareSnapshot}
label={shareSnapshot.label!} icon={didCopySnapshotLink ? 'clipboard-copied' : 'clipboard-copy'}
onClick={async () => { onSelect={async () => {
setIsUploadingSnapshot(true) setIsUploadingSnapshot(true)
await shareSnapshot.onSelect('share-menu') await shareSnapshot.onSelect('share-menu')
setIsUploadingSnapshot(false) setIsUploadingSnapshot(false)
setDidCopySnapshotLink(true) setDidCopySnapshotLink(true)
setTimeout(() => setDidCopySnapshotLink(false), 1000) setTimeout(() => setDidCopySnapshotLink(false), 1000)
}} }}
spinner={isUploadingSnapshot} spinner={isUploadingSnapshot}
/> />
<p className="tlui-menu__group tlui-share-zone__details"> <p className="tlui-menu__group tlui-share-zone__details">
{msg('share-menu.snapshot-link-note')} {msg('share-menu.snapshot-link-note')}
</p> </p>
</div> </TldrawUiMenuGroup>
</> </>
)} ) : (
<>
<TldrawUiMenuGroup id="share">
<TldrawUiMenuItem
id="share-project"
label="share-menu.share-project"
icon="share-1"
onSelect={async () => {
if (isUploading) return
setIsUploading(true)
await shareProject.onSelect('menu')
setIsUploading(false)
}}
spinner={isUploading}
/>
<p className="tlui-menu__group tlui-share-zone__details">
{msg(
shareState.state === 'offline'
? 'share-menu.offline-note'
: isReadOnlyLink
? 'share-menu.copy-readonly-link-note'
: 'share-menu.copy-link-note'
)}
</p>
</TldrawUiMenuGroup>
<TldrawUiMenuGroup id="copy-snapshot-link">
<TldrawUiMenuItem
id="copy-snapshot-link"
icon={didCopySnapshotLink ? 'clipboard-copied' : 'clipboard-copy'}
label={unwrapLabel(shareSnapshot.label)}
onSelect={async () => {
setIsUploadingSnapshot(true)
await shareSnapshot.onSelect('share-menu')
setIsUploadingSnapshot(false)
setDidCopySnapshotLink(true)
setTimeout(() => setDidCopySnapshotLink(false), 1000)
}}
spinner={isUploadingSnapshot}
/>
<p className="tlui-menu__group tlui-share-zone__details">
{msg('share-menu.snapshot-link-note')}
</p>
</TldrawUiMenuGroup>
</>
)}
</TldrawUiMenuContextProvider>
</Popover.Content> </Popover.Content>
</Popover.Portal> </Popover.Portal>
</Popover.Root> </Popover.Root>

View file

@ -1,14 +1,60 @@
import { SerializedSchema, TLRecord, Tldraw } from '@tldraw/tldraw' import {
DefaultHelpMenu,
DefaultHelpMenuContent,
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
DefaultMainMenu,
DefaultMainMenuContent,
SerializedSchema,
TLComponents,
TLRecord,
Tldraw,
TldrawUiMenuGroup,
TldrawUiMenuItem,
useActions,
} from '@tldraw/tldraw'
import { UrlStateSync } from '../components/MultiplayerEditor' import { UrlStateSync } from '../components/MultiplayerEditor'
import { StoreErrorScreen } from '../components/StoreErrorScreen' import { StoreErrorScreen } from '../components/StoreErrorScreen'
import { useLocalStore } from '../hooks/useLocalStore' import { useLocalStore } from '../hooks/useLocalStore'
import { assetUrls } from '../utils/assetUrls' import { assetUrls } from '../utils/assetUrls'
import { linksUiOverrides } from '../utils/links'
import { DebugMenuItems } from '../utils/migration/DebugMenuItems' import { DebugMenuItems } from '../utils/migration/DebugMenuItems'
import { useSharing } from '../utils/sharing' import { useSharing } from '../utils/sharing'
import { useFileSystem } from '../utils/useFileSystem' import { SAVE_FILE_COPY_ACTION, useFileSystem } from '../utils/useFileSystem'
import { useHandleUiEvents } from '../utils/useHandleUiEvent' import { useHandleUiEvents } from '../utils/useHandleUiEvent'
import { ExportMenu } from './ExportMenu' import { ExportMenu } from './ExportMenu'
import { MultiplayerFileMenu } from './FileMenu'
import { Links } from './Links'
const components: TLComponents = {
ErrorFallback: ({ error }) => {
throw error
},
HelpMenu: () => (
<DefaultHelpMenu>
<TldrawUiMenuGroup id="help">
<DefaultHelpMenuContent />
</TldrawUiMenuGroup>
<Links />
</DefaultHelpMenu>
),
MainMenu: () => (
<DefaultMainMenu>
<MultiplayerFileMenu />
<DefaultMainMenuContent />
</DefaultMainMenu>
),
KeyboardShortcutsDialog: (props) => {
const actions = useActions()
return (
<DefaultKeyboardShortcutsDialog {...props}>
<TldrawUiMenuGroup id="shortcuts-dialog.file">
<TldrawUiMenuItem {...actions[SAVE_FILE_COPY_ACTION]} />
</TldrawUiMenuGroup>
<DefaultKeyboardShortcutsDialogContent />
</DefaultKeyboardShortcutsDialog>
)
},
}
type SnapshotEditorProps = { type SnapshotEditorProps = {
schema: SerializedSchema schema: SerializedSchema
@ -17,7 +63,7 @@ type SnapshotEditorProps = {
export function SnapshotsEditor(props: SnapshotEditorProps) { export function SnapshotsEditor(props: SnapshotEditorProps) {
const handleUiEvent = useHandleUiEvents() const handleUiEvent = useHandleUiEvents()
const sharingUiOverrides = useSharing({ isMultiplayer: true }) const sharingUiOverrides = useSharing()
const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true }) const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true })
const storeResult = useLocalStore(props.records, props.schema) const storeResult = useLocalStore(props.records, props.schema)
if (!storeResult?.ok) return <StoreErrorScreen error={new Error(storeResult?.error)} /> if (!storeResult?.ok) return <StoreErrorScreen error={new Error(storeResult?.error)} />
@ -27,16 +73,12 @@ export function SnapshotsEditor(props: SnapshotEditorProps) {
<Tldraw <Tldraw
assetUrls={assetUrls} assetUrls={assetUrls}
store={storeResult.value} store={storeResult.value}
overrides={[sharingUiOverrides, fileSystemUiOverrides, linksUiOverrides]} overrides={[sharingUiOverrides, fileSystemUiOverrides]}
onUiEvent={handleUiEvent} onUiEvent={handleUiEvent}
onMount={(editor) => { onMount={(editor) => {
editor.updateInstanceState({ isReadonly: true }) editor.updateInstanceState({ isReadonly: true })
}} }}
components={{ components={components}
ErrorFallback: ({ error }) => {
throw error
},
}}
shareZone={ shareZone={
<div className="tlui-share-zone" draggable={false}> <div className="tlui-share-zone" draggable={false}>
<ExportMenu /> <ExportMenu />

View file

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

View file

@ -1,56 +0,0 @@
import { menuGroup, menuItem, TLUiOverrides } from '@tldraw/tldraw'
export const GITHUB_URL = 'https://github.com/tldraw/tldraw'
const linksMenuGroup = menuGroup(
'links',
menuItem({
id: 'github',
label: 'help-menu.github',
readonlyOk: true,
icon: 'github',
onSelect() {
window.open(GITHUB_URL)
},
}),
menuItem({
id: 'twitter',
label: 'help-menu.twitter',
icon: 'twitter',
readonlyOk: true,
onSelect() {
window.open('https://twitter.com/tldraw')
},
}),
menuItem({
id: 'discord',
label: 'help-menu.discord',
icon: 'discord',
readonlyOk: true,
onSelect() {
window.open('https://discord.gg/SBBEVCA4PG')
},
}),
menuItem({
id: 'about',
label: 'help-menu.about',
icon: 'external-link',
readonlyOk: true,
onSelect() {
window.open('https://www.tldraw.dev')
},
})
)!
export const linksUiOverrides: TLUiOverrides = {
helpMenu(editor, schema) {
schema.push(linksMenuGroup)
return schema
},
menu(editor, schema, { isMobile }) {
if (isMobile) {
schema.push(linksMenuGroup)
}
return schema
},
}

View file

@ -1,30 +1,28 @@
import { DropdownMenu } from '@tldraw/tldraw' import { TldrawUiMenuGroup, TldrawUiMenuItem } from '@tldraw/tldraw'
import { env } from '../env' import { env } from '../env'
const RELEASE_INFO = `${env} ${process.env.NEXT_PUBLIC_TLDRAW_RELEASE_INFO ?? 'unreleased'}` const RELEASE_INFO = `${env} ${process.env.NEXT_PUBLIC_TLDRAW_RELEASE_INFO ?? 'unreleased'}`
export function DebugMenuItems() { export function DebugMenuItems() {
return ( return (
<DropdownMenu.Group> <TldrawUiMenuGroup id="release">
<DropdownMenu.Item <TldrawUiMenuItem
type="menu" id="release-info"
onClick={() => { title={`${RELEASE_INFO}`}
label="Version"
onSelect={() => {
window.alert(`${RELEASE_INFO}`) window.alert(`${RELEASE_INFO}`)
}} }}
title={`${RELEASE_INFO}`} />
> <TldrawUiMenuItem
Version id="v1"
</DropdownMenu.Item> label="Test v1 content"
<DropdownMenu.Item onSelect={async () => {
type="menu"
onClick={async () => {
const { writeV1ContentsToIdb } = await import('./writeV1ContentsToIdb') const { writeV1ContentsToIdb } = await import('./writeV1ContentsToIdb')
await writeV1ContentsToIdb() await writeV1ContentsToIdb()
window.location.reload() window.location.reload()
}} }}
> />
Test v1 content </TldrawUiMenuGroup>
</DropdownMenu.Item>
</DropdownMenu.Group>
) )
} }

View file

@ -12,11 +12,7 @@ import {
TLUiOverrides, TLUiOverrides,
TLUiToastsContextType, TLUiToastsContextType,
TLUiTranslationKey, TLUiTranslationKey,
assert,
findMenuItem,
isShape, isShape,
menuGroup,
menuItem,
} from '@tldraw/tldraw' } from '@tldraw/tldraw'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
@ -30,8 +26,9 @@ import { UI_OVERRIDE_TODO_EVENT, useHandleUiEvents } from './useHandleUiEvent'
export const SHARE_PROJECT_ACTION = 'share-project' as const export const SHARE_PROJECT_ACTION = 'share-project' as const
export const SHARE_SNAPSHOT_ACTION = 'share-snapshot' as const export const SHARE_SNAPSHOT_ACTION = 'share-snapshot' as const
const LEAVE_SHARED_PROJECT_ACTION = 'leave-shared-project' as const export const LEAVE_SHARED_PROJECT_ACTION = 'leave-shared-project' as const
export const FORK_PROJECT_ACTION = 'fork-project' as const export const FORK_PROJECT_ACTION = 'fork-project' as const
const CREATE_SNAPSHOT_ENDPOINT = `/api/snapshots` const CREATE_SNAPSHOT_ENDPOINT = `/api/snapshots`
const SNAPSHOT_UPLOAD_URL = `/api/new-room` const SNAPSHOT_UPLOAD_URL = `/api/new-room`
@ -93,7 +90,7 @@ async function getSnapshotLink(
}) })
} }
export function useSharing({ isMultiplayer }: { isMultiplayer: boolean }): TLUiOverrides { export function useSharing(): TLUiOverrides {
const navigate = useNavigate() const navigate = useNavigate()
const id = useSearchParams()[0].get('id') ?? undefined const id = useSearchParams()[0].get('id') ?? undefined
const uploadFileToAsset = useMultiplayerAssets(ASSET_UPLOADER_URL) const uploadFileToAsset = useMultiplayerAssets(ASSET_UPLOADER_URL)
@ -188,24 +185,8 @@ export function useSharing({ isMultiplayer }: { isMultiplayer: boolean }): TLUiO
} }
return actions return actions
}, },
menu(editor, menu, { actions }) {
const fileMenu = findMenuItem(menu, ['menu', 'file'])
assert(fileMenu.type === 'submenu')
if (isMultiplayer) {
fileMenu.children.unshift(
menuGroup(
'share',
menuItem(actions[FORK_PROJECT_ACTION]),
menuItem(actions[LEAVE_SHARED_PROJECT_ACTION])
)!
)
} else {
fileMenu.children.unshift(menuGroup('share', menuItem(actions[SHARE_PROJECT_ACTION]))!)
}
return menu
},
}), }),
[handleUiEvent, navigate, uploadFileToAsset, id, isMultiplayer] [handleUiEvent, navigate, uploadFileToAsset, id]
) )
} }

View file

@ -1,4 +1,13 @@
import { Button, Dialog, TLUiDialogsContextType, useTranslation } from '@tldraw/tldraw' import {
Button,
DialogBody,
DialogCloseButton,
DialogFooter,
DialogHeader,
DialogTitle,
TLUiDialogsContextType,
useTranslation,
} from '@tldraw/tldraw'
import { useState } from 'react' import { useState } from 'react'
import { userPreferences } from './userPreferences' import { userPreferences } from './userPreferences'
@ -40,14 +49,14 @@ function ConfirmClearDialog({
const [dontShowAgain, setDontShowAgain] = useState(false) const [dontShowAgain, setDontShowAgain] = useState(false)
return ( return (
<> <>
<Dialog.Header> <DialogHeader>
<Dialog.Title>{msg('file-system.confirm-clear.title')}</Dialog.Title> <DialogTitle>{msg('file-system.confirm-clear.title')}</DialogTitle>
<Dialog.CloseButton /> <DialogCloseButton />
</Dialog.Header> </DialogHeader>
<Dialog.Body style={{ maxWidth: 350 }}> <DialogBody style={{ maxWidth: 350 }}>
{msg('file-system.confirm-clear.description')} {msg('file-system.confirm-clear.description')}
</Dialog.Body> </DialogBody>
<Dialog.Footer className="tlui-dialog__footer__actions"> <DialogFooter className="tlui-dialog__footer__actions">
<Button <Button
type="normal" type="normal"
onClick={() => setDontShowAgain(!dontShowAgain)} onClick={() => setDontShowAgain(!dontShowAgain)}
@ -70,7 +79,7 @@ function ConfirmClearDialog({
> >
{msg('file-system.confirm-clear.continue')} {msg('file-system.confirm-clear.continue')}
</Button> </Button>
</Dialog.Footer> </DialogFooter>
</> </>
) )
} }

View file

@ -1,6 +1,10 @@
import { import {
Button, Button,
Dialog, DialogBody,
DialogCloseButton,
DialogFooter,
DialogHeader,
DialogTitle,
TLUiDialogsContextType, TLUiDialogsContextType,
useLocalStorageState, useLocalStorageState,
useTranslation, useTranslation,
@ -46,14 +50,12 @@ function ConfirmLeaveDialog({
return ( return (
<> <>
<Dialog.Header> <DialogHeader>
<Dialog.Title>{msg('sharing.confirm-leave.title')}</Dialog.Title> <DialogTitle>{msg('sharing.confirm-leave.title')}</DialogTitle>
<Dialog.CloseButton /> <DialogCloseButton />
</Dialog.Header> </DialogHeader>
<Dialog.Body style={{ maxWidth: 350 }}> <DialogBody style={{ maxWidth: 350 }}>{msg('sharing.confirm-leave.description')}</DialogBody>
{msg('sharing.confirm-leave.description')} <DialogFooter className="tlui-dialog__footer__actions">
</Dialog.Body>
<Dialog.Footer className="tlui-dialog__footer__actions">
<Button <Button
type="normal" type="normal"
onClick={() => setDontShowAgain(!dontShowAgain)} onClick={() => setDontShowAgain(!dontShowAgain)}
@ -76,7 +78,7 @@ function ConfirmLeaveDialog({
> >
{msg('sharing.confirm-leave.leave')} {msg('sharing.confirm-leave.leave')}
</Button> </Button>
</Dialog.Footer> </DialogFooter>
</> </>
) )
} }

View file

@ -1,4 +1,13 @@
import { Button, Dialog, TLUiDialogsContextType, useTranslation } from '@tldraw/tldraw' import {
Button,
DialogBody,
DialogCloseButton,
DialogFooter,
DialogHeader,
DialogTitle,
TLUiDialogsContextType,
useTranslation,
} from '@tldraw/tldraw'
import { useState } from 'react' import { useState } from 'react'
import { userPreferences } from './userPreferences' import { userPreferences } from './userPreferences'
@ -40,14 +49,14 @@ function ConfirmOpenDialog({
const [dontShowAgain, setDontShowAgain] = useState(false) const [dontShowAgain, setDontShowAgain] = useState(false)
return ( return (
<> <>
<Dialog.Header> <DialogHeader>
<Dialog.Title>{msg('file-system.confirm-open.title')}</Dialog.Title> <DialogTitle>{msg('file-system.confirm-open.title')}</DialogTitle>
<Dialog.CloseButton /> <DialogCloseButton />
</Dialog.Header> </DialogHeader>
<Dialog.Body style={{ maxWidth: 350 }}> <DialogBody style={{ maxWidth: 350 }}>
{msg('file-system.confirm-open.description')} {msg('file-system.confirm-open.description')}
</Dialog.Body> </DialogBody>
<Dialog.Footer className="tlui-dialog__footer__actions"> <DialogFooter className="tlui-dialog__footer__actions">
<Button <Button
type="normal" type="normal"
onClick={() => setDontShowAgain(!dontShowAgain)} onClick={() => setDontShowAgain(!dontShowAgain)}
@ -70,7 +79,7 @@ function ConfirmOpenDialog({
> >
{msg('file-system.confirm-open.open')} {msg('file-system.confirm-open.open')}
</Button> </Button>
</Dialog.Footer> </DialogFooter>
</> </>
) )
} }

View file

@ -0,0 +1,3 @@
export function openUrl(url: string) {
window.open(url, '_blank')
}

View file

@ -1,4 +1,4 @@
import { TLUiOverrides, menuGroup, menuItem } from '@tldraw/tldraw' import { TLUiOverrides } from '@tldraw/tldraw'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useHandleUiEvents } from './useHandleUiEvent' import { useHandleUiEvents } from './useHandleUiEvent'
@ -27,36 +27,6 @@ export function useCursorChat(): TLUiOverrides {
} }
return actions return actions
}, },
contextMenu(editor, contextMenu, { actions }) {
if (editor.getSelectedShapes().length > 0 || editor.getInstanceState().isCoarsePointer) {
return contextMenu
}
const cursorChatGroup = menuGroup('cursor-chat', menuItem(actions[CURSOR_CHAT_ACTION]))
if (!cursorChatGroup) {
return contextMenu
}
const clipboardGroupIndex = contextMenu.findIndex((group) => group.id === 'clipboard-group')
if (clipboardGroupIndex === -1) {
contextMenu.push(cursorChatGroup)
return contextMenu
}
contextMenu.splice(clipboardGroupIndex + 1, 0, cursorChatGroup)
return contextMenu
},
keyboardShortcutsMenu(editor, keyboardShortcutsMenu, { actions }) {
const group = menuGroup(
'shortcuts-dialog.collaboration',
menuItem(actions[CURSOR_CHAT_ACTION])
)
if (!group) {
return keyboardShortcutsMenu
}
keyboardShortcutsMenu.push(group)
return keyboardShortcutsMenu
},
}), }),
[handleUiEvent] [handleUiEvent]
) )

View file

@ -5,10 +5,6 @@ import {
TLUiActionItem, TLUiActionItem,
TLUiEventHandler, TLUiEventHandler,
TLUiOverrides, TLUiOverrides,
assert,
findMenuItem,
menuGroup,
menuItem,
parseAndLoadDocument, parseAndLoadDocument,
serializeTldrawJsonBlob, serializeTldrawJsonBlob,
transact, transact,
@ -19,9 +15,9 @@ import { shouldClearDocument } from './shouldClearDocument'
import { shouldOverrideDocument } from './shouldOverrideDocument' import { shouldOverrideDocument } from './shouldOverrideDocument'
import { useHandleUiEvents } from './useHandleUiEvent' import { useHandleUiEvents } from './useHandleUiEvent'
const SAVE_FILE_COPY_ACTION = 'save-file-copy' export const SAVE_FILE_COPY_ACTION = 'save-file-copy'
const OPEN_FILE_ACTION = 'open-file' export const OPEN_FILE_ACTION = 'open-file'
const NEW_PROJECT_ACTION = 'new-file' export const NEW_PROJECT_ACTION = 'new-file'
const saveFileNames = new WeakMap<TLStore, string>() const saveFileNames = new WeakMap<TLStore, string>()
@ -92,31 +88,6 @@ export function useFileSystem({ isMultiplayer }: { isMultiplayer: boolean }): TL
} }
return actions return actions
}, },
menu(editor, menu, { actions }) {
const fileMenu = findMenuItem(menu, ['menu', 'file'])
assert(fileMenu.type === 'submenu')
const saveItem = menuItem(actions[SAVE_FILE_COPY_ACTION])
const openItem = menuItem(actions[OPEN_FILE_ACTION])
const newItem = menuItem(actions[NEW_PROJECT_ACTION])
const group = isMultiplayer
? // open is not currently supported in multiplayer
menuGroup('filesystem', saveItem)
: menuGroup('filesystem', newItem, openItem, saveItem)
fileMenu.children.unshift(group!)
return menu
},
keyboardShortcutsMenu(editor, menu, { actions }) {
const fileItems = findMenuItem(menu, ['shortcuts-dialog.file'])
assert(fileItems.type === 'group')
fileItems.children.unshift(menuItem(actions[SAVE_FILE_COPY_ACTION]))
if (!isMultiplayer) {
fileItems.children.unshift(menuItem(actions[OPEN_FILE_ACTION]))
}
return menu
},
} }
}, [isMultiplayer, handleUiEvent]) }, [isMultiplayer, handleUiEvent])
} }

View file

@ -0,0 +1,42 @@
import test, { Page, expect } from '@playwright/test'
import { setupPage, setupPageWithShapes } from '../shared-e2e'
declare const __tldraw_ui_event: { name: string }
// We're just testing the events, not the actual results.
let page: Page
test.describe('Context menu', async () => {
test.beforeEach(async ({ browser }) => {
page = await browser.newPage()
await setupPage(page)
await setupPageWithShapes(page)
})
test('distribute horizontal', async () => {
// distribute horizontal
await page.keyboard.press('Control+a')
await page.mouse.click(200, 200, { button: 'right' })
await page.getByTestId('context-menu-sub-trigger.arrange').click()
await page.getByTestId('context-menu.distribute-horizontal').focus()
await page.keyboard.press('Enter')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'distribute-shapes',
data: { operation: 'horizontal', source: 'context-menu' },
})
})
test('distribute vertical', async () => {
// distribute vertical — Shift+Alt+V
await page.keyboard.press('Control+a')
await page.mouse.click(200, 200, { button: 'right' })
await page.getByTestId('context-menu-sub-trigger.arrange').click()
await page.getByTestId('context-menu.distribute-vertical').focus()
await page.keyboard.press('Enter')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'distribute-shapes',
data: { operation: 'vertical', source: 'context-menu' },
})
})
})

View file

@ -1,10 +1,13 @@
import test, { Page, expect } from '@playwright/test' import test from '@playwright/test'
import { Editor, TLShapeId, TLShapePartial } from '@tldraw/tldraw' import { TLShapeId, TLShapePartial } from '@tldraw/tldraw'
import assert from 'assert'
import { rename, writeFile } from 'fs/promises'
import { setupPage } from '../shared-e2e'
declare const editor: Editor // import test, { Page, expect } from '@playwright/test'
// import assert from 'assert'
// import { rename, writeFile } from 'fs/promises'
// import { setupPage } from '../shared-e2e'
// import { Editor, TLShapeId, TLShapePartial } from '@tldraw/tldraw'
// declare const editor: Editor
test.describe('Export snapshots', () => { test.describe('Export snapshots', () => {
const snapshots = { const snapshots = {
@ -186,50 +189,50 @@ test.describe('Export snapshots', () => {
] ]
} }
const snapshotsToTest = Object.entries(snapshots) // const snapshotsToTest = Object.entries(snapshots)
const filteredSnapshots = snapshotsToTest // maybe we filter these down, there are a lot of them // const filteredSnapshots = snapshotsToTest // maybe we filter these down, there are a lot of them
for (const [name, shapes] of filteredSnapshots) { // for (const [name, shapes] of filteredSnapshots) {
test(`Exports with ${name} in dark mode`, async ({ browser }) => { // test(`Exports with ${name} in dark mode`, async ({ browser }) => {
const page = await browser.newPage() // const page = await browser.newPage()
await setupPage(page) // await setupPage(page)
await page.evaluate((shapes) => { // await page.evaluate((shapes) => {
editor.user.updateUserPreferences({ isDarkMode: true }) // editor.user.updateUserPreferences({ isDarkMode: true })
editor // editor
.updateInstanceState({ exportBackground: false }) // .updateInstanceState({ exportBackground: false })
.selectAll() // .selectAll()
.deleteShapes(editor.getSelectedShapeIds()) // .deleteShapes(editor.getSelectedShapeIds())
.createShapes(shapes) // .createShapes(shapes)
}, shapes as any) // }, shapes as any)
await snapshotTest(page) // await snapshotTest(page)
}) // })
} // }
async function snapshotTest(page: Page) { // async function snapshotTest(page: Page) {
const downloadAndSnapshot = page.waitForEvent('download').then(async (download) => { // const downloadAndSnapshot = page.waitForEvent('download').then(async (download) => {
const path = (await download.path()) as string // const path = (await download.path()) as string
assert(path) // assert(path)
await rename(path, path + '.svg') // await rename(path, path + '.svg')
await writeFile( // await writeFile(
path + '.html', // path + '.html',
` // `
<!DOCTYPE html> // <!DOCTYPE html>
<meta charset="utf-8" /> // <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> // <meta name="viewport" content="width=device-width, initial-scale=1" />
<img src="${path}.svg" /> // <img src="${path}.svg" />
`, // `,
'utf-8' // 'utf-8'
) // )
await page.goto(`file://${path}.html`) // await page.goto(`file://${path}.html`)
const clip = await page.$eval('img', (img) => img.getBoundingClientRect()) // const clip = await page.$eval('img', (img) => img.getBoundingClientRect())
await expect(page).toHaveScreenshot({ // await expect(page).toHaveScreenshot({
omitBackground: true, // omitBackground: true,
clip, // clip,
}) // })
}) // })
await page.evaluate(() => (window as any)['tldraw-export']()) // await page.evaluate(() => (window as any)['tldraw-export']())
await downloadAndSnapshot // await downloadAndSnapshot
} // }
}) })

View file

@ -46,12 +46,12 @@ test.describe.skip('clipboard tests', () => {
expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1) expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1)
await page.getByTestId('main.menu').click() await page.getByTestId('main.menu').click()
await page.getByTestId('menu-item.edit').click() await page.getByTestId('main-menu-sub-trigger.edit').click()
await page.getByTestId('menu-item.copy').click() await page.getByTestId('main-menu.copy').click()
await sleep(100) await sleep(100)
await page.getByTestId('main.menu').click() await page.getByTestId('main.menu').click()
await page.getByTestId('menu-item.edit').click() await page.getByTestId('main-menu-sub-trigger.edit').click()
await page.getByTestId('menu-item.paste').click() await page.getByTestId('main-menu.paste').click()
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2) expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2)
expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1) expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1)
@ -67,11 +67,11 @@ test.describe.skip('clipboard tests', () => {
expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1) expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1)
await page.mouse.click(100, 100, { button: 'right' }) await page.mouse.click(100, 100, { button: 'right' })
await page.getByTestId('menu-item.copy').click() await page.getByTestId('main-menu.copy').click()
await sleep(100) await sleep(100)
await page.mouse.move(200, 200) await page.mouse.move(200, 200)
await page.mouse.click(100, 100, { button: 'right' }) await page.mouse.click(100, 100, { button: 'right' })
await page.getByTestId('menu-item.paste').click() await page.getByTestId('main-menu.paste').click()
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2) expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2)
expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1) expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1)

View file

@ -364,38 +364,6 @@ test.describe('Actions on shapes', () => {
}) })
}) })
test.describe('Context menu', async () => {
test.beforeEach(async ({ browser }) => {
page = await browser.newPage()
await setupPage(page)
await setupPageWithShapes(page)
})
test('distribute horizontal', async () => {
// distribute horizontal
await page.keyboard.press('Control+a')
await page.mouse.click(200, 200, { button: 'right' })
await page.getByTestId('menu-item.arrange').click()
await page.getByTestId('menu-item.distribute-horizontal').click()
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'distribute-shapes',
data: { operation: 'horizontal', source: 'context-menu' },
})
})
test('distribute vertical', async () => {
// distribute vertical — Shift+Alt+V
await page.keyboard.press('Control+a')
await page.mouse.click(200, 200, { button: 'right' })
await page.getByTestId('menu-item.arrange').click()
await page.getByTestId('menu-item.distribute-vertical').click()
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'distribute-shapes',
data: { operation: 'vertical', source: 'context-menu' },
})
})
})
test.describe('Delete bug', () => { test.describe('Delete bug', () => {
test.beforeEach(async ({ browser }) => { test.beforeEach(async ({ browser }) => {
page = await browser.newPage() page = await browser.newPage()

View file

@ -26,8 +26,8 @@ test.describe('smoke tests', () => {
test('undo and redo', async ({ page }) => { test('undo and redo', async ({ page }) => {
// buttons should be disabled when there is no history // buttons should be disabled when there is no history
expect(page.getByTestId('main.undo')).toBeDisabled() expect(page.getByTestId('quick-actions.undo')).toBeDisabled()
expect(page.getByTestId('main.redo')).toBeDisabled() expect(page.getByTestId('quick-actions.redo')).toBeDisabled()
// create a shape // create a shape
await page.keyboard.press('r') await page.keyboard.press('r')
@ -39,22 +39,22 @@ test.describe('smoke tests', () => {
expect(await getAllShapeTypes(page)).toEqual(['geo']) expect(await getAllShapeTypes(page)).toEqual(['geo'])
// We should have an undoable shape // We should have an undoable shape
expect(page.getByTestId('main.undo')).not.toBeDisabled() expect(page.getByTestId('quick-actions.undo')).not.toBeDisabled()
expect(page.getByTestId('main.redo')).toBeDisabled() expect(page.getByTestId('quick-actions.redo')).toBeDisabled()
// Click the undo button to undo the shape // Click the undo button to undo the shape
await page.getByTestId('main.undo').click() await page.getByTestId('quick-actions.undo').click()
expect(await getAllShapeTypes(page)).toEqual([]) expect(await getAllShapeTypes(page)).toEqual([])
expect(page.getByTestId('main.undo')).toBeDisabled() expect(page.getByTestId('quick-actions.undo')).toBeDisabled()
expect(page.getByTestId('main.redo')).not.toBeDisabled() expect(page.getByTestId('quick-actions.redo')).not.toBeDisabled()
// Click the redo button to redo the shape // Click the redo button to redo the shape
await page.getByTestId('main.redo').click() await page.getByTestId('quick-actions.redo').click()
expect(await getAllShapeTypes(page)).toEqual(['geo']) expect(await getAllShapeTypes(page)).toEqual(['geo'])
expect(await page.getByTestId('main.undo').isDisabled()).not.toBe(true) expect(await page.getByTestId('quick-actions.undo').isDisabled()).not.toBe(true)
expect(await page.getByTestId('main.redo').isDisabled()).toBe(true) expect(await page.getByTestId('quick-actions.redo').isDisabled()).toBe(true)
}) })
test('style panel + undo and redo squashing', async ({ page }) => { test('style panel + undo and redo squashing', async ({ page }) => {
@ -108,8 +108,8 @@ test.describe('smoke tests', () => {
await page.mouse.up() await page.mouse.up()
// Now undo and redo // Now undo and redo
const undo = page.getByTestId('main.undo') const undo = page.getByTestId('quick-actions.undo')
const redo = page.getByTestId('main.redo') const redo = page.getByTestId('quick-actions.redo')
await undo.click() // orange -> light blue await undo.click() // orange -> light blue
expect(await getSelectedShapeColor()).toBe('light-blue') // skipping squashed colors! expect(await getSelectedShapeColor()).toBe('light-blue') // skipping squashed colors!
@ -124,7 +124,7 @@ test.describe('smoke tests', () => {
await redo.click() // black -> light blue await redo.click() // black -> light blue
await redo.click() // light-blue -> orange await redo.click() // light-blue -> orange
expect(await page.getByTestId('main.undo').isDisabled()).not.toBe(true) expect(await page.getByTestId('quick-actions.undo').isDisabled()).not.toBe(true)
expect(await page.getByTestId('main.redo').isDisabled()).toBe(true) expect(await page.getByTestId('quick-actions.redo').isDisabled()).toBe(true)
}) })
}) })

View file

@ -0,0 +1,22 @@
import { DefaultActionsMenu, TLComponents, Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
function CustomActionsMenu() {
return (
<div style={{ transform: 'rotate(90deg)' }}>
<DefaultActionsMenu />
</div>
)
}
const components: TLComponents = {
ActionsMenu: CustomActionsMenu,
}
export default function CustomActionsMenuExample() {
return (
<div className="tldraw__editor">
<Tldraw components={components} />
</div>
)
}

View file

@ -0,0 +1,11 @@
---
title: Custom actions menu
component: ./CustomActionsMenuExample.tsx
category: ui
---
You can customize tldraw's actions menu.
---
The actions menu can be customized by providing a `ActionsMenu` component to the `Tldraw` component's `uiComponents` prop. If you provide `null`, then that component will be hidden.

View file

@ -1,4 +1,12 @@
import { TLUiMenuGroup, TLUiOverrides, menuItem, toolbarItem } from '@tldraw/tldraw' import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
TLComponents,
TLUiOverrides,
TldrawUiMenuItem,
toolbarItem,
useTools,
} from '@tldraw/tldraw'
// There's a guide at the bottom of this file! // There's a guide at the bottom of this file!
@ -10,7 +18,6 @@ export const uiOverrides: TLUiOverrides = {
icon: 'color', icon: 'color',
label: 'Card', label: 'Card',
kbd: 'c', kbd: 'c',
readonlyOk: false,
onSelect: () => { onSelect: () => {
editor.setCurrentTool('card') editor.setCurrentTool('card')
}, },
@ -22,13 +29,18 @@ export const uiOverrides: TLUiOverrides = {
toolbar.splice(4, 0, toolbarItem(tools.card)) toolbar.splice(4, 0, toolbarItem(tools.card))
return toolbar return toolbar
}, },
keyboardShortcutsMenu(_app, keyboardShortcutsMenu, { tools }) { }
// Add the tool item from the context to the keyboard shortcuts dialog.
const toolsGroup = keyboardShortcutsMenu.find( export const components: TLComponents = {
(group) => group.id === 'shortcuts-dialog.tools' KeyboardShortcutsDialog: (props) => {
) as TLUiMenuGroup const tools = useTools()
toolsGroup.children.push(menuItem(tools.card)) return (
return keyboardShortcutsMenu <DefaultKeyboardShortcutsDialog {...props}>
<DefaultKeyboardShortcutsDialogContent />
{/* Ideally, we'd interleave this into the tools group */}
<TldrawUiMenuItem {...tools['card']} />
</DefaultKeyboardShortcutsDialog>
)
}, },
} }

View file

@ -0,0 +1,41 @@
import {
DefaultContextMenu,
DefaultContextMenuContent,
TLComponents,
TLUiContextMenuProps,
Tldraw,
TldrawUiMenuGroup,
TldrawUiMenuItem,
} from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
function CustomContextMenu(props: TLUiContextMenuProps) {
return (
<DefaultContextMenu {...props}>
<TldrawUiMenuGroup id="example">
<TldrawUiMenuItem
id="like"
label="Like my posts"
icon="external-link"
readonlyOk
onSelect={() => {
window.open('https://x.com/tldraw', '_blank')
}}
/>
</TldrawUiMenuGroup>
<DefaultContextMenuContent />
</DefaultContextMenu>
)
}
const components: TLComponents = {
ContextMenu: CustomContextMenu,
}
export default function CustomContextMenuExample() {
return (
<div className="tldraw__editor">
<Tldraw components={components} />
</div>
)
}

View file

@ -0,0 +1,11 @@
---
title: Custom context menu
component: ./CustomContextMenuExample.tsx
category: ui
---
You can customize tldraw's context menu.
---
The context menu can be customized by providing a `ContextMenu` component to the `Tldraw` component's `uiComponents` prop. If you provide `null`, then that component will be hidden instead.

View file

@ -0,0 +1,40 @@
import {
DefaultDebugMenu,
DefaultDebugMenuContent,
TLComponents,
Tldraw,
TldrawUiMenuGroup,
TldrawUiMenuItem,
} from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
function CustomDebugMenu() {
return (
<DefaultDebugMenu>
<DefaultDebugMenuContent />
<TldrawUiMenuGroup id="example">
<TldrawUiMenuItem
id="like"
label="Like my posts"
icon="external-link"
readonlyOk
onSelect={() => {
window.open('https://x.com/tldraw', '_blank')
}}
/>
</TldrawUiMenuGroup>
</DefaultDebugMenu>
)
}
const components: TLComponents = {
DebugMenu: CustomDebugMenu,
}
export default function CustomDebugMenuExample() {
return (
<div className="tldraw__editor">
<Tldraw components={components} />
</div>
)
}

View file

@ -0,0 +1,11 @@
---
title: Custom debug menu
component: ./CustomDebugMenuExample.tsx
category: ui
---
You can customize tldraw's debug menu.
---
The help menu can be customized by providing a `DebugMenu` component to the `Tldraw` component's `uiComponents` prop. If you provide `null`, then that component will be hidden.

View file

@ -0,0 +1,40 @@
import {
DefaultHelpMenu,
DefaultHelpMenuContent,
TLComponents,
Tldraw,
TldrawUiMenuGroup,
TldrawUiMenuItem,
} from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
function CustomHelpMenu() {
return (
<DefaultHelpMenu>
<TldrawUiMenuGroup id="example">
<TldrawUiMenuItem
id="like"
label="Like my posts"
icon="external-link"
readonlyOk
onSelect={() => {
window.open('https://x.com/tldraw', '_blank')
}}
/>
</TldrawUiMenuGroup>
<DefaultHelpMenuContent />
</DefaultHelpMenu>
)
}
const components: TLComponents = {
HelpMenu: CustomHelpMenu,
}
export default function CustomHelpMenuExample() {
return (
<div className="tldraw__editor">
<Tldraw components={components} />
</div>
)
}

View file

@ -0,0 +1,11 @@
---
title: Custom help menu
component: ./CustomHelpMenuExample.tsx
category: ui
---
You can customize tldraw's help menu.
---
The help menu can be customized by providing a `HelpMenu` component to the `Tldraw` component's `uiComponents` prop. If you provide `null`, then that component will be hidden.

View file

@ -0,0 +1,38 @@
import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
TLComponents,
TLUiKeyboardShortcutsDialogProps,
Tldraw,
TldrawUiMenuItem,
} from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
function CustomKeyboardShortcutsDialog(props: TLUiKeyboardShortcutsDialogProps) {
return (
<DefaultKeyboardShortcutsDialog {...props}>
<TldrawUiMenuItem
id="about"
label="Like my posts"
icon="external-link"
readonlyOk
onSelect={() => {
window.open('https://x.com/tldraw', '_blank')
}}
/>
<DefaultKeyboardShortcutsDialogContent />
</DefaultKeyboardShortcutsDialog>
)
}
const components: TLComponents = {
KeyboardShortcutsDialog: CustomKeyboardShortcutsDialog,
}
export default function CustomKeyboardShortcutsDialogExample() {
return (
<div className="tldraw__editor">
<Tldraw components={components} />
</div>
)
}

View file

@ -0,0 +1,11 @@
---
title: Custom keyboard shortcuts dialog
component: ./CustomKeyboardShortcutsDialogExample.tsx
category: ui
---
You can customize tldraw's keyboard shortcuts dialog.
---
The keyboard shortcuts dialog can be customized by providing a `KeyboardShortcutsDialog` component to the `Tldraw` component's `uiComponents` prop. If you provide `null`, then that component will be hidden instead.

View file

@ -0,0 +1,40 @@
import {
DefaultMainMenu,
DefaultMainMenuContent,
TLComponents,
Tldraw,
TldrawUiMenuGroup,
TldrawUiMenuItem,
} from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
function CustomMainMenu() {
return (
<DefaultMainMenu>
<TldrawUiMenuGroup id="example">
<TldrawUiMenuItem
id="like"
label="Like my posts"
icon="external-link"
readonlyOk
onSelect={() => {
window.open('https://x.com/tldraw', '_blank')
}}
/>
</TldrawUiMenuGroup>
<DefaultMainMenuContent />
</DefaultMainMenu>
)
}
const components: TLComponents = {
MainMenu: CustomMainMenu,
}
export default function CustomMainMenuExample() {
return (
<div className="tldraw__editor">
<Tldraw components={components} />
</div>
)
}

View file

@ -0,0 +1,11 @@
---
title: Custom main menu
component: ./CustomMainMenuExample.tsx
category: ui
---
You can customize tldraw's main menu.
---
The actions menu can be customized by providing a `MainMenu` component to the `Tldraw` component's `uiComponents` prop. If you provide `null`, then that component will be hidden.

View file

@ -0,0 +1,18 @@
import { TLComponents, Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
function CustomNavigationPanel() {
return <div className="tlui-navigation-panel">here you are</div>
}
const components: TLComponents = {
NavigationPanel: CustomNavigationPanel, // null will hide the panel instead
}
export default function CustomNagiationPanelExample() {
return (
<div className="tldraw__editor">
<Tldraw components={components} />
</div>
)
}

View file

@ -0,0 +1,11 @@
---
title: Custom navigation panel
component: ./CustomNavigationPanelExample.tsx
category: ui
---
You can customize tldraw's navigation panel or remove it entirely.
---
The navigation panel can be customized by providing a `NavigationPanel` component to the `Tldraw` component's `uiComponents` prop. If you provide `null`, then that component will be hidden instead.

View file

@ -0,0 +1,22 @@
import { DefaultPageMenu, TLComponents, Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
function CustomPageMenu() {
return (
<div style={{ transform: 'rotate(3.14rad)' }}>
<DefaultPageMenu />
</div>
)
}
const components: TLComponents = {
PageMenu: CustomPageMenu, // null will hide the page menu instead
}
export default function CustomPageMenuExample() {
return (
<div className="tldraw__editor">
<Tldraw components={components} />
</div>
)
}

View file

@ -0,0 +1,11 @@
---
title: Custom page menu
component: ./CustomPageMenuExample.tsx
category: ui
---
You can customize tldraw's page menu, or remove it entirely.
---
The page menu can be customized by providing a `PageMenu` component to the `Tldraw` component's `uiComponents` prop. If you provide `null`, then that component will be hidden.

View file

@ -0,0 +1,29 @@
import {
Button,
DefaultQuickActions,
DefaultQuickActionsContent,
TLComponents,
Tldraw,
} from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
function CustomQuickActions() {
return (
<DefaultQuickActions>
<DefaultQuickActionsContent />
<Button type="icon" icon="code" smallIcon />
</DefaultQuickActions>
)
}
const components: TLComponents = {
QuickActions: CustomQuickActions, // null will hide the page menu instead
}
export default function CustomQuickActionsExample() {
return (
<div className="tldraw__editor">
<Tldraw components={components} />
</div>
)
}

View file

@ -0,0 +1,11 @@
---
title: Custom quick actions
component: ./CustomQuickActions.tsx
category: ui
---
You can customize tldraw's quick actions, a collection of components that appear next to the menu button, or in the toolbar on smaller sizes.
---
The quick actions component can be customized by providing a `QuickActionsContent` component to the `Tldraw` component's `uiComponents` prop. If you provide `null`, then that component will be hidden.

View file

@ -0,0 +1,51 @@
import {
Button,
DefaultColorStyle,
DefaultStylePanel,
DefaultStylePanelContent,
TLComponents,
TLUiStylePanelProps,
Tldraw,
useEditor,
} from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
function CustomStylePanel(props: TLUiStylePanelProps) {
const editor = useEditor()
// Styles are complex, sorry. Check our DefaultStylePanel for an example.
return (
<DefaultStylePanel {...props}>
<Button
type="menu"
onClick={() => {
editor.setStyleForSelectedShapes(DefaultColorStyle, 'red', { squashing: true })
}}
>
Red
</Button>
<Button
type="menu"
onClick={() => {
editor.setStyleForSelectedShapes(DefaultColorStyle, 'green', { squashing: true })
}}
>
Green
</Button>
<DefaultStylePanelContent relevantStyles={props.relevantStyles} />
</DefaultStylePanel>
)
}
const components: TLComponents = {
StylePanel: CustomStylePanel, // null will hide the panel instead
}
export default function CustomStylePanelExample() {
return (
<div className="tldraw__editor">
<Tldraw components={components} />
</div>
)
}

View file

@ -0,0 +1,11 @@
---
title: Custom style panel
component: ./CustomStylePanelExample.tsx
category: ui
---
You can customize tldraw's style panel or remove it entirely.
---
The style panel can be customized by providing a `StylePanel` component to the `Tldraw` component's `uiComponents` prop. If you provide `null`, then that component will be hidden instead.

View file

@ -2,7 +2,7 @@ import { Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css' import '@tldraw/tldraw/tldraw.css'
import { CardShapeTool, CardShapeUtil } from './CardShape' import { CardShapeTool, CardShapeUtil } from './CardShape'
import { FilterStyleUi } from './FilterStyleUi' import { FilterStyleUi } from './FilterStyleUi'
import { uiOverrides } from './ui-overrides' import { components, uiOverrides } from './ui-overrides'
// There's a guide at the bottom of this file! // There's a guide at the bottom of this file!
@ -19,6 +19,7 @@ export default function CustomStylesExample() {
shapeUtils={customShapeUtils} shapeUtils={customShapeUtils}
tools={customTools} tools={customTools}
overrides={uiOverrides} overrides={uiOverrides}
components={components}
> >
<FilterStyleUi /> <FilterStyleUi />
</Tldraw> </Tldraw>

View file

@ -1,4 +1,12 @@
import { TLUiMenuGroup, TLUiOverrides, menuItem, toolbarItem } from '@tldraw/tldraw' import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
TLComponents,
TLUiOverrides,
TldrawUiMenuItem,
toolbarItem,
useTools,
} from '@tldraw/tldraw'
// There's a guide at the bottom of this file! // There's a guide at the bottom of this file!
@ -9,7 +17,6 @@ export const uiOverrides: TLUiOverrides = {
icon: 'color', icon: 'color',
label: 'Card' as any, label: 'Card' as any,
kbd: 'c', kbd: 'c',
readonlyOk: false,
onSelect: () => { onSelect: () => {
editor.setCurrentTool('card') editor.setCurrentTool('card')
}, },
@ -20,12 +27,19 @@ export const uiOverrides: TLUiOverrides = {
toolbar.splice(4, 0, toolbarItem(tools.card)) toolbar.splice(4, 0, toolbarItem(tools.card))
return toolbar return toolbar
}, },
keyboardShortcutsMenu(_app, keyboardShortcutsMenu, { tools }) { }
const toolsGroup = keyboardShortcutsMenu.find(
(group) => group.id === 'shortcuts-dialog.tools' export const components: TLComponents = {
) as TLUiMenuGroup KeyboardShortcutsDialog: (props) => {
toolsGroup.children.push(menuItem(tools.card)) const tools = useTools()
return keyboardShortcutsMenu
return (
<DefaultKeyboardShortcutsDialog {...props}>
<DefaultKeyboardShortcutsDialogContent />
{/* Ideally, we'd interleave this into the tools section */}
<TldrawUiMenuItem {...tools['card']} />
</DefaultKeyboardShortcutsDialog>
)
}, },
} }

View file

@ -0,0 +1,23 @@
import { TLComponents, Tldraw } from '@tldraw/tldraw'
import { DefaultToolbar } from '@tldraw/tldraw/src/lib/ui/components/Toolbar/DefaultToolbar'
import '@tldraw/tldraw/tldraw.css'
function CustomToolbar() {
return (
<div style={{ transform: 'rotate(180deg)' }}>
<DefaultToolbar />
</div>
)
}
const components: TLComponents = {
Toolbar: CustomToolbar, // null will hide the panel instead
}
export default function CustomToolbarExample() {
return (
<div className="tldraw__editor">
<Tldraw components={components} />
</div>
)
}

View file

@ -0,0 +1,11 @@
---
title: Custom toolbar
component: ./CustomToolbarExample.tsx
category: ui
---
You can customize tldraw's toolbar or remove it entirely.
---
The toolbar can be customized by providing a `Toolbar` component to the `Tldraw` component's `uiComponents` prop. If you provide `null`, then that component will be hidden instead.

View file

@ -0,0 +1,40 @@
import {
DefaultZoomMenu,
DefaultZoomMenuContent,
TLComponents,
Tldraw,
TldrawUiMenuGroup,
TldrawUiMenuItem,
} from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
function CustomZoomMenu() {
return (
<DefaultZoomMenu>
<TldrawUiMenuGroup id="example">
<TldrawUiMenuItem
id="like"
label="Like my posts"
icon="external-link"
readonlyOk
onSelect={() => {
window.open('https://x.com/tldraw', '_blank')
}}
/>
</TldrawUiMenuGroup>
<DefaultZoomMenuContent />
</DefaultZoomMenu>
)
}
const components: TLComponents = {
ZoomMenu: CustomZoomMenu,
}
export default function CustomZoomMenuExample() {
return (
<div className="tldraw__editor">
<Tldraw components={components} />
</div>
)
}

View file

@ -0,0 +1,11 @@
---
title: Custom zoom menu
component: ./CustomZoomMenuExample.tsx
category: ui
---
You can customize tldraw's zoom menu.
---
The zoom menu can be customized by providing a `ZoomMenu` component to the `Tldraw` component's `uiComponents` prop. If you provide `null`, then that component will be hidden instead.

View file

@ -1,6 +1,7 @@
import { import {
Canvas, Canvas,
ContextMenu, ContextMenu,
DefaultContextMenuContent,
TldrawEditor, TldrawEditor,
TldrawHandles, TldrawHandles,
TldrawHoveredShapeIndicator, TldrawHoveredShapeIndicator,
@ -38,8 +39,8 @@ export default function ExplodedExample() {
persistenceKey="exploded-example" persistenceKey="exploded-example"
> >
<TldrawUi> <TldrawUi>
<ContextMenu> <ContextMenu canvas={<Canvas />}>
<Canvas /> <DefaultContextMenuContent />
</ContextMenu> </ContextMenu>
</TldrawUi> </TldrawUi>
</TldrawEditor> </TldrawEditor>

View file

@ -1,11 +1,4 @@
import { import { TLUiActionsContextType, TLUiOverrides, TLUiToolsContextType, Tldraw } from '@tldraw/tldraw'
TLUiActionsContextType,
TLUiMenuGroup,
TLUiOverrides,
TLUiToolsContextType,
Tldraw,
menuItem,
} from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css' import '@tldraw/tldraw/tldraw.css'
import jsonSnapshot from './snapshot.json' import jsonSnapshot from './snapshot.json'
@ -15,7 +8,6 @@ import jsonSnapshot from './snapshot.json'
const overrides: TLUiOverrides = { const overrides: TLUiOverrides = {
//[a] //[a]
actions(_editor, actions): TLUiActionsContextType { actions(_editor, actions): TLUiActionsContextType {
actions['copy-as-png'].kbd = '$1'
actions['toggle-grid'].kbd = 'x' actions['toggle-grid'].kbd = 'x'
return actions return actions
}, },
@ -24,15 +16,6 @@ const overrides: TLUiOverrides = {
tools['draw'].kbd = 'p' tools['draw'].kbd = 'p'
return tools return tools
}, },
//[c]
keyboardShortcutsMenu(_editor, shortcutsMenu, { actions }) {
const editGroup = shortcutsMenu.find(
(group) => group.id === 'shortcuts-dialog.edit'
) as TLUiMenuGroup
editGroup.children.push(menuItem(actions['copy-as-png']))
return shortcutsMenu
},
} }
// [2] // [2]
@ -75,15 +58,6 @@ add a new shortcut to the keyboard shortcuts dialog [c].
We're overriding the draw tool's shortcut to 'p', maybe we want to rename it to the pen We're overriding the draw tool's shortcut to 'p', maybe we want to rename it to the pen
tool or something. tool or something.
[c] keyboardShortcutsMenu
This function takes 3 arguments, the editor instance (which we don't need), the menu
schema, and the ui context. The shortcutsMenu is an array, so we'll need to use the
find method to return the edit group and add our new menu item to it. Check out the
useKeyboardShortcutsSchema.tsx file in the tldraw repo to see the full list of groups
and the menu items they contain. menuItem() is a helper function that creates a new menu
item for us, we just need to pass it an action or tool. We'll use the copy-as-png action
that we modified in [a], we can grab it from the ui context's actions object.
[2] [2]
Finally, we pass our overrides object into the Tldraw component's overrides prop. Now when Finally, we pass our overrides object into the Tldraw component's overrides prop. Now when
the component mounts, our overrides will be applied. If you open the keyboard shortcuts the component mounts, our overrides will be applied. If you open the keyboard shortcuts

View file

@ -1,6 +1,6 @@
/* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable import/no-extraneous-dependencies */
import { Editor, PositionedOnCanvas, TldrawEditor, createShapeId, track } from '@tldraw/editor' import { Editor, TldrawEditor, createShapeId } from '@tldraw/editor'
import { MiniBoxShapeUtil } from './MiniBoxShape' import { MiniBoxShapeUtil } from './MiniBoxShape'
import { MiniSelectTool } from './MiniSelectTool' import { MiniSelectTool } from './MiniSelectTool'
@ -32,24 +32,28 @@ export default function OnlyEditorExample() {
]) ])
}} }}
components={{ components={{
Background: BackgroundComponent, // [3]
OnTheCanvas: () => {
return (
<div
style={{
position: 'absolute',
transform: `translate(16px, 16px)`,
width: '320px',
}}
>
<p>Double click to create or delete shapes.</p>
<p>Click or Shift+Click to select shapes.</p>
<p>Click and drag to move shapes.</p>
</div>
)
},
}} }}
/> />
</div> </div>
) )
} }
// [3]
const BackgroundComponent = track(() => {
return (
<PositionedOnCanvas x={16} y={16}>
<p>Double click to create or delete shapes.</p>
<p>Click or Shift+Click to select shapes.</p>
<p>Click and drag to move shapes.</p>
</PositionedOnCanvas>
)
})
/* /*
This example shows how to use the TldrawEditor component on its own. This is useful if you want to This example shows how to use the TldrawEditor component on its own. This is useful if you want to
create your own custom UI, shape and tool interactions. create your own custom UI, shape and tool interactions.

View file

@ -26,7 +26,6 @@ const customUiOverrides: TLUiOverrides = {
screenshot: { screenshot: {
id: 'screenshot', id: 'screenshot',
label: 'Screenshot', label: 'Screenshot',
readonlyOk: false,
icon: 'tool-screenshot', icon: 'tool-screenshot',
kbd: 'j', kbd: 'j',
onSelect() { onSelect() {

View file

@ -2,7 +2,7 @@ import { Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css' import '@tldraw/tldraw/tldraw.css'
import { SpeechBubbleTool } from './SpeechBubble/SpeechBubbleTool' import { SpeechBubbleTool } from './SpeechBubble/SpeechBubbleTool'
import { SpeechBubbleUtil } from './SpeechBubble/SpeechBubbleUtil' import { SpeechBubbleUtil } from './SpeechBubble/SpeechBubbleUtil'
import { customAssetUrls, uiOverrides } from './SpeechBubble/ui-overrides' import { components, customAssetUrls, uiOverrides } from './SpeechBubble/ui-overrides'
import './customhandles.css' import './customhandles.css'
// There's a guide at the bottom of this file! // There's a guide at the bottom of this file!
@ -20,6 +20,7 @@ export default function CustomShapeWithHandles() {
tools={tools} tools={tools}
overrides={uiOverrides} overrides={uiOverrides}
assetUrls={customAssetUrls} assetUrls={customAssetUrls}
components={components}
persistenceKey="whatever" persistenceKey="whatever"
/> />
</div> </div>

View file

@ -1,9 +1,12 @@
import { import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
TLComponents,
TLUiAssetUrlOverrides, TLUiAssetUrlOverrides,
TLUiMenuGroup,
TLUiOverrides, TLUiOverrides,
menuItem, TldrawUiMenuItem,
toolbarItem, toolbarItem,
useTools,
} from '@tldraw/tldraw' } from '@tldraw/tldraw'
// There's a guide at the bottom of this file! // There's a guide at the bottom of this file!
@ -16,7 +19,6 @@ export const uiOverrides: TLUiOverrides = {
icon: 'speech-bubble', icon: 'speech-bubble',
label: 'Speech Bubble', label: 'Speech Bubble',
kbd: 's', kbd: 's',
readonlyOk: false,
onSelect: () => { onSelect: () => {
editor.setCurrentTool('speech-bubble') editor.setCurrentTool('speech-bubble')
}, },
@ -27,13 +29,6 @@ export const uiOverrides: TLUiOverrides = {
toolbar.splice(4, 0, toolbarItem(tools.speech)) toolbar.splice(4, 0, toolbarItem(tools.speech))
return toolbar return toolbar
}, },
keyboardShortcutsMenu(_app, keyboardShortcutsMenu, { tools }) {
const toolsGroup = keyboardShortcutsMenu.find(
(group) => group.id === 'shortcuts-dialog.tools'
) as TLUiMenuGroup
toolsGroup.children.push(menuItem(tools.speech))
return keyboardShortcutsMenu
},
} }
// [2] // [2]
@ -44,6 +39,18 @@ export const customAssetUrls: TLUiAssetUrlOverrides = {
}, },
} }
export const components: TLComponents = {
KeyboardShortcutsDialog: (props) => {
const tools = useTools()
return (
<DefaultKeyboardShortcutsDialog {...props}>
<DefaultKeyboardShortcutsDialogContent />
<TldrawUiMenuItem {...tools['speech']} />
</DefaultKeyboardShortcutsDialog>
)
},
}
/* /*
This file contains overrides for the Tldraw UI. These overrides are used to add your custom tools This file contains overrides for the Tldraw UI. These overrides are used to add your custom tools

View file

@ -0,0 +1,11 @@
---
title: Hidden UI components
component: ./UiComponentsHiddenExample.tsx
category: ui
---
You can hide tldraw's UI components.
---
Custom UI components can be hidden providing a `null` as the value for a component in `uiComponents`. In this case, all configurable UI components are hidden.

View file

@ -0,0 +1,28 @@
import { TLUiComponents, Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
// The type here is include only to ensure this example contains all possible ui components,
const components: Required<TLUiComponents> = {
ContextMenu: null,
ActionsMenu: null,
HelpMenu: null,
ZoomMenu: null,
MainMenu: null,
Minimap: null,
StylePanel: null,
PageMenu: null,
NavigationPanel: null,
Toolbar: null,
KeyboardShortcutsDialog: null,
QuickActions: null,
HelperButtons: null,
DebugMenu: null,
}
export default function UiComponentsHiddenExample() {
return (
<div className="tldraw__editor">
<Tldraw components={components} />
</div>
)
}

View file

@ -0,0 +1,45 @@
import { TldrawUiMenuGroup, TldrawUiMenuItem } from '@tldraw/tldraw'
import { openUrl } from './utils/url'
export function Links() {
return (
<TldrawUiMenuGroup id="links">
<TldrawUiMenuItem
id="github"
label="help-menu.github"
readonlyOk
icon="github"
onSelect={() => {
openUrl('https://github.com/tldraw/tldraw')
}}
/>
<TldrawUiMenuItem
id="twitter"
label="help-menu.twitter"
icon="twitter"
readonlyOk
onSelect={() => {
openUrl('https://twitter.com/tldraw')
}}
/>
<TldrawUiMenuItem
id="discord"
label="help-menu.discord"
icon="discord"
readonlyOk
onSelect={() => {
openUrl('https://discord.gg/SBBEVCA4PG')
}}
/>
<TldrawUiMenuItem
id="about"
label="help-menu.about"
icon="external-link"
readonlyOk
onSelect={() => {
openUrl('https://tldraw.dev')
}}
/>
</TldrawUiMenuGroup>
)
}

View file

@ -1,15 +1,24 @@
import { linksUiOverrides } from './utils/links'
// eslint-disable-next-line import/no-internal-modules // eslint-disable-next-line import/no-internal-modules
import '@tldraw/tldraw/tldraw.css' import '@tldraw/tldraw/tldraw.css'
// eslint-disable-next-line import/no-internal-modules // eslint-disable-next-line import/no-internal-modules
import { getAssetUrlsByImport } from '@tldraw/assets/imports' import { getAssetUrlsByImport } from '@tldraw/assets/imports'
import { Editor, ErrorBoundary, TLUiMenuSchema, Tldraw, setRuntimeOverrides } from '@tldraw/tldraw' import {
DefaultHelpMenu,
DefaultHelpMenuContent,
Editor,
ErrorBoundary,
TLComponents,
Tldraw,
TldrawUiMenuGroup,
setRuntimeOverrides,
} from '@tldraw/tldraw'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { VscodeMessage } from '../../messages' import { VscodeMessage } from '../../messages'
import '../public/index.css' import '../public/index.css'
import { ChangeResponder } from './ChangeResponder' import { ChangeResponder } from './ChangeResponder'
import { FileOpen } from './FileOpen' import { FileOpen } from './FileOpen'
import { FullPageMessage } from './FullPageMessage' import { FullPageMessage } from './FullPageMessage'
import { Links } from './Links'
import { onCreateAssetFromUrl } from './utils/bookmarks' import { onCreateAssetFromUrl } from './utils/bookmarks'
import { vscode } from './utils/vscode' import { vscode } from './utils/vscode'
@ -53,24 +62,6 @@ export function WrappedTldrawEditor() {
) )
} }
const menuOverrides = {
menu: (_editor: Editor, schema: TLUiMenuSchema, _helpers: any) => {
schema.forEach((item) => {
if (item.id === 'menu' && item.type === 'group') {
item.children = item.children.filter((menuItem) => {
if (!menuItem) return false
if (menuItem.id === 'file' && menuItem.type === 'submenu') {
return false
}
return true
})
}
})
return schema
},
}
export const TldrawWrapper = () => { export const TldrawWrapper = () => {
const [tldrawInnerProps, setTldrawInnerProps] = useState<TLDrawInnerProps | null>(null) const [tldrawInnerProps, setTldrawInnerProps] = useState<TLDrawInnerProps | null>(null)
@ -114,6 +105,16 @@ export type TLDrawInnerProps = {
isDarkMode: boolean isDarkMode: boolean
} }
const components: TLComponents = {
HelpMenu: () => (
<DefaultHelpMenu>
<TldrawUiMenuGroup id="help">
<DefaultHelpMenuContent />
</TldrawUiMenuGroup>
<Links />
</DefaultHelpMenu>
),
}
function TldrawInner({ uri, assetSrc, isDarkMode, fileContents }: TLDrawInnerProps) { function TldrawInner({ uri, assetSrc, isDarkMode, fileContents }: TLDrawInnerProps) {
const assetUrls = useMemo(() => getAssetUrlsByImport({ baseUrl: assetSrc }), [assetSrc]) const assetUrls = useMemo(() => getAssetUrlsByImport({ baseUrl: assetSrc }), [assetSrc])
@ -126,10 +127,11 @@ function TldrawInner({ uri, assetSrc, isDarkMode, fileContents }: TLDrawInnerPro
assetUrls={assetUrls} assetUrls={assetUrls}
persistenceKey={uri} persistenceKey={uri}
onMount={handleMount} onMount={handleMount}
overrides={[menuOverrides, linksUiOverrides]} components={components}
autoFocus autoFocus
> >
{/* <DarkModeHandler themeKind={themeKind} /> */} {/* <DarkModeHandler themeKind={themeKind} /> */}
<FileOpen fileContents={fileContents} forceDarkMode={isDarkMode} /> <FileOpen fileContents={fileContents} forceDarkMode={isDarkMode} />
<ChangeResponder /> <ChangeResponder />
</Tldraw> </Tldraw>

View file

@ -1,57 +0,0 @@
import { menuGroup, menuItem, TLUiOverrides } from '@tldraw/tldraw'
import { openUrl } from './openUrl'
export const GITHUB_URL = 'https://github.com/tldraw/tldraw'
const linksMenuGroup = menuGroup(
'links',
menuItem({
id: 'github',
label: 'help-menu.github',
readonlyOk: true,
icon: 'github',
onSelect() {
openUrl(GITHUB_URL)
},
}),
menuItem({
id: 'twitter',
label: 'help-menu.twitter',
icon: 'twitter',
readonlyOk: true,
onSelect() {
openUrl('https://twitter.com/tldraw')
},
}),
menuItem({
id: 'discord',
label: 'help-menu.discord',
icon: 'discord',
readonlyOk: true,
onSelect() {
openUrl('https://discord.gg/SBBEVCA4PG')
},
}),
menuItem({
id: 'about',
label: 'help-menu.about',
icon: 'external-link',
readonlyOk: true,
onSelect() {
openUrl('https://www.tldraw.dev')
},
})
)!
export const linksUiOverrides: TLUiOverrides = {
helpMenu(editor, schema) {
schema.push(linksMenuGroup)
return schema
},
menu(editor, schema, { isMobile }) {
if (isMobile) {
schema.push(linksMenuGroup)
}
return schema
},
}

View file

@ -1,358 +1,358 @@
{ {
"action.convert-to-bookmark": "Pretvori v zaznamek", "action.convert-to-bookmark": "Pretvori v zaznamek",
"action.convert-to-embed": "Pretvori v vdelavo", "action.convert-to-embed": "Pretvori v vdelavo",
"action.open-embed-link": "Odpri povezavo", "action.open-embed-link": "Odpri povezavo",
"action.align-bottom": "Poravnaj dno", "action.align-bottom": "Poravnaj dno",
"action.align-center-horizontal": "Poravnaj vodoravno", "action.align-center-horizontal": "Poravnaj vodoravno",
"action.align-center-vertical": "Poravnaj navpično", "action.align-center-vertical": "Poravnaj navpično",
"action.align-center-horizontal.short": "Poravnaj vodoravno", "action.align-center-horizontal.short": "Poravnaj vodoravno",
"action.align-center-vertical.short": "Poravnaj navpično", "action.align-center-vertical.short": "Poravnaj navpično",
"action.align-left": "Poravnaj levo", "action.align-left": "Poravnaj levo",
"action.align-right": "Poravnaj desno", "action.align-right": "Poravnaj desno",
"action.align-top": "Poravnaj vrh", "action.align-top": "Poravnaj vrh",
"action.back-to-content": "Nazaj na vsebino", "action.back-to-content": "Nazaj na vsebino",
"action.bring-forward": "Premakni naprej", "action.bring-forward": "Premakni naprej",
"action.bring-to-front": "Premakni v ospredje", "action.bring-to-front": "Premakni v ospredje",
"action.copy-as-json.short": "JSON", "action.copy-as-json.short": "JSON",
"action.copy-as-json": "Kopiraj kot JSON", "action.copy-as-json": "Kopiraj kot JSON",
"action.copy-as-png.short": "PNG", "action.copy-as-png.short": "PNG",
"action.copy-as-png": "Kopiraj kot PNG", "action.copy-as-png": "Kopiraj kot PNG",
"action.copy-as-svg.short": "SVG", "action.copy-as-svg.short": "SVG",
"action.copy-as-svg": "Kopiraj kot SVG", "action.copy-as-svg": "Kopiraj kot SVG",
"action.copy": "Kopiraj", "action.copy": "Kopiraj",
"action.cut": "Izreži", "action.cut": "Izreži",
"action.delete": "Izbriši", "action.delete": "Izbriši",
"action.distribute-horizontal": "Porazdeli vodoravno", "action.distribute-horizontal": "Porazdeli vodoravno",
"action.distribute-vertical": "Porazdeli navpično", "action.distribute-vertical": "Porazdeli navpično",
"action.distribute-horizontal.short": "Porazdeli vodoravno", "action.distribute-horizontal.short": "Porazdeli vodoravno",
"action.distribute-vertical.short": "Porazdeli navpično", "action.distribute-vertical.short": "Porazdeli navpično",
"action.duplicate": "Podvoji", "action.duplicate": "Podvoji",
"action.edit-link": "Uredi povezavo", "action.edit-link": "Uredi povezavo",
"action.exit-pen-mode": "Zapustite način peresa", "action.exit-pen-mode": "Zapustite način peresa",
"action.export-as-json.short": "JSON", "action.export-as-json.short": "JSON",
"action.export-as-json": "Izvozi kot JSON", "action.export-as-json": "Izvozi kot JSON",
"action.export-as-png.short": "PNG", "action.export-as-png.short": "PNG",
"action.export-as-png": "Izvozi kot PNG", "action.export-as-png": "Izvozi kot PNG",
"action.export-as-svg.short": "SVG", "action.export-as-svg.short": "SVG",
"action.export-as-svg": "Izvozi kot SVG", "action.export-as-svg": "Izvozi kot SVG",
"action.flip-horizontal": "Zrcali vodoravno", "action.flip-horizontal": "Zrcali vodoravno",
"action.flip-vertical": "Zrcali navpično", "action.flip-vertical": "Zrcali navpično",
"action.flip-horizontal.short": "Zrcali horizontalno", "action.flip-horizontal.short": "Zrcali horizontalno",
"action.flip-vertical.short": "Zrcali vertikalno", "action.flip-vertical.short": "Zrcali vertikalno",
"action.group": "Združi", "action.group": "Združi",
"action.insert-media": "Naloži predstavnost", "action.insert-media": "Naloži predstavnost",
"action.new-shared-project": "Nov skupni projekt", "action.new-shared-project": "Nov skupni projekt",
"action.open-file": "Odpri datoteko", "action.open-file": "Odpri datoteko",
"action.pack": "Spakiraj", "action.pack": "Spakiraj",
"action.paste": "Prilepi", "action.paste": "Prilepi",
"action.print": "Natisni", "action.print": "Natisni",
"action.redo": "Uveljavi", "action.redo": "Uveljavi",
"action.rotate-ccw": "Zavrti v nasprotni smeri urinega kazalca", "action.rotate-ccw": "Zavrti v nasprotni smeri urinega kazalca",
"action.rotate-cw": "Zavrti v smeri urinega kazalca", "action.rotate-cw": "Zavrti v smeri urinega kazalca",
"action.save-copy": "Shrani kopijo", "action.save-copy": "Shrani kopijo",
"action.select-all": "Izberi vse", "action.select-all": "Izberi vse",
"action.select-none": "Počisti izbiro", "action.select-none": "Počisti izbiro",
"action.send-backward": "Pošlji nazaj", "action.send-backward": "Pošlji nazaj",
"action.send-to-back": "Pošlji v ozadje", "action.send-to-back": "Pošlji v ozadje",
"action.share-project": "Deli ta projekt", "action.share-project": "Deli ta projekt",
"action.stack-horizontal": "Naloži vodoravno", "action.stack-horizontal": "Naloži vodoravno",
"action.stack-vertical": "Naloži navpično", "action.stack-vertical": "Naloži navpično",
"action.stack-horizontal.short": "Naloži vodoravno", "action.stack-horizontal.short": "Naloži vodoravno",
"action.stack-vertical.short": "Naloži navpično", "action.stack-vertical.short": "Naloži navpično",
"action.stretch-horizontal": "Raztegnite vodoravno", "action.stretch-horizontal": "Raztegnite vodoravno",
"action.stretch-vertical": "Raztegni navpično", "action.stretch-vertical": "Raztegni navpično",
"action.stretch-horizontal.short": "Raztezanje vodoravno", "action.stretch-horizontal.short": "Raztezanje vodoravno",
"action.stretch-vertical.short": "Raztezanje navpično", "action.stretch-vertical.short": "Raztezanje navpično",
"action.toggle-auto-size": "Preklopi samodejno velikost", "action.toggle-auto-size": "Preklopi samodejno velikost",
"action.toggle-dark-mode.menu": "Temni način", "action.toggle-dark-mode.menu": "Temni način",
"action.toggle-dark-mode": "Preklopi temni način", "action.toggle-dark-mode": "Preklopi temni način",
"action.toggle-debug-mode.menu": "Način odpravljanja napak", "action.toggle-debug-mode.menu": "Način odpravljanja napak",
"action.toggle-debug-mode": "Preklopi način odpravljanja napak", "action.toggle-debug-mode": "Preklopi način odpravljanja napak",
"action.toggle-focus-mode.menu": "Osredotočen način", "action.toggle-focus-mode.menu": "Osredotočen način",
"action.toggle-focus-mode": "Preklopi na osredotočen način", "action.toggle-focus-mode": "Preklopi na osredotočen način",
"action.toggle-grid.menu": "Prikaži mrežo", "action.toggle-grid.menu": "Prikaži mrežo",
"action.toggle-grid": "Preklopi mrežo", "action.toggle-grid": "Preklopi mrežo",
"action.toggle-snap-mode.menu": "Vedno pripni", "action.toggle-snap-mode.menu": "Vedno pripni",
"action.toggle-snap-mode": "Preklopi pripenjanje", "action.toggle-snap-mode": "Preklopi pripenjanje",
"action.toggle-tool-lock.menu": "Zaklepanje orodja", "action.toggle-tool-lock.menu": "Zaklepanje orodja",
"action.toggle-tool-lock": "Preklopi zaklepanje orodja", "action.toggle-tool-lock": "Preklopi zaklepanje orodja",
"action.toggle-transparent.context-menu": "Prozorno", "action.toggle-transparent.context-menu": "Prozorno",
"action.toggle-transparent.menu": "Prozorno", "action.toggle-transparent.menu": "Prozorno",
"action.toggle-transparent": "Preklopi prosojno ozadje", "action.toggle-transparent": "Preklopi prosojno ozadje",
"action.undo": "Razveljavi", "action.undo": "Razveljavi",
"action.ungroup": "Razdruži", "action.ungroup": "Razdruži",
"action.zoom-in": "Povečaj", "action.zoom-in": "Povečaj",
"action.zoom-out": "Pomanjšaj", "action.zoom-out": "Pomanjšaj",
"action.zoom-to-100": "Povečaj na 100 %", "action.zoom-to-100": "Povečaj na 100 %",
"action.zoom-to-fit": "Povečaj do prileganja", "action.zoom-to-fit": "Povečaj do prileganja",
"action.zoom-to-selection": "Pomakni na izbiro", "action.zoom-to-selection": "Pomakni na izbiro",
"color-style.black": "Črna", "color-style.black": "Črna",
"color-style.blue": "Modra", "color-style.blue": "Modra",
"color-style.green": "Zelena", "color-style.green": "Zelena",
"color-style.grey": "Siva", "color-style.grey": "Siva",
"color-style.light-blue": "Svetlo modra", "color-style.light-blue": "Svetlo modra",
"color-style.light-green": "Svetlo zelena", "color-style.light-green": "Svetlo zelena",
"color-style.light-red": "Svetlo rdeča", "color-style.light-red": "Svetlo rdeča",
"color-style.light-violet": "Svetlo vijolična", "color-style.light-violet": "Svetlo vijolična",
"color-style.orange": "Oranžna", "color-style.orange": "Oranžna",
"color-style.red": "Rdeča", "color-style.red": "Rdeča",
"color-style.violet": "Vijolična", "color-style.violet": "Vijolična",
"color-style.yellow": "Rumena", "color-style.yellow": "Rumena",
"fill-style.none": "Brez", "fill-style.none": "Brez",
"fill-style.semi": "Polovično", "fill-style.semi": "Polovično",
"fill-style.solid": "Polno", "fill-style.solid": "Polno",
"fill-style.pattern": "Vzorec", "fill-style.pattern": "Vzorec",
"dash-style.dashed": "Črtkano", "dash-style.dashed": "Črtkano",
"dash-style.dotted": "Pikčasto", "dash-style.dotted": "Pikčasto",
"dash-style.draw": "Narisano", "dash-style.draw": "Narisano",
"dash-style.solid": "Polno", "dash-style.solid": "Polno",
"size-style.s": "Malo", "size-style.s": "Malo",
"size-style.m": "Srednje", "size-style.m": "Srednje",
"size-style.l": "Veliko", "size-style.l": "Veliko",
"size-style.xl": "Zelo veliko", "size-style.xl": "Zelo veliko",
"opacity-style.0.1": "10 %", "opacity-style.0.1": "10 %",
"opacity-style.0.25": "25 %", "opacity-style.0.25": "25 %",
"opacity-style.0.5": "50 %", "opacity-style.0.5": "50 %",
"opacity-style.0.75": "75 %", "opacity-style.0.75": "75 %",
"opacity-style.1": "100 %", "opacity-style.1": "100 %",
"font-style.draw": "Draw", "font-style.draw": "Draw",
"font-style.sans": "Sans", "font-style.sans": "Sans",
"font-style.serif": "Serif", "font-style.serif": "Serif",
"font-style.mono": "Mono", "font-style.mono": "Mono",
"align-style.start": "Začetek", "align-style.start": "Začetek",
"align-style.middle": "Sredina", "align-style.middle": "Sredina",
"align-style.end": "Konec", "align-style.end": "Konec",
"align-style.justify": "Poravnaj", "align-style.justify": "Poravnaj",
"geo-style.arrow-down": "Puščica navzdol", "geo-style.arrow-down": "Puščica navzdol",
"geo-style.arrow-left": "Puščica levo", "geo-style.arrow-left": "Puščica levo",
"geo-style.arrow-right": "Puščica desno", "geo-style.arrow-right": "Puščica desno",
"geo-style.arrow-up": "Puščica navzgor", "geo-style.arrow-up": "Puščica navzgor",
"geo-style.diamond": "Diamant", "geo-style.diamond": "Diamant",
"geo-style.ellipse": "Elipsa", "geo-style.ellipse": "Elipsa",
"geo-style.hexagon": "Šesterokotnik", "geo-style.hexagon": "Šesterokotnik",
"geo-style.octagon": "Osmerokotnik", "geo-style.octagon": "Osmerokotnik",
"geo-style.oval": "Oval", "geo-style.oval": "Oval",
"geo-style.pentagon": "Peterokotnik", "geo-style.pentagon": "Peterokotnik",
"geo-style.rectangle": "Pravokotnik", "geo-style.rectangle": "Pravokotnik",
"geo-style.rhombus-2": "Romb 2", "geo-style.rhombus-2": "Romb 2",
"geo-style.rhombus": "Romb", "geo-style.rhombus": "Romb",
"geo-style.star": "Zvezda", "geo-style.star": "Zvezda",
"geo-style.trapezoid": "Trapez", "geo-style.trapezoid": "Trapez",
"geo-style.triangle": "Trikotnik", "geo-style.triangle": "Trikotnik",
"geo-style.x-box": "X polje", "geo-style.x-box": "X polje",
"arrowheadStart-style.none": "Brez", "arrowheadStart-style.none": "Brez",
"arrowheadStart-style.arrow": "Puščica", "arrowheadStart-style.arrow": "Puščica",
"arrowheadStart-style.bar": "Črta", "arrowheadStart-style.bar": "Črta",
"arrowheadStart-style.diamond": "Diamant", "arrowheadStart-style.diamond": "Diamant",
"arrowheadStart-style.dot": "Pika", "arrowheadStart-style.dot": "Pika",
"arrowheadStart-style.inverted": "Obrnjeno", "arrowheadStart-style.inverted": "Obrnjeno",
"arrowheadStart-style.pipe": "Cev", "arrowheadStart-style.pipe": "Cev",
"arrowheadStart-style.square": "Kvadrat", "arrowheadStart-style.square": "Kvadrat",
"arrowheadStart-style.triangle": "Trikotnik", "arrowheadStart-style.triangle": "Trikotnik",
"arrowheadEnd-style.none": "Brez", "arrowheadEnd-style.none": "Brez",
"arrowheadEnd-style.arrow": "Puščica", "arrowheadEnd-style.arrow": "Puščica",
"arrowheadEnd-style.bar": "Črta", "arrowheadEnd-style.bar": "Črta",
"arrowheadEnd-style.diamond": "Diamant", "arrowheadEnd-style.diamond": "Diamant",
"arrowheadEnd-style.dot": "Pika", "arrowheadEnd-style.dot": "Pika",
"arrowheadEnd-style.inverted": "Obrnjeno", "arrowheadEnd-style.inverted": "Obrnjeno",
"arrowheadEnd-style.pipe": "Cev", "arrowheadEnd-style.pipe": "Cev",
"arrowheadEnd-style.square": "Kvadrat", "arrowheadEnd-style.square": "Kvadrat",
"arrowheadEnd-style.triangle": "Trikotnik", "arrowheadEnd-style.triangle": "Trikotnik",
"spline-style.line": "Črta", "spline-style.line": "Črta",
"spline-style.cubic": "Kubično", "spline-style.cubic": "Kubično",
"tool.select": "Izbor", "tool.select": "Izbor",
"tool.hand": "Roka", "tool.hand": "Roka",
"tool.draw": "Risanje", "tool.draw": "Risanje",
"tool.eraser": "Radirka", "tool.eraser": "Radirka",
"tool.arrow-down": "Puščica navzdol", "tool.arrow-down": "Puščica navzdol",
"tool.arrow-left": "Puščica levo", "tool.arrow-left": "Puščica levo",
"tool.arrow-right": "Puščica desno", "tool.arrow-right": "Puščica desno",
"tool.arrow-up": "Puščica navzgor", "tool.arrow-up": "Puščica navzgor",
"tool.arrow": "Puščica", "tool.arrow": "Puščica",
"tool.diamond": "Diamant", "tool.diamond": "Diamant",
"tool.ellipse": "Elipsa", "tool.ellipse": "Elipsa",
"tool.hexagon": "Šesterokotnik", "tool.hexagon": "Šesterokotnik",
"tool.line": "Črta", "tool.line": "Črta",
"tool.octagon": "Osmerokotnik", "tool.octagon": "Osmerokotnik",
"tool.oval": "Oval", "tool.oval": "Oval",
"tool.pentagon": "Peterokotnik", "tool.pentagon": "Peterokotnik",
"tool.rectangle": "Pravokotnik", "tool.rectangle": "Pravokotnik",
"tool.rhombus": "Romb", "tool.rhombus": "Romb",
"tool.star": "Zvezda", "tool.star": "Zvezda",
"tool.trapezoid": "Trapez", "tool.trapezoid": "Trapez",
"tool.triangle": "Trikotnik", "tool.triangle": "Trikotnik",
"tool.x-box": "X polje", "tool.x-box": "X polje",
"tool.asset": "Sredstvo", "tool.asset": "Sredstvo",
"tool.frame": "Okvir", "tool.frame": "Okvir",
"tool.note": "Opomba", "tool.note": "Opomba",
"tool.embed": "Vdelava", "tool.embed": "Vdelava",
"tool.text": "Besedilo", "tool.text": "Besedilo",
"menu.title": "Meni", "menu.title": "Meni",
"menu.copy-as": "Kopiraj kot", "menu.copy-as": "Kopiraj kot",
"menu.edit": "Uredi", "menu.edit": "Uredi",
"menu.export-as": "Izvozi kot", "menu.export-as": "Izvozi kot",
"menu.file": "Datoteka", "menu.file": "Datoteka",
"menu.language": "Jezik", "menu.language": "Jezik",
"menu.preferences": "Nastavitve", "menu.preferences": "Nastavitve",
"menu.view": "Pogled", "menu.view": "Pogled",
"context-menu.arrange": "Preuredi", "context-menu.arrange": "Preuredi",
"context-menu.copy-as": "Kopiraj kot", "context-menu.copy-as": "Kopiraj kot",
"context-menu.export-as": "Izvozi kot", "context-menu.export-as": "Izvozi kot",
"context-menu.move-to-page": "Premakni na stran", "context-menu.move-to-page": "Premakni na stran",
"context-menu.reorder": "Preuredite", "context-menu.reorder": "Preuredite",
"page-menu.title": "Strani", "page-menu.title": "Strani",
"page-menu.create-new-page": "Ustvari novo stran", "page-menu.create-new-page": "Ustvari novo stran",
"page-menu.max-page-count-reached": "Doseženo največje število strani", "page-menu.max-page-count-reached": "Doseženo največje število strani",
"page-menu.new-page-initial-name": "Stran 1", "page-menu.new-page-initial-name": "Stran 1",
"page-menu.edit-start": "Uredi", "page-menu.edit-start": "Uredi",
"page-menu.edit-done": "Zaključi", "page-menu.edit-done": "Zaključi",
"page-menu.submenu.rename": "Preimenuj", "page-menu.submenu.rename": "Preimenuj",
"page-menu.submenu.duplicate-page": "Podvoji", "page-menu.submenu.duplicate-page": "Podvoji",
"page-menu.submenu.title": "Meni", "page-menu.submenu.title": "Meni",
"page-menu.submenu.move-down": "Premakni navzdol", "page-menu.submenu.move-down": "Premakni navzdol",
"page-menu.submenu.move-up": "Premakni navzgor", "page-menu.submenu.move-up": "Premakni navzgor",
"page-menu.submenu.delete": "Izbriši", "page-menu.submenu.delete": "Izbriši",
"share-menu.title": "Deli", "share-menu.title": "Deli",
"share-menu.share-project": "Deli ta projekt", "share-menu.share-project": "Deli ta projekt",
"share-menu.copy-link": "Kopiraj povezavo", "share-menu.copy-link": "Kopiraj povezavo",
"share-menu.readonly-link": "Samo za branje", "share-menu.readonly-link": "Samo za branje",
"share-menu.copy-readonly-link": "Kopiraj povezavo samo za branje", "share-menu.copy-readonly-link": "Kopiraj povezavo samo za branje",
"share-menu.offline-note": "Skupna raba tega projekta bo ustvarila živo kopijo na novem URL-ju. URL lahko delite z do tridesetimi drugimi osebami, s katerimi lahko skupaj gledate in urejate vsebino.", "share-menu.offline-note": "Skupna raba tega projekta bo ustvarila živo kopijo na novem URL-ju. URL lahko delite z do tridesetimi drugimi osebami, s katerimi lahko skupaj gledate in urejate vsebino.",
"share-menu.copy-link-note": "Vsakdo s povezavo si bo lahko ogledal in urejal ta projekt.", "share-menu.copy-link-note": "Vsakdo s povezavo si bo lahko ogledal in urejal ta projekt.",
"share-menu.copy-readonly-link-note": "Vsakdo s povezavo si bo lahko ogledal (vendar ne urejal) ta projekt.", "share-menu.copy-readonly-link-note": "Vsakdo s povezavo si bo lahko ogledal (vendar ne urejal) ta projekt.",
"share-menu.project-too-large": "Žal tega projekta ni mogoče deliti, ker je prevelik. Delamo na tem!", "share-menu.project-too-large": "Žal tega projekta ni mogoče deliti, ker je prevelik. Delamo na tem!",
"people-menu.title": "Ljudje", "people-menu.title": "Ljudje",
"people-menu.change-name": "Spremeni ime", "people-menu.change-name": "Spremeni ime",
"people-menu.change-color": "Spremeni barvo", "people-menu.change-color": "Spremeni barvo",
"people-menu.user": "(Ti)", "people-menu.user": "(Ti)",
"people-menu.invite": "Povabi ostale", "people-menu.invite": "Povabi ostale",
"help-menu.title": "Pomoč in viri", "help-menu.title": "Pomoč in viri",
"help-menu.about": "O nas", "help-menu.about": "O nas",
"help-menu.discord": "Discord", "help-menu.discord": "Discord",
"help-menu.github": "GitHub", "help-menu.github": "GitHub",
"help-menu.keyboard-shortcuts": "Bližnjice na tipkovnici", "help-menu.keyboard-shortcuts": "Bližnjice na tipkovnici",
"help-menu.twitter": "Twitter", "help-menu.twitter": "Twitter",
"actions-menu.title": "Akcije", "actions-menu.title": "Akcije",
"edit-link-dialog.title": "Uredi povezavo", "edit-link-dialog.title": "Uredi povezavo",
"edit-link-dialog.invalid-url": "Povezava mora biti veljavna", "edit-link-dialog.invalid-url": "Povezava mora biti veljavna",
"edit-link-dialog.detail": "Povezave se bodo odprle v novem zavihku.", "edit-link-dialog.detail": "Povezave se bodo odprle v novem zavihku.",
"edit-link-dialog.url": "URL", "edit-link-dialog.url": "URL",
"edit-link-dialog.clear": "Počisti", "edit-link-dialog.clear": "Počisti",
"edit-link-dialog.save": "Nadaljuj", "edit-link-dialog.save": "Nadaljuj",
"edit-link-dialog.cancel": "Prekliči", "edit-link-dialog.cancel": "Prekliči",
"embed-dialog.title": "Ustvari vdelavo", "embed-dialog.title": "Ustvari vdelavo",
"embed-dialog.back": "Nazaj", "embed-dialog.back": "Nazaj",
"embed-dialog.create": "Ustvari", "embed-dialog.create": "Ustvari",
"embed-dialog.cancel": "Prekliči", "embed-dialog.cancel": "Prekliči",
"embed-dialog.url": "URL", "embed-dialog.url": "URL",
"embed-dialog.instruction": "Prilepite URL spletnega mesta, da ustvarite vdelavo.", "embed-dialog.instruction": "Prilepite URL spletnega mesta, da ustvarite vdelavo.",
"embed-dialog.invalid-url": "Iz tega URL-ja nismo mogli ustvariti vdelave.", "embed-dialog.invalid-url": "Iz tega URL-ja nismo mogli ustvariti vdelave.",
"edit-pages-dialog.move-down": "Premakni navzdol", "edit-pages-dialog.move-down": "Premakni navzdol",
"edit-pages-dialog.move-up": "Premakni navzgor", "edit-pages-dialog.move-up": "Premakni navzgor",
"shortcuts-dialog.title": "Bližnjice na tipkovnici", "shortcuts-dialog.title": "Bližnjice na tipkovnici",
"shortcuts-dialog.edit": "Uredi", "shortcuts-dialog.edit": "Uredi",
"shortcuts-dialog.file": "Datoteka", "shortcuts-dialog.file": "Datoteka",
"shortcuts-dialog.preferences": "Nastavitve", "shortcuts-dialog.preferences": "Nastavitve",
"shortcuts-dialog.tools": "Orodja", "shortcuts-dialog.tools": "Orodja",
"shortcuts-dialog.transform": "Preoblikuj", "shortcuts-dialog.transform": "Preoblikuj",
"shortcuts-dialog.view": "Pogled", "shortcuts-dialog.view": "Pogled",
"style-panel.title": "Stili", "style-panel.title": "Stili",
"style-panel.align": "Poravnava", "style-panel.align": "Poravnava",
"style-panel.arrowheads": "Puščice", "style-panel.arrowheads": "Puščice",
"style-panel.color": "Barva", "style-panel.color": "Barva",
"style-panel.dash": "Črtasto", "style-panel.dash": "Črtasto",
"style-panel.fill": "Polnilo", "style-panel.fill": "Polnilo",
"style-panel.font": "Pisava", "style-panel.font": "Pisava",
"style-panel.geo": "Oblika", "style-panel.geo": "Oblika",
"style-panel.mixed": "Mešano", "style-panel.mixed": "Mešano",
"style-panel.opacity": "Motnost", "style-panel.opacity": "Motnost",
"style-panel.size": "Velikost", "style-panel.size": "Velikost",
"style-panel.spline": "Krivulja", "style-panel.spline": "Krivulja",
"tool-panel.drawing": "Risanje", "tool-panel.drawing": "Risanje",
"tool-panel.shapes": "Oblike", "tool-panel.shapes": "Oblike",
"navigation-zone.toggle-minimap": "Preklopi mini zemljevid", "navigation-zone.toggle-minimap": "Preklopi mini zemljevid",
"navigation-zone.zoom": "Povečava", "navigation-zone.zoom": "Povečava",
"focus-mode.toggle-focus-mode": "Preklopi na osredotočen način", "focus-mode.toggle-focus-mode": "Preklopi na osredotočen način",
"toast.close": "Zapri", "toast.close": "Zapri",
"file-system.file-open-error.title": "Datoteke ni bilo mogoče odpreti", "file-system.file-open-error.title": "Datoteke ni bilo mogoče odpreti",
"file-system.file-open-error.not-a-tldraw-file": "Datoteka, ki ste jo poskušali odpreti, ni videti kot datoteka tldraw.", "file-system.file-open-error.not-a-tldraw-file": "Datoteka, ki ste jo poskušali odpreti, ni videti kot datoteka tldraw.",
"file-system.file-open-error.file-format-version-too-new": "Datoteka, ki ste jo poskušali odpreti, je iz novejše različice tldraw. Ponovno naložite stran in poskusite znova.", "file-system.file-open-error.file-format-version-too-new": "Datoteka, ki ste jo poskušali odpreti, je iz novejše različice tldraw. Ponovno naložite stran in poskusite znova.",
"file-system.file-open-error.generic-corrupted-file": "Datoteka, ki ste jo poskušali odpreti, je poškodovana.", "file-system.file-open-error.generic-corrupted-file": "Datoteka, ki ste jo poskušali odpreti, je poškodovana.",
"file-system.confirm-open.title": "Prepiši trenutni projekt?", "file-system.confirm-open.title": "Prepiši trenutni projekt?",
"file-system.confirm-open.description": "Če ustvarite nov projekt, boste izbrisali trenutni projekt in vse neshranjene spremembe bodo izgubljene. Ste prepričani, da želite nadaljevati?", "file-system.confirm-open.description": "Če ustvarite nov projekt, boste izbrisali trenutni projekt in vse neshranjene spremembe bodo izgubljene. Ste prepričani, da želite nadaljevati?",
"file-system.confirm-open.cancel": "Prekliči", "file-system.confirm-open.cancel": "Prekliči",
"file-system.confirm-open.open": "Odpri datoteko", "file-system.confirm-open.open": "Odpri datoteko",
"file-system.confirm-open.dont-show-again": "Ne sprašuj znova", "file-system.confirm-open.dont-show-again": "Ne sprašuj znova",
"toast.error.export-fail.title": "Izvoz ni uspel", "toast.error.export-fail.title": "Izvoz ni uspel",
"toast.error.export-fail.desc": "Izvoz slike ni uspel", "toast.error.export-fail.desc": "Izvoz slike ni uspel",
"toast.error.copy-fail.title": "Kopiranje ni uspelo", "toast.error.copy-fail.title": "Kopiranje ni uspelo",
"toast.error.copy-fail.desc": "Kopiranje slike ni uspelo", "toast.error.copy-fail.desc": "Kopiranje slike ni uspelo",
"file-system.shared-document-file-open-error.title": "Datoteke ni bilo mogoče odpreti", "file-system.shared-document-file-open-error.title": "Datoteke ni bilo mogoče odpreti",
"file-system.shared-document-file-open-error.description": "Odpiranje datotek v skupnih projektih ni podprto.", "file-system.shared-document-file-open-error.description": "Odpiranje datotek v skupnih projektih ni podprto.",
"vscode.file-open.dont-show-again": "Ne sprašuj znova", "vscode.file-open.dont-show-again": "Ne sprašuj znova",
"vscode.file-open.desc": "Ta datoteka je bila ustvarjena s starejšo različico tldraw. Ali jo želite posodobiti, da bo deloval z novo različico?", "vscode.file-open.desc": "Ta datoteka je bila ustvarjena s starejšo različico tldraw. Ali jo želite posodobiti, da bo deloval z novo različico?",
"context.pages.new-page": "Nova stran", "context.pages.new-page": "Nova stran",
"style-panel.arrowhead-start": "Začetek", "style-panel.arrowhead-start": "Začetek",
"style-panel.arrowhead-end": "Konec", "style-panel.arrowhead-end": "Konec",
"vscode.file-open.open": "Nadaljuj", "vscode.file-open.open": "Nadaljuj",
"vscode.file-open.backup": "Varnostna kopija", "vscode.file-open.backup": "Varnostna kopija",
"vscode.file-open.backup-saved": "Varnostna kopija shranjena", "vscode.file-open.backup-saved": "Varnostna kopija shranjena",
"vscode.file-open.backup-failed": "Varnostno kopiranje ni uspelo: to ni datoteka .tldr.", "vscode.file-open.backup-failed": "Varnostno kopiranje ni uspelo: to ni datoteka .tldr.",
"tool-panel.more": "Več", "tool-panel.more": "Več",
"debug-panel.more": "Več", "debug-panel.more": "Več",
"action.new-project": "Nov projekt", "action.new-project": "Nov projekt",
"file-system.confirm-clear.title": "Počisti trenutni projekt?", "file-system.confirm-clear.title": "Počisti trenutni projekt?",
"file-system.confirm-clear.description": "Če ustvarite nov projekt, boste izbrisali trenutni projekt in vse neshranjene spremembe bodo izgubljene. Ste prepričani, da želite nadaljevati?", "file-system.confirm-clear.description": "Če ustvarite nov projekt, boste izbrisali trenutni projekt in vse neshranjene spremembe bodo izgubljene. Ste prepričani, da želite nadaljevati?",
"file-system.confirm-clear.cancel": "Prekliči", "file-system.confirm-clear.cancel": "Prekliči",
"file-system.confirm-clear.continue": "Nadaljuj", "file-system.confirm-clear.continue": "Nadaljuj",
"file-system.confirm-clear.dont-show-again": "Ne sprašuj znova", "file-system.confirm-clear.dont-show-again": "Ne sprašuj znova",
"action.stop-following": "Prenehaj slediti", "action.stop-following": "Prenehaj slediti",
"people-menu.follow": "Sledi", "people-menu.follow": "Sledi",
"style-panel.position": "Položaj", "style-panel.position": "Položaj",
"page-menu.go-to-page": "Pojdi na stran", "page-menu.go-to-page": "Pojdi na stran",
"action.insert-embed": "Vstavi vdelavo", "action.insert-embed": "Vstavi vdelavo",
"people-menu.following": "Sledim", "people-menu.following": "Sledim",
"people-menu.leading": "Sledi vam", "people-menu.leading": "Sledi vam",
"geo-style.check-box": "Potrditveno polje", "geo-style.check-box": "Potrditveno polje",
"tool.check-box": "Potrditveno polje", "tool.check-box": "Potrditveno polje",
"share-menu.create-snapshot-link": "Ustvari povezavo do posnetka", "share-menu.create-snapshot-link": "Ustvari povezavo do posnetka",
"share-menu.save-note": "Ta projekt prenesite na svoj računalnik kot datoteko .tldr.", "share-menu.save-note": "Ta projekt prenesite na svoj računalnik kot datoteko .tldr.",
"share-menu.fork-note": "Na podlagi tega posnetka ustvarite nov skupni projekt.", "share-menu.fork-note": "Na podlagi tega posnetka ustvarite nov skupni projekt.",
"share-menu.snapshot-link-note": "Zajemite in delite ta projekt kot povezavo do posnetka samo za branje.", "share-menu.snapshot-link-note": "Zajemite in delite ta projekt kot povezavo do posnetka samo za branje.",
"share-menu.upload-failed": "Oprostite, trenutno nismo mogli naložiti vašega projekta. Poskusite znova ali nam sporočite, če se težava ponovi.", "share-menu.upload-failed": "Oprostite, trenutno nismo mogli naložiti vašega projekta. Poskusite znova ali nam sporočite, če se težava ponovi.",
"style-panel.vertical-align": "Navpična poravnava", "style-panel.vertical-align": "Navpična poravnava",
"tool.laser": "Laser", "tool.laser": "Laser",
"action.fork-project": "Naredi kopijo projekta", "action.fork-project": "Naredi kopijo projekta",
"action.leave-shared-project": "Zapusti skupni projekt", "action.leave-shared-project": "Zapusti skupni projekt",
"sharing.confirm-leave.title": "Zapusti trenutni projekt?", "sharing.confirm-leave.title": "Zapusti trenutni projekt?",
"sharing.confirm-leave.description": "Ali ste prepričani, da želite zapustiti ta skupni projekt? Nanj se lahko vrnete tako, da se ponovno vrnete na njegov URL.", "sharing.confirm-leave.description": "Ali ste prepričani, da želite zapustiti ta skupni projekt? Nanj se lahko vrnete tako, da se ponovno vrnete na njegov URL.",
"sharing.confirm-leave.cancel": "Prekliči", "sharing.confirm-leave.cancel": "Prekliči",
"sharing.confirm-leave.leave": "Zapusti", "sharing.confirm-leave.leave": "Zapusti",
"sharing.confirm-leave.dont-show-again": "Ne sprašuj znova", "sharing.confirm-leave.dont-show-again": "Ne sprašuj znova",
"action.toggle-reduce-motion.menu": "Zmanjšaj gibanje", "action.toggle-reduce-motion.menu": "Zmanjšaj gibanje",
"action.toggle-reduce-motion": "Preklop zmanjšanja gibanja", "action.toggle-reduce-motion": "Preklop zmanjšanja gibanja",
"tool.highlight": "Marker", "tool.highlight": "Marker",
"action.toggle-lock": "Zakleni \/ odkleni", "action.toggle-lock": "Zakleni / odkleni",
"share-menu.default-project-name": "Skupni projekt", "share-menu.default-project-name": "Skupni projekt",
"home-project-dialog.title": "Lokalni projekt", "home-project-dialog.title": "Lokalni projekt",
"home-project-dialog.description": "To je vaš lokalni projekt. Namenjen je samo vam!", "home-project-dialog.description": "To je vaš lokalni projekt. Namenjen je samo vam!",
"rename-project-dialog.title": "Preimenuj projekt", "rename-project-dialog.title": "Preimenuj projekt",
"rename-project-dialog.cancel": "Prekliči", "rename-project-dialog.cancel": "Prekliči",
"rename-project-dialog.rename": "Preimenuj", "rename-project-dialog.rename": "Preimenuj",
"home-project-dialog.ok": "V redu", "home-project-dialog.ok": "V redu",
"action.open-cursor-chat": "Klepet s kazalcem", "action.open-cursor-chat": "Klepet s kazalcem",
"shortcuts-dialog.collaboration": "Sodelovanje", "shortcuts-dialog.collaboration": "Sodelovanje",
"cursor-chat.type-to-chat": "Vnesite za klepet ...", "cursor-chat.type-to-chat": "Vnesite za klepet ...",
"geo-style.cloud": "Oblak", "geo-style.cloud": "Oblak",
"tool.cloud": "Oblak", "tool.cloud": "Oblak",
"action.unlock-all": "Odkleni vse", "action.unlock-all": "Odkleni vse",
"status.offline": "Brez povezave", "status.offline": "Brez povezave",
"status.online": "Povezan", "status.online": "Povezan",
"action.remove-frame": "Odstrani okvir", "action.remove-frame": "Odstrani okvir",
"action.fit-frame-to-content": "Prilagodi vsebini", "action.fit-frame-to-content": "Prilagodi vsebini",
"action.toggle-edge-scrolling.menu": "Pomikanje ob robovih", "action.toggle-edge-scrolling.menu": "Pomikanje ob robovih",
"action.toggle-edge-scrolling": "Preklopi pomikanje ob robovih", "action.toggle-edge-scrolling": "Preklopi pomikanje ob robovih",
"verticalAlign-style.start": "Vrh", "verticalAlign-style.start": "Vrh",
"verticalAlign-style.middle": "Sredina", "verticalAlign-style.middle": "Sredina",
"verticalAlign-style.end": "Dno" "verticalAlign-style.end": "Dno"
} }

View file

@ -16,11 +16,9 @@ import { EmbedDefinition } from '@tldraw/tlschema';
import { EMPTY_ARRAY } from '@tldraw/state'; import { EMPTY_ARRAY } from '@tldraw/state';
import { EventEmitter } from 'eventemitter3'; import { EventEmitter } from 'eventemitter3';
import { HistoryEntry } from '@tldraw/store'; import { HistoryEntry } from '@tldraw/store';
import { HTMLProps } from 'react';
import { IndexKey } from '@tldraw/utils'; import { IndexKey } from '@tldraw/utils';
import { JsonObject } from '@tldraw/utils'; import { JsonObject } from '@tldraw/utils';
import { JSX as JSX_2 } from 'react/jsx-runtime'; import { JSX as JSX_2 } from 'react/jsx-runtime';
import { MemoExoticComponent } from 'react';
import { Migrations } from '@tldraw/store'; import { Migrations } from '@tldraw/store';
import { NamedExoticComponent } from 'react'; import { NamedExoticComponent } from 'react';
import { PointerEventHandler } from 'react'; import { PointerEventHandler } from 'react';
@ -426,22 +424,7 @@ export function dataUrlToFile(url: string, filename: string, mimeType: string):
export type DebugFlag<T> = DebugFlagDef<T> & Atom<T>; export type DebugFlag<T> = DebugFlagDef<T> & Atom<T>;
// @internal (undocumented) // @internal (undocumented)
export const debugFlags: { export const debugFlags: Record<string, DebugFlag<boolean>>;
preventDefaultLogging: DebugFlag<boolean>;
pointerCaptureLogging: DebugFlag<boolean>;
pointerCaptureTracking: DebugFlag<boolean>;
pointerCaptureTrackingObject: DebugFlag<Map<Element, number>>;
elementRemovalLogging: DebugFlag<boolean>;
debugSvg: DebugFlag<boolean>;
showFps: DebugFlag<boolean>;
throwToBlob: DebugFlag<boolean>;
logMessages: DebugFlag<any[]>;
resetConnectionEveryPing: DebugFlag<boolean>;
debugCursors: DebugFlag<boolean>;
forceSrgb: DebugFlag<boolean>;
debugGeometry: DebugFlag<boolean>;
hideShapes: DebugFlag<boolean>;
};
// @internal (undocumented) // @internal (undocumented)
export const DEFAULT_ANIMATION_OPTIONS: { export const DEFAULT_ANIMATION_OPTIONS: {
@ -1452,13 +1435,6 @@ export class Polyline2d extends Geometry2d {
_segments?: Edge2d[]; _segments?: Edge2d[];
} }
// @public (undocumented)
export const PositionedOnCanvas: MemoExoticComponent<({ x: offsetX, y: offsetY, rotation, ...rest }: {
x?: number | undefined;
y?: number | undefined;
rotation?: number | undefined;
} & HTMLProps<HTMLDivElement>) => JSX_2.Element>;
// @public (undocumented) // @public (undocumented)
export function precise(A: VecLike): string; export function precise(A: VecLike): string;

View file

@ -27915,83 +27915,6 @@
}, },
"implementsTokenRanges": [] "implementsTokenRanges": []
}, },
{
"kind": "Variable",
"canonicalReference": "@tldraw/editor!PositionedOnCanvas:var",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "PositionedOnCanvas: "
},
{
"kind": "Content",
"text": "import(\"react\")."
},
{
"kind": "Reference",
"text": "MemoExoticComponent",
"canonicalReference": "@types/react!React.MemoExoticComponent:type"
},
{
"kind": "Content",
"text": "<({ "
},
{
"kind": "Reference",
"text": "x",
"canonicalReference": "@tldraw/editor!~__type#x"
},
{
"kind": "Content",
"text": ": offsetX, "
},
{
"kind": "Reference",
"text": "y",
"canonicalReference": "@tldraw/editor!~__type#y"
},
{
"kind": "Content",
"text": ": offsetY, rotation, ...rest }: {\n x?: number | undefined;\n y?: number | undefined;\n rotation?: number | undefined;\n} & "
},
{
"kind": "Reference",
"text": "HTMLProps",
"canonicalReference": "@types/react!React.HTMLProps:interface"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "HTMLDivElement",
"canonicalReference": "!HTMLDivElement:interface"
},
{
"kind": "Content",
"text": ">) => import(\"react/jsx-runtime\")."
},
{
"kind": "Reference",
"text": "JSX.Element",
"canonicalReference": "@types/react!JSX.Element:interface"
},
{
"kind": "Content",
"text": ">"
}
],
"fileUrlPath": "packages/editor/src/lib/components/PositionedOnCanvas.tsx",
"isReadonly": true,
"releaseTag": "Public",
"name": "PositionedOnCanvas",
"variableTypeTokenRange": {
"startIndex": 1,
"endIndex": 14
}
},
{ {
"kind": "Function", "kind": "Function",
"canonicalReference": "@tldraw/editor!precise:function(1)", "canonicalReference": "@tldraw/editor!precise:function(1)",

View file

@ -41,7 +41,6 @@ export {
type TLErrorBoundaryProps, type TLErrorBoundaryProps,
} from './lib/components/ErrorBoundary' } from './lib/components/ErrorBoundary'
export { HTMLContainer, type HTMLContainerProps } from './lib/components/HTMLContainer' export { HTMLContainer, type HTMLContainerProps } from './lib/components/HTMLContainer'
export { PositionedOnCanvas } from './lib/components/PositionedOnCanvas'
export { SVGContainer, type SVGContainerProps } from './lib/components/SVGContainer' export { SVGContainer, type SVGContainerProps } from './lib/components/SVGContainer'
export { ShapeIndicator, type TLShapeIndicatorComponent } from './lib/components/ShapeIndicator' export { ShapeIndicator, type TLShapeIndicatorComponent } from './lib/components/ShapeIndicator'
export { export {

View file

@ -99,7 +99,6 @@ export function Canvas({ className }: { className?: string }) {
> >
{Background && <Background />} {Background && <Background />}
<GridWrapper /> <GridWrapper />
<UiLogger />
<svg className="tl-svg-context"> <svg className="tl-svg-context">
<defs> <defs>
{shapeSvgDefs} {shapeSvgDefs}
@ -511,26 +510,6 @@ const DebugSvgCopy = track(function DupSvg({ id }: { id: TLShapeId }) {
) )
}) })
function UiLogger() {
const uiLog = useValue('debugging ui log', () => debugFlags.logMessages.get(), [debugFlags])
if (!uiLog.length) return null
return (
<div className="debug__ui-logger">
{uiLog.map((message, messageIndex) => {
const text = typeof message === 'string' ? message : JSON.stringify(message)
return (
<div className="debug__ui-logger__line" key={messageIndex}>
{text}
</div>
)
})}
</div>
)
}
function SelectionForegroundWrapper() { function SelectionForegroundWrapper() {
const editor = useEditor() const editor = useEditor()
const selectionRotation = useValue('selection rotation', () => editor.getSelectionRotation(), [ const selectionRotation = useValue('selection rotation', () => editor.getSelectionRotation(), [

View file

@ -1,31 +0,0 @@
import { track } from '@tldraw/state'
import classNames from 'classnames'
import { HTMLProps, useLayoutEffect, useRef } from 'react'
import { useEditor } from '../hooks/useEditor'
/** @public */
export const PositionedOnCanvas = track(function PositionedOnCanvas({
x: offsetX = 0,
y: offsetY = 0,
rotation = 0,
...rest
}: {
x?: number
y?: number
rotation?: number
} & HTMLProps<HTMLDivElement>) {
const editor = useEditor()
const rContainer = useRef<HTMLDivElement>(null)
const camera = editor.getCamera()
useLayoutEffect(() => {
const { x, y, z } = editor.getCamera()
const elm = rContainer.current
if (!elm) return
if (x === undefined) return
elm.style.transform = `translate(${x}px, ${y}px) scale(${z}) rotate(${rotation}rad) translate(${offsetX}px, ${offsetY}px)`
}, [camera, editor, offsetX, offsetY, rotation])
return <div ref={rContainer} {...rest} className={classNames('tl-positioned', rest.className)} />
})

View file

@ -2,9 +2,9 @@ import { useStateTracking, useValue } from '@tldraw/state'
import { TLShape, TLShapeId } from '@tldraw/tlschema' import { TLShape, TLShapeId } from '@tldraw/tlschema'
import classNames from 'classnames' import classNames from 'classnames'
import * as React from 'react' import * as React from 'react'
import { useEditor } from '../..'
import type { Editor } from '../editor/Editor' import type { Editor } from '../editor/Editor'
import { ShapeUtil } from '../editor/shapes/ShapeUtil' import { ShapeUtil } from '../editor/shapes/ShapeUtil'
import { useEditor } from '../hooks/useEditor'
import { useEditorComponents } from '../hooks/useEditorComponents' import { useEditorComponents } from '../hooks/useEditorComponents'
import { OptionalErrorBoundary } from './ErrorBoundary' import { OptionalErrorBoundary } from './ErrorBoundary'

View file

@ -50,6 +50,7 @@ import {
} from '../components/default-components/DefaultSnapIndictor' } from '../components/default-components/DefaultSnapIndictor'
import { DefaultSpinner, TLSpinnerComponent } from '../components/default-components/DefaultSpinner' import { DefaultSpinner, TLSpinnerComponent } from '../components/default-components/DefaultSpinner'
import { DefaultSvgDefs, TLSvgDefsComponent } from '../components/default-components/DefaultSvgDefs' import { DefaultSvgDefs, TLSvgDefsComponent } from '../components/default-components/DefaultSvgDefs'
import { useShallowObjectIdentity } from './useIdentity'
export interface BaseEditorComponents { export interface BaseEditorComponents {
Background: TLBackgroundComponent Background: TLBackgroundComponent
@ -97,7 +98,11 @@ type ComponentsContextProviderProps = {
children: any children: any
} }
export function EditorComponentsProvider({ overrides, children }: ComponentsContextProviderProps) { export function EditorComponentsProvider({
overrides = {},
children,
}: ComponentsContextProviderProps) {
const _overrides = useShallowObjectIdentity(overrides)
return ( return (
<EditorComponentsContext.Provider <EditorComponentsContext.Provider
value={useMemo( value={useMemo(
@ -127,9 +132,9 @@ export function EditorComponentsProvider({ overrides, children }: ComponentsCont
HoveredShapeIndicator: DefaultHoveredShapeIndicator, HoveredShapeIndicator: DefaultHoveredShapeIndicator,
OnTheCanvas: null, OnTheCanvas: null,
InFrontOfTheCanvas: null, InFrontOfTheCanvas: null,
...overrides, ..._overrides,
}), }),
[overrides] [_overrides]
)} )}
> >
{children} {children}

View file

@ -12,26 +12,25 @@ export const featureFlags: Record<string, DebugFlag<boolean>> = {
} }
/** @internal */ /** @internal */
export const debugFlags = { export const pointerCaptureTrackingObject = createDebugValue(
'pointerCaptureTrackingObject',
// ideally we wouldn't store this mutable value in an atom but it's not
// a big deal for debug values
{
defaults: { all: new Map<Element, number>() },
shouldStoreForSession: false,
}
)
/** @internal */
export const debugFlags: Record<string, DebugFlag<boolean>> = {
// --- DEBUG VALUES --- // --- DEBUG VALUES ---
preventDefaultLogging: createDebugValue('preventDefaultLogging', { preventDefaultLogging: createDebugValue('preventDefaultLogging', {
defaults: { all: false }, defaults: { all: false },
}), }),
pointerCaptureLogging: createDebugValue('pointerCaptureLogging', {
defaults: { all: false },
}),
pointerCaptureTracking: createDebugValue('pointerCaptureTracking', { pointerCaptureTracking: createDebugValue('pointerCaptureTracking', {
defaults: { all: false }, defaults: { all: false },
}), }),
pointerCaptureTrackingObject: createDebugValue(
'pointerCaptureTrackingObject',
// ideally we wouldn't store this mutable value in an atom but it's not
// a big deal for debug values
{
defaults: { all: new Map<Element, number>() },
shouldStoreForSession: false,
}
),
elementRemovalLogging: createDebugValue('elementRemovalLogging', { elementRemovalLogging: createDebugValue('elementRemovalLogging', {
defaults: { all: false }, defaults: { all: false },
}), }),
@ -44,7 +43,6 @@ export const debugFlags = {
throwToBlob: createDebugValue('throwToBlob', { throwToBlob: createDebugValue('throwToBlob', {
defaults: { all: false }, defaults: { all: false },
}), }),
logMessages: createDebugValue('uiLog', { defaults: { all: [] as any[] } }),
resetConnectionEveryPing: createDebugValue('resetConnectionEveryPing', { resetConnectionEveryPing: createDebugValue('resetConnectionEveryPing', {
defaults: { all: false }, defaults: { all: false },
}), }),
@ -62,12 +60,6 @@ declare global {
} }
} }
if (typeof window !== 'undefined') {
window.tldrawLog = (message: any) => {
debugFlags.logMessages.set(debugFlags.logMessages.get().concat(message))
}
}
// --- 2. USE --- // --- 2. USE ---
// In normal code, read from debug flags directly by calling .value on them: // In normal code, read from debug flags directly by calling .value on them:
// if (debugFlags.preventDefaultLogging.value) { ... } // if (debugFlags.preventDefaultLogging.value) { ... }

View file

@ -14,7 +14,7 @@ whatever reason.
*/ */
import React from 'react' import React from 'react'
import { debugFlags } from './debug-flags' import { debugFlags, pointerCaptureTrackingObject } from './debug-flags'
/** @public */ /** @public */
export function loopToHtmlElement(elm: Element): HTMLElement { export function loopToHtmlElement(elm: Element): HTMLElement {
@ -49,10 +49,8 @@ export function setPointerCapture(
) { ) {
element.setPointerCapture(event.pointerId) element.setPointerCapture(event.pointerId)
if (debugFlags.pointerCaptureTracking.get()) { if (debugFlags.pointerCaptureTracking.get()) {
const trackingObj = debugFlags.pointerCaptureTrackingObject.get() const trackingObj = pointerCaptureTrackingObject.get()
trackingObj.set(element, (trackingObj.get(element) ?? 0) + 1) trackingObj.set(element, (trackingObj.get(element) ?? 0) + 1)
}
if (debugFlags.pointerCaptureLogging.get()) {
console.warn('setPointerCapture called on element:', element, event) console.warn('setPointerCapture called on element:', element, event)
} }
} }
@ -68,7 +66,7 @@ export function releasePointerCapture(
element.releasePointerCapture(event.pointerId) element.releasePointerCapture(event.pointerId)
if (debugFlags.pointerCaptureTracking.get()) { if (debugFlags.pointerCaptureTracking.get()) {
const trackingObj = debugFlags.pointerCaptureTrackingObject.get() const trackingObj = pointerCaptureTrackingObject.get()
if (trackingObj.get(element) === 1) { if (trackingObj.get(element) === 1) {
trackingObj.delete(element) trackingObj.delete(element)
} else if (trackingObj.has(element)) { } else if (trackingObj.has(element)) {
@ -77,9 +75,7 @@ export function releasePointerCapture(
console.warn('Release without capture') console.warn('Release without capture')
} }
} }
if (debugFlags.pointerCaptureLogging.get()) { console.warn('releasePointerCapture called on element:', element, event)
console.warn('releasePointerCapture called on element:', element, event)
}
} }
/** @public */ /** @public */

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -39,56 +39,29 @@ export { SelectTool } from './lib/tools/SelectTool/SelectTool'
export { ZoomTool } from './lib/tools/ZoomTool/ZoomTool' export { ZoomTool } from './lib/tools/ZoomTool/ZoomTool'
// UI // UI
export { TldrawUi, type TldrawUiBaseProps, type TldrawUiProps } from './lib/ui/TldrawUi' export { TldrawUi, type TldrawUiBaseProps, type TldrawUiProps } from './lib/ui/TldrawUi'
export {
TldrawUiContextProvider,
type TldrawUiContextProviderProps,
} from './lib/ui/TldrawUiContextProvider'
export { setDefaultUiAssetUrls, type TLUiAssetUrlOverrides } from './lib/ui/assetUrls' export { setDefaultUiAssetUrls, type TLUiAssetUrlOverrides } from './lib/ui/assetUrls'
export { ContextMenu, type TLUiContextMenuProps } from './lib/ui/components/ContextMenu'
export { OfflineIndicator } from './lib/ui/components/OfflineIndicator/OfflineIndicator' export { OfflineIndicator } from './lib/ui/components/OfflineIndicator/OfflineIndicator'
export { Spinner } from './lib/ui/components/Spinner' export { Spinner } from './lib/ui/components/Spinner'
export { Button, type TLUiButtonProps } from './lib/ui/components/primitives/Button' export { Button, type TLUiButtonProps } from './lib/ui/components/primitives/Button'
export { Icon, type TLUiIconProps } from './lib/ui/components/primitives/Icon' export { Icon, type TLUiIconProps } from './lib/ui/components/primitives/Icon'
export { Input, type TLUiInputProps } from './lib/ui/components/primitives/Input' export { Input, type TLUiInputProps } from './lib/ui/components/primitives/Input'
export { export {
compactMenuItems, TldrawUiContextProvider,
findMenuItem, type TldrawUiContextProviderProps,
menuCustom, } from './lib/ui/context/TldrawUiContextProvider'
menuGroup,
menuItem,
menuSubmenu,
type TLUiCustomMenuItem,
type TLUiMenuChild,
type TLUiMenuGroup,
type TLUiMenuItem,
type TLUiMenuSchema,
type TLUiSubMenu,
} from './lib/ui/hooks/menuHelpers'
export { export {
useActions, useActions,
type TLUiActionItem, type TLUiActionItem,
type TLUiActionsContextType, type TLUiActionsContextType,
} from './lib/ui/hooks/useActions' } from './lib/ui/context/actions'
export { export { AssetUrlsProvider, useAssetUrls } from './lib/ui/context/asset-urls'
useActionsMenuSchema, export { BreakPointProvider, useBreakpoint } from './lib/ui/context/breakpoints'
type TLUiActionsMenuSchemaContextType,
} from './lib/ui/hooks/useActionsMenuSchema'
export { AssetUrlsProvider, useAssetUrls } from './lib/ui/hooks/useAssetUrls'
export { BreakPointProvider, useBreakpoint } from './lib/ui/hooks/useBreakpoint'
export { useCanRedo } from './lib/ui/hooks/useCanRedo'
export { useCanUndo } from './lib/ui/hooks/useCanUndo'
export { useMenuClipboardEvents, useNativeClipboardEvents } from './lib/ui/hooks/useClipboardEvents'
export {
useContextMenuSchema,
type TLUiContextTTLUiMenuSchemaContextType,
} from './lib/ui/hooks/useContextMenuSchema'
export { useCopyAs } from './lib/ui/hooks/useCopyAs'
export { export {
useDialogs, useDialogs,
type TLUiDialog, type TLUiDialog,
type TLUiDialogProps, type TLUiDialogProps,
type TLUiDialogsContextType, type TLUiDialogsContextType,
} from './lib/ui/hooks/useDialogsProvider' } from './lib/ui/context/dialogs'
export { export {
UiEventsProvider, UiEventsProvider,
useUiEvents, useUiEvents,
@ -97,32 +70,20 @@ export {
type TLUiEventHandler, type TLUiEventHandler,
type TLUiEventMap, type TLUiEventMap,
type TLUiEventSource, type TLUiEventSource,
} from './lib/ui/hooks/useEventsProvider' } from './lib/ui/context/events'
export { useExportAs } from './lib/ui/hooks/useExportAs'
export {
useHelpMenuSchema,
type TLUiHelpMenuSchemaContextType,
} from './lib/ui/hooks/useHelpMenuSchema'
export { useKeyboardShortcuts } from './lib/ui/hooks/useKeyboardShortcuts'
export {
useKeyboardShortcutsSchema,
type TLUiKeyboardShortcutsSchemaContextType,
type TLUiKeyboardShortcutsSchemaProviderProps,
} from './lib/ui/hooks/useKeyboardShortcutsSchema'
export { useLocalStorageState } from './lib/ui/hooks/useLocalStorageState'
export { useMenuIsOpen } from './lib/ui/hooks/useMenuIsOpen'
export {
useMenuSchema,
type TLUiMenuSchemaContextType,
type TLUiMenuSchemaProviderProps,
} from './lib/ui/hooks/useMenuSchema'
export { useReadonly } from './lib/ui/hooks/useReadonly'
export { export {
useToasts, useToasts,
type TLUiToast, type TLUiToast,
type TLUiToastAction, type TLUiToastAction,
type TLUiToastsContextType, type TLUiToastsContextType,
} from './lib/ui/hooks/useToastsProvider' } from './lib/ui/context/toasts'
export { useMenuClipboardEvents, useNativeClipboardEvents } from './lib/ui/hooks/useClipboardEvents'
export { useCopyAs } from './lib/ui/hooks/useCopyAs'
export { useExportAs } from './lib/ui/hooks/useExportAs'
export { useKeyboardShortcuts } from './lib/ui/hooks/useKeyboardShortcuts'
export { useLocalStorageState } from './lib/ui/hooks/useLocalStorageState'
export { useMenuIsOpen } from './lib/ui/hooks/useMenuIsOpen'
export { useReadonly } from './lib/ui/hooks/useReadonly'
export { export {
toolbarItem, toolbarItem,
useToolbarSchema, useToolbarSchema,
@ -170,9 +131,150 @@ export {
type TldrawFile, type TldrawFile,
} from './lib/utils/tldr/file' } from './lib/utils/tldr/file'
import * as Dialog from './lib/ui/components/primitives/Dialog' // Minimap default component
import * as DropdownMenu from './lib/ui/components/primitives/DropdownMenu' export { DefaultMinimap } from './lib/ui/components/Minimap/DefaultMinimap'
// N.B. Preserve order of import / export here with this comment. // Helper to unwrap label from action items
// Sometimes this can cause an import problem depending on build setup downstream. export { unwrapLabel } from './lib/ui/context/actions'
export { Dialog, DropdownMenu }
// General UI components for building menus
export {
TldrawUiMenuCheckboxItem,
type TLUiMenuCheckboxItemProps,
} from './lib/ui/components/menus/TldrawUiMenuCheckboxItem'
export {
TldrawUiMenuContextProvider,
type TLUiMenuContextProviderProps,
} from './lib/ui/components/menus/TldrawUiMenuContext'
export {
TldrawUiMenuGroup,
type TLUiMenuGroupProps,
} from './lib/ui/components/menus/TldrawUiMenuGroup'
export {
TldrawUiMenuItem,
type TLUiMenuItemProps,
} from './lib/ui/components/menus/TldrawUiMenuItem'
export {
TldrawUiMenuSubmenu,
type TLUiMenuSubmenuProps,
} from './lib/ui/components/menus/TldrawUiMenuSubmenu'
export {
TldrawUiComponentsProvider,
useTldrawUiComponents,
type TLUiComponents,
} from './lib/ui/context/components'
// Menus / UI elements that can be customized
export { DefaultPageMenu } from './lib/ui/components/PageMenu/DefaultPageMenu'
export {
DefaultStylePanel,
type TLUiStylePanelProps,
} from './lib/ui/components/StylePanel/DefaultStylePanel'
export {
DefaultStylePanelContent,
type TLUiStylePanelContentProps,
} from './lib/ui/components/StylePanel/DefaultStylePanelContent'
export {
DefaultActionsMenu,
type TLUiActionsMenuProps,
} from './lib/ui/components/ActionsMenu/DefaultActionsMenu'
export { DefaultActionsMenuContent } from './lib/ui/components/ActionsMenu/DefaultActionsMenuContent'
export {
DefaultContextMenu as ContextMenu,
DefaultContextMenu,
type TLUiContextMenuProps,
} from './lib/ui/components/ContextMenu/DefaultContextMenu'
export { DefaultContextMenuContent } from './lib/ui/components/ContextMenu/DefaultContextMenuContent'
export {
DefaultHelpMenu,
type TLUiHelpMenuProps,
} from './lib/ui/components/HelpMenu/DefaultHelpMenu'
export { DefaultHelpMenuContent } from './lib/ui/components/HelpMenu/DefaultHelpMenuContent'
export {
DefaultMainMenu,
type TLUiMainMenuProps,
} from './lib/ui/components/MainMenu/DefaultMainMenu'
export { DefaultMainMenuContent } from './lib/ui/components/MainMenu/DefaultMainMenuContent'
export {
DefaultQuickActions,
type TLUiQuickActionsProps,
} from './lib/ui/components/QuickActions/DefaultQuickActions'
export { DefaultQuickActionsContent } from './lib/ui/components/QuickActions/DefaultQuickActionsContent'
export {
DefaultZoomMenu,
type TLUiZoomMenuProps,
} from './lib/ui/components/ZoomMenu/DefaultZoomMenu'
export { DefaultZoomMenuContent } from './lib/ui/components/ZoomMenu/DefaultZoomMenuContent'
export {
DefaultHelperButtons,
type TLUiHelperButtonsProps,
} from './lib/ui/components/HelperButtons/DefaultHelperButtons'
export { DefaultHelperButtonsContent } from './lib/ui/components/HelperButtons/DefaultHelperButtonsContent'
export {
DefaultKeyboardShortcutsDialog,
type TLUiKeyboardShortcutsDialogProps,
} from './lib/ui/components/KeyboardShortcutsDialog/DefaultKeyboardShortcutsDialog'
export { DefaultKeyboardShortcutsDialogContent } from './lib/ui/components/KeyboardShortcutsDialog/DefaultKeyboardShortcutsDialogContent'
export {
DefaultDebugMenu,
type TLUiDebugMenuProps,
} from './lib/ui/components/DebugMenu/DefaultDebugMenu'
export { DefaultDebugMenuContent } from './lib/ui/components/DebugMenu/DefaultDebugMenuContent'
export { DefaultToolbar } from './lib/ui/components/Toolbar/DefaultToolbar'
export { type TLComponents } from './lib/Tldraw'
export {
DialogBody,
DialogCloseButton,
DialogFooter,
DialogHeader,
DialogTitle,
type TLUiDialogBodyProps,
type TLUiDialogFooterProps,
type TLUiDialogHeaderProps,
type TLUiDialogTitleProps,
} from './lib/ui/components/primitives/Dialog'
export {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuIndicator,
DropdownMenuItem,
DropdownMenuRadioItem,
DropdownMenuRoot,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
type TLUiDropdownMenuCheckboxItemProps,
type TLUiDropdownMenuContentProps,
type TLUiDropdownMenuGroupProps,
type TLUiDropdownMenuItemProps,
type TLUiDropdownMenuRadioItemProps,
type TLUiDropdownMenuRootProps,
type TLUiDropdownMenuSubProps,
type TLUiDropdownMenuSubTriggerProps,
type TLUiDropdownMenuTriggerProps,
} from './lib/ui/components/primitives/DropdownMenu'
export {
Popover,
PopoverContent,
PopoverTrigger,
type TLUiPopoverContentProps,
type TLUiPopoverProps,
type TLUiPopoverTriggerProps,
} from './lib/ui/components/primitives/Popover'

View file

@ -4,13 +4,13 @@ import {
ErrorScreen, ErrorScreen,
LoadingScreen, LoadingScreen,
StoreSnapshot, StoreSnapshot,
TLEditorComponents,
TLOnMountHandler, TLOnMountHandler,
TLRecord, TLRecord,
TLStore, TLStore,
TLStoreWithStatus, TLStoreWithStatus,
TldrawEditor, TldrawEditor,
TldrawEditorBaseProps, TldrawEditorBaseProps,
TldrawEditorProps,
assert, assert,
useEditor, useEditor,
useShallowArrayIdentity, useShallowArrayIdentity,
@ -31,29 +31,37 @@ import { defaultShapeUtils } from './defaultShapeUtils'
import { registerDefaultSideEffects } from './defaultSideEffects' import { registerDefaultSideEffects } from './defaultSideEffects'
import { defaultTools } from './defaultTools' import { defaultTools } from './defaultTools'
import { TldrawUi, TldrawUiProps } from './ui/TldrawUi' import { TldrawUi, TldrawUiProps } from './ui/TldrawUi'
import { ContextMenu } from './ui/components/ContextMenu' import { TLUiComponents, useTldrawUiComponents } from './ui/context/components'
import { usePreloadAssets } from './ui/hooks/usePreloadAssets' import { usePreloadAssets } from './ui/hooks/usePreloadAssets'
import { useDefaultEditorAssetsWithOverrides } from './utils/static-assets/assetUrls' import { useDefaultEditorAssetsWithOverrides } from './utils/static-assets/assetUrls'
/**@public */
export type TLComponents = TLEditorComponents & TLUiComponents
/** @public */ /** @public */
export type TldrawProps = TldrawEditorBaseProps & export type TldrawProps =
( // combine components from base editor and ui
| { (Omit<TldrawUiProps, 'components'> &
store: TLStore | TLStoreWithStatus Omit<TldrawEditorBaseProps, 'components'> & {
} components?: TLComponents
| { }) &
store?: undefined // external content
persistenceKey?: string Partial<TLExternalContentProps> &
sessionId?: string // store stuff
defaultName?: string (| {
/** store: TLStore | TLStoreWithStatus
* A snapshot to load for the store's initial data / schema. }
*/ | {
snapshot?: StoreSnapshot<TLRecord> store?: undefined
} persistenceKey?: string
) & sessionId?: string
TldrawUiProps & defaultName?: string
Partial<TLExternalContentProps> /**
* A snapshot to load for the store's initial data / schema.
*/
snapshot?: StoreSnapshot<TLRecord>
}
)
/** @public */ /** @public */
export function Tldraw(props: TldrawProps) { export function Tldraw(props: TldrawProps) {
@ -64,31 +72,37 @@ export function Tldraw(props: TldrawProps) {
acceptedImageMimeTypes, acceptedImageMimeTypes,
acceptedVideoMimeTypes, acceptedVideoMimeTypes,
onMount, onMount,
components = {},
shapeUtils = [],
tools = [],
...rest ...rest
} = props } = props
const components = useShallowObjectIdentity(rest.components ?? {}) const _components = useShallowObjectIdentity(components)
const shapeUtils = useShallowArrayIdentity(rest.shapeUtils ?? []) const componentsWithDefault = useMemo(
const tools = useShallowArrayIdentity(rest.tools ?? []) () => ({
Scribble: TldrawScribble,
CollaboratorScribble: TldrawScribble,
SelectionForeground: TldrawSelectionForeground,
SelectionBackground: TldrawSelectionBackground,
Handles: TldrawHandles,
HoveredShapeIndicator: TldrawHoveredShapeIndicator,
..._components,
}),
[_components]
)
const withDefaults: TldrawEditorProps = { const _shapeUtils = useShallowArrayIdentity(shapeUtils)
initialState: 'select', const shapeUtilsWithDefaults = useMemo(
...rest, () => [...defaultShapeUtils, ..._shapeUtils],
components: useMemo( [_shapeUtils]
() => ({ )
Scribble: TldrawScribble,
CollaboratorScribble: TldrawScribble, const _tools = useShallowArrayIdentity(tools)
SelectionForeground: TldrawSelectionForeground, const toolsWithDefaults = useMemo(
SelectionBackground: TldrawSelectionBackground, () => [...defaultTools, ...defaultShapeTools, ..._tools],
Handles: TldrawHandles, [_tools]
HoveredShapeIndicator: TldrawHoveredShapeIndicator, )
...components,
}),
[components]
),
shapeUtils: useMemo(() => [...defaultShapeUtils, ...shapeUtils], [shapeUtils]),
tools: useMemo(() => [...defaultTools, ...defaultShapeTools, ...tools], [tools]),
}
const assets = useDefaultEditorAssetsWithOverrides(rest.assetUrls) const assets = useDefaultEditorAssetsWithOverrides(rest.assetUrls)
@ -103,11 +117,14 @@ export function Tldraw(props: TldrawProps) {
} }
return ( return (
<TldrawEditor {...withDefaults}> <TldrawEditor
<TldrawUi {...withDefaults}> initialState="select"
<ContextMenu> {...rest}
<Canvas /> components={componentsWithDefault}
</ContextMenu> shapeUtils={shapeUtilsWithDefaults}
tools={toolsWithDefaults}
>
<TldrawUi {...rest} components={componentsWithDefault}>
<InsideOfEditorContext <InsideOfEditorContext
maxImageDimension={maxImageDimension} maxImageDimension={maxImageDimension}
maxAssetSize={maxAssetSize} maxAssetSize={maxAssetSize}
@ -121,12 +138,21 @@ export function Tldraw(props: TldrawProps) {
) )
} }
const defaultAcceptedImageMimeTypes = Object.freeze([
'image/jpeg',
'image/png',
'image/gif',
'image/svg+xml',
])
const defaultAcceptedVideoMimeTypes = Object.freeze(['video/mp4', 'video/quicktime'])
// We put these hooks into a component here so that they can run inside of the context provided by TldrawEditor. // We put these hooks into a component here so that they can run inside of the context provided by TldrawEditor.
function InsideOfEditorContext({ function InsideOfEditorContext({
maxImageDimension = 1000, maxImageDimension = 1000,
maxAssetSize = 10 * 1024 * 1024, // 10mb maxAssetSize = 10 * 1024 * 1024, // 10mb
acceptedImageMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'], acceptedImageMimeTypes = defaultAcceptedImageMimeTypes,
acceptedVideoMimeTypes = ['video/mp4', 'video/quicktime'], acceptedVideoMimeTypes = defaultAcceptedVideoMimeTypes,
onMount, onMount,
}: Partial<TLExternalContentProps & { onMount: TLOnMountHandler }>) { }: Partial<TLExternalContentProps & { onMount: TLOnMountHandler }>) {
const editor = useEditor() const editor = useEditor()
@ -156,7 +182,10 @@ function InsideOfEditorContext({
if (editor) return onMountEvent?.(editor) if (editor) return onMountEvent?.(editor)
}, [editor, onMountEvent]) }, [editor, onMountEvent])
return null const { ContextMenu } = useTldrawUiComponents()
if (!ContextMenu) return <Canvas />
return <ContextMenu canvas={<Canvas />} />
} }
// duped from tldraw editor // duped from tldraw editor

View file

@ -30,9 +30,9 @@ export type TLExternalContentProps = {
// The maximum size (in bytes) of an asset. Assets larger than this will be rejected. Defaults to 10mb (10 * 1024 * 1024). // The maximum size (in bytes) of an asset. Assets larger than this will be rejected. Defaults to 10mb (10 * 1024 * 1024).
maxAssetSize: number maxAssetSize: number
// The mime types of images that are allowed to be handled. Defaults to ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml']. // The mime types of images that are allowed to be handled. Defaults to ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'].
acceptedImageMimeTypes: string[] acceptedImageMimeTypes: readonly string[]
// The mime types of videos that are allowed to be handled. Defaults to ['video/mp4', 'video/webm', 'video/quicktime']. // The mime types of videos that are allowed to be handled. Defaults to ['video/mp4', 'video/webm', 'video/quicktime'].
acceptedVideoMimeTypes: string[] acceptedVideoMimeTypes: readonly string[]
} }
export function registerDefaultExternalContentHandlers( export function registerDefaultExternalContentHandlers(

View file

@ -38,6 +38,7 @@
.tlui-button:disabled { .tlui-button:disabled {
color: var(--color-text-3); color: var(--color-text-3);
text-shadow: none; text-shadow: none;
cursor: default;
} }
.tlui-button:disabled .tlui-kbd { .tlui-button:disabled .tlui-kbd {
@ -310,6 +311,9 @@
.tlui-buttons__horizontal > *:nth-last-child(1) { .tlui-buttons__horizontal > *:nth-last-child(1) {
margin-right: 0px; margin-right: 0px;
} }
.tlui-buttons__horizontal > *:only-child {
width: 56px;
}
/* Button Grid */ /* Button Grid */
@ -504,7 +508,6 @@
grid-auto-flow: column; grid-auto-flow: column;
grid-template-columns: auto; grid-template-columns: auto;
grid-auto-columns: minmax(1em, auto); grid-auto-columns: minmax(1em, auto);
gap: 1px;
align-self: bottom; align-self: bottom;
color: var(--color-text-1); color: var(--color-text-1);
margin-left: var(--space-4); margin-left: var(--space-4);
@ -860,6 +863,10 @@
height: 48px; height: 48px;
} }
.tlui-toolbar__extras:empty {
display: none;
}
.tlui-toolbar__extras__controls { .tlui-toolbar__extras__controls {
display: flex; display: flex;
position: relative; position: relative;
@ -928,6 +935,10 @@
/* ---------------------- Menu ---------------------- */ /* ---------------------- Menu ---------------------- */
.tlui-menu:empty {
display: none;
}
.tlui-menu { .tlui-menu {
z-index: var(--layer-menus); z-index: var(--layer-menus);
height: fit-content; height: fit-content;
@ -954,24 +965,15 @@
stroke-width: 1px; stroke-width: 1px;
} }
.tlui-menu__group[data-size='large'] { .tlui-menu__group:empty {
min-width: initial; display: none;
} }
.tlui-menu__group[data-size='medium'] { .tlui-menu__group {
min-width: 144px; border-bottom: 1px solid var(--color-divider);
} }
.tlui-menu__group:nth-last-of-type(1) {
.tlui-menu__group[data-size='small'] { border-bottom: none;
min-width: 96px;
}
.tlui-menu__group[data-size='tiny'] {
min-width: 0px;
}
.tlui-menu__group + .tlui-menu__group {
border-top: 1px solid var(--color-divider);
} }
.tlui-menu__submenu__trigger[data-state='open']:not(:hover)::after { .tlui-menu__submenu__trigger[data-state='open']:not(:hover)::after {
@ -984,6 +986,27 @@
background: linear-gradient(270deg, rgba(144, 144, 144, 0) 0%, var(--color-muted-2) 100%); background: linear-gradient(270deg, rgba(144, 144, 144, 0) 0%, var(--color-muted-2) 100%);
} }
/* Menu Sizes */
.tlui-menu[data-size='large'] > .tlui-menu__group,
.tlui-menu__submenu__content[data-size='large'] > .tlui-menu__group {
min-width: initial;
}
.tlui-menu[data-size='medium'] > .tlui-menu__group,
.tlui-menu__submenu__content[data-size='medium'] > .tlui-menu__group {
min-width: 144px;
}
.tlui-menu[data-size='small'] > .tlui-menu__group,
.tlui-menu__submenu__content[data-size='small'] > .tlui-menu__group {
min-width: 96px;
}
.tlui-menu[data-size='tiny'] > .tlui-menu__group,
.tlui-menu__submenu__content[data-size='tiny'] > .tlui-menu__group {
min-width: 0px;
}
/* ------------------ Actions Menu ------------------ */ /* ------------------ Actions Menu ------------------ */
.tlui-actions-menu { .tlui-actions-menu {
@ -1105,7 +1128,7 @@
/* ------------------- Navigation ------------------- */ /* ------------------- Navigation ------------------- */
.tlui-navigation-zone { .tlui-navigation-panel {
display: flex; display: flex;
width: min-content; width: min-content;
flex-direction: column; flex-direction: column;
@ -1116,7 +1139,7 @@
bottom: 0px; bottom: 0px;
} }
.tlui-navigation-zone::before { .tlui-navigation-panel::before {
content: ''; content: '';
display: block; display: block;
position: absolute; position: absolute;
@ -1129,16 +1152,16 @@
background-color: var(--color-low); background-color: var(--color-low);
} }
.tlui-navigation-zone__toggle .tlui-icon { .tlui-navigation-panel__toggle .tlui-icon {
opacity: 0.24; opacity: 0.24;
} }
.tlui-navigation-zone__toggle:active .tlui-icon { .tlui-navigation-panel__toggle:active .tlui-icon {
opacity: 1; opacity: 1;
} }
@media (hover: hover) { @media (hover: hover) {
.tlui-navigation-zone__toggle:hover .tlui-icon { .tlui-navigation-panel__toggle:hover .tlui-icon {
opacity: 1; opacity: 1;
} }
} }

View file

@ -2,26 +2,24 @@ import { ToastProvider } from '@radix-ui/react-toast'
import { useEditor, useValue } from '@tldraw/editor' import { useEditor, useValue } from '@tldraw/editor'
import classNames from 'classnames' import classNames from 'classnames'
import React, { ReactNode } from 'react' import React, { ReactNode } from 'react'
import { TldrawUiContextProvider, TldrawUiContextProviderProps } from './TldrawUiContextProvider'
import { TLUiAssetUrlOverrides } from './assetUrls' import { TLUiAssetUrlOverrides } from './assetUrls'
import { BackToContent } from './components/BackToContent'
import { DebugPanel } from './components/DebugPanel' import { DebugPanel } from './components/DebugPanel'
import { Dialogs } from './components/Dialogs' import { Dialogs } from './components/Dialogs'
import { FollowingIndicator } from './components/FollowingIndicator' import { FollowingIndicator } from './components/FollowingIndicator'
import { HelpMenu } from './components/HelpMenu'
import { MenuZone } from './components/MenuZone' import { MenuZone } from './components/MenuZone'
import { NavigationZone } from './components/NavigationZone/NavigationZone'
import { ExitPenMode } from './components/PenModeToggle'
import { StopFollowing } from './components/StopFollowing'
import { StylePanel } from './components/StylePanel/StylePanel'
import { ToastViewport, Toasts } from './components/Toasts' import { ToastViewport, Toasts } from './components/Toasts'
import { Toolbar } from './components/Toolbar/Toolbar'
import { Button } from './components/primitives/Button' import { Button } from './components/primitives/Button'
import { useActions } from './hooks/useActions' import {
import { useBreakpoint } from './hooks/useBreakpoint' TldrawUiContextProvider,
TldrawUiContextProviderProps,
} from './context/TldrawUiContextProvider'
import { useActions } from './context/actions'
import { useBreakpoint } from './context/breakpoints'
import { TLUiComponents, useTldrawUiComponents } from './context/components'
import { useNativeClipboardEvents } from './hooks/useClipboardEvents' import { useNativeClipboardEvents } from './hooks/useClipboardEvents'
import { useEditorEvents } from './hooks/useEditorEvents' import { useEditorEvents } from './hooks/useEditorEvents'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts' import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { useRelevantStyles } from './hooks/useRevelantStyles'
import { useTranslation } from './hooks/useTranslation/useTranslation' import { useTranslation } from './hooks/useTranslation/useTranslation'
/** /**
@ -47,6 +45,11 @@ export interface TldrawUiBaseProps {
*/ */
hideUi?: boolean hideUi?: boolean
/**
* Overrides for the UI components.
*/
components?: TLUiComponents
/** /**
* A component to use for the share zone (will be deprecated) * A component to use for the share zone (will be deprecated)
*/ */
@ -76,10 +79,11 @@ export const TldrawUi = React.memo(function TldrawUi({
renderDebugMenuItems, renderDebugMenuItems,
children, children,
hideUi, hideUi,
components,
...rest ...rest
}: TldrawUiProps) { }: TldrawUiProps) {
return ( return (
<TldrawUiContextProvider {...rest}> <TldrawUiContextProvider {...rest} components={components}>
<TldrawUiInner <TldrawUiInner
hideUi={hideUi} hideUi={hideUi}
shareZone={shareZone} shareZone={shareZone}
@ -116,11 +120,7 @@ const TldrawUiInner = React.memo(function TldrawUiInner({
) )
}) })
const TldrawUiContent = React.memo(function TldrawUI({ const TldrawUiContent = React.memo(function TldrawUI({ shareZone, topZone }: TldrawUiContentProps) {
shareZone,
topZone,
renderDebugMenuItems,
}: TldrawUiContentProps) {
const editor = useEditor() const editor = useEditor()
const msg = useTranslation() const msg = useTranslation()
const breakpoint = useBreakpoint() const breakpoint = useBreakpoint()
@ -130,6 +130,8 @@ const TldrawUiContent = React.memo(function TldrawUI({
const isFocusMode = useValue('focus', () => editor.getInstanceState().isFocusMode, [editor]) const isFocusMode = useValue('focus', () => editor.getInstanceState().isFocusMode, [editor])
const isDebugMode = useValue('debug', () => editor.getInstanceState().isDebugMode, [editor]) const isDebugMode = useValue('debug', () => editor.getInstanceState().isDebugMode, [editor])
const { StylePanel, Toolbar, HelpMenu, NavigationPanel, HelperButtons } = useTldrawUiComponents()
useKeyboardShortcuts() useKeyboardShortcuts()
useNativeClipboardEvents() useNativeClipboardEvents()
useEditorEvents() useEditorEvents()
@ -159,29 +161,21 @@ const TldrawUiContent = React.memo(function TldrawUI({
<div className="tlui-layout__top"> <div className="tlui-layout__top">
<div className="tlui-layout__top__left"> <div className="tlui-layout__top__left">
<MenuZone /> <MenuZone />
<div className="tlui-helper-buttons"> {HelperButtons && <HelperButtons />}
<ExitPenMode />
<BackToContent />
<StopFollowing />
</div>
</div> </div>
<div className="tlui-layout__top__center">{topZone}</div> <div className="tlui-layout__top__center">{topZone}</div>
<div className="tlui-layout__top__right"> <div className="tlui-layout__top__right">
{shareZone} {shareZone}
{breakpoint >= 5 && !isReadonlyMode && ( {StylePanel && breakpoint >= 5 && !isReadonlyMode && <_StylePanel />}
<div className="tlui-style-panel__wrapper">
<StylePanel />
</div>
)}
</div> </div>
</div> </div>
<div className="tlui-layout__bottom"> <div className="tlui-layout__bottom">
<div className="tlui-layout__bottom__main"> <div className="tlui-layout__bottom__main">
<NavigationZone /> {NavigationPanel && <NavigationPanel />}
<Toolbar /> {Toolbar && <Toolbar />}
{breakpoint >= 4 && <HelpMenu />} {HelpMenu && <HelpMenu />}
</div> </div>
{isDebugMode && <DebugPanel renderDebugMenuItems={renderDebugMenuItems ?? null} />} {isDebugMode && <DebugPanel />}
</div> </div>
</> </>
)} )}
@ -193,3 +187,11 @@ const TldrawUiContent = React.memo(function TldrawUI({
</ToastProvider> </ToastProvider>
) )
}) })
function _StylePanel() {
const { StylePanel } = useTldrawUiComponents()
const relevantStyles = useRelevantStyles()
if (!StylePanel) return null
return <StylePanel relevantStyles={relevantStyles} />
}

View file

@ -1,77 +0,0 @@
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { useContainer } from '@tldraw/editor'
import { memo } from 'react'
import { TLUiMenuChild } from '../hooks/menuHelpers'
import { useActionsMenuSchema } from '../hooks/useActionsMenuSchema'
import { useBreakpoint } from '../hooks/useBreakpoint'
import { useReadonly } from '../hooks/useReadonly'
import { useTranslation } from '../hooks/useTranslation/useTranslation'
import { Button } from './primitives/Button'
import { Popover, PopoverTrigger } from './primitives/Popover'
import { kbdStr } from './primitives/shared'
export const ActionsMenu = memo(function ActionsMenu() {
const msg = useTranslation()
const container = useContainer()
const menuSchema = useActionsMenuSchema()
const isReadonly = useReadonly()
const breakpoint = useBreakpoint()
function getActionMenuItem(item: TLUiMenuChild) {
if (!item) return null
if (isReadonly && !item.readonlyOk) return null
switch (item.type) {
case 'item': {
const { id, icon, label, kbd, onSelect } = item.actionItem
return (
<Button
key={id}
data-testid={`menu-item.${item.id}`}
icon={icon}
type="icon"
title={
label
? kbd
? `${msg(label)} ${kbdStr(kbd)}`
: `${msg(label)}`
: kbd
? `${kbdStr(kbd)}`
: ''
}
onClick={() => onSelect('actions-menu')}
disabled={item.disabled}
/>
)
}
}
}
return (
<Popover id="actions-menu">
<PopoverTrigger>
<Button
className="tlui-menu__trigger"
data-testid="main.action-menu"
icon="dots-vertical"
title={msg('actions-menu.title')}
type="icon" // needs to be here because the trigger also passes down type="button"
smallIcon
/>
</PopoverTrigger>
<PopoverPrimitive.Portal container={container}>
<PopoverPrimitive.Content
className="tlui-popover__content"
side={breakpoint >= 6 ? 'bottom' : 'top'}
dir="ltr"
sideOffset={6}
>
<div className="tlui-actions-menu tlui-buttons__grid">
{menuSchema.map(getActionMenuItem)}
</div>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</Popover>
)
})

View file

@ -0,0 +1,57 @@
import { useEditor, useValue } from '@tldraw/editor'
import { memo } from 'react'
import { useBreakpoint } from '../../context/breakpoints'
import { useReadonly } from '../../hooks/useReadonly'
import { useTranslation } from '../../hooks/useTranslation/useTranslation'
import { TldrawUiMenuContextProvider } from '../menus/TldrawUiMenuContext'
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/Popover'
import { DefaultActionsMenuContent } from './DefaultActionsMenuContent'
/** @public */
export type TLUiActionsMenuProps = {
children?: any
}
/** @public */
export const DefaultActionsMenu = memo(function DefaultActionsMenu({
children,
}: TLUiActionsMenuProps) {
const msg = useTranslation()
const breakpoint = useBreakpoint()
const isReadonlyMode = useReadonly()
const editor = useEditor()
const isInAcceptableReadonlyState = useValue(
'should display quick actions when in readonly',
() => editor.isInAny('hand', 'zoom'),
[editor]
)
// Get the actions menu content, either the default component or the user's
// override. If there's no menu content, then the user has set it to null,
// so skip rendering the menu.
const content = children ?? <DefaultActionsMenuContent />
if (isReadonlyMode && !isInAcceptableReadonlyState) return
return (
<Popover id="actions-menu">
<PopoverTrigger
className="tlui-menu__trigger"
data-testid="main.action-menu"
icon="dots-vertical"
title={msg('actions-menu.title')}
type="icon" // needs to be here because the trigger also passes down type="button"
smallIcon
/>
<PopoverContent side={breakpoint >= 6 ? 'bottom' : 'top'} sideOffset={6}>
<div className="tlui-actions-menu tlui-buttons__grid">
<TldrawUiMenuContextProvider type="icons" sourceId="actions-menu">
{content}
</TldrawUiMenuContextProvider>
</div>
</PopoverContent>
</Popover>
)
})

View file

@ -0,0 +1,135 @@
import { useEditor, useValue } from '@tldraw/editor'
import { useActions } from '../../context/actions'
import { useBreakpoint } from '../../context/breakpoints'
import {
useAllowGroup,
useAllowUngroup,
useHasLinkShapeSelected,
useThreeStackableItems,
useUnlockedSelectedShapesCount,
} from '../../hooks/menu-hooks'
import { TldrawUiMenuItem } from '../menus/TldrawUiMenuItem'
/** @public */
export function DefaultActionsMenuContent() {
return (
<>
<AlignMenuItems />
<DistributeMenuItems />
<StackMenuItems />
<ReorderMenuItems />
<ZoomOrRotateMenuItem />
<RotateCWMenuItem />
<EditLinkMenuItem />
<GroupOrUngroupMenuItem />
</>
)
}
function AlignMenuItems() {
const actions = useActions()
const twoSelected = useUnlockedSelectedShapesCount(2)
return (
<>
<TldrawUiMenuItem {...actions['align-left']} disabled={!twoSelected} />
<TldrawUiMenuItem {...actions['align-center-horizontal']} disabled={!twoSelected} />
<TldrawUiMenuItem {...actions['align-right']} disabled={!twoSelected} />
<TldrawUiMenuItem {...actions['stretch-horizontal']} disabled={!twoSelected} />
<TldrawUiMenuItem {...actions['align-top']} disabled={!twoSelected} />
<TldrawUiMenuItem {...actions['align-center-vertical']} disabled={!twoSelected} />
<TldrawUiMenuItem {...actions['align-bottom']} disabled={!twoSelected} />
<TldrawUiMenuItem {...actions['stretch-vertical']} disabled={!twoSelected} />
</>
)
}
function DistributeMenuItems() {
const actions = useActions()
const threeSelected = useUnlockedSelectedShapesCount(3)
return (
<>
<TldrawUiMenuItem {...actions['distribute-horizontal']} disabled={!threeSelected} />
<TldrawUiMenuItem {...actions['distribute-vertical']} disabled={!threeSelected} />
</>
)
}
function StackMenuItems() {
const actions = useActions()
const threeStackableItems = useThreeStackableItems()
return (
<>
<TldrawUiMenuItem {...actions['stack-horizontal']} disabled={!threeStackableItems} />
<TldrawUiMenuItem {...actions['stack-vertical']} disabled={!threeStackableItems} />
</>
)
}
function ReorderMenuItems() {
const actions = useActions()
const oneSelected = useUnlockedSelectedShapesCount(1)
return (
<>
<TldrawUiMenuItem {...actions['send-to-back']} disabled={!oneSelected} />
<TldrawUiMenuItem {...actions['send-backward']} disabled={!oneSelected} />
<TldrawUiMenuItem {...actions['bring-forward']} disabled={!oneSelected} />
<TldrawUiMenuItem {...actions['bring-to-front']} disabled={!oneSelected} />
</>
)
}
function ZoomOrRotateMenuItem() {
const breakpoint = useBreakpoint()
return breakpoint < 5 ? <ZoomTo100MenuItem /> : <RotateCCWMenuItem />
}
function ZoomTo100MenuItem() {
const actions = useActions()
const editor = useEditor()
const isZoomedTo100 = useValue('zoom is 1', () => editor.getZoomLevel() === 1, [editor])
return <TldrawUiMenuItem {...actions['zoom-to-100']} disabled={isZoomedTo100} />
}
function RotateCCWMenuItem() {
const actions = useActions()
const oneSelected = useUnlockedSelectedShapesCount(1)
return <TldrawUiMenuItem {...actions['rotate-ccw']} disabled={!oneSelected} />
}
function RotateCWMenuItem() {
const actions = useActions()
const oneSelected = useUnlockedSelectedShapesCount(1)
return <TldrawUiMenuItem {...actions['rotate-cw']} disabled={!oneSelected} />
}
function EditLinkMenuItem() {
const actions = useActions()
const showEditLink = useHasLinkShapeSelected()
return <TldrawUiMenuItem {...actions['edit-link']} disabled={!showEditLink} />
}
function GroupOrUngroupMenuItem() {
const allowGroup = useAllowGroup()
const allowUngroup = useAllowUngroup()
return allowGroup ? <GroupMenuItem /> : allowUngroup ? <UngroupMenuItem /> : <GroupMenuItem />
}
function GroupMenuItem() {
const actions = useActions()
const twoSelected = useUnlockedSelectedShapesCount(2)
return <TldrawUiMenuItem {...actions['group']} disabled={!twoSelected} />
}
function UngroupMenuItem() {
const actions = useActions()
return <TldrawUiMenuItem {...actions['ungroup']} />
}

View file

@ -1,233 +0,0 @@
import * as _ContextMenu from '@radix-ui/react-context-menu'
import { Editor, preventDefault, useContainer, useEditor, useValue } from '@tldraw/editor'
import classNames from 'classnames'
import { forwardRef, useCallback, useState } from 'react'
import { TLUiMenuChild } from '../hooks/menuHelpers'
import { useBreakpoint } from '../hooks/useBreakpoint'
import { useContextMenuSchema } from '../hooks/useContextMenuSchema'
import { useMenuIsOpen } from '../hooks/useMenuIsOpen'
import { useReadonly } from '../hooks/useReadonly'
import { TLUiTranslationKey } from '../hooks/useTranslation/TLUiTranslationKey'
import { useTranslation } from '../hooks/useTranslation/useTranslation'
import { TLUiIconType } from '../icon-types'
import { MoveToPageMenu } from './MoveToPageMenu'
import { Button } from './primitives/Button'
import { Icon } from './primitives/Icon'
import { Kbd } from './primitives/Kbd'
/** @public */
export interface TLUiContextMenuProps {
children: any
}
/** @public */
export const ContextMenu = function ContextMenu({ children }: { children: any }) {
const editor = useEditor()
const contextTLUiMenuSchema = useContextMenuSchema()
const cb = useCallback(
(isOpen: boolean) => {
if (!isOpen) {
const onlySelectedShape = editor.getOnlySelectedShape()
if (onlySelectedShape && editor.isShapeOrAncestorLocked(onlySelectedShape)) {
editor.setSelectedShapes([])
}
} else {
// Weird route: selecting locked shapes on long press
if (editor.getInstanceState().isCoarsePointer) {
const selectedShapes = editor.getSelectedShapes()
const {
inputs: { currentPagePoint },
} = editor
// get all of the shapes under the current pointer
const shapesAtPoint = editor.getShapesAtPoint(currentPagePoint)
if (
// if there are no selected shapes
!editor.getSelectedShapes().length ||
// OR if none of the shapes at the point include the selected shape
!shapesAtPoint.some((s) => selectedShapes.includes(s))
) {
// then are there any locked shapes under the current pointer?
const lockedShapes = shapesAtPoint.filter((s) => editor.isShapeOrAncestorLocked(s))
if (lockedShapes.length) {
// nice, let's select them
editor.select(...lockedShapes.map((s) => s.id))
}
}
}
}
},
[editor]
)
const [isOpen, handleOpenChange] = useMenuIsOpen('context menu', cb)
// If every item in the menu is readonly, then we don't want to show the menu
const isReadonly = useReadonly()
const noItemsToShow =
contextTLUiMenuSchema.length === 0 ||
(isReadonly && contextTLUiMenuSchema.every((item) => !item.readonlyOk))
// Todo: remove this dependency on the select tool; not sure how else to say "only show the context menu when we're using a tool that supports it"
const selectToolActive = useValue(
'isSelectToolActive',
() => editor.getCurrentToolId() === 'select',
[editor]
)
const disabled = !selectToolActive || noItemsToShow
return (
<_ContextMenu.Root dir="ltr" onOpenChange={handleOpenChange} modal={false}>
<_ContextMenu.Trigger
onContextMenu={disabled ? preventDefault : undefined}
dir="ltr"
disabled={disabled}
>
{children}
</_ContextMenu.Trigger>
{isOpen && <ContextMenuContent />}
</_ContextMenu.Root>
)
}
const ContextMenuContent = forwardRef(function ContextMenuContent() {
const editor = useEditor()
const msg = useTranslation()
const menuSchema = useContextMenuSchema()
const [_, handleSubOpenChange] = useMenuIsOpen('context menu sub')
const isReadonly = useReadonly()
const breakpoint = useBreakpoint()
const container = useContainer()
const [disableClicks, setDisableClicks] = useState(false)
function getContextMenuItem(
editor: Editor,
item: TLUiMenuChild,
parent: TLUiMenuChild | null,
depth: number
) {
if (!item) return null
if (isReadonly && !item.readonlyOk) return null
switch (item.type) {
case 'custom': {
switch (item.id) {
case 'MOVE_TO_PAGE_MENU': {
return <MoveToPageMenu key={item.id} />
}
}
break
}
case 'group': {
return (
<_ContextMenu.Group
dir="ltr"
className={classNames('tlui-menu__group', {
'tlui-menu__group__small': parent?.type === 'submenu',
})}
data-testid={`menu-item.${item.id}`}
key={item.id}
>
{item.children.map((child) => getContextMenuItem(editor, child, item, depth + 1))}
</_ContextMenu.Group>
)
}
case 'submenu': {
return (
<_ContextMenu.Sub key={item.id} onOpenChange={handleSubOpenChange}>
<_ContextMenu.SubTrigger dir="ltr" disabled={item.disabled} asChild>
<Button
type="menu"
label={item.label as TLUiTranslationKey}
data-testid={`menu-item.${item.id}`}
icon="chevron-right"
/>
</_ContextMenu.SubTrigger>
<_ContextMenu.Portal container={container}>
<_ContextMenu.SubContent className="tlui-menu" sideOffset={-4} collisionPadding={4}>
{item.children.map((child) => getContextMenuItem(editor, child, item, depth + 1))}
</_ContextMenu.SubContent>
</_ContextMenu.Portal>
</_ContextMenu.Sub>
)
}
case 'item': {
if (isReadonly && !item.readonlyOk) return null
const { id, checkbox, contextMenuLabel, label, onSelect, kbd, icon } = item.actionItem
const labelToUse = contextMenuLabel ?? label
const labelStr = labelToUse ? msg(labelToUse as TLUiTranslationKey) : undefined
if (checkbox) {
// Item is in a checkbox group
return (
<_ContextMenu.CheckboxItem
key={id}
className="tlui-button tlui-button__menu tlui-button__checkbox"
dir="ltr"
disabled={item.disabled}
onSelect={(e) => {
onSelect('context-menu')
preventDefault(e)
}}
title={labelStr ? labelStr : undefined}
checked={item.checked}
>
<Icon small icon={item.checked ? 'check' : 'checkbox-empty'} />
{labelStr && (
<span className="tlui-button__label" draggable={false}>
{labelStr}
</span>
)}
{kbd && <Kbd>{kbd}</Kbd>}
</_ContextMenu.CheckboxItem>
)
}
return (
<_ContextMenu.Item key={id} dir="ltr" asChild>
<Button
type="menu"
data-testid={`menu-item.${id}`}
kbd={kbd}
label={labelToUse as TLUiTranslationKey}
disabled={item.disabled}
iconLeft={breakpoint < 3 && depth > 2 ? (icon as TLUiIconType) : undefined}
onClick={() => {
if (disableClicks) {
setDisableClicks(false)
} else {
onSelect('context-menu')
}
}}
/>
</_ContextMenu.Item>
)
}
}
}
return (
<_ContextMenu.Portal container={container}>
<_ContextMenu.Content
className="tlui-menu scrollable"
data-testid="context-menu"
alignOffset={-4}
collisionPadding={4}
onContextMenu={preventDefault}
>
{menuSchema.map((item) => getContextMenuItem(editor, item, null, 0))}
</_ContextMenu.Content>
</_ContextMenu.Portal>
)
})

View file

@ -0,0 +1,90 @@
import * as _ContextMenu from '@radix-ui/react-context-menu'
import { preventDefault, useContainer, useEditor } from '@tldraw/editor'
import { memo, useCallback } from 'react'
import { useMenuIsOpen } from '../../hooks/useMenuIsOpen'
import { TldrawUiMenuContextProvider } from '../menus/TldrawUiMenuContext'
import { DefaultContextMenuContent } from './DefaultContextMenuContent'
/** @public */
export interface TLUiContextMenuProps {
canvas: any
children?: any
}
/** @public */
export const DefaultContextMenu = memo(function DefaultContextMenu({
canvas,
children,
}: TLUiContextMenuProps) {
const editor = useEditor()
const cb = useCallback(
(isOpen: boolean) => {
if (!isOpen) {
const onlySelectedShape = editor.getOnlySelectedShape()
if (onlySelectedShape && editor.isShapeOrAncestorLocked(onlySelectedShape)) {
editor.setSelectedShapes([])
}
} else {
// Weird route: selecting locked shapes on long press
if (editor.getInstanceState().isCoarsePointer) {
const selectedShapes = editor.getSelectedShapes()
const {
inputs: { currentPagePoint },
} = editor
// get all of the shapes under the current pointer
const shapesAtPoint = editor.getShapesAtPoint(currentPagePoint)
if (
// if there are no selected shapes
!editor.getSelectedShapes().length ||
// OR if none of the shapes at the point include the selected shape
!shapesAtPoint.some((s) => selectedShapes.includes(s))
) {
// then are there any locked shapes under the current pointer?
const lockedShapes = shapesAtPoint.filter((s) => editor.isShapeOrAncestorLocked(s))
if (lockedShapes.length) {
// nice, let's select them
editor.select(...lockedShapes.map((s) => s.id))
}
}
}
}
},
[editor]
)
const container = useContainer()
const [isOpen, handleOpenChange] = useMenuIsOpen('context menu', cb)
// Get the context menu content, either the default component or the user's
// override. If there's no menu content, then the user has set it to null,
// so skip rendering the menu.
const content = children ?? <DefaultContextMenuContent />
return (
<_ContextMenu.Root dir="ltr" onOpenChange={handleOpenChange} modal={false}>
<_ContextMenu.Trigger onContextMenu={undefined} dir="ltr">
{canvas}
</_ContextMenu.Trigger>
{isOpen && (
<_ContextMenu.Portal container={container}>
<_ContextMenu.Content
className="tlui-menu scrollable"
data-testid="context-menu"
alignOffset={-4}
collisionPadding={4}
onContextMenu={preventDefault}
>
<TldrawUiMenuContextProvider type="context-menu" sourceId="context-menu">
{content}
</TldrawUiMenuContextProvider>
</_ContextMenu.Content>
</_ContextMenu.Portal>
)}
</_ContextMenu.Root>
)
})

View file

@ -0,0 +1,58 @@
import { useEditor, useValue } from '@tldraw/editor'
import {
ArrangeMenuSubmenu,
ClipboardMenuGroup,
ConversionsMenuGroup,
DeleteGroup,
DuplicateMenuItem,
EditLinkMenuItem,
EmbedsGroup,
FitFrameToContentMenuItem,
GroupMenuItem,
MoveToPageMenu,
RemoveFrameMenuItem,
ReorderMenuSubmenu,
SetSelectionGroup,
ToggleAutoSizeMenuItem,
ToggleLockMenuItem,
UngroupMenuItem,
} from '../menu-items'
import { TldrawUiMenuGroup } from '../menus/TldrawUiMenuGroup'
/** @public */
export function DefaultContextMenuContent() {
const editor = useEditor()
const selectToolActive = useValue(
'isSelectToolActive',
() => editor.getCurrentToolId() === 'select',
[editor]
)
if (!selectToolActive) return null
return (
<>
<TldrawUiMenuGroup id="selection">
<ToggleAutoSizeMenuItem />
<EditLinkMenuItem />
<DuplicateMenuItem />
<GroupMenuItem />
<UngroupMenuItem />
<RemoveFrameMenuItem />
<FitFrameToContentMenuItem />
<ToggleLockMenuItem />
</TldrawUiMenuGroup>
<EmbedsGroup />
<TldrawUiMenuGroup id="modify">
<ArrangeMenuSubmenu />
<ReorderMenuSubmenu />
<MoveToPageMenu />
</TldrawUiMenuGroup>
<ClipboardMenuGroup />
<ConversionsMenuGroup />
<SetSelectionGroup />
<DeleteGroup />
</>
)
}

View file

@ -0,0 +1,30 @@
import { useTranslation } from '../../hooks/useTranslation/useTranslation'
import { TldrawUiMenuContextProvider } from '../menus/TldrawUiMenuContext'
import {
DropdownMenuContent,
DropdownMenuRoot,
DropdownMenuTrigger,
} from '../primitives/DropdownMenu'
import { DefaultDebugMenuContent } from './DefaultDebugMenuContent'
/** @public */
export type TLUiDebugMenuProps = {
children?: any
}
/** @public */
export function DefaultDebugMenu({ children }: TLUiDebugMenuProps) {
const msg = useTranslation()
const content = children ?? <DefaultDebugMenuContent />
return (
<DropdownMenuRoot id="debug">
<DropdownMenuTrigger type="icon" icon="dots-horizontal" title={msg('debug-panel.more')} />
<DropdownMenuContent side="top" align="end" alignOffset={0}>
<TldrawUiMenuContextProvider type="menu" sourceId="debug-panel">
{content}
</TldrawUiMenuContextProvider>
</DropdownMenuContent>
</DropdownMenuRoot>
)
}

View file

@ -0,0 +1,298 @@
import { DialogTitle } from '@radix-ui/react-dialog'
import {
DebugFlag,
Editor,
TLShapePartial,
createShapeId,
debugFlags,
featureFlags,
hardResetEditor,
track,
uniqueId,
useEditor,
} from '@tldraw/editor'
import React from 'react'
import { useDialogs } from '../../context/dialogs'
import { useToasts } from '../../context/toasts'
import { untranslated } from '../../hooks/useTranslation/useTranslation'
import { TldrawUiMenuCheckboxItem } from '../menus/TldrawUiMenuCheckboxItem'
import { TldrawUiMenuGroup } from '../menus/TldrawUiMenuGroup'
import { TldrawUiMenuItem } from '../menus/TldrawUiMenuItem'
import { TldrawUiMenuSubmenu } from '../menus/TldrawUiMenuSubmenu'
import { Button } from '../primitives/Button'
import { DialogBody, DialogCloseButton, DialogFooter, DialogHeader } from '../primitives/Dialog'
/** @public */
export function DefaultDebugMenuContent() {
const editor = useEditor()
const { addToast } = useToasts()
const { addDialog } = useDialogs()
const [error, setError] = React.useState<boolean>(false)
return (
<>
<TldrawUiMenuGroup id="items">
<TldrawUiMenuItem
id="add-toast"
onSelect={() => {
addToast({
id: uniqueId(),
title: 'Something happened',
description: 'Hey, attend to this thing over here. It might be important!',
keepOpen: true,
// icon?: string
// title?: string
// description?: string
// actions?: TLUiToastAction[]
})
addToast({
id: uniqueId(),
title: 'Something happened',
description: 'Hey, attend to this thing over here. It might be important!',
keepOpen: true,
actions: [
{
label: 'Primary',
type: 'primary',
onClick: () => {
void null
},
},
{
label: 'Normal',
type: 'normal',
onClick: () => {
void null
},
},
{
label: 'Danger',
type: 'danger',
onClick: () => {
void null
},
},
],
// icon?: string
// title?: string
// description?: string
// actions?: TLUiToastAction[]
})
addToast({
id: uniqueId(),
title: 'Something happened',
description: 'Hey, attend to this thing over here. It might be important!',
keepOpen: true,
icon: 'twitter',
actions: [
{
label: 'Primary',
type: 'primary',
onClick: () => {
void null
},
},
{
label: 'Normal',
type: 'normal',
onClick: () => {
void null
},
},
{
label: 'Danger',
type: 'danger',
onClick: () => {
void null
},
},
],
})
}}
label={untranslated('Show toast')}
/>
<TldrawUiMenuItem
id="show-dialog"
label={'Show dialog'}
onSelect={() => {
addDialog({
component: ({ onClose }) => (
<ExampleDialog
displayDontShowAgain
onCancel={() => onClose()}
onContinue={() => onClose()}
/>
),
onClose: () => {
void null
},
})
}}
/>
<TldrawUiMenuItem
id="create-shapes"
label={'Create 100 shapes'}
onSelect={() => createNShapes(editor, 100)}
/>
<TldrawUiMenuItem
id="count-nodes"
label={'Count shapes / nodes'}
onSelect={() => {
function countDescendants({ children }: HTMLElement) {
let count = 0
if (!children.length) return 0
for (const el of [...(children as any)]) {
count++
count += countDescendants(el)
}
return count
}
const selectedShapes = editor.getSelectedShapes()
const shapes =
selectedShapes.length === 0 ? editor.getRenderingShapes() : selectedShapes
const elms = shapes.map(
(shape) => (document.getElementById(shape.id) as HTMLElement)!.parentElement!
)
let descendants = elms.length
for (const elm of elms) {
descendants += countDescendants(elm)
}
window.alert(`Shapes ${shapes.length}, DOM nodes:${descendants}`)
}}
/>
{(() => {
if (error) throw Error('oh no!')
})()}
<TldrawUiMenuItem id="throw-error" onSelect={() => setError(true)} label={'Throw error'} />
<TldrawUiMenuItem id="hard-reset" onSelect={hardResetEditor} label={'Hard reset'} />
</TldrawUiMenuGroup>
<TldrawUiMenuGroup id="flags">
<DebugFlags />
<FeatureFlags />
</TldrawUiMenuGroup>
{/* {...children} */}
</>
)
}
function DebugFlags() {
const items = Object.values(debugFlags)
if (!items.length) return null
return (
<TldrawUiMenuSubmenu id="debug flags" label="Debug Flags">
<TldrawUiMenuGroup id="debug flags">
{items.map((flag) => (
<DebugFlagToggle key={flag.name} flag={flag} />
))}
</TldrawUiMenuGroup>
</TldrawUiMenuSubmenu>
)
}
function FeatureFlags() {
const items = Object.values(featureFlags)
if (!items.length) return null
return (
<TldrawUiMenuSubmenu id="feature flags" label="Feature Flags">
<TldrawUiMenuGroup id="feature flags">
{items.map((flag) => (
<DebugFlagToggle key={flag.name} flag={flag} />
))}
</TldrawUiMenuGroup>
</TldrawUiMenuSubmenu>
)
}
function ExampleDialog({
title = 'title',
body = 'hello hello hello',
cancel = 'Cancel',
confirm = 'Continue',
displayDontShowAgain = false,
onCancel,
onContinue,
}: {
title?: string
body?: string
cancel?: string
confirm?: string
displayDontShowAgain?: boolean
onCancel: () => void
onContinue: () => void
}) {
const [dontShowAgain, setDontShowAgain] = React.useState(false)
return (
<>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogCloseButton />
</DialogHeader>
<DialogBody style={{ maxWidth: 350 }}>{body}</DialogBody>
<DialogFooter className="tlui-dialog__footer__actions">
{displayDontShowAgain && (
<Button
type="normal"
onClick={() => setDontShowAgain(!dontShowAgain)}
iconLeft={dontShowAgain ? 'check' : 'checkbox-empty'}
style={{ marginRight: 'auto' }}
>
{`Don't show again`}
</Button>
)}
<Button type="normal" onClick={onCancel}>
{cancel}
</Button>
<Button type="primary" onClick={async () => onContinue()}>
{confirm}
</Button>
</DialogFooter>
</>
)
}
const DebugFlagToggle = track(function DebugFlagToggle({
flag,
onChange,
}: {
flag: DebugFlag<boolean>
onChange?: (newValue: boolean) => void
}) {
const value = flag.get()
return (
<TldrawUiMenuCheckboxItem
id={flag.name}
title={flag.name}
label={flag.name
.replace(/([a-z0-9])([A-Z])/g, (m) => `${m[0]} ${m[1].toLowerCase()}`)
.replace(/^[a-z]/, (m) => m.toUpperCase())}
checked={value}
onSelect={() => {
flag.set(!value)
onChange?.(!value)
}}
/>
)
})
let t = 0
function createNShapes(editor: Editor, n: number) {
const shapesToCreate: TLShapePartial[] = Array(n)
const cols = Math.floor(Math.sqrt(n))
for (let i = 0; i < n; i++) {
t++
shapesToCreate[i] = {
id: createShapeId('box' + t),
type: 'geo',
x: (i % cols) * 132,
y: Math.floor(i / cols) * 132,
}
}
editor.batch(() => {
editor.createShapes(shapesToCreate).setSelectedShapes(shapesToCreate.map((s) => s.id))
})
}

View file

@ -1,76 +1,24 @@
import { import { debugFlags, track, useEditor, useValue, Vec } from '@tldraw/editor'
createShapeId, import { memo, useEffect, useRef, useState } from 'react'
DebugFlag, import { useTldrawUiComponents } from '../context/components'
debugFlags,
Editor,
featureFlags,
hardResetEditor,
TLShapePartial,
track,
uniqueId,
useEditor,
useValue,
Vec,
} from '@tldraw/editor'
import * as React from 'react'
import { useDialogs } from '../hooks/useDialogsProvider'
import { useToasts } from '../hooks/useToastsProvider'
import { untranslated, useTranslation } from '../hooks/useTranslation/useTranslation'
import { Button } from './primitives/Button'
import * as Dialog from './primitives/Dialog'
import * as DropdownMenu from './primitives/DropdownMenu'
let t = 0
function createNShapes(editor: Editor, n: number) {
const shapesToCreate: TLShapePartial[] = Array(n)
const cols = Math.floor(Math.sqrt(n))
for (let i = 0; i < n; i++) {
t++
shapesToCreate[i] = {
id: createShapeId('box' + t),
type: 'geo',
x: (i % cols) * 132,
y: Math.floor(i / cols) * 132,
}
}
editor.batch(() => {
editor.createShapes(shapesToCreate).setSelectedShapes(shapesToCreate.map((s) => s.id))
})
}
/** @internal */ /** @internal */
export const DebugPanel = React.memo(function DebugPanel({ export const DebugPanel = memo(function DebugPanel() {
renderDebugMenuItems, const { DebugMenu } = useTldrawUiComponents()
}: {
renderDebugMenuItems: (() => React.ReactNode) | null
}) {
const msg = useTranslation()
const showFps = useValue('show_fps', () => debugFlags.showFps.get(), [debugFlags])
return ( return (
<div className="tlui-debug-panel"> <div className="tlui-debug-panel">
<CurrentState /> <CurrentState />
{showFps && <FPS />} <FPS />
<ShapeCount /> {DebugMenu && <DebugMenu />}
<DropdownMenu.Root id="debug">
<DropdownMenu.Trigger>
<Button type="icon" icon="dots-horizontal" title={msg('debug-panel.more')} />
</DropdownMenu.Trigger>
<DropdownMenu.Content side="top" align="end" alignOffset={0}>
<DebugMenuContent renderDebugMenuItems={renderDebugMenuItems} />
</DropdownMenu.Content>
</DropdownMenu.Root>
</div> </div>
) )
}) })
function useTick(isEnabled = true) { function useTick(isEnabled = true) {
const [_, setTick] = React.useState(0) const [_, setTick] = useState(0)
const editor = useEditor() const editor = useEditor()
React.useEffect(() => { useEffect(() => {
if (!isEnabled) return if (!isEnabled) return
const update = () => setTick((tick) => tick + 1) const update = () => setTick((tick) => tick + 1)
editor.on('tick', update) editor.on('tick', update)
@ -107,9 +55,13 @@ const CurrentState = track(function CurrentState() {
}) })
function FPS() { function FPS() {
const fpsRef = React.useRef<HTMLDivElement>(null) const showFps = useValue('show_fps', () => debugFlags.showFps.get(), [debugFlags])
const fpsRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!showFps) return
React.useEffect(() => {
const TICK_LENGTH = 250 const TICK_LENGTH = 250
let maxKnownFps = 0 let maxKnownFps = 0
let cancelled = false let cancelled = false
@ -168,294 +120,9 @@ function FPS() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, []) }, [showFps])
if (!showFps) return null
return <div ref={fpsRef} /> return <div ref={fpsRef} />
} }
const ShapeCount = function ShapeCount() {
const editor = useEditor()
const count = useValue('rendering shapes count', () => editor.getRenderingShapes().length, [
editor,
])
return <div>{count} Shapes</div>
}
const DebugMenuContent = track(function DebugMenuContent({
renderDebugMenuItems,
}: {
renderDebugMenuItems: (() => React.ReactNode) | null
}) {
const editor = useEditor()
const { addToast } = useToasts()
const { addDialog } = useDialogs()
const [error, setError] = React.useState<boolean>(false)
return (
<>
<DropdownMenu.Group>
<DropdownMenu.Item
type="menu"
onClick={() => {
addToast({
id: uniqueId(),
title: 'Something happened',
description: 'Hey, attend to this thing over here. It might be important!',
keepOpen: true,
// icon?: string
// title?: string
// description?: string
// actions?: TLUiToastAction[]
})
addToast({
id: uniqueId(),
title: 'Something happened',
description: 'Hey, attend to this thing over here. It might be important!',
keepOpen: true,
actions: [
{
label: 'Primary',
type: 'primary',
onClick: () => {
void null
},
},
{
label: 'Normal',
type: 'normal',
onClick: () => {
void null
},
},
{
label: 'Danger',
type: 'danger',
onClick: () => {
void null
},
},
],
// icon?: string
// title?: string
// description?: string
// actions?: TLUiToastAction[]
})
addToast({
id: uniqueId(),
title: 'Something happened',
description: 'Hey, attend to this thing over here. It might be important!',
keepOpen: true,
icon: 'twitter',
actions: [
{
label: 'Primary',
type: 'primary',
onClick: () => {
void null
},
},
{
label: 'Normal',
type: 'normal',
onClick: () => {
void null
},
},
{
label: 'Danger',
type: 'danger',
onClick: () => {
void null
},
},
],
})
}}
label={untranslated('Show toast')}
/>
<DropdownMenu.Item
type="menu"
onClick={() => {
addDialog({
component: ({ onClose }) => (
<ExampleDialog
displayDontShowAgain
onCancel={() => {
onClose()
}}
onContinue={() => {
onClose()
}}
/>
),
onClose: () => {
void null
},
})
}}
label={untranslated('Show dialog')}
/>
<DropdownMenu.Item
type="menu"
onClick={() => createNShapes(editor, 100)}
label={untranslated('Create 100 shapes')}
/>
<DropdownMenu.Item
type="menu"
onClick={() => {
function countDescendants({ children }: HTMLElement) {
let count = 0
if (!children.length) return 0
for (const el of [...(children as any)]) {
count++
count += countDescendants(el)
}
return count
}
const selectedShapes = editor.getSelectedShapes()
const shapes =
selectedShapes.length === 0 ? editor.getRenderingShapes() : selectedShapes
const elms = shapes.map(
(shape) => (document.getElementById(shape.id) as HTMLElement)!.parentElement!
)
let descendants = elms.length
for (const elm of elms) {
descendants += countDescendants(elm)
}
window.alert(`Shapes ${shapes.length}, DOM nodes:${descendants}`)
}}
label={untranslated('Count shapes / nodes')}
/>
{(() => {
if (error) throw Error('oh no!')
})()}
<DropdownMenu.Item
type="menu"
onClick={() => {
setError(true)
}}
label={untranslated('Throw error')}
/>
<DropdownMenu.Item
type="menu"
onClick={() => {
hardResetEditor()
}}
label={untranslated('Hard reset')}
/>
</DropdownMenu.Group>
<DropdownMenu.Group>
<DebugFlagToggle flag={debugFlags.debugSvg} />
<DebugFlagToggle flag={debugFlags.showFps} />
<DebugFlagToggle flag={debugFlags.forceSrgb} />
<DebugFlagToggle flag={debugFlags.debugGeometry} />
<DebugFlagToggle flag={debugFlags.hideShapes} />
</DropdownMenu.Group>
<DropdownMenu.Group>
{Object.values(featureFlags).map((flag) => {
return <DebugFlagToggle key={flag.name} flag={flag} />
})}
</DropdownMenu.Group>
{renderDebugMenuItems?.()}
</>
)
})
function Toggle({
label,
value,
onChange,
}: {
label: string
value: boolean
onChange: (newValue: boolean) => void
}) {
return (
<DropdownMenu.CheckboxItem
title={untranslated(label)}
checked={value}
onSelect={() => onChange(!value)}
>
<span className="tlui-button__label" draggable={false}>
{label}
</span>
</DropdownMenu.CheckboxItem>
)
}
const DebugFlagToggle = track(function DebugFlagToggle({
flag,
onChange,
}: {
flag: DebugFlag<boolean>
onChange?: (newValue: boolean) => void
}) {
return (
<Toggle
label={flag.name
.replace(/([a-z0-9])([A-Z])/g, (m) => `${m[0]} ${m[1].toLowerCase()}`)
.replace(/^[a-z]/, (m) => m.toUpperCase())}
value={flag.get()}
onChange={(newValue) => {
flag.set(newValue)
onChange?.(newValue)
}}
/>
)
})
function ExampleDialog({
title = 'title',
body = 'hello hello hello',
cancel = 'Cancel',
confirm = 'Continue',
displayDontShowAgain = false,
onCancel,
onContinue,
}: {
title?: string
body?: string
cancel?: string
confirm?: string
displayDontShowAgain?: boolean
onCancel: () => void
onContinue: () => void
}) {
const [dontShowAgain, setDontShowAgain] = React.useState(false)
return (
<>
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.CloseButton />
</Dialog.Header>
<Dialog.Body style={{ maxWidth: 350 }}>{body}</Dialog.Body>
<Dialog.Footer className="tlui-dialog__footer__actions">
{displayDontShowAgain && (
<Button
type="normal"
onClick={() => setDontShowAgain(!dontShowAgain)}
iconLeft={dontShowAgain ? 'check' : 'checkbox-empty'}
style={{ marginRight: 'auto' }}
>
{`Don't show again`}
</Button>
)}
<Button type="normal" onClick={onCancel}>
{cancel}
</Button>
<Button type="primary" onClick={async () => onContinue()}>
{confirm}
</Button>
</Dialog.Footer>
</>
)
}

View file

@ -1,7 +1,7 @@
import * as _Dialog from '@radix-ui/react-dialog' import * as _Dialog from '@radix-ui/react-dialog'
import { useContainer } from '@tldraw/editor' import { useContainer } from '@tldraw/editor'
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { TLUiDialog, useDialogs } from '../hooks/useDialogsProvider' import { TLUiDialog, useDialogs } from '../context/dialogs'
const Dialog = ({ id, component: ModalContent, onClose }: TLUiDialog) => { const Dialog = ({ id, component: ModalContent, onClose }: TLUiDialog) => {
const { removeDialog } = useDialogs() const { removeDialog } = useDialogs()

View file

@ -1,26 +0,0 @@
import { track, useEditor } from '@tldraw/editor'
import { useRef } from 'react'
import { useActions } from '../hooks/useActions'
import { useTranslation } from '../hooks/useTranslation/useTranslation'
import { Button } from './primitives/Button'
import { kbdStr } from './primitives/shared'
export const DuplicateButton = track(function DuplicateButton() {
const editor = useEditor()
const actions = useActions()
const msg = useTranslation()
const action = actions['duplicate']
const ref = useRef<HTMLButtonElement>(null)
return (
<Button
icon={action.icon}
type="icon"
onClick={() => action.onSelect('quick-actions')}
disabled={!(editor.isIn('select') && editor.getSelectedShapeIds().length > 0)}
title={`${msg(action.label!)} ${kbdStr(action.kbd!)}`}
smallIcon
ref={ref}
/>
)
})

View file

@ -1,9 +1,10 @@
import { DialogTitle } from '@radix-ui/react-dialog'
import { T, TLBaseShape, track, useEditor } from '@tldraw/editor' import { T, TLBaseShape, track, useEditor } from '@tldraw/editor'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { TLUiDialogProps } from '../hooks/useDialogsProvider' import { TLUiDialogProps } from '../context/dialogs'
import { useTranslation } from '../hooks/useTranslation/useTranslation' import { useTranslation } from '../hooks/useTranslation/useTranslation'
import { Button } from './primitives/Button' import { Button } from './primitives/Button'
import * as Dialog from './primitives/Dialog' import { DialogBody, DialogCloseButton, DialogFooter, DialogHeader } from './primitives/Dialog'
import { Input } from './primitives/Input' import { Input } from './primitives/Input'
// A url can either be invalid, or valid with a protocol, or valid without a protocol. // A url can either be invalid, or valid with a protocol, or valid without a protocol.
@ -133,36 +134,32 @@ export const EditLinkDialogInner = track(function EditLinkDialogInner({
return ( return (
<> <>
<Dialog.Header> <DialogHeader>
<Dialog.Title>{msg('edit-link-dialog.title')}</Dialog.Title> <DialogTitle>{msg('edit-link-title')}</DialogTitle>
<Dialog.CloseButton /> <DialogCloseButton />
</Dialog.Header> </DialogHeader>
<Dialog.Body> <DialogBody>
<div className="tlui-edit-link-dialog"> <div className="tlui-edit-link-dialog">
<Input <Input
ref={rInput} ref={rInput}
className="tlui-edit-link-dialog__input" className="tlui-edit-link-dialog__input"
label="edit-link-dialog.url" label="edit-link-url"
autofocus autofocus
value={urlInputState.actual} value={urlInputState.actual}
onValueChange={handleChange} onValueChange={handleChange}
onComplete={handleComplete} onComplete={handleComplete}
onCancel={handleCancel} onCancel={handleCancel}
/> />
<div> <div>{urlInputState.valid ? msg('edit-link-detail') : msg('edit-link-invalid-url')}</div>
{urlInputState.valid
? msg('edit-link-dialog.detail')
: msg('edit-link-dialog.invalid-url')}
</div>
</div> </div>
</Dialog.Body> </DialogBody>
<Dialog.Footer className="tlui-dialog__footer__actions"> <DialogFooter className="tlui-dialog__footer__actions">
<Button type="normal" onClick={handleCancel} onTouchEnd={handleCancel}> <Button type="normal" onClick={handleCancel} onTouchEnd={handleCancel}>
{msg('edit-link-dialog.cancel')} {msg('edit-link-cancel')}
</Button> </Button>
{isRemoving ? ( {isRemoving ? (
<Button type={'danger'} onTouchEnd={handleClear} onClick={handleClear}> <Button type={'danger'} onTouchEnd={handleClear} onClick={handleClear}>
{msg('edit-link-dialog.clear')} {msg('edit-link-clear')}
</Button> </Button>
) : ( ) : (
<Button <Button
@ -171,10 +168,10 @@ export const EditLinkDialogInner = track(function EditLinkDialogInner({
onTouchEnd={handleComplete} onTouchEnd={handleComplete}
onClick={handleComplete} onClick={handleComplete}
> >
{msg('edit-link-dialog.save')} {msg('edit-link-save')}
</Button> </Button>
)} )}
</Dialog.Footer> </DialogFooter>
</> </>
) )
}) })

View file

@ -1,11 +1,12 @@
import { DialogTitle } from '@radix-ui/react-dialog'
import { EMBED_DEFINITIONS, EmbedDefinition, track, useEditor } from '@tldraw/editor' import { EMBED_DEFINITIONS, EmbedDefinition, track, useEditor } from '@tldraw/editor'
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { TLEmbedResult, getEmbedInfo } from '../../utils/embeds/embeds' import { TLEmbedResult, getEmbedInfo } from '../../utils/embeds/embeds'
import { useAssetUrls } from '../hooks/useAssetUrls' import { useAssetUrls } from '../context/asset-urls'
import { TLUiDialogProps } from '../hooks/useDialogsProvider' import { TLUiDialogProps } from '../context/dialogs'
import { untranslated, useTranslation } from '../hooks/useTranslation/useTranslation' import { untranslated, useTranslation } from '../hooks/useTranslation/useTranslation'
import { Button } from './primitives/Button' import { Button } from './primitives/Button'
import * as Dialog from './primitives/Dialog' import { DialogBody, DialogCloseButton, DialogFooter, DialogHeader } from './primitives/Dialog'
import { Icon } from './primitives/Icon' import { Icon } from './primitives/Icon'
import { Input } from './primitives/Input' import { Input } from './primitives/Input'
@ -29,20 +30,20 @@ export const EmbedDialog = track(function EmbedDialog({ onClose }: TLUiDialogPro
return ( return (
<> <>
<Dialog.Header> <DialogHeader>
<Dialog.Title> <DialogTitle>
{embedDefinition {embedDefinition
? `${msg('embed-dialog.title')}${embedDefinition.title}` ? `${msg('embed-title')}${embedDefinition.title}`
: msg('embed-dialog.title')} : msg('embed-title')}
</Dialog.Title> </DialogTitle>
<Dialog.CloseButton /> <DialogCloseButton />
</Dialog.Header> </DialogHeader>
{embedDefinition ? ( {embedDefinition ? (
<> <>
<Dialog.Body className="tlui-embed-dialog__enter"> <DialogBody className="tlui-embed-dialog__enter">
<Input <Input
className="tlui-embed-dialog__input" className="tlui-embed-dialog__input"
label="embed-dialog.url" label="embed-url"
placeholder="http://example.com" placeholder="http://example.com"
autofocus autofocus
onValueChange={(value) => { onValueChange={(value) => {
@ -67,7 +68,7 @@ export const EmbedDialog = track(function EmbedDialog({ onClose }: TLUiDialogPro
/> />
{url === '' ? ( {url === '' ? (
<div className="tlui-embed-dialog__instruction"> <div className="tlui-embed-dialog__instruction">
<span>{msg('embed-dialog.instruction')}</span>{' '} <span>{msg('embed-instruction')}</span>{' '}
{embedDefinition.instructionLink && ( {embedDefinition.instructionLink && (
<a <a
target="_blank" target="_blank"
@ -82,11 +83,11 @@ export const EmbedDialog = track(function EmbedDialog({ onClose }: TLUiDialogPro
</div> </div>
) : ( ) : (
<div className="tlui-embed-dialog__warning"> <div className="tlui-embed-dialog__warning">
{showError ? msg('embed-dialog.invalid-url') : '\xa0'} {showError ? msg('embed-invalid-url') : '\xa0'}
</div> </div>
)} )}
</Dialog.Body> </DialogBody>
<Dialog.Footer className="tlui-dialog__footer__actions"> <DialogFooter className="tlui-dialog__footer__actions">
<Button <Button
type="normal" type="normal"
onClick={() => { onClick={() => {
@ -94,14 +95,14 @@ export const EmbedDialog = track(function EmbedDialog({ onClose }: TLUiDialogPro
setEmbedInfoForUrl(null) setEmbedInfoForUrl(null)
setUrl('') setUrl('')
}} }}
label="embed-dialog.back" label="embed-back"
/> />
<div className="tlui-embed__spacer" /> <div className="tlui-embed__spacer" />
<Button type="normal" label="embed-dialog.cancel" onClick={onClose} /> <Button type="normal" label="embed-cancel" onClick={onClose} />
<Button <Button
type="primary" type="primary"
disabled={!embedInfoForUrl} disabled={!embedInfoForUrl}
label="embed-dialog.create" label="embed-create"
onClick={() => { onClick={() => {
if (!embedInfoForUrl) return if (!embedInfoForUrl) return
@ -115,11 +116,11 @@ export const EmbedDialog = track(function EmbedDialog({ onClose }: TLUiDialogPro
onClose() onClose()
}} }}
/> />
</Dialog.Footer> </DialogFooter>
</> </>
) : ( ) : (
<> <>
<Dialog.Body className="tlui-embed-dialog__list"> <DialogBody className="tlui-embed-dialog__list">
{EMBED_DEFINITIONS.map((def) => { {EMBED_DEFINITIONS.map((def) => {
return ( return (
<Button <Button
@ -135,7 +136,7 @@ export const EmbedDialog = track(function EmbedDialog({ onClose }: TLUiDialogPro
</Button> </Button>
) )
})} })}
</Dialog.Body> </DialogBody>
</> </>
)} )}
</> </>

View file

@ -1,54 +0,0 @@
import { track, useEditor } from '@tldraw/editor'
import * as React from 'react'
/** @internal */
export const HTMLCanvas = track(function HTMLCanvas() {
const editor = useEditor()
const rCanvas = React.useRef<HTMLCanvasElement>(null)
const camera = editor.getCamera()
const shapes = editor.getCurrentPageShapes()
if (rCanvas.current) {
const cvs = rCanvas.current
const ctx = cvs.getContext('2d')!
ctx.resetTransform()
ctx.clearRect(0, 0, cvs.width, cvs.height)
const path = new Path2D()
ctx.translate(camera.x, camera.y)
for (const shape of shapes) {
const bounds = editor.getShapePageBounds(shape)!
path.rect(bounds.minX, bounds.minY, bounds.width, bounds.height)
}
ctx.fillStyle = '#cccccc'
ctx.fill(path)
// for (const shape of shapes) {
// ctx.save()
// const corners = editor.getPageCorners(shape)
// corners.forEach((corner) => dot(ctx, corner.x, corner.y, 'red'))
// ctx.restore()
// }
}
return (
<canvas
ref={rCanvas}
width={editor.getViewportScreenBounds().width}
height={editor.getViewportScreenBounds().height}
style={{ width: '100%', height: '100%' }}
/>
)
})
// function dot(ctx: CanvasRenderingContext2D, x: number, y: number, color = '#000') {
// ctx.save()
// ctx.beginPath()
// ctx.ellipse(x, y, 4, 4, 0, 0, Math.PI * 2)
// ctx.fillStyle = color
// ctx.fill()
// ctx.restore()
// }

View file

@ -1,109 +0,0 @@
import { Content, Portal, Root, Trigger } from '@radix-ui/react-dropdown-menu'
import { useContainer } from '@tldraw/editor'
import * as React from 'react'
import { TLUiMenuChild } from '../hooks/menuHelpers'
import { useHelpMenuSchema } from '../hooks/useHelpMenuSchema'
import { useMenuIsOpen } from '../hooks/useMenuIsOpen'
import { useReadonly } from '../hooks/useReadonly'
import { TLUiTranslationKey } from '../hooks/useTranslation/TLUiTranslationKey'
import { useTranslation } from '../hooks/useTranslation/useTranslation'
import { TLUiIconType } from '../icon-types'
import { LanguageMenu } from './LanguageMenu'
import { Button } from './primitives/Button'
import * as M from './primitives/DropdownMenu'
interface HelpMenuLink {
label: TLUiTranslationKey | Exclude<string, TLUiTranslationKey>
icon: TLUiIconType | Exclude<string, TLUiIconType>
url: string
}
/** @internal */
export interface HelpMenuProps {
links?: HelpMenuLink[]
}
/** @internal */
export const HelpMenu = React.memo(function HelpMenu() {
const container = useContainer()
const msg = useTranslation()
const [isOpen, onOpenChange] = useMenuIsOpen('help menu')
return (
<div className="tlui-help-menu">
<Root dir="ltr" open={isOpen} onOpenChange={onOpenChange} modal={false}>
<Trigger asChild dir="ltr">
<Button
type="help"
className="tlui-button"
smallIcon
title={msg('help-menu.title')}
icon="question-mark"
/>
</Trigger>
<Portal container={container}>
<Content
className="tlui-menu"
side="top"
sideOffset={8}
align="end"
alignOffset={0}
collisionPadding={4}
>
<HelpMenuContent />
</Content>
</Portal>
</Root>
</div>
)
})
function HelpMenuContent() {
const menuSchema = useHelpMenuSchema()
const isReadonly = useReadonly()
function getHelpMenuItem(item: TLUiMenuChild) {
if (!item) return null
if (isReadonly && !item.readonlyOk) return null
switch (item.type) {
case 'custom': {
if (item.id === 'LANGUAGE_MENU') {
return <LanguageMenu key="item" />
}
break
}
case 'group': {
return (
<M.Group size="small" key={item.id}>
{item.children.map(getHelpMenuItem)}
</M.Group>
)
}
case 'submenu': {
return (
<M.Sub id={`help menu ${item.id}`} key={item.id}>
<M.SubTrigger label={item.label} />
<M.SubContent>{item.children.map(getHelpMenuItem)}</M.SubContent>
</M.Sub>
)
}
case 'item': {
const { id, kbd, label, onSelect, icon } = item.actionItem
return (
<M.Item
type="menu"
key={id}
kbd={kbd}
label={label}
onClick={() => onSelect('help-menu')}
iconLeft={icon}
/>
)
}
}
}
return <>{menuSchema.map(getHelpMenuItem)}</>
}

View file

@ -0,0 +1,46 @@
import { memo } from 'react'
import { useBreakpoint } from '../../context/breakpoints'
import { useTranslation } from '../../hooks/useTranslation/useTranslation'
import { TldrawUiMenuContextProvider } from '../menus/TldrawUiMenuContext'
import {
DropdownMenuContent,
DropdownMenuRoot,
DropdownMenuTrigger,
} from '../primitives/DropdownMenu'
import { DefaultHelpMenuContent } from './DefaultHelpMenuContent'
/** @public */
export type TLUiHelpMenuProps = {
children?: any
}
/** @public */
export const DefaultHelpMenu = memo(function DefaultHelpMenu({ children }: TLUiHelpMenuProps) {
const msg = useTranslation()
const breakpoint = useBreakpoint()
// Get the help menu content, either the default component or the user's
// override. If there's no menu content, then the user has set it to null,
// so skip rendering the menu.
const content = children ?? <DefaultHelpMenuContent />
if (breakpoint < 4) return null
return (
<div className="tlui-help-menu">
<DropdownMenuRoot id="help menu">
<DropdownMenuTrigger
type="help"
smallIcon
title={msg('help-menu.title')}
icon="question-mark"
/>
<DropdownMenuContent side="top" align="end" alignOffset={0} sideOffset={8}>
<TldrawUiMenuContextProvider type="menu" sourceId="help-menu">
{content}
</TldrawUiMenuContextProvider>
</DropdownMenuContent>
</DropdownMenuRoot>
</div>
)
})

View file

@ -0,0 +1,32 @@
import { useTldrawUiComponents } from '../../context/components'
import { useDialogs } from '../../context/dialogs'
import { LanguageMenu } from '../LanguageMenu'
import { TldrawUiMenuItem } from '../menus/TldrawUiMenuItem'
/** @public */
export function DefaultHelpMenuContent() {
return (
<>
<LanguageMenu />
<KeyboardShortcutsMenuItem />
</>
)
}
function KeyboardShortcutsMenuItem() {
const { KeyboardShortcutsDialog } = useTldrawUiComponents()
const { addDialog } = useDialogs()
if (!KeyboardShortcutsDialog) return null
return (
<TldrawUiMenuItem
id="keyboard-shortcuts"
label="help-menu.keyboard-shortcuts"
readonlyOk
onSelect={() => {
addDialog({ component: KeyboardShortcutsDialog })
}}
/>
)
}

View file

@ -1,13 +1,12 @@
import { useEditor } from '@tldraw/editor' import { useEditor } from '@tldraw/editor'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useActions } from '../hooks/useActions' import { useActions } from '../../context/actions'
import { Button } from './primitives/Button' import { TldrawUiMenuItem } from '../menus/TldrawUiMenuItem'
export function BackToContent() { export function BackToContent() {
const editor = useEditor() const editor = useEditor()
const actions = useActions() const actions = useActions()
const action = actions['back-to-content']
const [showBackToContent, setShowBackToContent] = useState(false) const [showBackToContent, setShowBackToContent] = useState(false)
@ -42,12 +41,10 @@ export function BackToContent() {
if (!showBackToContent) return null if (!showBackToContent) return null
return ( return (
<Button <TldrawUiMenuItem
iconLeft={action.icon} {...actions['back-to-content']}
label={action.label} onSelect={() => {
type="low" actions['back-to-content'].onSelect('helper-buttons')
onClick={() => {
action.onSelect('helper-buttons')
setShowBackToContent(false) setShowBackToContent(false)
}} }}
/> />

View file

@ -0,0 +1,19 @@
import { TldrawUiMenuContextProvider } from '../menus/TldrawUiMenuContext'
import { DefaultHelperButtonsContent } from './DefaultHelperButtonsContent'
/** @public */
export type TLUiHelperButtonsProps = {
children?: any
}
/** @public */
export function DefaultHelperButtons({ children }: TLUiHelperButtonsProps) {
const content = children ?? <DefaultHelperButtonsContent />
return (
<div className="tlui-helper-buttons">
<TldrawUiMenuContextProvider type="helper-buttons" sourceId="helper-buttons">
{content}
</TldrawUiMenuContextProvider>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show more