From 3437ca89d9e9e9c1397c06896d1768c196954cb6 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Thu, 11 May 2023 23:14:58 +0100 Subject: [PATCH] [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 / 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 / - (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 --- .ignore | 21 ++ apps/examples/package.json | 11 +- apps/examples/src/12-events/EventsExample.tsx | 95 +++++ apps/examples/src/index.tsx | 5 + packages/editor/api-report.md | 79 +++- packages/editor/editor.css | 2 + packages/editor/src/index.ts | 2 +- packages/editor/src/lib/TldrawEditor.tsx | 6 +- packages/editor/src/lib/app/App.ts | 341 ++++++++++++++---- .../src/lib/app/managers/HistoryManager.ts | 36 +- .../editor/src/lib/app/types/emit-types.ts | 22 ++ .../editor/src/lib/app/types/reorder-types.ts | 2 - .../test/commands/distributeShapes.test.ts | 4 +- .../src/lib/test/commands/stackShapes.test.ts | 4 +- .../src/lib/test/commands/stretch.test.ts | 4 +- .../src/lib/test/tools/TLArrowTool.test.ts | 2 +- .../src/lib/test/tools/TLFrameTool.test.ts | 4 +- .../src/lib/test/tools/TLGeoTool.test.ts | 4 +- .../src/lib/test/tools/TLLineTool.test.ts | 12 +- .../src/lib/test/tools/TLNoteTool.test.ts | 4 +- packages/ui/api-report.md | 6 +- packages/ui/src/lib/TldrawUi.tsx | 2 +- .../ui/src/lib/TldrawUiContextProvider.tsx | 7 +- .../ui/src/lib/components/ActionsMenu.tsx | 2 +- .../ui/src/lib/components/BackToContent.tsx | 2 +- .../ui/src/lib/components/ContextMenu.tsx | 4 +- .../ui/src/lib/components/DuplicateButton.tsx | 2 +- .../ui/src/lib/components/EditLinkDialog.tsx | 4 +- packages/ui/src/lib/components/HelpMenu.tsx | 10 +- packages/ui/src/lib/components/Menu.tsx | 4 +- .../ui/src/lib/components/MoveToPageMenu.tsx | 21 ++ .../NavigationZone/NavigationZone.tsx | 4 +- .../components/NavigationZone/ZoomMenu.tsx | 2 +- .../ui/src/lib/components/PenModeToggle.tsx | 8 +- packages/ui/src/lib/components/RedoButton.tsx | 2 +- .../ui/src/lib/components/StopFollowing.tsx | 8 +- .../ui/src/lib/components/Toolbar/Toolbar.tsx | 1 + .../ui/src/lib/components/TrashButton.tsx | 3 +- packages/ui/src/lib/components/UndoButton.tsx | 2 +- packages/ui/src/lib/hooks/useActions.tsx | 265 +++++++------- packages/ui/src/lib/hooks/useAppEvents.ts | 21 +- .../ui/src/lib/hooks/useClipboardEvents.ts | 26 +- .../ui/src/lib/hooks/useDialogsProvider.tsx | 22 +- .../ui/src/lib/hooks/useEventsProvider.tsx | 113 ++++++ packages/ui/src/lib/hooks/useExportAs.ts | 2 + .../ui/src/lib/hooks/useKeyboardShortcuts.ts | 11 +- packages/ui/src/lib/hooks/useMenuIsOpen.ts | 38 +- packages/ui/src/lib/hooks/usePrint.ts | 3 +- packages/ui/src/lib/hooks/useTools.tsx | 16 +- public-yarn.lock | 8 + 50 files changed, 935 insertions(+), 344 deletions(-) create mode 100644 .ignore create mode 100644 apps/examples/src/12-events/EventsExample.tsx create mode 100644 packages/editor/src/lib/app/types/emit-types.ts delete mode 100644 packages/editor/src/lib/app/types/reorder-types.ts create mode 100644 packages/ui/src/lib/hooks/useEventsProvider.tsx diff --git a/.ignore b/.ignore new file mode 100644 index 000000000..f3e114740 --- /dev/null +++ b/.ignore @@ -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 \ No newline at end of file diff --git a/apps/examples/package.json b/apps/examples/package.json index 51d408b73..7999bd79e 100644 --- a/apps/examples/package.json +++ b/apps/examples/package.json @@ -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" } } diff --git a/apps/examples/src/12-events/EventsExample.tsx b/apps/examples/src/12-events/EventsExample.tsx new file mode 100644 index 000000000..c354cb4ad --- /dev/null +++ b/apps/examples/src/12-events/EventsExample.tsx @@ -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() + + const setAppToState = useCallback((app: App) => { + setApp(app) + }, []) + + const [uiEvents, setUiEvents] = useState([]) + + const handleEvent = useCallback((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 ( +
+
+ +
+
+
+ {uiEvents.map((t, i) => ( +
{t}
+ ))} +
+
+
+ ) +} diff --git a/apps/examples/src/index.tsx b/apps/examples/src/index.tsx index 5147c196b..fbba8211e 100644 --- a/apps/examples/src/index.tsx +++ b/apps/examples/src/index.tsx @@ -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: , }, + { + path: '/events', + element: , + }, { path: '/user-presence', element: , diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 20b66eeb3..5cb68aa86 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -7,6 +7,7 @@ /// 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 { 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 | null; - openMenus: Set; + 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): 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, 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; 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; +export type TLChange = any> = HistoryEntry; // @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]; + // (undocumented) + crash: [{ + error: unknown; + }]; + // (undocumented) + event: [TLEventInfo]; + // (undocumented) + mount: []; + // (undocumented) + tick: [number]; + // (undocumented) + update: []; +} + +// @public (undocumented) +export type TLEventMapHandler = (...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; diff --git a/packages/editor/editor.css b/packages/editor/editor.css index 84a70a635..5eff6faad 100644 --- a/packages/editor/editor.css +++ b/packages/editor/editor.css @@ -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; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index eee16e3bb..935e52a3a 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -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, diff --git a/packages/editor/src/lib/TldrawEditor.tsx b/packages/editor/src/lib/TldrawEditor.tsx index 3a1d541ca..948845a7c 100644 --- a/packages/editor/src/lib/TldrawEditor.tsx +++ b/packages/editor/src/lib/TldrawEditor.tsx @@ -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. diff --git a/packages/editor/src/lib/app/App.ts b/packages/editor/src/lib/app/App.ts index 42bc5a258..34ecceb40 100644 --- a/packages/editor/src/lib/app/App.ts +++ b/packages/editor/src/lib/app/App.ts @@ -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 +export type TLChange = any> = HistoryEntry /** @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 { 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() + @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() - 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) { + setCursor(cursor: Partial): 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) { diff --git a/packages/editor/src/lib/app/managers/HistoryManager.ts b/packages/editor/src/lib/app/managers/HistoryManager.ts index 47a683f70..cf3e8286e 100644 --- a/packages/editor/src/lib/app/managers/HistoryManager.ts +++ b/packages/editor/src/lib/app/managers/HistoryManager.ts @@ -18,7 +18,11 @@ type CommandFn = (...args: any[]) => type ExtractData = Fn extends CommandFn ? Data : never type ExtractArgs = Parameters any>> -export class HistoryManager void }> { +export class HistoryManager< + CTX extends { + emit: (name: 'change-history' | 'mark-history', ...args: any) => void + } +> { _undos = atom>('HistoryManager.undos', stack()) // Updated by each action that includes and undo _redos = atom>('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 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 void }> { }) => { this.ignoringUpdates((undos, redos) => { if (undos.length === 0) { - this.ctx.emit('change-history') return { undos, redos } } @@ -176,13 +179,19 @@ export class HistoryManager 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 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 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 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 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 void }> { } } - this.ctx.emit('change-history') + this.ctx.emit('change-history', { reason: 'redo' }) return { undos, redos } }) @@ -284,6 +298,8 @@ export class HistoryManager void }> { this._undos.update((undos) => undos.push({ type: 'STOP', id, onUndo, onRedo })) + this.ctx.emit('mark-history', { id }) + return id } diff --git a/packages/editor/src/lib/app/types/emit-types.ts b/packages/editor/src/lib/app/types/emit-types.ts new file mode 100644 index 000000000..f3d4143f1 --- /dev/null +++ b/packages/editor/src/lib/app/types/emit-types.ts @@ -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] + 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 = (...args: TLEventMap[T]) => void diff --git a/packages/editor/src/lib/app/types/reorder-types.ts b/packages/editor/src/lib/app/types/reorder-types.ts deleted file mode 100644 index 462ac719d..000000000 --- a/packages/editor/src/lib/app/types/reorder-types.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** @public */ -export type TLReorderOperation = 'toBack' | 'toFront' | 'forward' | 'backward' diff --git a/packages/editor/src/lib/test/commands/distributeShapes.test.ts b/packages/editor/src/lib/test/commands/distributeShapes.test.ts index 6b0451bbe..b03b972ba 100644 --- a/packages/editor/src/lib/test/commands/distributeShapes.test.ts +++ b/packages/editor/src/lib/test/commands/distributeShapes.test.ts @@ -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() diff --git a/packages/editor/src/lib/test/commands/stackShapes.test.ts b/packages/editor/src/lib/test/commands/stackShapes.test.ts index a43c4bb89..401a74bdf 100644 --- a/packages/editor/src/lib/test/commands/stackShapes.test.ts +++ b/packages/editor/src/lib/test/commands/stackShapes.test.ts @@ -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() diff --git a/packages/editor/src/lib/test/commands/stretch.test.ts b/packages/editor/src/lib/test/commands/stretch.test.ts index 7d97f042e..a71bf85e9 100644 --- a/packages/editor/src/lib/test/commands/stretch.test.ts +++ b/packages/editor/src/lib/test/commands/stretch.test.ts @@ -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) diff --git a/packages/editor/src/lib/test/tools/TLArrowTool.test.ts b/packages/editor/src/lib/test/tools/TLArrowTool.test.ts index 158801dd3..dd4ebc02c 100644 --- a/packages/editor/src/lib/test/tools/TLArrowTool.test.ts +++ b/packages/editor/src/lib/test/tools/TLArrowTool.test.ts @@ -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') diff --git a/packages/editor/src/lib/test/tools/TLFrameTool.test.ts b/packages/editor/src/lib/test/tools/TLFrameTool.test.ts index f1b13e78e..786f257cf 100644 --- a/packages/editor/src/lib/test/tools/TLFrameTool.test.ts +++ b/packages/editor/src/lib/test/tools/TLFrameTool.test.ts @@ -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) diff --git a/packages/editor/src/lib/test/tools/TLGeoTool.test.ts b/packages/editor/src/lib/test/tools/TLGeoTool.test.ts index f2df21acf..d170bd5dd 100644 --- a/packages/editor/src/lib/test/tools/TLGeoTool.test.ts +++ b/packages/editor/src/lib/test/tools/TLGeoTool.test.ts @@ -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) diff --git a/packages/editor/src/lib/test/tools/TLLineTool.test.ts b/packages/editor/src/lib/test/tools/TLLineTool.test.ts index 475827307..dc97216e0 100644 --- a/packages/editor/src/lib/test/tools/TLLineTool.test.ts +++ b/packages/editor/src/lib/test/tools/TLLineTool.test.ts @@ -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' }) diff --git a/packages/editor/src/lib/test/tools/TLNoteTool.test.ts b/packages/editor/src/lib/test/tools/TLNoteTool.test.ts index 1327930f0..26ac9e825 100644 --- a/packages/editor/src/lib/test/tools/TLNoteTool.test.ts +++ b/packages/editor/src/lib/test/tools/TLNoteTool.test.ts @@ -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) diff --git a/packages/ui/api-report.md b/packages/ui/api-report.md index 776d2723f..0e0e474ee 100644 --- a/packages/ui/api-report.md +++ b/packages/ui/api-report.md @@ -39,7 +39,7 @@ export interface ActionItem { // (undocumented) menuLabel?: TLTranslationKey; // (undocumented) - onSelect: () => Promise | void; + onSelect: (source: TLUiEventSource) => Promise | void; // (undocumented) readonlyOk: boolean; // (undocumented) @@ -636,7 +636,7 @@ export const TldrawUi: React_2.NamedExoticComponent<{ export const TldrawUiContent: React_2.NamedExoticComponent; // @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[]; } diff --git a/packages/ui/src/lib/TldrawUi.tsx b/packages/ui/src/lib/TldrawUi.tsx index 9d62045fc..5de53ac9f 100644 --- a/packages/ui/src/lib/TldrawUi.tsx +++ b/packages/ui/src/lib/TldrawUi.tsx @@ -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')} /> ) : ( diff --git a/packages/ui/src/lib/TldrawUiContextProvider.tsx b/packages/ui/src/lib/TldrawUiContextProvider.tsx index 081356e31..488ef9d86 100644 --- a/packages/ui/src/lib/TldrawUiContextProvider.tsx +++ b/packages/ui/src/lib/TldrawUiContextProvider.tsx @@ -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({ - {children} + + {children} + diff --git a/packages/ui/src/lib/components/ActionsMenu.tsx b/packages/ui/src/lib/components/ActionsMenu.tsx index 5c313f886..db4381ded 100644 --- a/packages/ui/src/lib/components/ActionsMenu.tsx +++ b/packages/ui/src/lib/components/ActionsMenu.tsx @@ -37,7 +37,7 @@ export const ActionsMenu = memo(function ActionsMenu() { ? `${kbdStr(kbd)}` : '' } - onClick={onSelect} + onClick={() => onSelect('actions-menu')} disabled={item.disabled} /> ) diff --git a/packages/ui/src/lib/components/BackToContent.tsx b/packages/ui/src/lib/components/BackToContent.tsx index 96d2e0c19..07c073026 100644 --- a/packages/ui/src/lib/components/BackToContent.tsx +++ b/packages/ui/src/lib/components/BackToContent.tsx @@ -42,7 +42,7 @@ export function BackToContent() { iconLeft={action.icon} label={action.label} onClick={() => { - action.onSelect() + action.onSelect('helper-buttons') setShowBackToContent(false) }} /> diff --git a/packages/ui/src/lib/components/ContextMenu.tsx b/packages/ui/src/lib/components/ContextMenu.tsx index 94651f01e..548057f31 100644 --- a/packages/ui/src/lib/components/ContextMenu.tsx +++ b/packages/ui/src/lib/components/ContextMenu.tsx @@ -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') } }} /> diff --git a/packages/ui/src/lib/components/DuplicateButton.tsx b/packages/ui/src/lib/components/DuplicateButton.tsx index bbd26434b..8ac7c4dcc 100644 --- a/packages/ui/src/lib/components/DuplicateButton.tsx +++ b/packages/ui/src/lib/components/DuplicateButton.tsx @@ -16,7 +16,7 @@ export const DuplicateButton = track(function DuplicateButton() { return (