[feature] ui events (#1326)

This PR updates the editor events:
- adds types to the events emitted by the app (by `app.emit`)
- removes a few events emitted by the app (e.g. `move-to-page`,
`change-camera`)
- adds `onEvent` prop to the <TldrawUi> / <Tldraw> components
- call the `onEvent` when actions occur or tools are selected
- does some superficial cleanup on editor app APIs

### Release Note

- Fix layout bug in error dialog
- (ui) Add `TLEventMap` for types emitted from editor app
- (editor) Update `crash` event emitted from editor app to include error
- (editor) Update `change-history` event emitted from editor app
- (editor) Remove `change-camera` event from editor app
- (editor) Remove `move-to-page` event from editor app
- (ui) Add `onEvent` prop and events to <Tldraw> / <TldrawUi>
- (editor) Replace `app.openMenus` plain Set with computed value
- (editor) Add `addOpenMenu` method
- (editor) Add `removeOpenMenu` method
- (editor) Add `setFocusMode` method 
- (editor) Add `setToolLocked` method  
- (editor) Add `setSnapMode` method 
- (editor) Add `isSnapMode` method 
- (editor) Update `setGridMode` method return type to editor app
- (editor) Update `setReadOnly` method return type to editor app
- (editor) Update `setPenMode` method return type to editor app
- (editor) Update `selectNone` method return type to editor app
- (editor) Rename `backToContent` to `zoomToContent`
- (editor) Remove `TLReorderOperation` type

---------

Co-authored-by: Orange Mug <orangemug@users.noreply.github.com>
pull/1355/head
Steve Ruiz 2023-05-11 23:14:58 +01:00 zatwierdzone przez GitHub
rodzic 5061240912
commit 3437ca89d9
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
50 zmienionych plików z 935 dodań i 344 usunięć

21
.ignore 100644
Wyświetl plik

@ -0,0 +1,21 @@
dist
.tsbuild-dev
.tsbuild-pub
.tsbuild
node\*modules
*.d.ts
*.md
**/_archive
**/*.tsbuildinfo
yarn.lock
**/*.tldr
**/*.d.ts
**/*.d.ts.map
**/*.js.map
**/*.css.map
apps/example/www/index.css
**/dist/*
*.cjs
bublic/packages/tldraw/ui.css
bublic/packages/tldraw/editor.css

Wyświetl plik

@ -31,18 +31,17 @@
"build": "vite build",
"lint": "yarn run -T tsx ../../scripts/lint.ts"
},
"devDependencies": {
"@babel/plugin-proposal-decorators": "^7.21.0",
"lazyrepo": "0.0.0-alpha.26",
"vite": "^4.3.4"
},
"dependencies": {
"@babel/plugin-proposal-decorators": "^7.21.0",
"@tldraw/assets": "workspace:*",
"@tldraw/tldraw": "workspace:*",
"@vercel/analytics": "^1.0.1",
"lazyrepo": "0.0.0-alpha.26",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.9.0",
"signia": "0.1.4",
"signia-react": "0.1.4"
"signia-react": "0.1.4",
"vite": "^4.3.4"
}
}

Wyświetl plik

@ -0,0 +1,95 @@
import { App, TLEventMapHandler, Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/editor.css'
import '@tldraw/tldraw/ui.css'
import { TLUiEventHandler } from '@tldraw/ui/src/lib/hooks/useEventsProvider'
import { useCallback, useEffect, useState } from 'react'
export default function Example() {
const [app, setApp] = useState<App>()
const setAppToState = useCallback((app: App) => {
setApp(app)
}, [])
const [uiEvents, setUiEvents] = useState<string[]>([])
const handleEvent = useCallback<TLUiEventHandler>((source, name, data) => {
setUiEvents((events) => [
data ? `${source} ${name} ${JSON.stringify(data)}` : `${source} ${name}`,
...events,
])
}, [])
useEffect(() => {
if (!app) return
function logChangeEvent(eventName: string) {
setUiEvents((events) => [eventName, ...events])
}
// This is the fire hose, it will be called at the end of every transaction
const handleChangeEvent: TLEventMapHandler<'change'> = (change) => {
if (change.source === 'user') {
// Added
for (const record of Object.values(change.changes.added)) {
if (record.typeName === 'shape') {
logChangeEvent(`created shape (${record.type})`)
}
}
// Updated
for (const [from, to] of Object.values(change.changes.updated)) {
if (
from.typeName === 'instance' &&
to.typeName === 'instance' &&
from.currentPageId !== to.currentPageId
) {
logChangeEvent(`changed page (${from.currentPageId}, ${to.currentPageId})`)
}
}
// Removed
for (const record of Object.values(change.changes.removed)) {
if (record.typeName === 'shape') {
logChangeEvent(`deleted shape (${record.type})`)
}
}
}
}
app.on('change', handleChangeEvent)
return () => {
app.off('change', handleChangeEvent)
}
}, [app])
return (
<div style={{ display: 'flex' }}>
<div style={{ width: '60vw', height: '100vh' }}>
<Tldraw autoFocus onMount={setAppToState} onEvent={handleEvent} />
</div>
<div>
<div
style={{
width: '40vw',
height: '100vh',
padding: 8,
background: '#eee',
border: 'none',
fontFamily: 'monospace',
fontSize: 12,
borderLeft: 'solid 2px #333',
display: 'flex',
flexDirection: 'column-reverse',
overflow: 'auto',
}}
>
{uiEvents.map((t, i) => (
<div key={i}>{t}</div>
))}
</div>
</div>
</div>
)
}

Wyświetl plik

@ -11,6 +11,7 @@ import { RouterProvider, createBrowserRouter } from 'react-router-dom'
import ExampleBasic from './1-basic/BasicExample'
import CustomComponentsExample from './10-custom-components/CustomComponentsExample'
import UserPresenceExample from './11-user-presence/UserPresenceExample'
import EventsExample from './12-events/EventsExample'
import ExampleApi from './2-api/APIExample'
import CustomConfigExample from './3-custom-config/CustomConfigExample'
import CustomUiExample from './4-custom-ui/CustomUiExample'
@ -71,6 +72,10 @@ export const allExamples: Example[] = [
path: '/custom-components',
element: <CustomComponentsExample />,
},
{
path: '/events',
element: <EventsExample />,
},
{
path: '/user-presence',
element: <UserPresenceExample />,

Wyświetl plik

@ -7,6 +7,7 @@
/// <reference types="react" />
import { Atom } from 'signia';
import { BaseRecord } from '@tldraw/tlstore';
import { Box2d } from '@tldraw/primitives';
import { Box2dModel } from '@tldraw/tlschema';
import { Computed } from 'signia';
@ -120,8 +121,9 @@ export type AnimationOptions = Partial<{
}>;
// @public (undocumented)
export class App extends EventEmitter {
export class App extends EventEmitter<TLEventMap> {
constructor({ config, store, getContainer }: AppOptions);
addOpenMenu: (id: string) => this;
alignShapes(operation: 'bottom' | 'center-horizontal' | 'center-vertical' | 'left' | 'right' | 'top', ids?: TLShapeId[]): this;
get allShapesCommonBounds(): Box2d | null;
animateCamera(x: number, y: number, z?: number, opts?: AnimationOptions): this;
@ -193,6 +195,7 @@ export class App extends EventEmitter {
// (undocumented)
get cursor(): TLCursor;
deleteAssets(ids: TLAssetId[]): this;
deleteOpenMenu: (id: string) => this;
deletePage(id: TLPageId): void;
deleteShapes(ids?: TLShapeId[]): this;
deselect(...ids: TLShapeId[]): this;
@ -326,8 +329,12 @@ export class App extends EventEmitter {
readonly isChromeForIos: boolean;
get isCoarsePointer(): boolean;
set isCoarsePointer(v: boolean);
// (undocumented)
get isDarkMode(): boolean;
get isFocused(): boolean;
// (undocumented)
get isFocusMode(): boolean;
// (undocumented)
get isGridMode(): boolean;
isIn(path: string): boolean;
isInAny(...paths: string[]): boolean;
@ -342,6 +349,10 @@ export class App extends EventEmitter {
isSelected(id: TLShapeId): boolean;
isShapeInPage(shape: TLShape, pageId?: TLPageId): boolean;
isShapeInViewport(id: TLShapeId): boolean;
// (undocumented)
get isSnapMode(): boolean;
// (undocumented)
get isToolLocked(): boolean;
isWithinSelection(id: TLShapeId): boolean;
// (undocumented)
lockShapes(_ids?: TLShapeId[]): this;
@ -355,7 +366,7 @@ export class App extends EventEmitter {
description: string;
}>;
get onlySelectedShape(): TLBaseShape<any, any> | null;
openMenus: Set<string>;
get openMenus(): string[];
packShapes(ids?: TLShapeId[], padding?: number): this;
get pages(): TLPage[];
get pageState(): TLInstancePageState;
@ -386,7 +397,7 @@ export class App extends EventEmitter {
isCulled: boolean;
isInViewport: boolean;
}[];
reorderShapes(operation: TLReorderOperation, ids: TLShapeId[]): this;
reorderShapes(operation: 'backward' | 'forward' | 'toBack' | 'toFront', ids: TLShapeId[]): this;
reparentShapesById(ids: TLShapeId[], parentId: TLParentId, insertIndex?: string): this;
// (undocumented)
replaceStoreContentsWithRecordsForOtherDocument(records: TLRecord[]): void;
@ -431,23 +442,29 @@ export class App extends EventEmitter {
setCurrentPageId(pageId: TLPageId, { stopFollowing }?: ViewportOptions): this;
setCursor(cursor: Partial<TLCursor>): this;
// (undocumented)
setDarkMode(isDarkMode: boolean): void;
setDarkMode(isDarkMode: boolean): this;
setEditingId(id: null | TLShapeId): this;
setErasingIds(ids?: TLShapeId[]): this;
setFocusLayer(next: null | TLShapeId): this;
// (undocumented)
setGridMode(isGridMode: boolean): void;
setFocusMode(isFocusMode: boolean): this;
// (undocumented)
setGridMode(isGridMode: boolean): this;
setHintingIds(ids: TLShapeId[]): this;
setHoveredId(id?: null | TLShapeId): this;
setInstancePageState(partial: Partial<TLInstancePageState>, ephemeral?: boolean): void;
// (undocumented)
setPenMode(isPenMode: boolean): void;
setPenMode(isPenMode: boolean): this;
setProp(key: TLShapeProp, value: any, ephemeral?: boolean, squashing?: boolean): this;
// (undocumented)
setReadOnly(isReadOnly: boolean): void;
setReadOnly(isReadOnly: boolean): this;
setScribble(scribble?: null | TLScribble): this;
setSelectedIds(ids: TLShapeId[], squashing?: boolean): this;
setSelectedTool(id: string, info?: {}): this;
// (undocumented)
setSnapMode(isSnapMode: boolean): this;
// (undocumented)
setToolLocked(isToolLocked: boolean): this;
setZoomBrush(zoomBrush?: Box2dModel | null): this;
get shapeIds(): Set<TLShapeId>;
get shapesArray(): TLShape[];
@ -507,6 +524,7 @@ export class App extends EventEmitter {
get zoomLevel(): number;
zoomOut(point?: Vec2d, opts?: AnimationOptions): this;
zoomToBounds(x: number, y: number, width: number, height: number, targetZoom?: number, opts?: AnimationOptions): this;
zoomToContent(): this;
zoomToFit(opts?: AnimationOptions): this;
zoomToSelection(opts?: AnimationOptions): this;
}
@ -1729,7 +1747,7 @@ export type TLCancelEventInfo = {
};
// @public (undocumented)
export type TLChange = HistoryEntry<any>;
export type TLChange<T extends BaseRecord<any> = any> = HistoryEntry<T>;
// @public (undocumented)
export type TLClickEvent = (info: TLClickEventInfo) => void;
@ -1990,6 +2008,48 @@ export interface TLEventHandlers {
// @public (undocumented)
export type TLEventInfo = TLCancelEventInfo | TLClickEventInfo | TLCompleteEventInfo | TLInterruptEventInfo | TLKeyboardEventInfo | TLPinchEventInfo | TLPointerEventInfo | TLWheelEventInfo;
// @public (undocumented)
export interface TLEventMap {
// (undocumented)
'change-history': [{
reason: 'bail';
markId?: string;
} | {
reason: 'push' | 'redo' | 'undo';
}];
// (undocumented)
'mark-history': [{
id: string;
}];
// (undocumented)
'max-shapes': [{
name: string;
pageId: TLPageId;
count: number;
}];
// (undocumented)
'stop-camera-animation': [];
// (undocumented)
'stop-following': [];
// (undocumented)
change: [TLChange<TLRecord>];
// (undocumented)
crash: [{
error: unknown;
}];
// (undocumented)
event: [TLEventInfo];
// (undocumented)
mount: [];
// (undocumented)
tick: [number];
// (undocumented)
update: [];
}
// @public (undocumented)
export type TLEventMapHandler<T extends keyof TLEventMap> = (...args: TLEventMap[T]) => void;
// @public (undocumented)
export type TLEventName = 'cancel' | 'complete' | 'interrupt' | 'wheel' | TLCLickEventName | TLKeyboardEventName | TLPinchEventName | TLPointerEventName;
@ -2403,9 +2463,6 @@ export type TLPointerEventTarget = {
shape: TLShape;
};
// @public (undocumented)
export type TLReorderOperation = 'backward' | 'forward' | 'toBack' | 'toFront';
// @public (undocumented)
export type TLResizeHandle = SelectionCorner | SelectionEdge;

Wyświetl plik

@ -1392,6 +1392,7 @@ input,
.tl-error-boundary__overlay {
position: absolute;
inset: 0px;
z-index: 500;
background-color: var(--color-overlay);
}
@ -1435,6 +1436,7 @@ it from receiving any pointer events or affecting the cursor. */
flex-direction: column;
gap: var(--space-5);
overflow: auto;
z-index: 600;
}
.tl-error-boundary__content__expanded {
width: 600px;

Wyświetl plik

@ -66,6 +66,7 @@ export { TLVideoShapeDef, TLVideoUtil } from './lib/app/shapeutils/TLVideoUtil/T
export { StateNode, type StateNodeConstructor } from './lib/app/statechart/StateNode'
export { TLBoxTool, type TLBoxLike } from './lib/app/statechart/TLBoxTool/TLBoxTool'
export { type ClipboardPayload, type TLClipboardModel } from './lib/app/types/clipboard-types'
export { type TLEventMap, type TLEventMapHandler } from './lib/app/types/emit-types'
export {
EVENT_NAME_MAP,
type TLBaseEventInfo,
@ -106,7 +107,6 @@ export {
type TLMark,
} from './lib/app/types/history-types'
export { type RequiredKeys, type TLEasingType } from './lib/app/types/misc-types'
export { type TLReorderOperation } from './lib/app/types/reorder-types'
export { type TLResizeHandle, type TLSelectionHandle } from './lib/app/types/selection-types'
export {
defaultEditorAssetUrls,

Wyświetl plik

@ -237,7 +237,11 @@ function TldrawEditorAfterLoading({
}
}, [app, onCreateAssetFromFile, onCreateBookmarkFromUrl])
const onMountEvent = useEvent((app: App) => onMount?.(app))
const onMountEvent = useEvent((app: App) => {
onMount?.(app)
app.emit('mount')
})
React.useEffect(() => {
if (app) {
// Set the initial theme state.

Wyświetl plik

@ -55,7 +55,7 @@ import {
TLVideoAsset,
Vec2dModel,
} from '@tldraw/tlschema'
import { ComputedCache, HistoryEntry } from '@tldraw/tlstore'
import { BaseRecord, ComputedCache, HistoryEntry } from '@tldraw/tlstore'
import { annotateError, compact, dedupe, deepCopy, partition, structuredClone } from '@tldraw/utils'
import { EventEmitter } from 'eventemitter3'
import { nanoid } from 'nanoid'
@ -126,13 +126,13 @@ import { TLTextShapeDef } from './shapeutils/TLTextUtil/TLTextUtil'
import { RootState } from './statechart/RootState'
import { StateNode } from './statechart/StateNode'
import { TLClipboardModel } from './types/clipboard-types'
import { TLEventMap } from './types/emit-types'
import { TLEventInfo, TLPinchEventInfo, TLPointerEventInfo } from './types/event-types'
import { RequiredKeys } from './types/misc-types'
import { TLReorderOperation } from './types/reorder-types'
import { TLResizeHandle } from './types/selection-types'
/** @public */
export type TLChange = HistoryEntry<any>
export type TLChange<T extends BaseRecord<any> = any> = HistoryEntry<T>
/** @public */
export type AnimationOptions = Partial<{
@ -168,7 +168,7 @@ export function isShapeWithHandles(shape: TLShape) {
}
/** @public */
export class App extends EventEmitter {
export class App extends EventEmitter<TLEventMap> {
constructor({ config = TldrawEditorConfig.default, store, getContainer }: AppOptions) {
super()
@ -282,14 +282,14 @@ export class App extends EventEmitter {
true
)
this.root.enter(undefined, 'initial')
if (this.instanceState.followingUserId) {
this.stopFollowingUser()
}
this.updateCullingBounds()
this.root.enter(undefined, 'initial')
requestAnimationFrame(() => {
this._tickManager.start()
})
@ -533,27 +533,71 @@ export class App extends EventEmitter {
crash(error: unknown) {
this._crashingError = error
this.store.markAsPossiblyCorrupted()
this.emit('crash')
this.emit('crash', { error })
}
get devicePixelRatio() {
return this._dprManager.dpr.value
}
private _openMenus = atom('open-menus', [] as string[])
/**
* A set of strings representing any open menus or modals.
* A set of strings representing any open menus. When menus are open,
* certain interactions will behave differently; for example, when a
* draw tool is selected and a menu is open, a pointer-down will not
* create a dot (because the user is probably trying to close the menu)
* however a pointer-down event followed by a drag will begin drawing
* a line (because the user is BOTH trying to close the menu AND start
* drawing a line).
*
* @public
*/
openMenus = new Set<string>()
@computed get openMenus(): string[] {
return this._openMenus.value
}
/**
* Add an open menu.
*
* ```ts
* app.addOpenMenu('menu-id')
* ```
* @public
*/
addOpenMenu = (id: string) => {
const menus = new Set(this.openMenus)
if (!menus.has(id)) {
menus.add(id)
this._openMenus.set([...menus])
}
return this
}
/**
* Delete an open menu.
*
* ```ts
* app.deleteOpenMenu('menu-id')
* ```
* @public
*/
deleteOpenMenu = (id: string) => {
const menus = new Set(this.openMenus)
if (menus.has(id)) {
menus.delete(id)
this._openMenus.set([...menus])
}
return this
}
/**
* Get whether any menus are open.
*
* @public
*/
get isMenuOpen() {
return this.openMenus.size > 0
@computed get isMenuOpen() {
return this.openMenus.length > 0
}
/** @internal */
@ -1452,6 +1496,50 @@ export class App extends EventEmitter {
return this.store.get(this.userId)!
}
get isSnapMode() {
return this.userDocumentSettings.isSnapMode
}
setSnapMode(isSnapMode: boolean) {
if (isSnapMode !== this.isSnapMode) {
this.updateUserDocumentSettings({ isSnapMode }, true)
}
return this
}
get isDarkMode() {
return this.userDocumentSettings.isDarkMode
}
setDarkMode(isDarkMode: boolean) {
if (isDarkMode !== this.isDarkMode) {
this.updateUserDocumentSettings({ isDarkMode }, true)
}
return this
}
get isFocusMode() {
return this.instanceState.isFocusMode
}
setFocusMode(isFocusMode: boolean) {
if (isFocusMode !== this.isFocusMode) {
this.updateInstanceState({ isFocusMode }, true)
}
return this
}
get isToolLocked() {
return this.instanceState.isToolLocked
}
setToolLocked(isToolLocked: boolean) {
if (isToolLocked !== this.isToolLocked) {
this.updateInstanceState({ isToolLocked }, true)
}
return this
}
/** @internal */
@computed private get _userDocumentSettings() {
return this.store.query.record('user_document', () => ({ userId: { eq: this.userId } }))
@ -1461,27 +1549,29 @@ export class App extends EventEmitter {
return this._userDocumentSettings.value!
}
get isReadOnly() {
return this.userDocumentSettings.isReadOnly
}
get isGridMode() {
return this.userDocumentSettings.isGridMode
}
setGridMode(isGridMode: boolean) {
this.updateUserDocumentSettings({ isGridMode }, true)
}
setDarkMode(isDarkMode: boolean) {
this.updateUserDocumentSettings({ isDarkMode }, true)
}
setReadOnly(isReadOnly: boolean) {
this.updateUserDocumentSettings({ isReadOnly }, true)
if (isReadOnly) {
this.setSelectedTool('hand')
setGridMode(isGridMode: boolean): this {
if (isGridMode === this.isGridMode) {
this.updateUserDocumentSettings({ isGridMode }, true)
}
return this
}
get isReadOnly() {
return this.userDocumentSettings.isReadOnly
}
setReadOnly(isReadOnly: boolean): this {
if (isReadOnly !== this.isReadOnly) {
this.updateUserDocumentSettings({ isReadOnly }, true)
if (isReadOnly) {
this.setSelectedTool('hand')
}
}
return this
}
/** @internal */
@ -1494,10 +1584,12 @@ export class App extends EventEmitter {
return this._isPenMode.value
}
setPenMode(isPenMode: boolean) {
setPenMode(isPenMode: boolean): this {
if (isPenMode) this._touchEventsRemainingBeforeExitingPenMode = 3
this._isPenMode.set(isPenMode)
if (isPenMode !== this.isPenMode) {
this._isPenMode.set(isPenMode)
}
return this
}
// User / User App State
@ -4410,6 +4502,8 @@ export class App extends EventEmitter {
return {
data: {
currentPageId: this.currentPageId,
createdIds: partials.map((p) => p.id),
prevSelectedIds,
partials: partialsToCreate,
select,
@ -4417,7 +4511,7 @@ export class App extends EventEmitter {
}
},
{
do: ({ partials, select }) => {
do: ({ createdIds, partials, select }) => {
const { focusLayerId } = this
// 1. Parents
@ -4463,7 +4557,7 @@ export class App extends EventEmitter {
const parentIndices = new Map<string, string>()
const shapeRecordsTocreate: TLShape[] = []
const shapeRecordsToCreate: TLShape[] = []
for (const partial of partials) {
const util = this.getShapeUtil(partial as TLShape)
@ -4516,20 +4610,23 @@ export class App extends EventEmitter {
shapeRecordToCreate = next
}
shapeRecordsTocreate.push(shapeRecordToCreate)
shapeRecordsToCreate.push(shapeRecordToCreate)
}
this.store.put(shapeRecordsTocreate)
this.store.put(shapeRecordsToCreate)
// If we're also selecting the newly created shapes, attempt to select all of them;
// the engine will filter out any shapes that are descendants of other new shapes.
if (select) {
const selectedIds = partials.map((partial) => partial.id)
this.store.update(this.pageState.id, (state) => ({ ...state, selectedIds }))
this.store.update(this.pageState.id, (state) => ({
...state,
selectedIds: createdIds,
}))
}
},
undo: ({ partials, prevSelectedIds }) => {
this.store.remove(partials.map((p) => p.id))
undo: ({ createdIds, prevSelectedIds }) => {
this.store.remove(createdIds)
if (prevSelectedIds) {
this.store.update(this.pageState.id, (state) => ({
@ -4987,6 +5084,7 @@ export class App extends EventEmitter {
undo: ({ newPage, prevPageState, prevTabState, newTabPageState }) => {
this.store.put([prevPageState, prevTabState])
this.store.remove([newTabPageState.id, newPage.id, newTabPageState.cameraId])
this.updateCullingBounds()
},
}
@ -5271,6 +5369,7 @@ export class App extends EventEmitter {
this.store.put(assets)
},
undo: ({ assets }) => {
// todo: should we actually remove assets here? or on cleanup elsewhere?
this.store.remove(assets.map((a) => a.id))
},
}
@ -5739,8 +5838,6 @@ export class App extends EventEmitter {
this.centerOnPoint(x, y)
})
this.emit('moved-to-page', { name: this.currentPage.name, toId: pageId, fromId: currentPageId })
return this
}
@ -5757,9 +5854,10 @@ export class App extends EventEmitter {
* @param ids - The ids to reorder.
* @public
*/
reorderShapes(operation: TLReorderOperation, ids: TLShapeId[]) {
reorderShapes(operation: 'toBack' | 'toFront' | 'forward' | 'backward', ids: TLShapeId[]) {
if (this.isReadOnly) return this
if (ids.length === 0) return this
// this.emit('reorder-shapes', { pageId: this.currentPageId, ids, operation })
const parents = this.getParentsMappedToChildren(ids)
@ -6980,6 +7078,7 @@ export class App extends EventEmitter {
// page might have no shapes
if (ids.length <= 0) return this
this.setSelectedIds(ids)
return this
}
@ -7020,10 +7119,11 @@ export class App extends EventEmitter {
*
* @public
*/
selectNone() {
selectNone(): this {
if (this.selectedIds.length > 0) {
this.setSelectedIds([])
}
return this
}
@ -7040,7 +7140,7 @@ export class App extends EventEmitter {
* @param options - Options for setting the current page.
* @public
*/
setCurrentPageId(pageId: TLPageId, { stopFollowing = true }: ViewportOptions = {}) {
setCurrentPageId(pageId: TLPageId, { stopFollowing = true }: ViewportOptions = {}): this {
this._setCurrentPageId(pageId, { stopFollowing })
return this
}
@ -7059,42 +7159,42 @@ export class App extends EventEmitter {
}
return {
data: { pageId, prev: this.currentPageId },
data: { toId: pageId, fromId: this.currentPageId },
squashing: true,
preservesRedoStack: true,
}
},
{
do: ({ pageId }) => {
if (!this.getPageStateByPageId(pageId)) {
do: ({ toId }) => {
if (!this.getPageStateByPageId(toId)) {
const camera = TLCamera.create({})
this.store.put([
camera,
TLInstancePageState.create({
pageId,
pageId: toId,
instanceId: this.instanceId,
cameraId: camera.id,
}),
])
}
this.store.put([{ ...this.instanceState, currentPageId: pageId }])
this.store.put([{ ...this.instanceState, currentPageId: toId }])
this.updateUserPresence({
viewportPageBounds: this.viewportPageBounds.toJson(),
})
this.updateCullingBounds()
},
undo: ({ prev }) => {
this.store.put([{ ...this.instanceState, currentPageId: prev }])
undo: ({ fromId }) => {
this.store.put([{ ...this.instanceState, currentPageId: fromId }])
this.updateUserPresence({
viewportPageBounds: this.viewportPageBounds.toJson(),
})
this.updateCullingBounds()
},
squash: ({ prev }, { pageId }) => {
return { pageId, prev }
squash: ({ fromId }, { toId }) => {
return { toId, fromId }
},
}
)
@ -7161,7 +7261,7 @@ export class App extends EventEmitter {
* @param id - The id of the page to set as the current page
* @public
*/
setHoveredId(id: TLShapeId | null = null) {
setHoveredId(id: TLShapeId | null = null): this {
if (id === this.pageState.hoveredId) return this
this.setInstancePageState({ hoveredId: id }, true)
@ -7181,7 +7281,7 @@ export class App extends EventEmitter {
* @param ids - The ids of shapes to set as erasing.
* @public
*/
setErasingIds(ids: TLShapeId[] = []) {
setErasingIds(ids: TLShapeId[] = []): this {
const erasingIds = this.erasingIdsSet
if (ids.length === erasingIds.size && ids.every((id) => erasingIds.has(id))) return this
@ -7202,7 +7302,7 @@ export class App extends EventEmitter {
* @param cursor - A partial of the cursor object.
* @public
*/
setCursor(cursor: Partial<TLCursor>) {
setCursor(cursor: Partial<TLCursor>): this {
const current = this.cursor
const next = {
...current,
@ -7236,7 +7336,7 @@ export class App extends EventEmitter {
* @param scribble - The new scribble object.
* @public
*/
setScribble(scribble: TLScribble | null = null) {
setScribble(scribble: TLScribble | null = null): this {
this.updateInstanceState({ scribble }, true)
return this
}
@ -7254,7 +7354,7 @@ export class App extends EventEmitter {
* @param brush - The brush box model to set, or null for no brush model.
* @public
*/
setBrush(brush: Box2dModel | null = null) {
setBrush(brush: Box2dModel | null = null): this {
if (!brush && !this.brush) return this
this.updateInstanceState({ brush }, true)
return this
@ -7273,7 +7373,7 @@ export class App extends EventEmitter {
* @param zoomBrush - The zoom box model to set, or null for no zoom model.
* @public
*/
setZoomBrush(zoomBrush: Box2dModel | null = null) {
setZoomBrush(zoomBrush: Box2dModel | null = null): this {
if (!zoomBrush && !this.zoomBrush) return this
this.updateInstanceState({ zoomBrush }, true)
return this
@ -7297,6 +7397,7 @@ export class App extends EventEmitter {
const snapshot = getRotationSnapshot({ app: this })
applyRotationToSnapshotShapes({ delta, snapshot, app: this, stage: 'one-off' })
return this
}
@ -7703,31 +7804,33 @@ export class App extends EventEmitter {
private _setCamera(x: number, y: number, z = this.camera.z) {
const currentCamera = this.camera
if (currentCamera.x === x && currentCamera.y === y && currentCamera.z === z) return this
this.store.put([{ ...currentCamera, x, y, z }])
const nextCamera = { ...currentCamera, x, y, z }
const { currentScreenPoint } = this.inputs
this.batch(() => {
this.store.put([nextCamera])
this.dispatch({
type: 'pointer',
target: 'canvas',
name: 'pointer_move',
point: currentScreenPoint,
pointerId: 0,
ctrlKey: this.inputs.ctrlKey,
altKey: this.inputs.altKey,
shiftKey: this.inputs.shiftKey,
button: 0,
isPen: this.isPenMode ?? false,
const { currentScreenPoint } = this.inputs
this.dispatch({
type: 'pointer',
target: 'canvas',
name: 'pointer_move',
point: currentScreenPoint,
pointerId: 0,
ctrlKey: this.inputs.ctrlKey,
altKey: this.inputs.altKey,
shiftKey: this.inputs.shiftKey,
button: 0,
isPen: this.isPenMode ?? false,
})
this.updateUserPresence({
viewportPageBounds: this.viewportPageBounds.toJson(),
})
this._cameraManager.tick()
})
this.updateUserPresence({
viewportPageBounds: this.viewportPageBounds.toJson(),
})
this._cameraManager.tick()
this.emit('change-camera', this.camera)
return this
}
@ -7829,6 +7932,28 @@ export class App extends EventEmitter {
return this
}
/**
* Move the camera to the nearest content.
*
* @public
*/
zoomToContent() {
const bounds = this.selectedPageBounds ?? this.allShapesCommonBounds
if (bounds) {
this.zoomToBounds(
bounds.minX,
bounds.minY,
bounds.width,
bounds.height,
Math.min(1, this.zoomLevel),
{ duration: 220 }
)
}
return this
}
/**
* Zoom the camera to fit the current page's content in the viewport.
*
@ -7880,6 +8005,7 @@ export class App extends EventEmitter {
} else {
this.setCamera(cx + (x / 1 - x) - (x / cz - x), cy + (y / 1 - y) - (y / cz - y), 1)
}
return this
}
@ -7923,6 +8049,7 @@ export class App extends EventEmitter {
} else {
this.setCamera(cx + (x / zoom - x) - (x / cz - x), cy + (y / zoom - y) - (y / cz - y), zoom)
}
return this
}
@ -7967,6 +8094,7 @@ export class App extends EventEmitter {
} else {
this.setCamera(cx + (x / zoom - x) - (x / cz - x), cy + (y / zoom - y) - (y / cz - y), zoom)
}
return this
}
@ -7998,6 +8126,7 @@ export class App extends EventEmitter {
Math.max(1, this.camera.z),
opts
)
return this
}
@ -8711,6 +8840,60 @@ export class App extends EventEmitter {
return this
}
// checkTracking(
// type: 'change' | 'create' | 'delete',
// prev: TLRecord | null,
// next: TLRecord | null
// ) {
// if (type === 'create' && next) {
// if (next && next.typeName === 'page') {
// this.trackEvent('page.add')
// }
// } else if (type === 'delete' && prev) {
// if (prev.typeName === 'page') {
// this.trackEvent('page.remove')
// }
// } else if (prev && next && type === 'change') {
// if (prev.typeName === 'page' && next.typeName === 'page' && prev.name !== next.name) {
// this.trackEvent('page.rename')
// }
// if (prev.typeName === 'instance' && next.typeName === 'instance') {
// // TODO: Not very performant
// for (const key of Object.keys(next.propsForNextShape)) {
// const prevValue = prev.propsForNextShape[key as keyof TLInstancePropsForNextShape]
// const nextValue = next.propsForNextShape[key as keyof TLInstancePropsForNextShape]
// if (prevValue !== nextValue) {
// this.trackEvent(`instance.propsForNextShape.${key}.change`, nextValue)
// }
// }
// if (prev.isToolLocked !== next.isToolLocked) {
// this.trackEvent('instance.isToolLocked.enabled', next.isToolLocked)
// }
// if (prev.isDebugMode !== next.isDebugMode) {
// this.trackEvent('instance.isDebugMode.enabled', next.isDebugMode)
// }
// if (prev.isFocusMode !== next.isFocusMode) {
// this.trackEvent('instance.isFocusMode.enabled', next.isFocusMode)
// }
// if (prev.currentPageId !== next.currentPageId) {
// this.trackEvent('instance.currentPageId.change')
// }
// }
// if (prev.typeName === 'user_document' && next.typeName === 'user_document') {
// if (prev.isDarkMode !== next.isDarkMode) {
// this.trackEvent('instance.isDarkMode.change', next.isDarkMode)
// }
// if (prev.isGridMode !== next.isGridMode) {
// this.trackEvent('instance.isGridMode.change', next.isGridMode)
// }
// if (prev.isSnapMode !== next.isSnapMode) {
// this.trackEvent('instance.isSnapMode.change', next.isSnapMode)
// }
// }
// }
// }
}
function alertMaxShapes(app: App, pageId = app.currentPageId) {

Wyświetl plik

@ -18,7 +18,11 @@ type CommandFn<Data> = (...args: any[]) =>
type ExtractData<Fn> = Fn extends CommandFn<infer Data> ? Data : never
type ExtractArgs<Fn> = Parameters<Extract<Fn, (...args: any[]) => any>>
export class HistoryManager<CTX extends { emit: (name: string) => void }> {
export class HistoryManager<
CTX extends {
emit: (name: 'change-history' | 'mark-history', ...args: any) => void
}
> {
_undos = atom<Stack<TLHistoryEntry>>('HistoryManager.undos', stack()) // Updated by each action that includes and undo
_redos = atom<Stack<TLHistoryEntry>>('HistoryManager.redos', stack()) // Updated when a user undoes
_batchDepth = 0 // A flag for whether the user is in a batch operation
@ -103,7 +107,7 @@ export class HistoryManager<CTX extends { emit: (name: string) => void }> {
this._redos.set(stack())
}
this.ctx.emit('change-history')
this.ctx.emit('change-history', { reason: 'push' })
}
return this.ctx
@ -165,7 +169,6 @@ export class HistoryManager<CTX extends { emit: (name: string) => void }> {
}) => {
this.ignoringUpdates((undos, redos) => {
if (undos.length === 0) {
this.ctx.emit('change-history')
return { undos, redos }
}
@ -176,13 +179,19 @@ export class HistoryManager<CTX extends { emit: (name: string) => void }> {
redos = redos.push(mark)
}
if (mark.id === toMark) {
this.ctx.emit('change-history')
this.ctx.emit(
'change-history',
pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark }
)
return { undos, redos }
}
}
if (undos.length === 0) {
this.ctx.emit('change-history')
this.ctx.emit(
'change-history',
pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark }
)
return { undos, redos }
}
@ -196,7 +205,10 @@ export class HistoryManager<CTX extends { emit: (name: string) => void }> {
if (command.type === 'STOP') {
if (command.onUndo && (!toMark || command.id === toMark)) {
this.ctx.emit('change-history')
this.ctx.emit(
'change-history',
pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark }
)
return { undos, redos }
}
} else {
@ -205,7 +217,10 @@ export class HistoryManager<CTX extends { emit: (name: string) => void }> {
}
}
this.ctx.emit('change-history')
this.ctx.emit(
'change-history',
pushToRedoStack ? { reason: 'undo' } : { reason: 'bail', markId: toMark }
)
return { undos, redos }
})
@ -221,7 +236,6 @@ export class HistoryManager<CTX extends { emit: (name: string) => void }> {
redo = () => {
this.ignoringUpdates((undos, redos) => {
if (redos.length === 0) {
this.ctx.emit('change-history')
return { undos, redos }
}
@ -231,7 +245,7 @@ export class HistoryManager<CTX extends { emit: (name: string) => void }> {
}
if (redos.length === 0) {
this.ctx.emit('change-history')
this.ctx.emit('change-history', { reason: 'redo' })
return { undos, redos }
}
@ -254,7 +268,7 @@ export class HistoryManager<CTX extends { emit: (name: string) => void }> {
}
}
this.ctx.emit('change-history')
this.ctx.emit('change-history', { reason: 'redo' })
return { undos, redos }
})
@ -284,6 +298,8 @@ export class HistoryManager<CTX extends { emit: (name: string) => void }> {
this._undos.update((undos) => undos.push({ type: 'STOP', id, onUndo, onRedo }))
this.ctx.emit('mark-history', { id })
return id
}

Wyświetl plik

@ -0,0 +1,22 @@
import { TLPageId, TLRecord } from '@tldraw/tlschema'
import { TLChange } from '../App'
import { TLEventInfo } from './event-types'
/** @public */
export interface TLEventMap {
// Lifecycle / Internal
mount: []
'max-shapes': [{ name: string; pageId: TLPageId; count: number }]
change: [TLChange<TLRecord>]
update: []
crash: [{ error: unknown }]
'stop-camera-animation': []
'stop-following': []
event: [TLEventInfo]
tick: [number]
'change-history': [{ reason: 'undo' | 'redo' | 'push' } | { reason: 'bail'; markId?: string }]
'mark-history': [{ id: string }]
}
/** @public */
export type TLEventMapHandler<T extends keyof TLEventMap> = (...args: TLEventMap[T]) => void

Wyświetl plik

@ -1,2 +0,0 @@
/** @public */
export type TLReorderOperation = 'toBack' | 'toFront' | 'forward' | 'backward'

Wyświetl plik

@ -45,9 +45,9 @@ describe('distributeShapes command', () => {
describe('when less than three shapes are selected', () => {
it('does nothing', () => {
const fn = jest.fn()
app.on('update_node', fn)
app.setSelectedIds([ids.boxA, ids.boxB])
const fn = jest.fn()
app.on('change-history', fn)
app.distributeShapes('horizontal')
jest.advanceTimersByTime(1000)
expect(fn).not.toHaveBeenCalled()

Wyświetl plik

@ -50,9 +50,9 @@ describe('distributeShapes command', () => {
describe('when less than three shapes are selected', () => {
it('does nothing', () => {
const fn = jest.fn()
app.on('update_node', fn)
app.setSelectedIds([ids.boxA, ids.boxB])
const fn = jest.fn()
app.on('change-history', fn)
app.stackShapes('horizontal')
jest.advanceTimersByTime(1000)
expect(fn).not.toHaveBeenCalled()

Wyświetl plik

@ -69,9 +69,9 @@ beforeEach(() => {
describe('when less than two shapes are selected', () => {
it('does nothing', () => {
const fn = jest.fn()
app.on('update_shape', fn)
app.setSelectedIds([ids.boxB])
const fn = jest.fn()
app.on('change-history', fn)
app.stretchShapes('horizontal')
jest.advanceTimersByTime(1000)

Wyświetl plik

@ -111,7 +111,7 @@ describe('When dragging the arrow', () => {
})
it('returns to arrow.idle, keeping shape, on pointer up when tool lock is active', () => {
app.updateInstanceState({ isToolLocked: true })
app.setToolLocked(true)
const shapesBefore = app.shapesArray.length
app
.setSelectedTool('arrow')

Wyświetl plik

@ -123,7 +123,7 @@ describe('When in the pointing state', () => {
})
it('Creates a frame and returns to frame.idle on pointer up if tool lock is enabled', () => {
app.updateInstanceState({ isToolLocked: true })
app.setToolLocked(true)
expect(app.shapesArray.length).toBe(0)
app.setSelectedTool('frame')
app.pointerDown(50, 50)
@ -152,7 +152,7 @@ describe('When in the resizing state', () => {
})
it('Returns to frame.idle on complete if tool lock is enabled', () => {
app.updateInstanceState({ isToolLocked: true })
app.setToolLocked(true)
app.setSelectedTool('frame')
app.pointerDown(50, 50)
app.pointerMove(100, 100)

Wyświetl plik

@ -152,7 +152,7 @@ describe('When in the pointing state', () => {
})
it('Creates a geo and returns to geo.idle on pointer up if tool lock is enabled', () => {
app.updateInstanceState({ isToolLocked: true })
app.setToolLocked(true)
expect(app.shapesArray.length).toBe(0)
app.setSelectedTool('geo')
app.pointerDown(50, 50)
@ -181,7 +181,7 @@ describe('When in the resizing state while creating a geo shape', () => {
})
it('Returns to geo.idle on complete if tool lock is enabled', () => {
app.updateInstanceState({ isToolLocked: true })
app.setToolLocked(true)
app.setSelectedTool('geo')
app.pointerDown(50, 50)
app.pointerMove(100, 100)

Wyświetl plik

@ -89,7 +89,7 @@ describe('When dragging the line', () => {
})
it('returns to line.idle, keeping shape, on pointer up if tool lock is enabled', () => {
app.updateInstanceState({ isToolLocked: true })
app.setToolLocked(true)
const shapesBefore = app.shapesArray.length
app
.setSelectedTool('line')
@ -113,7 +113,7 @@ describe('When dragging the line', () => {
describe('When extending the line with the shift-key in tool-lock mode', () => {
it('extends a line by joining-the-dots', () => {
app.updateInstanceState({ isToolLocked: true })
app.setToolLocked(true)
app
.setSelectedTool('line')
.pointerDown(0, 0, { target: 'canvas' })
@ -130,7 +130,7 @@ describe('When extending the line with the shift-key in tool-lock mode', () => {
})
it('extends a line after a click by shift-click dragging', () => {
app.updateInstanceState({ isToolLocked: true })
app.setToolLocked(true)
app
.setSelectedTool('line')
.pointerDown(0, 0, { target: 'canvas' })
@ -147,7 +147,7 @@ describe('When extending the line with the shift-key in tool-lock mode', () => {
})
it('extends a line by shift-click dragging', () => {
app.updateInstanceState({ isToolLocked: true })
app.setToolLocked(true)
app
.setSelectedTool('line')
.pointerDown(0, 0, { target: 'canvas' })
@ -165,7 +165,7 @@ describe('When extending the line with the shift-key in tool-lock mode', () => {
})
it('extends a line by shift-clicking even after canceling a pointerdown', () => {
app.updateInstanceState({ isToolLocked: true })
app.setToolLocked(true)
app
.setSelectedTool('line')
.pointerDown(0, 0, { target: 'canvas' })
@ -185,7 +185,7 @@ describe('When extending the line with the shift-key in tool-lock mode', () => {
})
it('extends a line by shift-clicking even after canceling a pointermove', () => {
app.updateInstanceState({ isToolLocked: true })
app.setToolLocked(true)
app
.setSelectedTool('line')
.pointerDown(0, 0, { target: 'canvas' })

Wyświetl plik

@ -110,7 +110,7 @@ describe('When in the pointing state', () => {
})
it('Returns to the note tool on complete from translating when tool lock is enabled', () => {
app.updateInstanceState({ isToolLocked: true })
app.setToolLocked(true)
app.setSelectedTool('note')
app.pointerDown(50, 50)
app.pointerMove(55, 55)
@ -135,7 +135,7 @@ describe('When in the pointing state', () => {
})
it('Creates a frame and returns to frame.idle on pointer up if tool lock is enabled', () => {
app.updateInstanceState({ isToolLocked: true })
app.setToolLocked(true)
expect(app.shapesArray.length).toBe(0)
app.setSelectedTool('note')
app.pointerDown(50, 50)

Wyświetl plik

@ -39,7 +39,7 @@ export interface ActionItem {
// (undocumented)
menuLabel?: TLTranslationKey;
// (undocumented)
onSelect: () => Promise<void> | void;
onSelect: (source: TLUiEventSource) => Promise<void> | void;
// (undocumented)
readonlyOk: boolean;
// (undocumented)
@ -636,7 +636,7 @@ export const TldrawUi: React_2.NamedExoticComponent<{
export const TldrawUiContent: React_2.NamedExoticComponent<TldrawUiContentProps>;
// @public (undocumented)
export function TldrawUiContextProvider({ overrides, assetUrls, children, }: TldrawUiContextProviderProps): JSX.Element;
export function TldrawUiContextProvider({ overrides, assetUrls, onEvent, children, }: TldrawUiContextProviderProps): JSX.Element;
// @public (undocumented)
export interface TldrawUiContextProviderProps {
@ -645,6 +645,8 @@ export interface TldrawUiContextProviderProps {
// (undocumented)
children?: any;
// (undocumented)
onEvent?: TLUiEventHandler;
// (undocumented)
overrides?: TldrawUiOverrides | TldrawUiOverrides[];
}

Wyświetl plik

@ -113,7 +113,7 @@ export const TldrawUiContent = React.memo(function TldrawUI({
className="tlui-focus-button"
title={`${msg('focus-mode.toggle-focus-mode')}`}
icon="dot"
onClick={toggleFocus.onSelect}
onClick={() => toggleFocus.onSelect('menu')}
/>
</div>
) : (

Wyświetl plik

@ -5,6 +5,7 @@ import { AssetUrlsProvider } from './hooks/useAssetUrls'
import { BreakPointProvider } from './hooks/useBreakpoint'
import { ContextMenuSchemaProvider } from './hooks/useContextMenuSchema'
import { DialogsProvider } from './hooks/useDialogsProvider'
import { EventsProvider, TLUiEventHandler } from './hooks/useEventsProvider'
import { HelpMenuSchemaProvider } from './hooks/useHelpMenuSchema'
import { KeyboardShortcutsSchemaProvider } from './hooks/useKeyboardShortcutsSchema'
import { MenuSchemaProvider } from './hooks/useMenuSchema'
@ -18,6 +19,7 @@ import { TldrawUiOverrides, useMergedOverrides, useMergedTranslationOverrides }
export interface TldrawUiContextProviderProps {
assetUrls?: UiAssetUrls
overrides?: TldrawUiOverrides | TldrawUiOverrides[]
onEvent?: TLUiEventHandler
children?: any
}
@ -25,6 +27,7 @@ export interface TldrawUiContextProviderProps {
export function TldrawUiContextProvider({
overrides,
assetUrls,
onEvent,
children,
}: TldrawUiContextProviderProps) {
return (
@ -33,7 +36,9 @@ export function TldrawUiContextProvider({
<ToastsProvider>
<DialogsProvider>
<BreakPointProvider>
<InternalProviders overrides={overrides}>{children}</InternalProviders>
<EventsProvider onEvent={onEvent}>
<InternalProviders overrides={overrides}>{children}</InternalProviders>
</EventsProvider>
</BreakPointProvider>
</DialogsProvider>
</ToastsProvider>

Wyświetl plik

@ -37,7 +37,7 @@ export const ActionsMenu = memo(function ActionsMenu() {
? `${kbdStr(kbd)}`
: ''
}
onClick={onSelect}
onClick={() => onSelect('actions-menu')}
disabled={item.disabled}
/>
)

Wyświetl plik

@ -42,7 +42,7 @@ export function BackToContent() {
iconLeft={action.icon}
label={action.label}
onClick={() => {
action.onSelect()
action.onSelect('helper-buttons')
setShowBackToContent(false)
}}
/>

Wyświetl plik

@ -164,7 +164,7 @@ function ContextMenuContent() {
dir="ltr"
disabled={item.disabled}
onSelect={(e) => {
onSelect()
onSelect('context-menu')
preventDefault(e)
}}
title={labelStr ? labelStr : undefined}
@ -199,7 +199,7 @@ function ContextMenuContent() {
if (disableClicks) {
setDisableClicks(false)
} else {
onSelect()
onSelect('context-menu')
}
}}
/>

Wyświetl plik

@ -16,7 +16,7 @@ export const DuplicateButton = track(function DuplicateButton() {
return (
<Button
icon={action.icon}
onClick={action.onSelect}
onClick={() => action.onSelect('quick-actions')}
disabled={noSelected}
title={`${msg(action.label!)} ${kbdStr(action.kbd!)}`}
smallIcon

Wyświetl plik

@ -25,7 +25,7 @@ export const EditLinkDialog = track(function EditLink({ onClose }: DialogProps)
const selectedShape = app.onlySelectedShape
const [validState, setIsValid] = useState(valiateUrl(selectedShape?.props.url))
const [validState, setValid] = useState(valiateUrl(selectedShape?.props.url))
const rInitialValue = useRef(selectedShape?.props.url)
@ -46,7 +46,7 @@ export const EditLinkDialog = track(function EditLink({ onClose }: DialogProps)
setUrlValue(value)
const validStateUrl = valiateUrl(value.trim())
setIsValid((s) => (s === validStateUrl ? s : validStateUrl))
setValid((s) => (s === validStateUrl ? s : validStateUrl))
if (validStateUrl) {
rValue.current = value
}

Wyświetl plik

@ -88,7 +88,15 @@ function HelpMenuContent() {
}
case 'item': {
const { id, kbd, label, onSelect, icon } = item.actionItem
return <M.Item key={id} kbd={kbd} label={label} onClick={onSelect} iconLeft={icon} />
return (
<M.Item
key={id}
kbd={kbd}
label={label}
onClick={() => onSelect('help-menu')}
iconLeft={icon}
/>
)
}
}
}

Wyświetl plik

@ -121,7 +121,7 @@ function MenuContent() {
return (
<M.CheckboxItem
key={id}
onSelect={onSelect}
onSelect={() => onSelect('menu')}
title={labelStr ? labelStr : ''}
checked={item.checked}
disabled={item.disabled}
@ -139,7 +139,7 @@ function MenuContent() {
data-wd={`menu-item.${item.id}`}
kbd={kbd}
label={labelToUse}
onClick={onSelect}
onClick={() => onSelect('menu')}
disabled={item.disabled}
/>
)

Wyświetl plik

@ -1,6 +1,7 @@
import * as _ContextMenu from '@radix-ui/react-context-menu'
import { TLPage, TLPageId, useApp, useContainer } from '@tldraw/editor'
import { track } from 'signia-react'
import { useToasts } from '../hooks/useToastsProvider'
import { useTranslation } from '../hooks/useTranslation/useTranslation'
import { Button } from './primitives/Button'
@ -10,6 +11,7 @@ export const MoveToPageMenu = track(function MoveToPageMenu() {
const pages = app.pages
const currentPageId = app.currentPageId
const msg = useTranslation()
const { addToast } = useToasts()
return (
<_ContextMenu.Sub>
@ -36,6 +38,25 @@ export const MoveToPageMenu = track(function MoveToPageMenu() {
onSelect={() => {
app.mark('move_shapes_to_page')
app.moveShapesToPage(app.selectedIds, page.id as TLPageId)
const toPage = app.getPageById(page.id)
if (toPage) {
addToast({
title: 'Changed Page',
description: `Moved to ${toPage.name}.`,
actions: [
{
label: 'Go Back',
type: 'primary',
onClick: () => {
app.mark('change-page')
app.setCurrentPageId(currentPageId)
},
},
],
})
}
}}
asChild
>

Wyświetl plik

@ -46,14 +46,14 @@ export const NavigationZone = memo(function NavigationZone() {
icon="minus"
data-wd="minimap.zoom-out"
title={`${msg(actions['zoom-out'].label!)} ${kbdStr(actions['zoom-out'].kbd!)}`}
onClick={actions['zoom-out'].onSelect}
onClick={() => actions['zoom-out'].onSelect('navigation-zone')}
/>
<ZoomMenu />
<Button
icon="plus"
data-wd="minimap.zoom-in"
title={`${msg(actions['zoom-in'].label!)} ${kbdStr(actions['zoom-in'].kbd!)}`}
onClick={actions['zoom-in'].onSelect}
onClick={() => actions['zoom-in'].onSelect('navigation-zone')}
/>
<Button
title={msg('navigation-zone.toggle-minimap')}

Wyświetl plik

@ -78,7 +78,7 @@ function ZoomMenuItem(props: {
label={actions[action].label}
kbd={actions[action].kbd}
data-wd={props['data-wd']}
onClick={actions[action].onSelect}
onClick={() => actions[action].onSelect('zoom-menu')}
noClose={noClose}
disabled={disabled}
/>

Wyświetl plik

@ -14,5 +14,11 @@ export const ExitPenMode = track(function ExitPenMode() {
const action = actions['exit-pen-mode']
return <Button label={action.label} iconLeft={action.icon} onClick={action.onSelect} />
return (
<Button
label={action.label}
iconLeft={action.icon}
onClick={() => action.onSelect('helper-buttons')}
/>
)
})

Wyświetl plik

@ -18,7 +18,7 @@ export const RedoButton = memo(function RedoButton() {
icon={redo.icon}
title={`${msg(redo.label!)} ${kbdStr(redo.kbd!)}`}
disabled={!canRedo}
onClick={redo.onSelect}
onClick={() => redo.onSelect('quick-actions')}
smallIcon
/>
)

Wyświetl plik

@ -13,5 +13,11 @@ export const StopFollowing = track(function ExitPenMode() {
const action = actions['stop-following']
return <Button label={action.label} iconLeft={action.icon} onClick={action.onSelect} />
return (
<Button
label={action.label}
iconLeft={action.icon}
onClick={() => action.onSelect('people-menu')}
/>
)
})

Wyświetl plik

@ -214,6 +214,7 @@ const OverflowToolsContent = track(function OverflowToolsContent({
toolbarItems: ToolbarItem[]
}) {
const msg = useTranslation()
return (
<div className="tlui-button-grid__four tlui-button-grid__reverse">
{toolbarItems.map(({ toolItem: { id, meta, kbd, label, onSelect, icon } }) => {

Wyświetl plik

@ -13,6 +13,7 @@ export const TrashButton = track(function TrashButton() {
const action = actions['delete']
const isReadonly = useReadonly()
if (isReadonly) return null
const noSelected = app.selectedIds.length <= 0
@ -20,7 +21,7 @@ export const TrashButton = track(function TrashButton() {
return (
<Button
icon={action.icon}
onClick={action.onSelect}
onClick={() => action.onSelect('quick-actions')}
disabled={noSelected}
title={`${msg(action.label!)} ${kbdStr(action.kbd!)}`}
smallIcon

Wyświetl plik

@ -18,7 +18,7 @@ export const UndoButton = memo(function UndoButton() {
icon={undo.icon}
title={`${msg(undo.label!)} ${kbdStr(undo.kbd!)}`}
disabled={!canUndo}
onClick={undo.onSelect}
onClick={() => undo.onSelect('quick-actions')}
smallIcon
/>
)

Wyświetl plik

@ -21,6 +21,7 @@ import { TLUiIconType } from '../icon-types'
import { useMenuClipboardEvents } from './useClipboardEvents'
import { useCopyAs } from './useCopyAs'
import { useDialogs } from './useDialogsProvider'
import { TLUiEventSource, useEvents } from './useEventsProvider'
import { useExportAs } from './useExportAs'
import { useInsertMedia } from './useInsertMedia'
import { usePrint } from './usePrint'
@ -39,7 +40,7 @@ export interface ActionItem {
contextMenuLabel?: TLTranslationKey
readonlyOk: boolean
checkbox?: boolean
onSelect: () => Promise<void> | void
onSelect: (source: TLUiEventSource) => Promise<void> | void
}
/** @public */
@ -76,46 +77,18 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
const copyAs = useCopyAs()
const exportAs = useExportAs()
const trackEvent = useEvents()
// should this be a useMemo? looks like it doesn't actually deref any reactive values
const actions = React.useMemo<ActionsContextType>(() => {
const actions = makeActions([
// 'new-project': {
// id: 'file.new',
// label: 'file.new',
// onSelect() {
// newFile()
// },
// },
// 'open-project': {
// id: 'file.open',
// label: 'file.open',
// kbd: '$o',
// onSelect() {
// openFile()
// },
// },
// 'save-project': {
// id: 'file.save',
// label: 'file.save',
// kbd: '$s',
// onSelect() {
// saveFile()
// },
// },
// 'save-project-as': {
// id: 'file.save-as',
// label: 'file.save-as',
// kbd: '$!s',
// onSelect() {
// saveFileAs()
// },
// },
{
id: 'edit-link',
label: 'action.edit-link',
icon: 'link',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'edit-link')
app.mark('edit-link')
addDialog({ component: EditLinkDialog })
},
@ -125,7 +98,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.insert-embed',
readonlyOk: false,
kbd: '$i',
onSelect() {
onSelect(source) {
trackEvent(source, 'insert-embed')
addDialog({ component: EmbedDialog })
},
},
@ -134,7 +108,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.insert-media',
kbd: '$u',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'insert-media')
insertMedia()
},
},
@ -144,7 +119,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
icon: 'undo',
kbd: '$z',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'undo')
app.undo()
},
},
@ -154,7 +130,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
icon: 'redo',
kbd: '$!z',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'redo')
app.redo()
},
},
@ -164,7 +141,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
menuLabel: 'action.export-as-svg.short',
contextMenuLabel: 'action.export-as-svg.short',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'export-as', { format: 'svg' })
exportAs(app.selectedIds, 'svg')
},
},
@ -174,7 +152,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
menuLabel: 'action.export-as-png.short',
contextMenuLabel: 'action.export-as-png.short',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'export-as', { format: 'png' })
exportAs(app.selectedIds, 'png')
},
},
@ -184,7 +163,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
menuLabel: 'action.export-as-json.short',
contextMenuLabel: 'action.export-as-json.short',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'export-as', { format: 'json' })
exportAs(app.selectedIds, 'json')
},
},
@ -195,7 +175,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
contextMenuLabel: 'action.copy-as-svg.short',
kbd: '$!c',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'copy-as', { format: 'svg' })
copyAs(app.selectedIds, 'svg')
},
},
@ -205,7 +186,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
menuLabel: 'action.copy-as-png.short',
contextMenuLabel: 'action.copy-as-png.short',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'copy-as', { format: 'png' })
copyAs(app.selectedIds, 'png')
},
},
@ -215,7 +197,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
menuLabel: 'action.copy-as-json.short',
contextMenuLabel: 'action.copy-as-json.short',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'copy-as', { format: 'json' })
copyAs(app.selectedIds, 'json')
},
},
@ -223,7 +206,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
id: 'toggle-auto-size',
label: 'action.toggle-auto-size',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'toggle-auto-size')
app.mark()
app.updateShapes(
app.selectedShapes
@ -246,7 +230,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
id: 'open-embed-link',
label: 'action.open-embed-link',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'open-embed-link')
const ids = app.selectedIds
const warnMsg = 'No embed shapes selected'
if (ids.length !== 1) {
@ -266,7 +251,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
id: 'convert-to-bookmark',
label: 'action.convert-to-bookmark',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'convert-to-bookmark')
const ids = app.selectedIds
const shapes = ids.map((id) => app.getShapeById(id))
@ -308,7 +294,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
id: 'convert-to-embed',
label: 'action.convert-to-embed',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'convert-to-embed')
const ids = app.selectedIds
const shapes = compact(ids.map((id) => app.getShapeById(id)))
@ -358,8 +345,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.duplicate',
icon: 'duplicate',
readonlyOk: false,
onSelect() {
onSelect(source) {
if (app.currentToolId !== 'select') return
trackEvent(source, 'duplicate-shapes')
const ids = app.selectedIds
const commonBounds = Box2d.Common(compact(ids.map((id) => app.getPageBoundsById(id))))
const offset = app.canMoveCamera
@ -381,7 +369,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
kbd: '$!g',
icon: 'ungroup',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'ungroup-shapes')
app.mark('ungroup')
app.ungroupShapes(app.selectedIds)
},
@ -392,7 +381,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
kbd: '$g',
icon: 'group',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'group-shapes')
if (app.selectedShapes.length === 1 && app.selectedShapes[0].type === 'group') {
app.mark('ungroup')
app.ungroupShapes(app.selectedIds)
@ -408,7 +398,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
kbd: '?A',
icon: 'align-left',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'align-shapes', { operation: 'left' })
app.mark('align left')
app.alignShapes('left', app.selectedIds)
},
@ -420,7 +411,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
kbd: '?H',
icon: 'align-center-horizontal',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'align-shapes', { operation: 'center-horizontal' })
app.mark('align center horizontal')
app.alignShapes('center-horizontal', app.selectedIds)
},
@ -431,7 +423,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
kbd: '?D',
icon: 'align-right',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'align-shapes', { operation: 'right' })
app.mark('align right')
app.alignShapes('right', app.selectedIds)
},
@ -443,7 +436,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
kbd: '?V',
icon: 'align-center-vertical',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'align-shapes', { operation: 'center-vertical' })
app.mark('align center vertical')
app.alignShapes('center-vertical', app.selectedIds)
},
@ -454,7 +448,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
icon: 'align-top',
kbd: '?W',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'align-shapes', { operation: 'top' })
app.mark('align top')
app.alignShapes('top', app.selectedIds)
},
@ -465,7 +460,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
icon: 'align-bottom',
kbd: '?S',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'align-shapes', { operation: 'bottom' })
app.mark('align bottom')
app.alignShapes('bottom', app.selectedIds)
},
@ -476,7 +472,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
contextMenuLabel: 'action.distribute-horizontal.short',
icon: 'distribute-horizontal',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'distribute-shapes', { operation: 'horizontal' })
app.mark('distribute horizontal')
app.distributeShapes('horizontal', app.selectedIds)
},
@ -487,7 +484,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
contextMenuLabel: 'action.distribute-vertical.short',
icon: 'distribute-vertical',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'distribute-shapes', { operation: 'vertical' })
app.mark('distribute vertical')
app.distributeShapes('vertical', app.selectedIds)
},
@ -498,7 +496,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
contextMenuLabel: 'action.stretch-horizontal.short',
icon: 'stretch-horizontal',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'stretch-shapes', { operation: 'horizontal' })
app.mark('stretch horizontal')
app.stretchShapes('horizontal', app.selectedIds)
},
@ -509,7 +508,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
contextMenuLabel: 'action.stretch-vertical.short',
icon: 'stretch-vertical',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'stretch-shapes', { operation: 'vertical' })
app.mark('stretch vertical')
app.stretchShapes('vertical', app.selectedIds)
},
@ -520,7 +520,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
contextMenuLabel: 'action.flip-horizontal.short',
kbd: '!h',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'flip-shapes', { operation: 'horizontal' })
app.mark('flip horizontal')
app.flipShapes('horizontal', app.selectedIds)
},
@ -531,7 +532,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
contextMenuLabel: 'action.flip-vertical.short',
kbd: '!v',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'flip-shapes', { operation: 'vertical' })
app.mark('flip vertical')
app.flipShapes('vertical', app.selectedIds)
},
@ -541,7 +543,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.pack',
icon: 'pack',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'pack-shapes')
app.mark('pack')
app.packShapes(app.selectedIds)
},
@ -552,7 +555,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
contextMenuLabel: 'action.stack-vertical.short',
icon: 'stack-vertical',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'stack-shapes', { operation: 'vertical' })
app.mark('stack-vertical')
app.stackShapes('vertical', app.selectedIds)
},
@ -563,7 +567,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
contextMenuLabel: 'action.stack-horizontal.short',
icon: 'stack-horizontal',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'stack-shapes', { operation: 'horizontal' })
app.mark('stack-horizontal')
app.stackShapes('horizontal', app.selectedIds)
},
@ -574,7 +579,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
kbd: ']',
icon: 'bring-to-front',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'reorder-shapes', { operation: 'toFront' })
app.mark('bring to front')
app.bringToFront()
},
@ -585,7 +591,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
icon: 'bring-forward',
kbd: '?]',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'reorder-shapes', { operation: 'forward' })
app.mark('bring forward')
app.bringForward()
},
@ -596,7 +603,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
icon: 'send-backward',
kbd: '?[',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'reorder-shapes', { operation: 'backward' })
app.mark('send backward')
app.sendBackward()
},
@ -607,7 +615,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
icon: 'send-to-back',
kbd: '[',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'reorder-shapes', { operation: 'toBack' })
app.mark('send to back')
app.sendToBack()
},
@ -617,7 +626,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.cut',
kbd: '$x',
readonlyOk: false,
onSelect() {
onSelect(source) {
trackEvent(source, 'cut')
app.mark('cut')
cut()
},
@ -627,7 +637,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.copy',
kbd: '$c',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'copy')
copy()
},
},
@ -646,7 +657,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.select-all',
kbd: '$a',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'select-all-shapes')
if (app.currentToolId !== 'select') {
app.cancel()
app.setSelectedTool('select')
@ -660,7 +672,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
id: 'select-none',
label: 'action.select-none',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'select-none-shapes')
app.mark('select none')
app.selectNone()
},
@ -671,8 +684,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
kbd: '⌫',
icon: 'trash',
readonlyOk: false,
onSelect() {
onSelect(source) {
if (app.currentToolId !== 'select') return
trackEvent(source, 'delete-shapes')
app.mark('delete')
app.deleteShapes()
},
@ -682,8 +696,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.rotate-cw',
icon: 'rotate-cw',
readonlyOk: false,
onSelect() {
onSelect(source) {
if (app.selectedIds.length === 0) return
trackEvent(source, 'rotate-cw')
app.mark('rotate-cw')
const offset = app.selectionRotation % (TAU / 2)
const dontUseOffset = approximately(offset, 0) || approximately(offset, TAU / 2)
@ -695,8 +710,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.rotate-ccw',
icon: 'rotate-ccw',
readonlyOk: false,
onSelect() {
onSelect(source) {
if (app.selectedIds.length === 0) return
trackEvent(source, 'rotate-ccw')
app.mark('rotate-ccw')
const offset = app.selectionRotation % (TAU / 2)
const offsetCloseToZero = approximately(offset, 0)
@ -708,7 +724,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.zoom-in',
kbd: '$=',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'zoom-in')
app.zoomIn(app.viewportScreenCenter, { duration: ANIMATION_MEDIUM_MS })
},
},
@ -717,7 +734,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.zoom-out',
kbd: '$-',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'zoom-out')
app.zoomOut(app.viewportScreenCenter, { duration: ANIMATION_MEDIUM_MS })
},
},
@ -727,7 +745,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
icon: 'reset-zoom',
kbd: '!0',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'reset-zoom')
app.resetZoom(app.viewportScreenCenter, { duration: ANIMATION_MEDIUM_MS })
},
},
@ -736,7 +755,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.zoom-to-fit',
kbd: '!1',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'zoom-to-fit')
app.zoomToFit({ duration: ANIMATION_MEDIUM_MS })
},
},
@ -745,7 +765,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.zoom-to-selection',
kbd: '!2',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'zoom-to-selection')
app.zoomToSelection({ duration: ANIMATION_MEDIUM_MS })
},
},
@ -754,13 +775,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.toggle-snap-mode',
menuLabel: 'action.toggle-snap-mode.menu',
readonlyOk: false,
onSelect() {
app.updateUserDocumentSettings(
{
isSnapMode: !app.userDocumentSettings.isSnapMode,
},
true
)
onSelect(source) {
trackEvent(source, 'toggle-snap-mode')
app.setSnapMode(!app.isSnapMode)
},
checkbox: true,
},
@ -770,13 +787,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
menuLabel: 'action.toggle-dark-mode.menu',
kbd: '$/',
readonlyOk: true,
onSelect() {
app.updateUserDocumentSettings(
{
isDarkMode: !app.userDocumentSettings.isDarkMode,
},
true
)
onSelect(source) {
trackEvent(source, 'toggle-dark-mode')
app.setDarkMode(!app.isDarkMode)
},
checkbox: true,
},
@ -786,7 +799,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
menuLabel: 'action.toggle-transparent.menu',
contextMenuLabel: 'action.toggle-transparent.context-menu',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'toggle-transparent')
app.updateInstanceState(
{
exportBackground: !app.instanceState.exportBackground,
@ -802,13 +816,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
menuLabel: 'action.toggle-tool-lock.menu',
readonlyOk: false,
kbd: 'q',
onSelect() {
app.updateInstanceState(
{
isToolLocked: !app.instanceState.isToolLocked,
},
true
)
onSelect(source) {
trackEvent(source, 'toggle-tool-lock')
app.setToolLocked(!app.isToolLocked)
},
checkbox: true,
},
@ -819,19 +829,15 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
readonlyOk: true,
kbd: '$.',
checkbox: true,
onSelect() {
onSelect(source) {
// this needs to be deferred because it causes the menu
// UI to unmount which puts us in a dodgy state
requestAnimationFrame(() => {
app.batch(() => {
trackEvent(source, 'toggle-focus-mode')
clearDialogs()
clearToasts()
app.updateInstanceState(
{
isFocusMode: !app.instanceState.isFocusMode,
},
true
)
app.setFocusMode(!app.isFocusMode)
})
})
},
@ -842,13 +848,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
menuLabel: 'action.toggle-grid.menu',
readonlyOk: true,
kbd: "$'",
onSelect() {
app.updateUserDocumentSettings(
{
isGridMode: !app.userDocumentSettings.isGridMode,
},
true
)
onSelect(source) {
trackEvent(source, 'toggle-grid-mode')
app.setGridMode(!app.isGridMode)
},
checkbox: true,
},
@ -857,7 +859,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.toggle-debug-mode',
menuLabel: 'action.toggle-debug-mode.menu',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'toggle-debug-mode')
app.updateInstanceState(
{
isDebugMode: !app.instanceState.isDebugMode,
@ -872,7 +875,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.print',
kbd: '$p',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'print')
printSelectionOrPages()
},
},
@ -881,7 +885,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.exit-pen-mode',
icon: 'cross-2',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'exit-pen-mode')
app.setPenMode(false)
},
},
@ -890,7 +895,8 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.stop-following',
icon: 'cross-2',
readonlyOk: true,
onSelect() {
onSelect(source) {
trackEvent(source, 'stop-following')
app.stopFollowingUser()
},
},
@ -899,19 +905,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.back-to-content',
icon: 'arrow-left',
readonlyOk: true,
onSelect() {
const bounds = app.selectedPageBounds ?? app.allShapesCommonBounds
if (bounds) {
app.zoomToBounds(
bounds.minX,
bounds.minY,
bounds.width,
bounds.height,
Math.min(1, app.zoomLevel),
{ duration: 220 }
)
}
onSelect(source) {
trackEvent(source, 'zoom-to-content')
app.zoomToContent()
},
},
])
@ -922,6 +918,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
return actions
}, [
trackEvent,
overrides,
app,
addDialog,

Wyświetl plik

@ -1,4 +1,4 @@
import { TLPageId, useApp } from '@tldraw/editor'
import { useApp } from '@tldraw/editor'
import { useEffect } from 'react'
import { useToasts } from './useToastsProvider'
@ -15,28 +15,9 @@ export function useAppEvents() {
})
}
function handleMoveToPage({ name, fromId }: { name: string; fromId: TLPageId }) {
addToast({
title: 'Changed Page',
description: `Moved to ${name}.`,
actions: [
{
label: 'Go Back',
type: 'primary',
onClick: () => {
app.mark('change-page')
app.setCurrentPageId(fromId)
},
}, // prev page
],
})
}
app.addListener('max-shapes', handleMaxShapes)
app.addListener('moved-to-page', handleMoveToPage)
return () => {
app.removeListener('max-shapes', handleMaxShapes)
app.removeListener('moved-to-page', handleMoveToPage)
}
}, [app, addToast])
}

Wyświetl plik

@ -40,6 +40,7 @@ import { compact, isNonNull } from '@tldraw/utils'
import { compressToBase64, decompressFromBase64 } from 'lz-string'
import { useCallback, useEffect } from 'react'
import { useAppIsFocused } from './useAppIsFocused'
import { useEvents } from './useEventsProvider'
/** @public */
export type EmbedInfo = {
@ -969,22 +970,27 @@ const handleNativeClipboardPaste = async (
/** @public */
export function useMenuClipboardEvents() {
const app = useApp()
const trackEvent = useEvents()
const copy = useCallback(
function onCopy() {
if (app.selectedIds.length === 0) return
handleMenuCopy(app)
trackEvent('menu', 'copy')
},
[app]
[app, trackEvent]
)
const cut = useCallback(
function onCut() {
if (app.selectedIds.length === 0) return
handleMenuCopy(app)
app.deleteShapes()
trackEvent('menu', 'cut')
},
[app]
[app, trackEvent]
)
const paste = useCallback(
@ -1000,8 +1006,10 @@ export function useMenuClipboardEvents() {
// else {
// handleScenePaste(app, point)
// }
trackEvent('menu', 'paste')
},
[app]
[app, trackEvent]
)
return {
@ -1014,6 +1022,7 @@ export function useMenuClipboardEvents() {
/** @public */
export function useNativeClipboardEvents() {
const app = useApp()
const trackEvent = useEvents()
const appIsFocused = useAppIsFocused()
@ -1023,6 +1032,7 @@ export function useNativeClipboardEvents() {
if (app.selectedIds.length === 0 || app.editingId !== null || disallowClipboardEvents(app))
return
handleMenuCopy(app)
trackEvent('kbd', 'copy')
}
function cut() {
@ -1030,12 +1040,13 @@ export function useNativeClipboardEvents() {
return
handleMenuCopy(app)
app.deleteShapes()
trackEvent('kbd', 'cut')
}
const paste = (event: ClipboardEvent) => {
const paste = (e: ClipboardEvent) => {
if (app.editingId !== null || disallowClipboardEvents(app)) return
if (event.clipboardData && !app.inputs.shiftKey) {
handleNativeDataTransferPaste(app, event.clipboardData)
if (e.clipboardData && !app.inputs.shiftKey) {
handleNativeDataTransferPaste(app, e.clipboardData)
} else {
navigator.clipboard.read().then((clipboardItems) => {
if (Array.isArray(clipboardItems) && clipboardItems[0] instanceof ClipboardItem) {
@ -1043,6 +1054,7 @@ export function useNativeClipboardEvents() {
}
})
}
trackEvent('kbd', 'paste')
}
document.addEventListener('copy', copy)
@ -1054,5 +1066,5 @@ export function useNativeClipboardEvents() {
document.removeEventListener('cut', cut)
document.removeEventListener('paste', paste)
}
}, [app, appIsFocused])
}, [app, trackEvent, appIsFocused])
}

Wyświetl plik

@ -1,5 +1,6 @@
import { App, uniqueId, useApp } from '@tldraw/editor'
import { createContext, useCallback, useContext, useState } from 'react'
import { useEvents } from './useEventsProvider'
/** @public */
export interface DialogProps {
@ -34,6 +35,7 @@ export type DialogsProviderProps = {
/** @public */
export function DialogsProvider({ children }: DialogsProviderProps) {
const app = useApp()
const trackEvent = useEvents()
const [dialogs, setDialogs] = useState<TLDialog[]>([])
@ -44,11 +46,12 @@ export function DialogsProvider({ children }: DialogsProviderProps) {
return [...d.filter((m) => m.id !== dialog.id), { ...dialog, id }]
})
app.openMenus.add(id)
trackEvent('dialog', 'open-menu', { id })
app.addOpenMenu(id)
return id
},
[app]
[app, trackEvent]
)
const updateDialog = useCallback(
@ -65,11 +68,12 @@ export function DialogsProvider({ children }: DialogsProviderProps) {
})
)
app.openMenus.add(id)
trackEvent('dialog', 'open-menu', { id })
app.addOpenMenu(id)
return id
},
[app]
[app, trackEvent]
)
const removeDialog = useCallback(
@ -84,22 +88,24 @@ export function DialogsProvider({ children }: DialogsProviderProps) {
})
)
app.openMenus.delete(id)
trackEvent('dialog', 'close-menu', { id })
app.deleteOpenMenu(id)
return id
},
[app]
[app, trackEvent]
)
const clearDialogs = useCallback(() => {
setDialogs((d) => {
d.forEach((m) => {
m.onClose?.()
app.openMenus.delete(m.id)
trackEvent('dialog', 'close-menu', { id: m.id })
app.deleteOpenMenu(m.id)
})
return []
})
}, [app])
}, [app, trackEvent])
return (
<DialogsContext.Provider

Wyświetl plik

@ -0,0 +1,113 @@
import * as React from 'react'
/** @public */
export type TLUiEventSource =
| 'menu'
| 'context-menu'
| 'zoom-menu'
| 'navigation-zone'
| 'quick-actions'
| 'actions-menu'
| 'kbd'
| 'debug-panel'
| 'page-menu'
| 'share-menu'
| 'toolbar'
| 'people-menu'
| 'dialog'
| 'help-menu'
| 'helper-buttons'
/** @public */
export interface TLUiEventMap {
// Actions
undo: undefined
redo: undefined
'group-shapes': undefined
'ungroup-shapes': undefined
'convert-to-embed': undefined
'convert-to-bookmark': undefined
'open-embed-link': undefined
'toggle-auto-size': undefined
'copy-as': { format: 'svg' | 'png' | 'json' }
'export-as': { format: 'svg' | 'png' | 'json' }
'edit-link': undefined
'insert-embed': undefined
'insert-media': undefined
'align-shapes': {
operation: 'left' | 'center-horizontal' | 'right' | 'top' | 'center-vertical' | 'bottom'
}
'duplicate-shapes': undefined
'pack-shapes': undefined
'stack-shapes': { operation: 'horizontal' | 'vertical' }
'flip-shapes': { operation: 'horizontal' | 'vertical' }
'distribute-shapes': { operation: 'horizontal' | 'vertical' }
'stretch-shapes': { operation: 'horizontal' | 'vertical' }
'reorder-shapes': {
operation: 'toBack' | 'toFront' | 'forward' | 'backward'
}
'delete-shapes': undefined
'select-all-shapes': undefined
'select-none-shapes': undefined
'rotate-ccw': undefined
'rotate-cw': undefined
'zoom-in': undefined
'zoom-out': undefined
'zoom-to-fit': undefined
'zoom-to-selection': undefined
'reset-zoom': undefined
'zoom-into-view': undefined
'zoom-to-content': undefined
'open-menu': { id: string }
'close-menu': { id: string }
'create-new-project': undefined
'save-project-to-file': undefined
'open-file': undefined
'select-tool': { id: string }
print: undefined
copy: undefined
paste: undefined
cut: undefined
'toggle-transparent': undefined
'toggle-snap-mode': undefined
'toggle-tool-lock': undefined
'toggle-grid-mode': undefined
'toggle-dark-mode': undefined
'toggle-focus-mode': undefined
'toggle-debug-mode': undefined
'exit-pen-mode': undefined
'stop-following': undefined
}
/** @public */
export type TLUiEventHandler<T extends keyof TLUiEventMap = keyof TLUiEventMap> = (
source: string,
name: T,
data?: TLUiEventMap[T]
) => void
/** @internal */
const defaultEventHandler: TLUiEventHandler = () => void null
/** @internal */
export const EventsContext = React.createContext({} as TLUiEventHandler)
/** @public */
export type EventsProviderProps = {
onEvent?: TLUiEventHandler
children: any
}
/** @public */
export function EventsProvider({ onEvent, children }: EventsProviderProps) {
return (
<EventsContext.Provider value={onEvent ?? defaultEventHandler}>
{children}
</EventsContext.Provider>
)
}
/** @public */
export function useEvents(): TLUiEventHandler<keyof TLUiEventMap> {
return React.useContext(EventsContext)
}

Wyświetl plik

@ -72,6 +72,7 @@ export function useExportAs() {
const dataURL = URL.createObjectURL(image)
downloadDataURLAsFile(dataURL, `${name || 'shapes'}.${format}`)
URL.revokeObjectURL(dataURL)
return
}
@ -83,6 +84,7 @@ export function useExportAs() {
)
downloadDataURLAsFile(dataURL, `${name || 'shapes'}.json`)
URL.revokeObjectURL(dataURL)
return
}

Wyświetl plik

@ -11,6 +11,7 @@ const SKIP_KBDS = [
'copy',
'cut',
'paste',
'delete',
// There's also an upload asset action, so we don't want to set the kbd twice
'asset',
]
@ -45,7 +46,7 @@ export function useKeyboardShortcuts() {
hot(getHotkeysStringFromKbd(action.kbd), (event) => {
if (areShortcutsDisabled()) return
preventDefault(event)
action.onSelect()
action.onSelect('kbd')
})
}
@ -69,19 +70,19 @@ export function useKeyboardShortcuts() {
app.setSelectedTool('geo')
})
hot('backspace,del', () => {
hot('del,backspace', () => {
if (areShortcutsDisabled()) return
actions['delete'].onSelect()
actions['delete'].onSelect('kbd')
})
hot('=', () => {
if (areShortcutsDisabled()) return
actions['zoom-in'].onSelect()
actions['zoom-in'].onSelect('kbd')
})
hot('-', () => {
if (areShortcutsDisabled()) return
actions['zoom-out'].onSelect()
actions['zoom-out'].onSelect('kbd')
})
hotkeys.setScope(app.instanceId)

Wyświetl plik

@ -1,27 +1,31 @@
import { useApp } from '@tldraw/editor'
import { useCallback, useEffect, useRef } from 'react'
import { useEvents } from './useEventsProvider'
/** @public */
export function useMenuIsOpen(id: string, cb?: (isOpen: boolean) => void) {
const app = useApp()
const rIsOpen = useRef(false)
const trackEvent = useEvents()
const onOpenChange = useCallback(
(isOpen: boolean) => {
rIsOpen.current = isOpen
if (isOpen) {
app.complete()
app.openMenus.add(id)
} else {
app.openMenus.delete(id)
app.openMenus.forEach((menuId) => {
if (menuId.startsWith(id)) {
app.openMenus.delete(menuId)
}
})
}
app.batch(() => {
if (isOpen) {
app.complete()
app.addOpenMenu(id)
} else {
app.deleteOpenMenu(id)
app.openMenus.forEach((menuId) => {
if (menuId.startsWith(id)) {
app.deleteOpenMenu(menuId)
}
})
}
cb?.(isOpen)
cb?.(isOpen)
})
},
[app, id, cb]
)
@ -36,25 +40,27 @@ export function useMenuIsOpen(id: string, cb?: (isOpen: boolean) => void) {
// hook but it's necessary to handle the case where the
// this effect runs twice or re-runs.
if (rIsOpen.current) {
app.openMenus.add(id)
trackEvent('menu', 'open-menu', { id })
app.addOpenMenu(id)
}
return () => {
if (rIsOpen.current) {
// Close menu on unmount
app.openMenus.delete(id)
app.deleteOpenMenu(id)
// Close menu and all submenus when the parent is closed
app.openMenus.forEach((menuId) => {
if (menuId.startsWith(id)) {
app.openMenus.delete(menuId)
trackEvent('menu', 'close-menu', { id })
app.deleteOpenMenu(menuId)
}
})
rIsOpen.current = false
}
}
}, [app, id])
}, [app, id, trackEvent])
return onOpenChange
}

Wyświetl plik

@ -129,8 +129,7 @@ export function usePrint() {
}
const afterPrintHandler = () => {
// TODO: This is kind of lazy at the moment. I guess we need an event for 'something-happens-on-canvas'
app.once('change-camera', () => {
app.once('change-history', () => {
clearElements(el, style)
})
}

Wyświetl plik

@ -3,6 +3,7 @@ import * as React from 'react'
import { EmbedDialog } from '../components/EmbedDialog'
import { TLUiIconType } from '../icon-types'
import { useDialogs } from './useDialogsProvider'
import { useEvents } from './useEventsProvider'
import { useInsertMedia } from './useInsertMedia'
import { TLTranslationKey } from './useTranslation/TLTranslationKey'
@ -39,6 +40,7 @@ export type ToolsProviderProps = {
/** @public */
export function ToolsProvider({ overrides, children }: ToolsProviderProps) {
const app = useApp()
const trackEvent = useEvents()
const { addDialog } = useDialogs()
const insertMedia = useInsertMedia()
@ -53,6 +55,7 @@ export function ToolsProvider({ overrides, children }: ToolsProviderProps) {
readonlyOk: true,
onSelect() {
app.setSelectedTool('select')
trackEvent('toolbar', 'select-tool', { id: 'select' })
},
},
{
@ -63,6 +66,7 @@ export function ToolsProvider({ overrides, children }: ToolsProviderProps) {
readonlyOk: true,
onSelect() {
app.setSelectedTool('hand')
trackEvent('toolbar', 'select-tool', { id: 'hand' })
},
},
{
@ -73,6 +77,7 @@ export function ToolsProvider({ overrides, children }: ToolsProviderProps) {
readonlyOk: true,
onSelect() {
app.setSelectedTool('eraser')
trackEvent('toolbar', 'select-tool', { id: 'eraser' })
},
},
{
@ -83,6 +88,7 @@ export function ToolsProvider({ overrides, children }: ToolsProviderProps) {
kbd: 'd,b,x',
onSelect() {
app.setSelectedTool('draw')
trackEvent('toolbar', 'select-tool', { id: 'draw' })
},
},
...[...TL_GEO_TYPES].map((id) => ({
@ -101,6 +107,7 @@ export function ToolsProvider({ overrides, children }: ToolsProviderProps) {
true
)
app.setSelectedTool('geo')
trackEvent('toolbar', 'select-tool', { id: `geo-${id}` })
})
},
})),
@ -112,6 +119,7 @@ export function ToolsProvider({ overrides, children }: ToolsProviderProps) {
kbd: 'a',
onSelect() {
app.setSelectedTool('arrow')
trackEvent('toolbar', 'select-tool', { id: 'arrow' })
},
},
{
@ -122,6 +130,7 @@ export function ToolsProvider({ overrides, children }: ToolsProviderProps) {
kbd: 'l',
onSelect() {
app.setSelectedTool('line')
trackEvent('toolbar', 'select-tool', { id: 'line' })
},
},
{
@ -132,6 +141,7 @@ export function ToolsProvider({ overrides, children }: ToolsProviderProps) {
kbd: 'f',
onSelect() {
app.setSelectedTool('frame')
trackEvent('toolbar', 'select-tool', { id: 'frame' })
},
},
{
@ -142,6 +152,7 @@ export function ToolsProvider({ overrides, children }: ToolsProviderProps) {
kbd: 't',
onSelect() {
app.setSelectedTool('text')
trackEvent('toolbar', 'select-tool', { id: 'text' })
},
},
{
@ -152,6 +163,7 @@ export function ToolsProvider({ overrides, children }: ToolsProviderProps) {
kbd: '$u',
onSelect() {
insertMedia()
trackEvent('toolbar', 'select-tool', { id: 'media' })
},
},
{
@ -162,6 +174,7 @@ export function ToolsProvider({ overrides, children }: ToolsProviderProps) {
kbd: 'n',
onSelect() {
app.setSelectedTool('note')
trackEvent('toolbar', 'select-tool', { id: 'note' })
},
},
{
@ -171,6 +184,7 @@ export function ToolsProvider({ overrides, children }: ToolsProviderProps) {
icon: 'tool-embed',
onSelect() {
addDialog({ component: EmbedDialog })
trackEvent('toolbar', 'select-tool', { id: 'embed' })
},
},
])
@ -180,7 +194,7 @@ export function ToolsProvider({ overrides, children }: ToolsProviderProps) {
}
return tools
}, [app, overrides, insertMedia, addDialog])
}, [app, trackEvent, overrides, insertMedia, addDialog])
return <ToolsContext.Provider value={tools}>{children}</ToolsContext.Provider>
}

Wyświetl plik

@ -5891,6 +5891,13 @@ __metadata:
languageName: node
linkType: hard
"@vercel/analytics@npm:^1.0.1":
version: 1.0.1
resolution: "@vercel/analytics@npm:1.0.1"
checksum: 6876e1d0868e85198d43a17ffec7266cdee5a53d5fc797d3f7e0d64e9d76a2ade3cbdcbc0a665e2ee2389f65dfc80591f7d667e9fc981cdbaa848006cbaefe19
languageName: node
linkType: hard
"@vercel/build-utils@npm:6.7.1":
version: 6.7.1
resolution: "@vercel/build-utils@npm:6.7.1"
@ -10819,6 +10826,7 @@ __metadata:
"@babel/plugin-proposal-decorators": ^7.21.0
"@tldraw/assets": "workspace:*"
"@tldraw/tldraw": "workspace:*"
"@vercel/analytics": ^1.0.1
lazyrepo: 0.0.0-alpha.26
react: ^18.2.0
react-dom: ^18.2.0