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

@ -18,4 +18,11 @@ apps/example/www/index.css
*.cjs
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',
label: 'tools.card',
kbd: 'c',
readonlyOk: false,
onSelect: () => {
// Whatever you want to happen when the tool is selected
editor.setCurrentTool('card')

View file

@ -155,33 +155,35 @@ The [Tldraw](?) component combines two lower-level components: [TldrawEditor](?)
### 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
<Tldraw
components={{
Background: YourCustomBackground,
SvgDefs: YourCustomSvgDefs,
Brush: YourCustomBrush,
ZoomBrush: YourCustomBrush,
CollaboratorBrush: YourCustomBrush,
Cursor: YourCustomCursor,
CollaboratorCursor: YourCustomCursor,
CollaboratorHint: YourCustomCollaboratorHint,
CollaboratorShapeIndicator: YourCustomdicator,
Grid: YourCustomGrid,
Scribble: YourCustomScribble,
SnapLine: YourCustomSnapLine,
Handles: YourCustomHandles,
Handle: YourCustomHandle,
CollaboratorScribble: YourCustomScribble,
ErrorFallback: YourCustomErrorFallback,
ShapeErrorFallback: YourCustomShapeErrorFallback,
ShapeIndicatorErrorFallback: YourCustomShapeIndicatorErrorFallback,
Spinner: YourCustomSpinner,
SelectionBackground: YourCustomSelectionBackground,
SelectionForeground: YourCustomSelectionForeground,
HoveredShapeIndicator: YourCustomHoveredShapeIndicator,
}}
/>
const components: TLComponents = {
Background: YourCustomBackground,
SvgDefs: YourCustomSvgDefs,
Brush: YourCustomBrush,
ZoomBrush: YourCustomBrush,
CollaboratorBrush: YourCustomBrush,
Cursor: YourCustomCursor,
CollaboratorCursor: YourCustomCursor,
CollaboratorHint: YourCustomCollaboratorHint,
CollaboratorShapeIndicator: YourCustomdicator,
Grid: YourCustomGrid,
Scribble: YourCustomScribble,
SnapLine: YourCustomSnapLine,
Handles: YourCustomHandles,
Handle: YourCustomHandle,
CollaboratorScribble: YourCustomScribble,
ErrorFallback: YourCustomErrorFallback,
ShapeErrorFallback: YourCustomShapeErrorFallback,
ShapeIndicatorErrorFallback: YourCustomShapeIndicatorErrorFallback,
Spinner: YourCustomSpinner,
SelectionBackground: YourCustomSelectionBackground,
SelectionForeground: YourCustomSelectionForeground,
HoveredShapeIndicator: YourCustomHoveredShapeIndicator,
// ...
}
<Tldraw components={components}/>
```

View file

@ -1,5 +1,14 @@
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 { useShareMenuIsOpen } from '../hooks/useShareMenuOpen'
import { SHARE_PROJECT_ACTION, SHARE_SNAPSHOT_ACTION } from '../utils/sharing'
@ -33,50 +42,39 @@ export const ExportMenu = React.memo(function ExportMenu() {
side="bottom"
sideOffset={6}
>
<div className="tlui-menu__group">
<Button
type="menu"
label={shareProject.label}
icon={'share-1'}
onClick={() => {
shareProject.onSelect('export-menu')
}}
/>
<p className="tlui-menu__group tlui-share-zone__details">
{msg('share-menu.fork-note')}
</p>
</div>
<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={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>
<TldrawUiMenuContextProvider type="panel" sourceId="export-menu">
<TldrawUiMenuGroup id="share">
<TldrawUiMenuItem {...shareProject} />
<p className="tlui-menu__group tlui-share-zone__details">
{msg('share-menu.fork-note')}
</p>
</TldrawUiMenuGroup>
<TldrawUiMenuGroup id="snapshot">
<TldrawUiMenuItem
id="copy-to-clipboard"
readonlyOk
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>
<TldrawUiMenuGroup id="save">
<TldrawUiMenuItem {...saveFileCopyAction} />
<p className="tlui-menu__group tlui-share-zone__details">
{msg('share-menu.save-note')}
</p>
</TldrawUiMenuGroup>
</TldrawUiMenuContextProvider>
</Popover.Content>
</Popover.Portal>
</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 { assetUrls } from '../utils/assetUrls'
import { createAssetFromUrl } from '../utils/createAssetFromUrl'
import { linksUiOverrides } from '../utils/links'
import { DebugMenuItems } from '../utils/migration/DebugMenuItems'
import { LocalMigration } from '../utils/migration/LocalMigration'
import { SCRATCH_PERSISTENCE_KEY } from '../utils/scratch-persistence-key'
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 { LocalFileMenu } from './FileMenu'
import { Links } from './Links'
import { ShareMenu } from './ShareMenu'
import { SneakyOnDropOverride } from './SneakyOnDropOverride'
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() {
const handleUiEvent = useHandleUiEvents()
const sharingUiOverrides = useSharing({ isMultiplayer: false })
const sharingUiOverrides = useSharing()
const fileSystemUiOverrides = useFileSystem({ isMultiplayer: false })
const handleMount = useCallback((editor: Editor) => {
@ -29,19 +85,14 @@ export function LocalEditor() {
persistenceKey={SCRATCH_PERSISTENCE_KEY}
onMount={handleMount}
autoFocus
overrides={[sharingUiOverrides, fileSystemUiOverrides, linksUiOverrides]}
overrides={[sharingUiOverrides, fileSystemUiOverrides]}
onUiEvent={handleUiEvent}
components={{
ErrorFallback: ({ error }) => {
throw error
},
}}
components={components}
shareZone={
<div className="tlui-share-zone" draggable={false}>
<ShareMenu />
</div>
}
renderDebugMenuItems={() => <DebugMenuItems />}
inferDarkMode
>
<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 { useRemoteSyncClient } from '../hooks/useRemoteSyncClient'
import { UrlStateParams, useUrlState } from '../hooks/useUrlState'
import { assetUrls } from '../utils/assetUrls'
import { MULTIPLAYER_SERVER } from '../utils/config'
import { CursorChatMenuItem } from '../utils/context-menu/CursorChatMenuItem'
import { createAssetFromFile } from '../utils/createAssetFromFile'
import { createAssetFromUrl } from '../utils/createAssetFromUrl'
import { linksUiOverrides } from '../utils/links'
import { useSharing } from '../utils/sharing'
import { trackAnalyticsEvent } from '../utils/trackAnalyticsEvent'
import { useCursorChat } from '../utils/useCursorChat'
import { useFileSystem } from '../utils/useFileSystem'
import { CURSOR_CHAT_ACTION, useCursorChat } from '../utils/useCursorChat'
import { OPEN_FILE_ACTION, SAVE_FILE_COPY_ACTION, useFileSystem } from '../utils/useFileSystem'
import { useHandleUiEvents } from '../utils/useHandleUiEvent'
import { CursorChatBubble } from './CursorChatBubble'
import { EmbeddedInIFrameWarning } from './EmbeddedInIFrameWarning'
import { MultiplayerFileMenu } from './FileMenu'
import { Links } from './Links'
import { PeopleMenu } from './PeopleMenu/PeopleMenu'
import { ShareMenu } from './ShareMenu'
import { SneakyOnDropOverride } from './SneakyOnDropOverride'
import { StoreErrorScreen } from './StoreErrorScreen'
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({
isReadOnly,
roomSlug,
@ -37,7 +97,7 @@ export function MultiplayerEditor({
})
const isEmbedded = useIsEmbedded(roomSlug)
const sharingUiOverrides = useSharing({ isMultiplayer: true })
const sharingUiOverrides = useSharing()
const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true })
const cursorChatOverrides = useCursorChat()
@ -67,19 +127,10 @@ export function MultiplayerEditor({
store={storeWithStatus}
assetUrls={assetUrls}
onMount={handleMount}
overrides={[sharingUiOverrides, fileSystemUiOverrides, cursorChatOverrides]}
initialState={isReadOnly ? 'hand' : 'select'}
overrides={[
sharingUiOverrides,
fileSystemUiOverrides,
linksUiOverrides,
cursorChatOverrides,
]}
onUiEvent={handleUiEvent}
components={{
ErrorFallback: ({ error }) => {
throw error
},
}}
components={components}
topZone={isOffline && <OfflineIndicator />}
shareZone={
<div className="tlui-share-zone" draggable={false}>

View file

@ -1,5 +1,14 @@
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 { useShareMenuIsOpen } from '../hooks/useShareMenuOpen'
import { createQRCodeImageDataString } from '../utils/qrcode'
@ -105,114 +114,118 @@ export const ShareMenu = React.memo(function ShareMenu() {
sideOffset={2}
alignOffset={4}
>
{shareState.state === 'shared' || shareState.state === 'readonly' ? (
<>
<button
className="tlui-share-zone__qr-code"
style={{ backgroundImage: `url(${currentQrCodeUrl})` }}
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'}
<TldrawUiMenuContextProvider type="panel" sourceId="share-menu">
{shareState.state === 'shared' || shareState.state === 'readonly' ? (
<>
<button
className="tlui-share-zone__qr-code"
style={{ backgroundImage: `url(${currentQrCodeUrl})` }}
title={msg(
isReadOnlyLink ? 'share-menu.copy-readonly-link' : 'share-menu.copy-link'
)}
onClick={() => {
setDidCopy(true)
setTimeout(() => setDidCopy(false), 750)
setTimeout(() => setDidCopy(false), 1000)
navigator.clipboard.writeText(currentShareLinkUrl)
}}
/>
{shareState.state === 'shared' && (
<Button
type="menu"
label="share-menu.readonly-link"
icon={isReadOnlyLink ? 'check' : 'checkbox-empty'}
onClick={async () => {
setIsReadOnlyLink(() => !isReadOnlyLink)
<TldrawUiMenuGroup id="copy">
<TldrawUiMenuItem
id="copy-to-clipboard"
readonlyOk
icon={didCopy ? 'clipboard-copied' : 'clipboard-copy'}
label={
isReadOnlyLink ? 'share-menu.copy-readonly-link' : 'share-menu.copy-link'
}
onSelect={() => {
setDidCopy(true)
setTimeout(() => setDidCopy(false), 750)
navigator.clipboard.writeText(currentShareLinkUrl)
}}
/>
)}
<p className="tlui-menu__group tlui-share-zone__details">
{msg(
isReadOnlyLink
? 'share-menu.copy-readonly-link-note'
: 'share-menu.copy-link-note'
{shareState.state === 'shared' && (
<TldrawUiMenuItem
id="toggle-read-only"
label="share-menu.readonly-link"
icon={isReadOnlyLink ? 'check' : 'checkbox-empty'}
onSelect={async () => {
setIsReadOnlyLink(() => !isReadOnlyLink)
}}
/>
)}
</p>
</div>
<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
<p className="tlui-menu__group tlui-share-zone__details">
{msg(
isReadOnlyLink
? 'share-menu.copy-readonly-link-note'
: 'share-menu.copy-link-note'
)}
</p>
</div>
<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>
</>
)}
)}
</p>
</TldrawUiMenuGroup>
<TldrawUiMenuGroup id="snapshot">
<TldrawUiMenuItem
{...shareSnapshot}
icon={didCopySnapshotLink ? 'clipboard-copied' : 'clipboard-copy'}
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>
</>
) : (
<>
<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.Portal>
</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 { StoreErrorScreen } from '../components/StoreErrorScreen'
import { useLocalStore } from '../hooks/useLocalStore'
import { assetUrls } from '../utils/assetUrls'
import { linksUiOverrides } from '../utils/links'
import { DebugMenuItems } from '../utils/migration/DebugMenuItems'
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 { 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 = {
schema: SerializedSchema
@ -17,7 +63,7 @@ type SnapshotEditorProps = {
export function SnapshotsEditor(props: SnapshotEditorProps) {
const handleUiEvent = useHandleUiEvents()
const sharingUiOverrides = useSharing({ isMultiplayer: true })
const sharingUiOverrides = useSharing()
const fileSystemUiOverrides = useFileSystem({ isMultiplayer: true })
const storeResult = useLocalStore(props.records, props.schema)
if (!storeResult?.ok) return <StoreErrorScreen error={new Error(storeResult?.error)} />
@ -27,16 +73,12 @@ export function SnapshotsEditor(props: SnapshotEditorProps) {
<Tldraw
assetUrls={assetUrls}
store={storeResult.value}
overrides={[sharingUiOverrides, fileSystemUiOverrides, linksUiOverrides]}
overrides={[sharingUiOverrides, fileSystemUiOverrides]}
onUiEvent={handleUiEvent}
onMount={(editor) => {
editor.updateInstanceState({ isReadonly: true })
}}
components={{
ErrorFallback: ({ error }) => {
throw error
},
}}
components={components}
shareZone={
<div className="tlui-share-zone" draggable={false}>
<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'
const RELEASE_INFO = `${env} ${process.env.NEXT_PUBLIC_TLDRAW_RELEASE_INFO ?? 'unreleased'}`
export function DebugMenuItems() {
return (
<DropdownMenu.Group>
<DropdownMenu.Item
type="menu"
onClick={() => {
<TldrawUiMenuGroup id="release">
<TldrawUiMenuItem
id="release-info"
title={`${RELEASE_INFO}`}
label="Version"
onSelect={() => {
window.alert(`${RELEASE_INFO}`)
}}
title={`${RELEASE_INFO}`}
>
Version
</DropdownMenu.Item>
<DropdownMenu.Item
type="menu"
onClick={async () => {
/>
<TldrawUiMenuItem
id="v1"
label="Test v1 content"
onSelect={async () => {
const { writeV1ContentsToIdb } = await import('./writeV1ContentsToIdb')
await writeV1ContentsToIdb()
window.location.reload()
}}
>
Test v1 content
</DropdownMenu.Item>
</DropdownMenu.Group>
/>
</TldrawUiMenuGroup>
)
}

View file

@ -12,11 +12,7 @@ import {
TLUiOverrides,
TLUiToastsContextType,
TLUiTranslationKey,
assert,
findMenuItem,
isShape,
menuGroup,
menuItem,
} from '@tldraw/tldraw'
import { useMemo } from 'react'
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_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
const CREATE_SNAPSHOT_ENDPOINT = `/api/snapshots`
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 id = useSearchParams()[0].get('id') ?? undefined
const uploadFileToAsset = useMultiplayerAssets(ASSET_UPLOADER_URL)
@ -188,24 +185,8 @@ export function useSharing({ isMultiplayer }: { isMultiplayer: boolean }): TLUiO
}
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 { userPreferences } from './userPreferences'
@ -40,14 +49,14 @@ function ConfirmClearDialog({
const [dontShowAgain, setDontShowAgain] = useState(false)
return (
<>
<Dialog.Header>
<Dialog.Title>{msg('file-system.confirm-clear.title')}</Dialog.Title>
<Dialog.CloseButton />
</Dialog.Header>
<Dialog.Body style={{ maxWidth: 350 }}>
<DialogHeader>
<DialogTitle>{msg('file-system.confirm-clear.title')}</DialogTitle>
<DialogCloseButton />
</DialogHeader>
<DialogBody style={{ maxWidth: 350 }}>
{msg('file-system.confirm-clear.description')}
</Dialog.Body>
<Dialog.Footer className="tlui-dialog__footer__actions">
</DialogBody>
<DialogFooter className="tlui-dialog__footer__actions">
<Button
type="normal"
onClick={() => setDontShowAgain(!dontShowAgain)}
@ -70,7 +79,7 @@ function ConfirmClearDialog({
>
{msg('file-system.confirm-clear.continue')}
</Button>
</Dialog.Footer>
</DialogFooter>
</>
)
}

View file

@ -1,6 +1,10 @@
import {
Button,
Dialog,
DialogBody,
DialogCloseButton,
DialogFooter,
DialogHeader,
DialogTitle,
TLUiDialogsContextType,
useLocalStorageState,
useTranslation,
@ -46,14 +50,12 @@ function ConfirmLeaveDialog({
return (
<>
<Dialog.Header>
<Dialog.Title>{msg('sharing.confirm-leave.title')}</Dialog.Title>
<Dialog.CloseButton />
</Dialog.Header>
<Dialog.Body style={{ maxWidth: 350 }}>
{msg('sharing.confirm-leave.description')}
</Dialog.Body>
<Dialog.Footer className="tlui-dialog__footer__actions">
<DialogHeader>
<DialogTitle>{msg('sharing.confirm-leave.title')}</DialogTitle>
<DialogCloseButton />
</DialogHeader>
<DialogBody style={{ maxWidth: 350 }}>{msg('sharing.confirm-leave.description')}</DialogBody>
<DialogFooter className="tlui-dialog__footer__actions">
<Button
type="normal"
onClick={() => setDontShowAgain(!dontShowAgain)}
@ -76,7 +78,7 @@ function ConfirmLeaveDialog({
>
{msg('sharing.confirm-leave.leave')}
</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 { userPreferences } from './userPreferences'
@ -40,14 +49,14 @@ function ConfirmOpenDialog({
const [dontShowAgain, setDontShowAgain] = useState(false)
return (
<>
<Dialog.Header>
<Dialog.Title>{msg('file-system.confirm-open.title')}</Dialog.Title>
<Dialog.CloseButton />
</Dialog.Header>
<Dialog.Body style={{ maxWidth: 350 }}>
<DialogHeader>
<DialogTitle>{msg('file-system.confirm-open.title')}</DialogTitle>
<DialogCloseButton />
</DialogHeader>
<DialogBody style={{ maxWidth: 350 }}>
{msg('file-system.confirm-open.description')}
</Dialog.Body>
<Dialog.Footer className="tlui-dialog__footer__actions">
</DialogBody>
<DialogFooter className="tlui-dialog__footer__actions">
<Button
type="normal"
onClick={() => setDontShowAgain(!dontShowAgain)}
@ -70,7 +79,7 @@ function ConfirmOpenDialog({
>
{msg('file-system.confirm-open.open')}
</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 { useHandleUiEvents } from './useHandleUiEvent'
@ -27,36 +27,6 @@ export function useCursorChat(): TLUiOverrides {
}
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]
)

View file

@ -5,10 +5,6 @@ import {
TLUiActionItem,
TLUiEventHandler,
TLUiOverrides,
assert,
findMenuItem,
menuGroup,
menuItem,
parseAndLoadDocument,
serializeTldrawJsonBlob,
transact,
@ -19,9 +15,9 @@ import { shouldClearDocument } from './shouldClearDocument'
import { shouldOverrideDocument } from './shouldOverrideDocument'
import { useHandleUiEvents } from './useHandleUiEvent'
const SAVE_FILE_COPY_ACTION = 'save-file-copy'
const OPEN_FILE_ACTION = 'open-file'
const NEW_PROJECT_ACTION = 'new-file'
export const SAVE_FILE_COPY_ACTION = 'save-file-copy'
export const OPEN_FILE_ACTION = 'open-file'
export const NEW_PROJECT_ACTION = 'new-file'
const saveFileNames = new WeakMap<TLStore, string>()
@ -92,31 +88,6 @@ export function useFileSystem({ isMultiplayer }: { isMultiplayer: boolean }): TL
}
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])
}

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

View file

@ -46,12 +46,12 @@ test.describe.skip('clipboard tests', () => {
expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1)
await page.getByTestId('main.menu').click()
await page.getByTestId('menu-item.edit').click()
await page.getByTestId('menu-item.copy').click()
await page.getByTestId('main-menu-sub-trigger.edit').click()
await page.getByTestId('main-menu.copy').click()
await sleep(100)
await page.getByTestId('main.menu').click()
await page.getByTestId('menu-item.edit').click()
await page.getByTestId('menu-item.paste').click()
await page.getByTestId('main-menu-sub-trigger.edit').click()
await page.getByTestId('main-menu.paste').click()
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2)
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)
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 page.mouse.move(200, 200)
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.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.beforeEach(async ({ browser }) => {
page = await browser.newPage()

View file

@ -26,8 +26,8 @@ test.describe('smoke tests', () => {
test('undo and redo', async ({ page }) => {
// buttons should be disabled when there is no history
expect(page.getByTestId('main.undo')).toBeDisabled()
expect(page.getByTestId('main.redo')).toBeDisabled()
expect(page.getByTestId('quick-actions.undo')).toBeDisabled()
expect(page.getByTestId('quick-actions.redo')).toBeDisabled()
// create a shape
await page.keyboard.press('r')
@ -39,22 +39,22 @@ test.describe('smoke tests', () => {
expect(await getAllShapeTypes(page)).toEqual(['geo'])
// We should have an undoable shape
expect(page.getByTestId('main.undo')).not.toBeDisabled()
expect(page.getByTestId('main.redo')).toBeDisabled()
expect(page.getByTestId('quick-actions.undo')).not.toBeDisabled()
expect(page.getByTestId('quick-actions.redo')).toBeDisabled()
// 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(page.getByTestId('main.undo')).toBeDisabled()
expect(page.getByTestId('main.redo')).not.toBeDisabled()
expect(page.getByTestId('quick-actions.undo')).toBeDisabled()
expect(page.getByTestId('quick-actions.redo')).not.toBeDisabled()
// 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 page.getByTestId('main.undo').isDisabled()).not.toBe(true)
expect(await page.getByTestId('main.redo').isDisabled()).toBe(true)
expect(await page.getByTestId('quick-actions.undo').isDisabled()).not.toBe(true)
expect(await page.getByTestId('quick-actions.redo').isDisabled()).toBe(true)
})
test('style panel + undo and redo squashing', async ({ page }) => {
@ -108,8 +108,8 @@ test.describe('smoke tests', () => {
await page.mouse.up()
// Now undo and redo
const undo = page.getByTestId('main.undo')
const redo = page.getByTestId('main.redo')
const undo = page.getByTestId('quick-actions.undo')
const redo = page.getByTestId('quick-actions.redo')
await undo.click() // orange -> light blue
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() // light-blue -> orange
expect(await page.getByTestId('main.undo').isDisabled()).not.toBe(true)
expect(await page.getByTestId('main.redo').isDisabled()).toBe(true)
expect(await page.getByTestId('quick-actions.undo').isDisabled()).not.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!
@ -10,7 +18,6 @@ export const uiOverrides: TLUiOverrides = {
icon: 'color',
label: 'Card',
kbd: 'c',
readonlyOk: false,
onSelect: () => {
editor.setCurrentTool('card')
},
@ -22,13 +29,18 @@ export const uiOverrides: TLUiOverrides = {
toolbar.splice(4, 0, toolbarItem(tools.card))
return toolbar
},
keyboardShortcutsMenu(_app, keyboardShortcutsMenu, { tools }) {
// Add the tool item from the context to the keyboard shortcuts dialog.
const toolsGroup = keyboardShortcutsMenu.find(
(group) => group.id === 'shortcuts-dialog.tools'
) as TLUiMenuGroup
toolsGroup.children.push(menuItem(tools.card))
return keyboardShortcutsMenu
}
export const components: TLComponents = {
KeyboardShortcutsDialog: (props) => {
const tools = useTools()
return (
<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 { CardShapeTool, CardShapeUtil } from './CardShape'
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!
@ -19,6 +19,7 @@ export default function CustomStylesExample() {
shapeUtils={customShapeUtils}
tools={customTools}
overrides={uiOverrides}
components={components}
>
<FilterStyleUi />
</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!
@ -9,7 +17,6 @@ export const uiOverrides: TLUiOverrides = {
icon: 'color',
label: 'Card' as any,
kbd: 'c',
readonlyOk: false,
onSelect: () => {
editor.setCurrentTool('card')
},
@ -20,12 +27,19 @@ export const uiOverrides: TLUiOverrides = {
toolbar.splice(4, 0, toolbarItem(tools.card))
return toolbar
},
keyboardShortcutsMenu(_app, keyboardShortcutsMenu, { tools }) {
const toolsGroup = keyboardShortcutsMenu.find(
(group) => group.id === 'shortcuts-dialog.tools'
) as TLUiMenuGroup
toolsGroup.children.push(menuItem(tools.card))
return keyboardShortcutsMenu
}
export const components: TLComponents = {
KeyboardShortcutsDialog: (props) => {
const tools = useTools()
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 {
Canvas,
ContextMenu,
DefaultContextMenuContent,
TldrawEditor,
TldrawHandles,
TldrawHoveredShapeIndicator,
@ -38,8 +39,8 @@ export default function ExplodedExample() {
persistenceKey="exploded-example"
>
<TldrawUi>
<ContextMenu>
<Canvas />
<ContextMenu canvas={<Canvas />}>
<DefaultContextMenuContent />
</ContextMenu>
</TldrawUi>
</TldrawEditor>

View file

@ -1,11 +1,4 @@
import {
TLUiActionsContextType,
TLUiMenuGroup,
TLUiOverrides,
TLUiToolsContextType,
Tldraw,
menuItem,
} from '@tldraw/tldraw'
import { TLUiActionsContextType, TLUiOverrides, TLUiToolsContextType, Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
import jsonSnapshot from './snapshot.json'
@ -15,7 +8,6 @@ import jsonSnapshot from './snapshot.json'
const overrides: TLUiOverrides = {
//[a]
actions(_editor, actions): TLUiActionsContextType {
actions['copy-as-png'].kbd = '$1'
actions['toggle-grid'].kbd = 'x'
return actions
},
@ -24,15 +16,6 @@ const overrides: TLUiOverrides = {
tools['draw'].kbd = 'p'
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]
@ -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
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]
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

View file

@ -1,6 +1,6 @@
/* 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 { MiniSelectTool } from './MiniSelectTool'
@ -32,24 +32,28 @@ export default function OnlyEditorExample() {
])
}}
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>
)
}
// [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
create your own custom UI, shape and tool interactions.

View file

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

View file

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

View file

@ -1,9 +1,12 @@
import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
TLComponents,
TLUiAssetUrlOverrides,
TLUiMenuGroup,
TLUiOverrides,
menuItem,
TldrawUiMenuItem,
toolbarItem,
useTools,
} from '@tldraw/tldraw'
// There's a guide at the bottom of this file!
@ -16,7 +19,6 @@ export const uiOverrides: TLUiOverrides = {
icon: 'speech-bubble',
label: 'Speech Bubble',
kbd: 's',
readonlyOk: false,
onSelect: () => {
editor.setCurrentTool('speech-bubble')
},
@ -27,13 +29,6 @@ export const uiOverrides: TLUiOverrides = {
toolbar.splice(4, 0, toolbarItem(tools.speech))
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]
@ -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

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
import '@tldraw/tldraw/tldraw.css'
// eslint-disable-next-line import/no-internal-modules
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 { VscodeMessage } from '../../messages'
import '../public/index.css'
import { ChangeResponder } from './ChangeResponder'
import { FileOpen } from './FileOpen'
import { FullPageMessage } from './FullPageMessage'
import { Links } from './Links'
import { onCreateAssetFromUrl } from './utils/bookmarks'
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 = () => {
const [tldrawInnerProps, setTldrawInnerProps] = useState<TLDrawInnerProps | null>(null)
@ -114,6 +105,16 @@ export type TLDrawInnerProps = {
isDarkMode: boolean
}
const components: TLComponents = {
HelpMenu: () => (
<DefaultHelpMenu>
<TldrawUiMenuGroup id="help">
<DefaultHelpMenuContent />
</TldrawUiMenuGroup>
<Links />
</DefaultHelpMenu>
),
}
function TldrawInner({ uri, assetSrc, isDarkMode, fileContents }: TLDrawInnerProps) {
const assetUrls = useMemo(() => getAssetUrlsByImport({ baseUrl: assetSrc }), [assetSrc])
@ -126,10 +127,11 @@ function TldrawInner({ uri, assetSrc, isDarkMode, fileContents }: TLDrawInnerPro
assetUrls={assetUrls}
persistenceKey={uri}
onMount={handleMount}
overrides={[menuOverrides, linksUiOverrides]}
components={components}
autoFocus
>
{/* <DarkModeHandler themeKind={themeKind} /> */}
<FileOpen fileContents={fileContents} forceDarkMode={isDarkMode} />
<ChangeResponder />
</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-embed": "Pretvori v vdelavo",
"action.open-embed-link": "Odpri povezavo",
"action.align-bottom": "Poravnaj dno",
"action.align-center-horizontal": "Poravnaj vodoravno",
"action.align-center-vertical": "Poravnaj navpično",
"action.align-center-horizontal.short": "Poravnaj vodoravno",
"action.align-center-vertical.short": "Poravnaj navpično",
"action.align-left": "Poravnaj levo",
"action.align-right": "Poravnaj desno",
"action.align-top": "Poravnaj vrh",
"action.back-to-content": "Nazaj na vsebino",
"action.bring-forward": "Premakni naprej",
"action.bring-to-front": "Premakni v ospredje",
"action.copy-as-json.short": "JSON",
"action.copy-as-json": "Kopiraj kot JSON",
"action.copy-as-png.short": "PNG",
"action.copy-as-png": "Kopiraj kot PNG",
"action.copy-as-svg.short": "SVG",
"action.copy-as-svg": "Kopiraj kot SVG",
"action.copy": "Kopiraj",
"action.cut": "Izreži",
"action.delete": "Izbriši",
"action.distribute-horizontal": "Porazdeli vodoravno",
"action.distribute-vertical": "Porazdeli navpično",
"action.distribute-horizontal.short": "Porazdeli vodoravno",
"action.distribute-vertical.short": "Porazdeli navpično",
"action.duplicate": "Podvoji",
"action.edit-link": "Uredi povezavo",
"action.exit-pen-mode": "Zapustite način peresa",
"action.export-as-json.short": "JSON",
"action.export-as-json": "Izvozi kot JSON",
"action.export-as-png.short": "PNG",
"action.export-as-png": "Izvozi kot PNG",
"action.export-as-svg.short": "SVG",
"action.export-as-svg": "Izvozi kot SVG",
"action.flip-horizontal": "Zrcali vodoravno",
"action.flip-vertical": "Zrcali navpično",
"action.flip-horizontal.short": "Zrcali horizontalno",
"action.flip-vertical.short": "Zrcali vertikalno",
"action.group": "Združi",
"action.insert-media": "Naloži predstavnost",
"action.new-shared-project": "Nov skupni projekt",
"action.open-file": "Odpri datoteko",
"action.pack": "Spakiraj",
"action.paste": "Prilepi",
"action.print": "Natisni",
"action.redo": "Uveljavi",
"action.rotate-ccw": "Zavrti v nasprotni smeri urinega kazalca",
"action.rotate-cw": "Zavrti v smeri urinega kazalca",
"action.save-copy": "Shrani kopijo",
"action.select-all": "Izberi vse",
"action.select-none": "Počisti izbiro",
"action.send-backward": "Pošlji nazaj",
"action.send-to-back": "Pošlji v ozadje",
"action.share-project": "Deli ta projekt",
"action.stack-horizontal": "Naloži vodoravno",
"action.stack-vertical": "Naloži navpično",
"action.stack-horizontal.short": "Naloži vodoravno",
"action.stack-vertical.short": "Naloži navpično",
"action.stretch-horizontal": "Raztegnite vodoravno",
"action.stretch-vertical": "Raztegni navpično",
"action.stretch-horizontal.short": "Raztezanje vodoravno",
"action.stretch-vertical.short": "Raztezanje navpično",
"action.toggle-auto-size": "Preklopi samodejno velikost",
"action.toggle-dark-mode.menu": "Temni način",
"action.toggle-dark-mode": "Preklopi temni način",
"action.toggle-debug-mode.menu": "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": "Preklopi na osredotočen način",
"action.toggle-grid.menu": "Prikaži mrežo",
"action.toggle-grid": "Preklopi mrežo",
"action.toggle-snap-mode.menu": "Vedno pripni",
"action.toggle-snap-mode": "Preklopi pripenjanje",
"action.toggle-tool-lock.menu": "Zaklepanje orodja",
"action.toggle-tool-lock": "Preklopi zaklepanje orodja",
"action.toggle-transparent.context-menu": "Prozorno",
"action.toggle-transparent.menu": "Prozorno",
"action.toggle-transparent": "Preklopi prosojno ozadje",
"action.undo": "Razveljavi",
"action.ungroup": "Razdruži",
"action.zoom-in": "Povečaj",
"action.zoom-out": "Pomanjšaj",
"action.zoom-to-100": "Povečaj na 100 %",
"action.zoom-to-fit": "Povečaj do prileganja",
"action.zoom-to-selection": "Pomakni na izbiro",
"color-style.black": "Črna",
"color-style.blue": "Modra",
"color-style.green": "Zelena",
"color-style.grey": "Siva",
"color-style.light-blue": "Svetlo modra",
"color-style.light-green": "Svetlo zelena",
"color-style.light-red": "Svetlo rdeča",
"color-style.light-violet": "Svetlo vijolična",
"color-style.orange": "Oranžna",
"color-style.red": "Rdeča",
"color-style.violet": "Vijolična",
"color-style.yellow": "Rumena",
"fill-style.none": "Brez",
"fill-style.semi": "Polovično",
"fill-style.solid": "Polno",
"fill-style.pattern": "Vzorec",
"dash-style.dashed": "Črtkano",
"dash-style.dotted": "Pikčasto",
"dash-style.draw": "Narisano",
"dash-style.solid": "Polno",
"size-style.s": "Malo",
"size-style.m": "Srednje",
"size-style.l": "Veliko",
"size-style.xl": "Zelo veliko",
"opacity-style.0.1": "10 %",
"opacity-style.0.25": "25 %",
"opacity-style.0.5": "50 %",
"opacity-style.0.75": "75 %",
"opacity-style.1": "100 %",
"font-style.draw": "Draw",
"font-style.sans": "Sans",
"font-style.serif": "Serif",
"font-style.mono": "Mono",
"align-style.start": "Začetek",
"align-style.middle": "Sredina",
"align-style.end": "Konec",
"align-style.justify": "Poravnaj",
"geo-style.arrow-down": "Puščica navzdol",
"geo-style.arrow-left": "Puščica levo",
"geo-style.arrow-right": "Puščica desno",
"geo-style.arrow-up": "Puščica navzgor",
"geo-style.diamond": "Diamant",
"geo-style.ellipse": "Elipsa",
"geo-style.hexagon": "Šesterokotnik",
"geo-style.octagon": "Osmerokotnik",
"geo-style.oval": "Oval",
"geo-style.pentagon": "Peterokotnik",
"geo-style.rectangle": "Pravokotnik",
"geo-style.rhombus-2": "Romb 2",
"geo-style.rhombus": "Romb",
"geo-style.star": "Zvezda",
"geo-style.trapezoid": "Trapez",
"geo-style.triangle": "Trikotnik",
"geo-style.x-box": "X polje",
"arrowheadStart-style.none": "Brez",
"arrowheadStart-style.arrow": "Puščica",
"arrowheadStart-style.bar": "Črta",
"arrowheadStart-style.diamond": "Diamant",
"arrowheadStart-style.dot": "Pika",
"arrowheadStart-style.inverted": "Obrnjeno",
"arrowheadStart-style.pipe": "Cev",
"arrowheadStart-style.square": "Kvadrat",
"arrowheadStart-style.triangle": "Trikotnik",
"arrowheadEnd-style.none": "Brez",
"arrowheadEnd-style.arrow": "Puščica",
"arrowheadEnd-style.bar": "Črta",
"arrowheadEnd-style.diamond": "Diamant",
"arrowheadEnd-style.dot": "Pika",
"arrowheadEnd-style.inverted": "Obrnjeno",
"arrowheadEnd-style.pipe": "Cev",
"arrowheadEnd-style.square": "Kvadrat",
"arrowheadEnd-style.triangle": "Trikotnik",
"spline-style.line": "Črta",
"spline-style.cubic": "Kubično",
"tool.select": "Izbor",
"tool.hand": "Roka",
"tool.draw": "Risanje",
"tool.eraser": "Radirka",
"tool.arrow-down": "Puščica navzdol",
"tool.arrow-left": "Puščica levo",
"tool.arrow-right": "Puščica desno",
"tool.arrow-up": "Puščica navzgor",
"tool.arrow": "Puščica",
"tool.diamond": "Diamant",
"tool.ellipse": "Elipsa",
"tool.hexagon": "Šesterokotnik",
"tool.line": "Črta",
"tool.octagon": "Osmerokotnik",
"tool.oval": "Oval",
"tool.pentagon": "Peterokotnik",
"tool.rectangle": "Pravokotnik",
"tool.rhombus": "Romb",
"tool.star": "Zvezda",
"tool.trapezoid": "Trapez",
"tool.triangle": "Trikotnik",
"tool.x-box": "X polje",
"tool.asset": "Sredstvo",
"tool.frame": "Okvir",
"tool.note": "Opomba",
"tool.embed": "Vdelava",
"tool.text": "Besedilo",
"menu.title": "Meni",
"menu.copy-as": "Kopiraj kot",
"menu.edit": "Uredi",
"menu.export-as": "Izvozi kot",
"menu.file": "Datoteka",
"menu.language": "Jezik",
"menu.preferences": "Nastavitve",
"menu.view": "Pogled",
"context-menu.arrange": "Preuredi",
"context-menu.copy-as": "Kopiraj kot",
"context-menu.export-as": "Izvozi kot",
"context-menu.move-to-page": "Premakni na stran",
"context-menu.reorder": "Preuredite",
"page-menu.title": "Strani",
"page-menu.create-new-page": "Ustvari novo stran",
"page-menu.max-page-count-reached": "Doseženo največje število strani",
"page-menu.new-page-initial-name": "Stran 1",
"page-menu.edit-start": "Uredi",
"page-menu.edit-done": "Zaključi",
"page-menu.submenu.rename": "Preimenuj",
"page-menu.submenu.duplicate-page": "Podvoji",
"page-menu.submenu.title": "Meni",
"page-menu.submenu.move-down": "Premakni navzdol",
"page-menu.submenu.move-up": "Premakni navzgor",
"page-menu.submenu.delete": "Izbriši",
"share-menu.title": "Deli",
"share-menu.share-project": "Deli ta projekt",
"share-menu.copy-link": "Kopiraj povezavo",
"share-menu.readonly-link": "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.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.project-too-large": "Žal tega projekta ni mogoče deliti, ker je prevelik. Delamo na tem!",
"people-menu.title": "Ljudje",
"people-menu.change-name": "Spremeni ime",
"people-menu.change-color": "Spremeni barvo",
"people-menu.user": "(Ti)",
"people-menu.invite": "Povabi ostale",
"help-menu.title": "Pomoč in viri",
"help-menu.about": "O nas",
"help-menu.discord": "Discord",
"help-menu.github": "GitHub",
"help-menu.keyboard-shortcuts": "Bližnjice na tipkovnici",
"help-menu.twitter": "Twitter",
"actions-menu.title": "Akcije",
"edit-link-dialog.title": "Uredi povezavo",
"edit-link-dialog.invalid-url": "Povezava mora biti veljavna",
"edit-link-dialog.detail": "Povezave se bodo odprle v novem zavihku.",
"edit-link-dialog.url": "URL",
"edit-link-dialog.clear": "Počisti",
"edit-link-dialog.save": "Nadaljuj",
"edit-link-dialog.cancel": "Prekliči",
"embed-dialog.title": "Ustvari vdelavo",
"embed-dialog.back": "Nazaj",
"embed-dialog.create": "Ustvari",
"embed-dialog.cancel": "Prekliči",
"embed-dialog.url": "URL",
"embed-dialog.instruction": "Prilepite URL spletnega mesta, da ustvarite vdelavo.",
"embed-dialog.invalid-url": "Iz tega URL-ja nismo mogli ustvariti vdelave.",
"edit-pages-dialog.move-down": "Premakni navzdol",
"edit-pages-dialog.move-up": "Premakni navzgor",
"shortcuts-dialog.title": "Bližnjice na tipkovnici",
"shortcuts-dialog.edit": "Uredi",
"shortcuts-dialog.file": "Datoteka",
"shortcuts-dialog.preferences": "Nastavitve",
"shortcuts-dialog.tools": "Orodja",
"shortcuts-dialog.transform": "Preoblikuj",
"shortcuts-dialog.view": "Pogled",
"style-panel.title": "Stili",
"style-panel.align": "Poravnava",
"style-panel.arrowheads": "Puščice",
"style-panel.color": "Barva",
"style-panel.dash": "Črtasto",
"style-panel.fill": "Polnilo",
"style-panel.font": "Pisava",
"style-panel.geo": "Oblika",
"style-panel.mixed": "Mešano",
"style-panel.opacity": "Motnost",
"style-panel.size": "Velikost",
"style-panel.spline": "Krivulja",
"tool-panel.drawing": "Risanje",
"tool-panel.shapes": "Oblike",
"navigation-zone.toggle-minimap": "Preklopi mini zemljevid",
"navigation-zone.zoom": "Povečava",
"focus-mode.toggle-focus-mode": "Preklopi na osredotočen način",
"toast.close": "Zapri",
"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.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.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.cancel": "Prekliči",
"file-system.confirm-open.open": "Odpri datoteko",
"file-system.confirm-open.dont-show-again": "Ne sprašuj znova",
"toast.error.export-fail.title": "Izvoz ni uspel",
"toast.error.export-fail.desc": "Izvoz slike ni uspel",
"toast.error.copy-fail.title": "Kopiranje 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.description": "Odpiranje datotek v skupnih projektih ni podprto.",
"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?",
"context.pages.new-page": "Nova stran",
"style-panel.arrowhead-start": "Začetek",
"style-panel.arrowhead-end": "Konec",
"vscode.file-open.open": "Nadaljuj",
"vscode.file-open.backup": "Varnostna kopija",
"vscode.file-open.backup-saved": "Varnostna kopija shranjena",
"vscode.file-open.backup-failed": "Varnostno kopiranje ni uspelo: to ni datoteka .tldr.",
"tool-panel.more": "Več",
"debug-panel.more": "Več",
"action.new-project": "Nov 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.cancel": "Prekliči",
"file-system.confirm-clear.continue": "Nadaljuj",
"file-system.confirm-clear.dont-show-again": "Ne sprašuj znova",
"action.stop-following": "Prenehaj slediti",
"people-menu.follow": "Sledi",
"style-panel.position": "Položaj",
"page-menu.go-to-page": "Pojdi na stran",
"action.insert-embed": "Vstavi vdelavo",
"people-menu.following": "Sledim",
"people-menu.leading": "Sledi vam",
"geo-style.check-box": "Potrditveno polje",
"tool.check-box": "Potrditveno polje",
"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.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.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",
"tool.laser": "Laser",
"action.fork-project": "Naredi kopijo projekta",
"action.leave-shared-project": "Zapusti skupni 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.cancel": "Prekliči",
"sharing.confirm-leave.leave": "Zapusti",
"sharing.confirm-leave.dont-show-again": "Ne sprašuj znova",
"action.toggle-reduce-motion.menu": "Zmanjšaj gibanje",
"action.toggle-reduce-motion": "Preklop zmanjšanja gibanja",
"tool.highlight": "Marker",
"action.toggle-lock": "Zakleni \/ odkleni",
"share-menu.default-project-name": "Skupni projekt",
"home-project-dialog.title": "Lokalni projekt",
"home-project-dialog.description": "To je vaš lokalni projekt. Namenjen je samo vam!",
"rename-project-dialog.title": "Preimenuj projekt",
"rename-project-dialog.cancel": "Prekliči",
"rename-project-dialog.rename": "Preimenuj",
"home-project-dialog.ok": "V redu",
"action.open-cursor-chat": "Klepet s kazalcem",
"shortcuts-dialog.collaboration": "Sodelovanje",
"cursor-chat.type-to-chat": "Vnesite za klepet ...",
"geo-style.cloud": "Oblak",
"tool.cloud": "Oblak",
"action.unlock-all": "Odkleni vse",
"status.offline": "Brez povezave",
"status.online": "Povezan",
"action.remove-frame": "Odstrani okvir",
"action.fit-frame-to-content": "Prilagodi vsebini",
"action.toggle-edge-scrolling.menu": "Pomikanje ob robovih",
"action.toggle-edge-scrolling": "Preklopi pomikanje ob robovih",
"verticalAlign-style.start": "Vrh",
"verticalAlign-style.middle": "Sredina",
"verticalAlign-style.end": "Dno"
}
"action.convert-to-bookmark": "Pretvori v zaznamek",
"action.convert-to-embed": "Pretvori v vdelavo",
"action.open-embed-link": "Odpri povezavo",
"action.align-bottom": "Poravnaj dno",
"action.align-center-horizontal": "Poravnaj vodoravno",
"action.align-center-vertical": "Poravnaj navpično",
"action.align-center-horizontal.short": "Poravnaj vodoravno",
"action.align-center-vertical.short": "Poravnaj navpično",
"action.align-left": "Poravnaj levo",
"action.align-right": "Poravnaj desno",
"action.align-top": "Poravnaj vrh",
"action.back-to-content": "Nazaj na vsebino",
"action.bring-forward": "Premakni naprej",
"action.bring-to-front": "Premakni v ospredje",
"action.copy-as-json.short": "JSON",
"action.copy-as-json": "Kopiraj kot JSON",
"action.copy-as-png.short": "PNG",
"action.copy-as-png": "Kopiraj kot PNG",
"action.copy-as-svg.short": "SVG",
"action.copy-as-svg": "Kopiraj kot SVG",
"action.copy": "Kopiraj",
"action.cut": "Izreži",
"action.delete": "Izbriši",
"action.distribute-horizontal": "Porazdeli vodoravno",
"action.distribute-vertical": "Porazdeli navpično",
"action.distribute-horizontal.short": "Porazdeli vodoravno",
"action.distribute-vertical.short": "Porazdeli navpično",
"action.duplicate": "Podvoji",
"action.edit-link": "Uredi povezavo",
"action.exit-pen-mode": "Zapustite način peresa",
"action.export-as-json.short": "JSON",
"action.export-as-json": "Izvozi kot JSON",
"action.export-as-png.short": "PNG",
"action.export-as-png": "Izvozi kot PNG",
"action.export-as-svg.short": "SVG",
"action.export-as-svg": "Izvozi kot SVG",
"action.flip-horizontal": "Zrcali vodoravno",
"action.flip-vertical": "Zrcali navpično",
"action.flip-horizontal.short": "Zrcali horizontalno",
"action.flip-vertical.short": "Zrcali vertikalno",
"action.group": "Združi",
"action.insert-media": "Naloži predstavnost",
"action.new-shared-project": "Nov skupni projekt",
"action.open-file": "Odpri datoteko",
"action.pack": "Spakiraj",
"action.paste": "Prilepi",
"action.print": "Natisni",
"action.redo": "Uveljavi",
"action.rotate-ccw": "Zavrti v nasprotni smeri urinega kazalca",
"action.rotate-cw": "Zavrti v smeri urinega kazalca",
"action.save-copy": "Shrani kopijo",
"action.select-all": "Izberi vse",
"action.select-none": "Počisti izbiro",
"action.send-backward": "Pošlji nazaj",
"action.send-to-back": "Pošlji v ozadje",
"action.share-project": "Deli ta projekt",
"action.stack-horizontal": "Naloži vodoravno",
"action.stack-vertical": "Naloži navpično",
"action.stack-horizontal.short": "Naloži vodoravno",
"action.stack-vertical.short": "Naloži navpično",
"action.stretch-horizontal": "Raztegnite vodoravno",
"action.stretch-vertical": "Raztegni navpično",
"action.stretch-horizontal.short": "Raztezanje vodoravno",
"action.stretch-vertical.short": "Raztezanje navpično",
"action.toggle-auto-size": "Preklopi samodejno velikost",
"action.toggle-dark-mode.menu": "Temni način",
"action.toggle-dark-mode": "Preklopi temni način",
"action.toggle-debug-mode.menu": "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": "Preklopi na osredotočen način",
"action.toggle-grid.menu": "Prikaži mrežo",
"action.toggle-grid": "Preklopi mrežo",
"action.toggle-snap-mode.menu": "Vedno pripni",
"action.toggle-snap-mode": "Preklopi pripenjanje",
"action.toggle-tool-lock.menu": "Zaklepanje orodja",
"action.toggle-tool-lock": "Preklopi zaklepanje orodja",
"action.toggle-transparent.context-menu": "Prozorno",
"action.toggle-transparent.menu": "Prozorno",
"action.toggle-transparent": "Preklopi prosojno ozadje",
"action.undo": "Razveljavi",
"action.ungroup": "Razdruži",
"action.zoom-in": "Povečaj",
"action.zoom-out": "Pomanjšaj",
"action.zoom-to-100": "Povečaj na 100 %",
"action.zoom-to-fit": "Povečaj do prileganja",
"action.zoom-to-selection": "Pomakni na izbiro",
"color-style.black": "Črna",
"color-style.blue": "Modra",
"color-style.green": "Zelena",
"color-style.grey": "Siva",
"color-style.light-blue": "Svetlo modra",
"color-style.light-green": "Svetlo zelena",
"color-style.light-red": "Svetlo rdeča",
"color-style.light-violet": "Svetlo vijolična",
"color-style.orange": "Oranžna",
"color-style.red": "Rdeča",
"color-style.violet": "Vijolična",
"color-style.yellow": "Rumena",
"fill-style.none": "Brez",
"fill-style.semi": "Polovično",
"fill-style.solid": "Polno",
"fill-style.pattern": "Vzorec",
"dash-style.dashed": "Črtkano",
"dash-style.dotted": "Pikčasto",
"dash-style.draw": "Narisano",
"dash-style.solid": "Polno",
"size-style.s": "Malo",
"size-style.m": "Srednje",
"size-style.l": "Veliko",
"size-style.xl": "Zelo veliko",
"opacity-style.0.1": "10 %",
"opacity-style.0.25": "25 %",
"opacity-style.0.5": "50 %",
"opacity-style.0.75": "75 %",
"opacity-style.1": "100 %",
"font-style.draw": "Draw",
"font-style.sans": "Sans",
"font-style.serif": "Serif",
"font-style.mono": "Mono",
"align-style.start": "Začetek",
"align-style.middle": "Sredina",
"align-style.end": "Konec",
"align-style.justify": "Poravnaj",
"geo-style.arrow-down": "Puščica navzdol",
"geo-style.arrow-left": "Puščica levo",
"geo-style.arrow-right": "Puščica desno",
"geo-style.arrow-up": "Puščica navzgor",
"geo-style.diamond": "Diamant",
"geo-style.ellipse": "Elipsa",
"geo-style.hexagon": "Šesterokotnik",
"geo-style.octagon": "Osmerokotnik",
"geo-style.oval": "Oval",
"geo-style.pentagon": "Peterokotnik",
"geo-style.rectangle": "Pravokotnik",
"geo-style.rhombus-2": "Romb 2",
"geo-style.rhombus": "Romb",
"geo-style.star": "Zvezda",
"geo-style.trapezoid": "Trapez",
"geo-style.triangle": "Trikotnik",
"geo-style.x-box": "X polje",
"arrowheadStart-style.none": "Brez",
"arrowheadStart-style.arrow": "Puščica",
"arrowheadStart-style.bar": "Črta",
"arrowheadStart-style.diamond": "Diamant",
"arrowheadStart-style.dot": "Pika",
"arrowheadStart-style.inverted": "Obrnjeno",
"arrowheadStart-style.pipe": "Cev",
"arrowheadStart-style.square": "Kvadrat",
"arrowheadStart-style.triangle": "Trikotnik",
"arrowheadEnd-style.none": "Brez",
"arrowheadEnd-style.arrow": "Puščica",
"arrowheadEnd-style.bar": "Črta",
"arrowheadEnd-style.diamond": "Diamant",
"arrowheadEnd-style.dot": "Pika",
"arrowheadEnd-style.inverted": "Obrnjeno",
"arrowheadEnd-style.pipe": "Cev",
"arrowheadEnd-style.square": "Kvadrat",
"arrowheadEnd-style.triangle": "Trikotnik",
"spline-style.line": "Črta",
"spline-style.cubic": "Kubično",
"tool.select": "Izbor",
"tool.hand": "Roka",
"tool.draw": "Risanje",
"tool.eraser": "Radirka",
"tool.arrow-down": "Puščica navzdol",
"tool.arrow-left": "Puščica levo",
"tool.arrow-right": "Puščica desno",
"tool.arrow-up": "Puščica navzgor",
"tool.arrow": "Puščica",
"tool.diamond": "Diamant",
"tool.ellipse": "Elipsa",
"tool.hexagon": "Šesterokotnik",
"tool.line": "Črta",
"tool.octagon": "Osmerokotnik",
"tool.oval": "Oval",
"tool.pentagon": "Peterokotnik",
"tool.rectangle": "Pravokotnik",
"tool.rhombus": "Romb",
"tool.star": "Zvezda",
"tool.trapezoid": "Trapez",
"tool.triangle": "Trikotnik",
"tool.x-box": "X polje",
"tool.asset": "Sredstvo",
"tool.frame": "Okvir",
"tool.note": "Opomba",
"tool.embed": "Vdelava",
"tool.text": "Besedilo",
"menu.title": "Meni",
"menu.copy-as": "Kopiraj kot",
"menu.edit": "Uredi",
"menu.export-as": "Izvozi kot",
"menu.file": "Datoteka",
"menu.language": "Jezik",
"menu.preferences": "Nastavitve",
"menu.view": "Pogled",
"context-menu.arrange": "Preuredi",
"context-menu.copy-as": "Kopiraj kot",
"context-menu.export-as": "Izvozi kot",
"context-menu.move-to-page": "Premakni na stran",
"context-menu.reorder": "Preuredite",
"page-menu.title": "Strani",
"page-menu.create-new-page": "Ustvari novo stran",
"page-menu.max-page-count-reached": "Doseženo največje število strani",
"page-menu.new-page-initial-name": "Stran 1",
"page-menu.edit-start": "Uredi",
"page-menu.edit-done": "Zaključi",
"page-menu.submenu.rename": "Preimenuj",
"page-menu.submenu.duplicate-page": "Podvoji",
"page-menu.submenu.title": "Meni",
"page-menu.submenu.move-down": "Premakni navzdol",
"page-menu.submenu.move-up": "Premakni navzgor",
"page-menu.submenu.delete": "Izbriši",
"share-menu.title": "Deli",
"share-menu.share-project": "Deli ta projekt",
"share-menu.copy-link": "Kopiraj povezavo",
"share-menu.readonly-link": "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.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.project-too-large": "Žal tega projekta ni mogoče deliti, ker je prevelik. Delamo na tem!",
"people-menu.title": "Ljudje",
"people-menu.change-name": "Spremeni ime",
"people-menu.change-color": "Spremeni barvo",
"people-menu.user": "(Ti)",
"people-menu.invite": "Povabi ostale",
"help-menu.title": "Pomoč in viri",
"help-menu.about": "O nas",
"help-menu.discord": "Discord",
"help-menu.github": "GitHub",
"help-menu.keyboard-shortcuts": "Bližnjice na tipkovnici",
"help-menu.twitter": "Twitter",
"actions-menu.title": "Akcije",
"edit-link-dialog.title": "Uredi povezavo",
"edit-link-dialog.invalid-url": "Povezava mora biti veljavna",
"edit-link-dialog.detail": "Povezave se bodo odprle v novem zavihku.",
"edit-link-dialog.url": "URL",
"edit-link-dialog.clear": "Počisti",
"edit-link-dialog.save": "Nadaljuj",
"edit-link-dialog.cancel": "Prekliči",
"embed-dialog.title": "Ustvari vdelavo",
"embed-dialog.back": "Nazaj",
"embed-dialog.create": "Ustvari",
"embed-dialog.cancel": "Prekliči",
"embed-dialog.url": "URL",
"embed-dialog.instruction": "Prilepite URL spletnega mesta, da ustvarite vdelavo.",
"embed-dialog.invalid-url": "Iz tega URL-ja nismo mogli ustvariti vdelave.",
"edit-pages-dialog.move-down": "Premakni navzdol",
"edit-pages-dialog.move-up": "Premakni navzgor",
"shortcuts-dialog.title": "Bližnjice na tipkovnici",
"shortcuts-dialog.edit": "Uredi",
"shortcuts-dialog.file": "Datoteka",
"shortcuts-dialog.preferences": "Nastavitve",
"shortcuts-dialog.tools": "Orodja",
"shortcuts-dialog.transform": "Preoblikuj",
"shortcuts-dialog.view": "Pogled",
"style-panel.title": "Stili",
"style-panel.align": "Poravnava",
"style-panel.arrowheads": "Puščice",
"style-panel.color": "Barva",
"style-panel.dash": "Črtasto",
"style-panel.fill": "Polnilo",
"style-panel.font": "Pisava",
"style-panel.geo": "Oblika",
"style-panel.mixed": "Mešano",
"style-panel.opacity": "Motnost",
"style-panel.size": "Velikost",
"style-panel.spline": "Krivulja",
"tool-panel.drawing": "Risanje",
"tool-panel.shapes": "Oblike",
"navigation-zone.toggle-minimap": "Preklopi mini zemljevid",
"navigation-zone.zoom": "Povečava",
"focus-mode.toggle-focus-mode": "Preklopi na osredotočen način",
"toast.close": "Zapri",
"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.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.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.cancel": "Prekliči",
"file-system.confirm-open.open": "Odpri datoteko",
"file-system.confirm-open.dont-show-again": "Ne sprašuj znova",
"toast.error.export-fail.title": "Izvoz ni uspel",
"toast.error.export-fail.desc": "Izvoz slike ni uspel",
"toast.error.copy-fail.title": "Kopiranje 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.description": "Odpiranje datotek v skupnih projektih ni podprto.",
"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?",
"context.pages.new-page": "Nova stran",
"style-panel.arrowhead-start": "Začetek",
"style-panel.arrowhead-end": "Konec",
"vscode.file-open.open": "Nadaljuj",
"vscode.file-open.backup": "Varnostna kopija",
"vscode.file-open.backup-saved": "Varnostna kopija shranjena",
"vscode.file-open.backup-failed": "Varnostno kopiranje ni uspelo: to ni datoteka .tldr.",
"tool-panel.more": "Več",
"debug-panel.more": "Več",
"action.new-project": "Nov 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.cancel": "Prekliči",
"file-system.confirm-clear.continue": "Nadaljuj",
"file-system.confirm-clear.dont-show-again": "Ne sprašuj znova",
"action.stop-following": "Prenehaj slediti",
"people-menu.follow": "Sledi",
"style-panel.position": "Položaj",
"page-menu.go-to-page": "Pojdi na stran",
"action.insert-embed": "Vstavi vdelavo",
"people-menu.following": "Sledim",
"people-menu.leading": "Sledi vam",
"geo-style.check-box": "Potrditveno polje",
"tool.check-box": "Potrditveno polje",
"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.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.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",
"tool.laser": "Laser",
"action.fork-project": "Naredi kopijo projekta",
"action.leave-shared-project": "Zapusti skupni 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.cancel": "Prekliči",
"sharing.confirm-leave.leave": "Zapusti",
"sharing.confirm-leave.dont-show-again": "Ne sprašuj znova",
"action.toggle-reduce-motion.menu": "Zmanjšaj gibanje",
"action.toggle-reduce-motion": "Preklop zmanjšanja gibanja",
"tool.highlight": "Marker",
"action.toggle-lock": "Zakleni / odkleni",
"share-menu.default-project-name": "Skupni projekt",
"home-project-dialog.title": "Lokalni projekt",
"home-project-dialog.description": "To je vaš lokalni projekt. Namenjen je samo vam!",
"rename-project-dialog.title": "Preimenuj projekt",
"rename-project-dialog.cancel": "Prekliči",
"rename-project-dialog.rename": "Preimenuj",
"home-project-dialog.ok": "V redu",
"action.open-cursor-chat": "Klepet s kazalcem",
"shortcuts-dialog.collaboration": "Sodelovanje",
"cursor-chat.type-to-chat": "Vnesite za klepet ...",
"geo-style.cloud": "Oblak",
"tool.cloud": "Oblak",
"action.unlock-all": "Odkleni vse",
"status.offline": "Brez povezave",
"status.online": "Povezan",
"action.remove-frame": "Odstrani okvir",
"action.fit-frame-to-content": "Prilagodi vsebini",
"action.toggle-edge-scrolling.menu": "Pomikanje ob robovih",
"action.toggle-edge-scrolling": "Preklopi pomikanje ob robovih",
"verticalAlign-style.start": "Vrh",
"verticalAlign-style.middle": "Sredina",
"verticalAlign-style.end": "Dno"
}

View file

@ -16,11 +16,9 @@ import { EmbedDefinition } from '@tldraw/tlschema';
import { EMPTY_ARRAY } from '@tldraw/state';
import { EventEmitter } from 'eventemitter3';
import { HistoryEntry } from '@tldraw/store';
import { HTMLProps } from 'react';
import { IndexKey } from '@tldraw/utils';
import { JsonObject } from '@tldraw/utils';
import { JSX as JSX_2 } from 'react/jsx-runtime';
import { MemoExoticComponent } from 'react';
import { Migrations } from '@tldraw/store';
import { NamedExoticComponent } 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>;
// @internal (undocumented)
export const debugFlags: {
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>;
};
export const debugFlags: Record<string, DebugFlag<boolean>>;
// @internal (undocumented)
export const DEFAULT_ANIMATION_OPTIONS: {
@ -1452,13 +1435,6 @@ export class Polyline2d extends Geometry2d {
_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)
export function precise(A: VecLike): string;

View file

@ -27915,83 +27915,6 @@
},
"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",
"canonicalReference": "@tldraw/editor!precise:function(1)",

View file

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

View file

@ -99,7 +99,6 @@ export function Canvas({ className }: { className?: string }) {
>
{Background && <Background />}
<GridWrapper />
<UiLogger />
<svg className="tl-svg-context">
<defs>
{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() {
const editor = useEditor()
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 classNames from 'classnames'
import * as React from 'react'
import { useEditor } from '../..'
import type { Editor } from '../editor/Editor'
import { ShapeUtil } from '../editor/shapes/ShapeUtil'
import { useEditor } from '../hooks/useEditor'
import { useEditorComponents } from '../hooks/useEditorComponents'
import { OptionalErrorBoundary } from './ErrorBoundary'

View file

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

View file

@ -12,26 +12,25 @@ export const featureFlags: Record<string, DebugFlag<boolean>> = {
}
/** @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 ---
preventDefaultLogging: createDebugValue('preventDefaultLogging', {
defaults: { all: false },
}),
pointerCaptureLogging: createDebugValue('pointerCaptureLogging', {
defaults: { all: false },
}),
pointerCaptureTracking: createDebugValue('pointerCaptureTracking', {
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', {
defaults: { all: false },
}),
@ -44,7 +43,6 @@ export const debugFlags = {
throwToBlob: createDebugValue('throwToBlob', {
defaults: { all: false },
}),
logMessages: createDebugValue('uiLog', { defaults: { all: [] as any[] } }),
resetConnectionEveryPing: createDebugValue('resetConnectionEveryPing', {
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 ---
// In normal code, read from debug flags directly by calling .value on them:
// if (debugFlags.preventDefaultLogging.value) { ... }

View file

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

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'
// UI
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 { ContextMenu, type TLUiContextMenuProps } from './lib/ui/components/ContextMenu'
export { OfflineIndicator } from './lib/ui/components/OfflineIndicator/OfflineIndicator'
export { Spinner } from './lib/ui/components/Spinner'
export { Button, type TLUiButtonProps } from './lib/ui/components/primitives/Button'
export { Icon, type TLUiIconProps } from './lib/ui/components/primitives/Icon'
export { Input, type TLUiInputProps } from './lib/ui/components/primitives/Input'
export {
compactMenuItems,
findMenuItem,
menuCustom,
menuGroup,
menuItem,
menuSubmenu,
type TLUiCustomMenuItem,
type TLUiMenuChild,
type TLUiMenuGroup,
type TLUiMenuItem,
type TLUiMenuSchema,
type TLUiSubMenu,
} from './lib/ui/hooks/menuHelpers'
TldrawUiContextProvider,
type TldrawUiContextProviderProps,
} from './lib/ui/context/TldrawUiContextProvider'
export {
useActions,
type TLUiActionItem,
type TLUiActionsContextType,
} from './lib/ui/hooks/useActions'
export {
useActionsMenuSchema,
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'
} from './lib/ui/context/actions'
export { AssetUrlsProvider, useAssetUrls } from './lib/ui/context/asset-urls'
export { BreakPointProvider, useBreakpoint } from './lib/ui/context/breakpoints'
export {
useDialogs,
type TLUiDialog,
type TLUiDialogProps,
type TLUiDialogsContextType,
} from './lib/ui/hooks/useDialogsProvider'
} from './lib/ui/context/dialogs'
export {
UiEventsProvider,
useUiEvents,
@ -97,32 +70,20 @@ export {
type TLUiEventHandler,
type TLUiEventMap,
type TLUiEventSource,
} from './lib/ui/hooks/useEventsProvider'
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'
} from './lib/ui/context/events'
export {
useToasts,
type TLUiToast,
type TLUiToastAction,
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 {
toolbarItem,
useToolbarSchema,
@ -170,9 +131,150 @@ export {
type TldrawFile,
} from './lib/utils/tldr/file'
import * as Dialog from './lib/ui/components/primitives/Dialog'
import * as DropdownMenu from './lib/ui/components/primitives/DropdownMenu'
// Minimap default component
export { DefaultMinimap } from './lib/ui/components/Minimap/DefaultMinimap'
// N.B. Preserve order of import / export here with this comment.
// Sometimes this can cause an import problem depending on build setup downstream.
export { Dialog, DropdownMenu }
// Helper to unwrap label from action items
export { unwrapLabel } from './lib/ui/context/actions'
// 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,
LoadingScreen,
StoreSnapshot,
TLEditorComponents,
TLOnMountHandler,
TLRecord,
TLStore,
TLStoreWithStatus,
TldrawEditor,
TldrawEditorBaseProps,
TldrawEditorProps,
assert,
useEditor,
useShallowArrayIdentity,
@ -31,29 +31,37 @@ import { defaultShapeUtils } from './defaultShapeUtils'
import { registerDefaultSideEffects } from './defaultSideEffects'
import { defaultTools } from './defaultTools'
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 { useDefaultEditorAssetsWithOverrides } from './utils/static-assets/assetUrls'
/**@public */
export type TLComponents = TLEditorComponents & TLUiComponents
/** @public */
export type TldrawProps = TldrawEditorBaseProps &
(
| {
store: TLStore | TLStoreWithStatus
}
| {
store?: undefined
persistenceKey?: string
sessionId?: string
defaultName?: string
/**
* A snapshot to load for the store's initial data / schema.
*/
snapshot?: StoreSnapshot<TLRecord>
}
) &
TldrawUiProps &
Partial<TLExternalContentProps>
export type TldrawProps =
// combine components from base editor and ui
(Omit<TldrawUiProps, 'components'> &
Omit<TldrawEditorBaseProps, 'components'> & {
components?: TLComponents
}) &
// external content
Partial<TLExternalContentProps> &
// store stuff
(| {
store: TLStore | TLStoreWithStatus
}
| {
store?: undefined
persistenceKey?: string
sessionId?: string
defaultName?: string
/**
* A snapshot to load for the store's initial data / schema.
*/
snapshot?: StoreSnapshot<TLRecord>
}
)
/** @public */
export function Tldraw(props: TldrawProps) {
@ -64,31 +72,37 @@ export function Tldraw(props: TldrawProps) {
acceptedImageMimeTypes,
acceptedVideoMimeTypes,
onMount,
components = {},
shapeUtils = [],
tools = [],
...rest
} = props
const components = useShallowObjectIdentity(rest.components ?? {})
const shapeUtils = useShallowArrayIdentity(rest.shapeUtils ?? [])
const tools = useShallowArrayIdentity(rest.tools ?? [])
const _components = useShallowObjectIdentity(components)
const componentsWithDefault = useMemo(
() => ({
Scribble: TldrawScribble,
CollaboratorScribble: TldrawScribble,
SelectionForeground: TldrawSelectionForeground,
SelectionBackground: TldrawSelectionBackground,
Handles: TldrawHandles,
HoveredShapeIndicator: TldrawHoveredShapeIndicator,
..._components,
}),
[_components]
)
const withDefaults: TldrawEditorProps = {
initialState: 'select',
...rest,
components: useMemo(
() => ({
Scribble: TldrawScribble,
CollaboratorScribble: TldrawScribble,
SelectionForeground: TldrawSelectionForeground,
SelectionBackground: TldrawSelectionBackground,
Handles: TldrawHandles,
HoveredShapeIndicator: TldrawHoveredShapeIndicator,
...components,
}),
[components]
),
shapeUtils: useMemo(() => [...defaultShapeUtils, ...shapeUtils], [shapeUtils]),
tools: useMemo(() => [...defaultTools, ...defaultShapeTools, ...tools], [tools]),
}
const _shapeUtils = useShallowArrayIdentity(shapeUtils)
const shapeUtilsWithDefaults = useMemo(
() => [...defaultShapeUtils, ..._shapeUtils],
[_shapeUtils]
)
const _tools = useShallowArrayIdentity(tools)
const toolsWithDefaults = useMemo(
() => [...defaultTools, ...defaultShapeTools, ..._tools],
[_tools]
)
const assets = useDefaultEditorAssetsWithOverrides(rest.assetUrls)
@ -103,11 +117,14 @@ export function Tldraw(props: TldrawProps) {
}
return (
<TldrawEditor {...withDefaults}>
<TldrawUi {...withDefaults}>
<ContextMenu>
<Canvas />
</ContextMenu>
<TldrawEditor
initialState="select"
{...rest}
components={componentsWithDefault}
shapeUtils={shapeUtilsWithDefaults}
tools={toolsWithDefaults}
>
<TldrawUi {...rest} components={componentsWithDefault}>
<InsideOfEditorContext
maxImageDimension={maxImageDimension}
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.
function InsideOfEditorContext({
maxImageDimension = 1000,
maxAssetSize = 10 * 1024 * 1024, // 10mb
acceptedImageMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'],
acceptedVideoMimeTypes = ['video/mp4', 'video/quicktime'],
acceptedImageMimeTypes = defaultAcceptedImageMimeTypes,
acceptedVideoMimeTypes = defaultAcceptedVideoMimeTypes,
onMount,
}: Partial<TLExternalContentProps & { onMount: TLOnMountHandler }>) {
const editor = useEditor()
@ -156,7 +182,10 @@ function InsideOfEditorContext({
if (editor) return onMountEvent?.(editor)
}, [editor, onMountEvent])
return null
const { ContextMenu } = useTldrawUiComponents()
if (!ContextMenu) return <Canvas />
return <ContextMenu canvas={<Canvas />} />
}
// 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).
maxAssetSize: number
// 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'].
acceptedVideoMimeTypes: string[]
acceptedVideoMimeTypes: readonly string[]
}
export function registerDefaultExternalContentHandlers(

View file

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

View file

@ -2,26 +2,24 @@ import { ToastProvider } from '@radix-ui/react-toast'
import { useEditor, useValue } from '@tldraw/editor'
import classNames from 'classnames'
import React, { ReactNode } from 'react'
import { TldrawUiContextProvider, TldrawUiContextProviderProps } from './TldrawUiContextProvider'
import { TLUiAssetUrlOverrides } from './assetUrls'
import { BackToContent } from './components/BackToContent'
import { DebugPanel } from './components/DebugPanel'
import { Dialogs } from './components/Dialogs'
import { FollowingIndicator } from './components/FollowingIndicator'
import { HelpMenu } from './components/HelpMenu'
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 { Toolbar } from './components/Toolbar/Toolbar'
import { Button } from './components/primitives/Button'
import { useActions } from './hooks/useActions'
import { useBreakpoint } from './hooks/useBreakpoint'
import {
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 { useEditorEvents } from './hooks/useEditorEvents'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { useRelevantStyles } from './hooks/useRevelantStyles'
import { useTranslation } from './hooks/useTranslation/useTranslation'
/**
@ -47,6 +45,11 @@ export interface TldrawUiBaseProps {
*/
hideUi?: boolean
/**
* Overrides for the UI components.
*/
components?: TLUiComponents
/**
* A component to use for the share zone (will be deprecated)
*/
@ -76,10 +79,11 @@ export const TldrawUi = React.memo(function TldrawUi({
renderDebugMenuItems,
children,
hideUi,
components,
...rest
}: TldrawUiProps) {
return (
<TldrawUiContextProvider {...rest}>
<TldrawUiContextProvider {...rest} components={components}>
<TldrawUiInner
hideUi={hideUi}
shareZone={shareZone}
@ -116,11 +120,7 @@ const TldrawUiInner = React.memo(function TldrawUiInner({
)
})
const TldrawUiContent = React.memo(function TldrawUI({
shareZone,
topZone,
renderDebugMenuItems,
}: TldrawUiContentProps) {
const TldrawUiContent = React.memo(function TldrawUI({ shareZone, topZone }: TldrawUiContentProps) {
const editor = useEditor()
const msg = useTranslation()
const breakpoint = useBreakpoint()
@ -130,6 +130,8 @@ const TldrawUiContent = React.memo(function TldrawUI({
const isFocusMode = useValue('focus', () => editor.getInstanceState().isFocusMode, [editor])
const isDebugMode = useValue('debug', () => editor.getInstanceState().isDebugMode, [editor])
const { StylePanel, Toolbar, HelpMenu, NavigationPanel, HelperButtons } = useTldrawUiComponents()
useKeyboardShortcuts()
useNativeClipboardEvents()
useEditorEvents()
@ -159,29 +161,21 @@ const TldrawUiContent = React.memo(function TldrawUI({
<div className="tlui-layout__top">
<div className="tlui-layout__top__left">
<MenuZone />
<div className="tlui-helper-buttons">
<ExitPenMode />
<BackToContent />
<StopFollowing />
</div>
{HelperButtons && <HelperButtons />}
</div>
<div className="tlui-layout__top__center">{topZone}</div>
<div className="tlui-layout__top__right">
{shareZone}
{breakpoint >= 5 && !isReadonlyMode && (
<div className="tlui-style-panel__wrapper">
<StylePanel />
</div>
)}
{StylePanel && breakpoint >= 5 && !isReadonlyMode && <_StylePanel />}
</div>
</div>
<div className="tlui-layout__bottom">
<div className="tlui-layout__bottom__main">
<NavigationZone />
<Toolbar />
{breakpoint >= 4 && <HelpMenu />}
{NavigationPanel && <NavigationPanel />}
{Toolbar && <Toolbar />}
{HelpMenu && <HelpMenu />}
</div>
{isDebugMode && <DebugPanel renderDebugMenuItems={renderDebugMenuItems ?? null} />}
{isDebugMode && <DebugPanel />}
</div>
</>
)}
@ -193,3 +187,11 @@ const TldrawUiContent = React.memo(function TldrawUI({
</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 {
createShapeId,
DebugFlag,
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))
})
}
import { debugFlags, track, useEditor, useValue, Vec } from '@tldraw/editor'
import { memo, useEffect, useRef, useState } from 'react'
import { useTldrawUiComponents } from '../context/components'
/** @internal */
export const DebugPanel = React.memo(function DebugPanel({
renderDebugMenuItems,
}: {
renderDebugMenuItems: (() => React.ReactNode) | null
}) {
const msg = useTranslation()
const showFps = useValue('show_fps', () => debugFlags.showFps.get(), [debugFlags])
export const DebugPanel = memo(function DebugPanel() {
const { DebugMenu } = useTldrawUiComponents()
return (
<div className="tlui-debug-panel">
<CurrentState />
{showFps && <FPS />}
<ShapeCount />
<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>
<FPS />
{DebugMenu && <DebugMenu />}
</div>
)
})
function useTick(isEnabled = true) {
const [_, setTick] = React.useState(0)
const [_, setTick] = useState(0)
const editor = useEditor()
React.useEffect(() => {
useEffect(() => {
if (!isEnabled) return
const update = () => setTick((tick) => tick + 1)
editor.on('tick', update)
@ -107,9 +55,13 @@ const CurrentState = track(function CurrentState() {
})
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
let maxKnownFps = 0
let cancelled = false
@ -168,294 +120,9 @@ function FPS() {
return () => {
cancelled = true
}
}, [])
}, [showFps])
if (!showFps) return null
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 { useContainer } from '@tldraw/editor'
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 { 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 { useCallback, useEffect, useRef, useState } from 'react'
import { TLUiDialogProps } from '../hooks/useDialogsProvider'
import { TLUiDialogProps } from '../context/dialogs'
import { useTranslation } from '../hooks/useTranslation/useTranslation'
import { Button } from './primitives/Button'
import * as Dialog from './primitives/Dialog'
import { DialogBody, DialogCloseButton, DialogFooter, DialogHeader } from './primitives/Dialog'
import { Input } from './primitives/Input'
// 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 (
<>
<Dialog.Header>
<Dialog.Title>{msg('edit-link-dialog.title')}</Dialog.Title>
<Dialog.CloseButton />
</Dialog.Header>
<Dialog.Body>
<DialogHeader>
<DialogTitle>{msg('edit-link-title')}</DialogTitle>
<DialogCloseButton />
</DialogHeader>
<DialogBody>
<div className="tlui-edit-link-dialog">
<Input
ref={rInput}
className="tlui-edit-link-dialog__input"
label="edit-link-dialog.url"
label="edit-link-url"
autofocus
value={urlInputState.actual}
onValueChange={handleChange}
onComplete={handleComplete}
onCancel={handleCancel}
/>
<div>
{urlInputState.valid
? msg('edit-link-dialog.detail')
: msg('edit-link-dialog.invalid-url')}
</div>
<div>{urlInputState.valid ? msg('edit-link-detail') : msg('edit-link-invalid-url')}</div>
</div>
</Dialog.Body>
<Dialog.Footer className="tlui-dialog__footer__actions">
</DialogBody>
<DialogFooter className="tlui-dialog__footer__actions">
<Button type="normal" onClick={handleCancel} onTouchEnd={handleCancel}>
{msg('edit-link-dialog.cancel')}
{msg('edit-link-cancel')}
</Button>
{isRemoving ? (
<Button type={'danger'} onTouchEnd={handleClear} onClick={handleClear}>
{msg('edit-link-dialog.clear')}
{msg('edit-link-clear')}
</Button>
) : (
<Button
@ -171,10 +168,10 @@ export const EditLinkDialogInner = track(function EditLinkDialogInner({
onTouchEnd={handleComplete}
onClick={handleComplete}
>
{msg('edit-link-dialog.save')}
{msg('edit-link-save')}
</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 { useRef, useState } from 'react'
import { TLEmbedResult, getEmbedInfo } from '../../utils/embeds/embeds'
import { useAssetUrls } from '../hooks/useAssetUrls'
import { TLUiDialogProps } from '../hooks/useDialogsProvider'
import { useAssetUrls } from '../context/asset-urls'
import { TLUiDialogProps } from '../context/dialogs'
import { untranslated, useTranslation } from '../hooks/useTranslation/useTranslation'
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 { Input } from './primitives/Input'
@ -29,20 +30,20 @@ export const EmbedDialog = track(function EmbedDialog({ onClose }: TLUiDialogPro
return (
<>
<Dialog.Header>
<Dialog.Title>
<DialogHeader>
<DialogTitle>
{embedDefinition
? `${msg('embed-dialog.title')}${embedDefinition.title}`
: msg('embed-dialog.title')}
</Dialog.Title>
<Dialog.CloseButton />
</Dialog.Header>
? `${msg('embed-title')}${embedDefinition.title}`
: msg('embed-title')}
</DialogTitle>
<DialogCloseButton />
</DialogHeader>
{embedDefinition ? (
<>
<Dialog.Body className="tlui-embed-dialog__enter">
<DialogBody className="tlui-embed-dialog__enter">
<Input
className="tlui-embed-dialog__input"
label="embed-dialog.url"
label="embed-url"
placeholder="http://example.com"
autofocus
onValueChange={(value) => {
@ -67,7 +68,7 @@ export const EmbedDialog = track(function EmbedDialog({ onClose }: TLUiDialogPro
/>
{url === '' ? (
<div className="tlui-embed-dialog__instruction">
<span>{msg('embed-dialog.instruction')}</span>{' '}
<span>{msg('embed-instruction')}</span>{' '}
{embedDefinition.instructionLink && (
<a
target="_blank"
@ -82,11 +83,11 @@ export const EmbedDialog = track(function EmbedDialog({ onClose }: TLUiDialogPro
</div>
) : (
<div className="tlui-embed-dialog__warning">
{showError ? msg('embed-dialog.invalid-url') : '\xa0'}
{showError ? msg('embed-invalid-url') : '\xa0'}
</div>
)}
</Dialog.Body>
<Dialog.Footer className="tlui-dialog__footer__actions">
</DialogBody>
<DialogFooter className="tlui-dialog__footer__actions">
<Button
type="normal"
onClick={() => {
@ -94,14 +95,14 @@ export const EmbedDialog = track(function EmbedDialog({ onClose }: TLUiDialogPro
setEmbedInfoForUrl(null)
setUrl('')
}}
label="embed-dialog.back"
label="embed-back"
/>
<div className="tlui-embed__spacer" />
<Button type="normal" label="embed-dialog.cancel" onClick={onClose} />
<Button type="normal" label="embed-cancel" onClick={onClose} />
<Button
type="primary"
disabled={!embedInfoForUrl}
label="embed-dialog.create"
label="embed-create"
onClick={() => {
if (!embedInfoForUrl) return
@ -115,11 +116,11 @@ export const EmbedDialog = track(function EmbedDialog({ onClose }: TLUiDialogPro
onClose()
}}
/>
</Dialog.Footer>
</DialogFooter>
</>
) : (
<>
<Dialog.Body className="tlui-embed-dialog__list">
<DialogBody className="tlui-embed-dialog__list">
{EMBED_DEFINITIONS.map((def) => {
return (
<Button
@ -135,7 +136,7 @@ export const EmbedDialog = track(function EmbedDialog({ onClose }: TLUiDialogPro
</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 { useEffect, useState } from 'react'
import { useActions } from '../hooks/useActions'
import { Button } from './primitives/Button'
import { useActions } from '../../context/actions'
import { TldrawUiMenuItem } from '../menus/TldrawUiMenuItem'
export function BackToContent() {
const editor = useEditor()
const actions = useActions()
const action = actions['back-to-content']
const [showBackToContent, setShowBackToContent] = useState(false)
@ -42,12 +41,10 @@ export function BackToContent() {
if (!showBackToContent) return null
return (
<Button
iconLeft={action.icon}
label={action.label}
type="low"
onClick={() => {
action.onSelect('helper-buttons')
<TldrawUiMenuItem
{...actions['back-to-content']}
onSelect={() => {
actions['back-to-content'].onSelect('helper-buttons')
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