import { ANIMATION_MEDIUM_MS, Box2d, Editor, TAU, TLBookmarkShape, TLEmbedShape, TLFrameShape, TLGroupShape, TLShapeId, TLShapePartial, TLTextShape, Vec2d, approximately, compact, createShapeId, openWindow, useEditor, } from '@tldraw/editor' import * as React from 'react' import { getEmbedInfo } from '../../utils/embeds/embeds' import { fitFrameToContent, removeFrame } from '../../utils/frames/frames' import { EditLinkDialog } from '../components/EditLinkDialog' import { EmbedDialog } from '../components/EmbedDialog' import { TLUiIconType } from '../icon-types' import { useMenuClipboardEvents } from './useClipboardEvents' import { useCopyAs } from './useCopyAs' import { useDialogs } from './useDialogsProvider' import { TLUiEventSource, useUiEvents } from './useEventsProvider' import { useExportAs } from './useExportAs' import { useInsertMedia } from './useInsertMedia' import { usePrint } from './usePrint' import { useToasts } from './useToastsProvider' import { TLUiTranslationKey } from './useTranslation/TLUiTranslationKey' /** @public */ export interface TLUiActionItem< TransationKey extends string = string, IconType extends string = string > { icon?: IconType id: string kbd?: string title?: string label?: TransationKey menuLabel?: TransationKey shortcutsLabel?: TransationKey contextMenuLabel?: TransationKey readonlyOk: boolean checkbox?: boolean onSelect: (source: TLUiEventSource) => Promise | void } /** @public */ export type TLUiActionsContextType = Record /** @internal */ export const ActionsContext = React.createContext({}) /** @public */ export type ActionsProviderProps = { overrides?: ( editor: Editor, actions: TLUiActionsContextType, helpers: undefined ) => TLUiActionsContextType children: any } function makeActions(actions: TLUiActionItem[]) { return Object.fromEntries(actions.map((action) => [action.id, action])) as TLUiActionsContextType } /** @internal */ export function ActionsProvider({ overrides, children }: ActionsProviderProps) { const editor = useEditor() const { addDialog, clearDialogs } = useDialogs() const { clearToasts } = useToasts() const insertMedia = useInsertMedia() const printSelectionOrPages = usePrint() const { cut, copy, paste } = useMenuClipboardEvents() const copyAs = useCopyAs() const exportAs = useExportAs() const trackEvent = useUiEvents() // should this be a useMemo? looks like it doesn't actually deref any reactive values const actions = React.useMemo(() => { function mustGoBackToSelectToolFirst() { if (!editor.isIn('select')) { editor.complete() editor.setCurrentTool('select') return false // false will still let the action happen, true will stop it // todo: remove this return value once we're suuuuure } return false } function hasSelectedShapes() { return editor.getSelectedShapeIds().length > 0 } const actionItems: TLUiActionItem[] = [ { id: 'edit-link', label: 'action.edit-link', icon: 'link', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('edit-link', { source }) editor.mark('edit-link') addDialog({ component: EditLinkDialog }) }, }, { id: 'insert-embed', label: 'action.insert-embed', readonlyOk: false, kbd: '$i', onSelect(source) { trackEvent('insert-embed', { source }) addDialog({ component: EmbedDialog }) }, }, { id: 'insert-media', label: 'action.insert-media', kbd: '$u', readonlyOk: false, onSelect(source) { trackEvent('insert-media', { source }) insertMedia() }, }, { id: 'undo', label: 'action.undo', icon: 'undo', kbd: '$z', readonlyOk: false, onSelect(source) { trackEvent('undo', { source }) editor.undo() }, }, { id: 'redo', label: 'action.redo', icon: 'redo', kbd: '$!z', readonlyOk: false, onSelect(source) { trackEvent('redo', { source }) editor.redo() }, }, { id: 'export-as-svg', label: 'action.export-as-svg', menuLabel: 'action.export-as-svg.short', contextMenuLabel: 'action.export-as-svg.short', readonlyOk: true, onSelect(source) { trackEvent('export-as', { format: 'svg', source }) exportAs(editor.getSelectedShapeIds(), 'svg') }, }, { id: 'export-as-png', label: 'action.export-as-png', menuLabel: 'action.export-as-png.short', contextMenuLabel: 'action.export-as-png.short', readonlyOk: true, onSelect(source) { trackEvent('export-as', { format: 'png', source }) exportAs(editor.getSelectedShapeIds(), 'png') }, }, { id: 'export-as-json', label: 'action.export-as-json', menuLabel: 'action.export-as-json.short', contextMenuLabel: 'action.export-as-json.short', readonlyOk: true, onSelect(source) { trackEvent('export-as', { format: 'json', source }) exportAs(editor.getSelectedShapeIds(), 'json') }, }, { id: 'copy-as-svg', label: 'action.copy-as-svg', menuLabel: 'action.copy-as-svg.short', contextMenuLabel: 'action.copy-as-svg.short', kbd: '$!c', readonlyOk: true, onSelect(source) { trackEvent('copy-as', { format: 'svg', source }) copyAs(editor.getSelectedShapeIds(), 'svg') }, }, { id: 'copy-as-png', label: 'action.copy-as-png', menuLabel: 'action.copy-as-png.short', contextMenuLabel: 'action.copy-as-png.short', readonlyOk: true, onSelect(source) { trackEvent('copy-as', { format: 'png', source }) copyAs(editor.getSelectedShapeIds(), 'png') }, }, { id: 'copy-as-json', label: 'action.copy-as-json', menuLabel: 'action.copy-as-json.short', contextMenuLabel: 'action.copy-as-json.short', readonlyOk: true, onSelect(source) { trackEvent('copy-as', { format: 'json', source }) copyAs(editor.getSelectedShapeIds(), 'json') }, }, { id: 'toggle-auto-size', label: 'action.toggle-auto-size', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('toggle-auto-size', { source }) editor.mark('toggling auto size') editor.updateShapes( editor .getSelectedShapes() .filter( (shape): shape is TLTextShape => editor.isShapeOfType(shape, 'text') && shape.props.autoSize === false ) .map((shape) => { return { id: shape.id, type: shape.type, props: { ...shape.props, w: 8, autoSize: true, }, } }) ) }, }, { id: 'open-embed-link', label: 'action.open-embed-link', readonlyOk: true, onSelect(source) { trackEvent('open-embed-link', { source }) const ids = editor.getSelectedShapeIds() const warnMsg = 'No embed shapes selected' if (ids.length !== 1) { console.error(warnMsg) return } const shape = editor.getShape(ids[0]) if (!shape || !editor.isShapeOfType(shape, 'embed')) { console.error(warnMsg) return } openWindow(shape.props.url, '_blank') }, }, { id: 'select-zoom-tool', readonlyOk: true, kbd: 'z', onSelect(source) { if (editor.root.getCurrent()?.id === 'zoom') return trackEvent('zoom-tool', { source }) if (!(editor.inputs.shiftKey || editor.inputs.ctrlKey)) { const currentTool = editor.root.getCurrent() if (currentTool && currentTool.getCurrent()?.id === 'idle') { editor.setCurrentTool('zoom', { onInteractionEnd: currentTool.id, maskAs: 'zoom' }) } } }, }, { id: 'convert-to-bookmark', label: 'action.convert-to-bookmark', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return editor.batch(() => { trackEvent('convert-to-bookmark', { source }) const shapes = editor.getSelectedShapes() const createList: TLShapePartial[] = [] const deleteList: TLShapeId[] = [] for (const shape of shapes) { if (!shape || !editor.isShapeOfType(shape, 'embed') || !shape.props.url) continue const newPos = new Vec2d(shape.x, shape.y) newPos.rot(-shape.rotation) newPos.add(new Vec2d(shape.props.w / 2 - 300 / 2, shape.props.h / 2 - 320 / 2)) // see bookmark shape util newPos.rot(shape.rotation) const partial: TLShapePartial = { id: createShapeId(), type: 'bookmark', rotation: shape.rotation, x: newPos.x, y: newPos.y, opacity: 1, props: { url: shape.props.url, }, } createList.push(partial) deleteList.push(shape.id) } editor.mark('convert shapes to bookmark') editor.deleteShapes(deleteList) editor.createShapes(createList) }) }, }, { id: 'convert-to-embed', label: 'action.convert-to-embed', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('convert-to-embed', { source }) editor.batch(() => { const ids = editor.getSelectedShapeIds() const shapes = compact(ids.map((id) => editor.getShape(id))) const createList: TLShapePartial[] = [] const deleteList: TLShapeId[] = [] for (const shape of shapes) { if (!editor.isShapeOfType(shape, 'bookmark')) continue const { url } = shape.props const embedInfo = getEmbedInfo(shape.props.url) if (!embedInfo) continue if (!embedInfo.definition) continue const { width, height } = embedInfo.definition const newPos = new Vec2d(shape.x, shape.y) newPos.rot(-shape.rotation) newPos.add(new Vec2d(shape.props.w / 2 - width / 2, shape.props.h / 2 - height / 2)) newPos.rot(shape.rotation) const shapeToCreate: TLShapePartial = { id: createShapeId(), type: 'embed', x: newPos.x, y: newPos.y, rotation: shape.rotation, props: { url: url, w: width, h: height, }, } createList.push(shapeToCreate) deleteList.push(shape.id) } editor.mark('convert shapes to embed') editor.deleteShapes(deleteList) editor.createShapes(createList) }) }, }, { id: 'duplicate', kbd: '$d', label: 'action.duplicate', icon: 'duplicate', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('duplicate-shapes', { source }) const ids = editor.getSelectedShapeIds() const commonBounds = Box2d.Common(compact(ids.map((id) => editor.getShapePageBounds(id)))) const offset = editor.getInstanceState().canMoveCamera ? { x: commonBounds.width + 10, y: 0, } : { x: 16 / editor.getZoomLevel(), y: 16 / editor.getZoomLevel(), } editor.mark('duplicate shapes') editor.duplicateShapes(ids, offset) }, }, { id: 'ungroup', label: 'action.ungroup', kbd: '$!g', icon: 'ungroup', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('ungroup-shapes', { source }) editor.mark('ungroup') editor.ungroupShapes(editor.getSelectedShapeIds()) }, }, { id: 'group', label: 'action.group', kbd: '$g', icon: 'group', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('group-shapes', { source }) const onlySelectedShape = editor.getOnlySelectedShape() if (onlySelectedShape && editor.isShapeOfType(onlySelectedShape, 'group')) { editor.mark('ungroup') editor.ungroupShapes(editor.getSelectedShapeIds()) } else { editor.mark('group') editor.groupShapes(editor.getSelectedShapeIds()) } }, }, { id: 'remove-frame', label: 'action.remove-frame', kbd: '$!f', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return trackEvent('remove-frame', { source }) const selectedShapes = editor.getSelectedShapes() if ( selectedShapes.length > 0 && selectedShapes.every((shape) => editor.isShapeOfType(shape, 'frame')) ) { editor.mark('remove-frame') removeFrame( editor, selectedShapes.map((shape) => shape.id) ) } }, }, { id: 'fit-frame-to-content', label: 'action.fit-frame-to-content', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return trackEvent('fit-frame-to-content', { source }) const onlySelectedShape = editor.getOnlySelectedShape() if (onlySelectedShape && editor.isShapeOfType(onlySelectedShape, 'frame')) { editor.mark('fit-frame-to-content') fitFrameToContent(editor, onlySelectedShape.id) } }, }, { id: 'align-left', label: 'action.align-left', kbd: '?A', icon: 'align-left', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('align-shapes', { operation: 'left', source }) editor.mark('align left') editor.alignShapes(editor.getSelectedShapeIds(), 'left') }, }, { id: 'align-center-horizontal', label: 'action.align-center-horizontal', contextMenuLabel: 'action.align-center-horizontal.short', kbd: '?H', icon: 'align-center-horizontal', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('align-shapes', { operation: 'center-horizontal', source }) editor.mark('align center horizontal') editor.alignShapes(editor.getSelectedShapeIds(), 'center-horizontal') }, }, { id: 'align-right', label: 'action.align-right', kbd: '?D', icon: 'align-right', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('align-shapes', { operation: 'right', source }) editor.mark('align right') editor.alignShapes(editor.getSelectedShapeIds(), 'right') }, }, { id: 'align-center-vertical', label: 'action.align-center-vertical', contextMenuLabel: 'action.align-center-vertical.short', kbd: '?V', icon: 'align-center-vertical', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('align-shapes', { operation: 'center-vertical', source }) editor.mark('align center vertical') editor.alignShapes(editor.getSelectedShapeIds(), 'center-vertical') }, }, { id: 'align-top', label: 'action.align-top', icon: 'align-top', kbd: '?W', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('align-shapes', { operation: 'top', source }) editor.mark('align top') editor.alignShapes(editor.getSelectedShapeIds(), 'top') }, }, { id: 'align-bottom', label: 'action.align-bottom', icon: 'align-bottom', kbd: '?S', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('align-shapes', { operation: 'bottom', source }) editor.mark('align bottom') editor.alignShapes(editor.getSelectedShapeIds(), 'bottom') }, }, { id: 'distribute-horizontal', label: 'action.distribute-horizontal', contextMenuLabel: 'action.distribute-horizontal.short', icon: 'distribute-horizontal', kbd: '?!h', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('distribute-shapes', { operation: 'horizontal', source }) editor.mark('distribute horizontal') editor.distributeShapes(editor.getSelectedShapeIds(), 'horizontal') }, }, { id: 'distribute-vertical', label: 'action.distribute-vertical', contextMenuLabel: 'action.distribute-vertical.short', icon: 'distribute-vertical', kbd: '?!V', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('distribute-shapes', { operation: 'vertical', source }) editor.mark('distribute vertical') editor.distributeShapes(editor.getSelectedShapeIds(), 'vertical') }, }, { id: 'stretch-horizontal', label: 'action.stretch-horizontal', contextMenuLabel: 'action.stretch-horizontal.short', icon: 'stretch-horizontal', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('stretch-shapes', { operation: 'horizontal', source }) editor.mark('stretch horizontal') editor.stretchShapes(editor.getSelectedShapeIds(), 'horizontal') }, }, { id: 'stretch-vertical', label: 'action.stretch-vertical', contextMenuLabel: 'action.stretch-vertical.short', icon: 'stretch-vertical', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('stretch-shapes', { operation: 'vertical', source }) editor.mark('stretch vertical') editor.stretchShapes(editor.getSelectedShapeIds(), 'vertical') }, }, { id: 'flip-horizontal', label: 'action.flip-horizontal', contextMenuLabel: 'action.flip-horizontal.short', kbd: '!h', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('flip-shapes', { operation: 'horizontal', source }) editor.mark('flip horizontal') editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal') }, }, { id: 'flip-vertical', label: 'action.flip-vertical', contextMenuLabel: 'action.flip-vertical.short', kbd: '!v', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('flip-shapes', { operation: 'vertical', source }) editor.mark('flip vertical') editor.flipShapes(editor.getSelectedShapeIds(), 'vertical') }, }, { id: 'pack', label: 'action.pack', icon: 'pack', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('pack-shapes', { source }) editor.mark('pack') editor.packShapes(editor.getSelectedShapeIds(), 16) }, }, { id: 'stack-vertical', label: 'action.stack-vertical', contextMenuLabel: 'action.stack-vertical.short', icon: 'stack-vertical', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('stack-shapes', { operation: 'vertical', source }) editor.mark('stack-vertical') editor.stackShapes(editor.getSelectedShapeIds(), 'vertical', 16) }, }, { id: 'stack-horizontal', label: 'action.stack-horizontal', contextMenuLabel: 'action.stack-horizontal.short', icon: 'stack-horizontal', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('stack-shapes', { operation: 'horizontal', source }) editor.mark('stack-horizontal') editor.stackShapes(editor.getSelectedShapeIds(), 'horizontal', 16) }, }, { id: 'bring-to-front', label: 'action.bring-to-front', kbd: ']', icon: 'bring-to-front', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('reorder-shapes', { operation: 'toFront', source }) editor.mark('bring to front') editor.bringToFront(editor.getSelectedShapeIds()) }, }, { id: 'bring-forward', label: 'action.bring-forward', icon: 'bring-forward', kbd: '?]', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('reorder-shapes', { operation: 'forward', source }) editor.mark('bring forward') editor.bringForward(editor.getSelectedShapeIds()) }, }, { id: 'send-backward', label: 'action.send-backward', icon: 'send-backward', kbd: '?[', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('reorder-shapes', { operation: 'backward', source }) editor.mark('send backward') editor.sendBackward(editor.getSelectedShapeIds()) }, }, { id: 'send-to-back', label: 'action.send-to-back', icon: 'send-to-back', kbd: '[', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('reorder-shapes', { operation: 'toBack', source }) editor.mark('send to back') editor.sendToBack(editor.getSelectedShapeIds()) }, }, { id: 'cut', label: 'action.cut', kbd: '$x', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return editor.mark('cut') cut(source) }, }, { id: 'copy', label: 'action.copy', kbd: '$c', readonlyOk: true, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return copy(source) }, }, { id: 'paste', label: 'action.paste', kbd: '$v', readonlyOk: false, onSelect(source) { navigator.clipboard?.read().then((clipboardItems) => { paste( clipboardItems, source, source === 'context-menu' ? editor.inputs.currentPagePoint : undefined ) }) }, }, { id: 'select-all', label: 'action.select-all', kbd: '$a', readonlyOk: true, onSelect(source) { editor.batch(() => { if (mustGoBackToSelectToolFirst()) return trackEvent('select-all-shapes', { source }) editor.mark('select all kbd') editor.selectAll() }) }, }, { id: 'select-none', label: 'action.select-none', readonlyOk: true, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('select-none-shapes', { source }) editor.mark('select none') editor.selectNone() }, }, { id: 'delete', label: 'action.delete', kbd: '⌫,del,backspace', icon: 'trash', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('delete-shapes', { source }) editor.mark('delete') editor.deleteShapes(editor.getSelectedShapeIds()) }, }, { id: 'rotate-cw', label: 'action.rotate-cw', icon: 'rotate-cw', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('rotate-cw', { source }) editor.mark('rotate-cw') const offset = editor.getSelectionRotation() % (TAU / 2) const dontUseOffset = approximately(offset, 0) || approximately(offset, TAU / 2) editor.rotateShapesBy( editor.getSelectedShapeIds(), TAU / 2 - (dontUseOffset ? 0 : offset) ) }, }, { id: 'rotate-ccw', label: 'action.rotate-ccw', icon: 'rotate-ccw', readonlyOk: false, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('rotate-ccw', { source }) editor.mark('rotate-ccw') const offset = editor.getSelectionRotation() % (TAU / 2) const offsetCloseToZero = approximately(offset, 0) editor.rotateShapesBy( editor.getSelectedShapeIds(), offsetCloseToZero ? -(TAU / 2) : -offset ) }, }, { id: 'zoom-in', label: 'action.zoom-in', kbd: '$=,=', readonlyOk: true, onSelect(source) { trackEvent('zoom-in', { source }) editor.zoomIn(editor.getViewportScreenCenter(), { duration: ANIMATION_MEDIUM_MS }) }, }, { id: 'zoom-out', label: 'action.zoom-out', kbd: '$-,-', readonlyOk: true, onSelect(source) { trackEvent('zoom-out', { source }) editor.zoomOut(editor.getViewportScreenCenter(), { duration: ANIMATION_MEDIUM_MS }) }, }, { id: 'zoom-to-100', label: 'action.zoom-to-100', icon: 'reset-zoom', kbd: '!0', readonlyOk: true, onSelect(source) { trackEvent('reset-zoom', { source }) editor.resetZoom(editor.getViewportScreenCenter(), { duration: ANIMATION_MEDIUM_MS }) }, }, { id: 'zoom-to-fit', label: 'action.zoom-to-fit', kbd: '!1', readonlyOk: true, onSelect(source) { trackEvent('zoom-to-fit', { source }) editor.zoomToFit({ duration: ANIMATION_MEDIUM_MS }) }, }, { id: 'zoom-to-selection', label: 'action.zoom-to-selection', kbd: '!2', readonlyOk: true, onSelect(source) { if (!hasSelectedShapes()) return if (mustGoBackToSelectToolFirst()) return trackEvent('zoom-to-selection', { source }) editor.zoomToSelection({ duration: ANIMATION_MEDIUM_MS }) }, }, { id: 'toggle-snap-mode', label: 'action.toggle-snap-mode', menuLabel: 'action.toggle-snap-mode.menu', readonlyOk: false, onSelect(source) { trackEvent('toggle-snap-mode', { source }) editor.user.updateUserPreferences({ isSnapMode: !editor.user.getIsSnapMode() }) }, checkbox: true, }, { id: 'toggle-dark-mode', label: 'action.toggle-dark-mode', menuLabel: 'action.toggle-dark-mode.menu', kbd: '$/', readonlyOk: true, onSelect(source) { trackEvent('toggle-dark-mode', { source }) editor.user.updateUserPreferences({ isDarkMode: !editor.user.getIsDarkMode() }) }, checkbox: true, }, { id: 'toggle-reduce-motion', label: 'action.toggle-reduce-motion', menuLabel: 'action.toggle-reduce-motion.menu', readonlyOk: true, onSelect(source) { trackEvent('toggle-reduce-motion', { source }) editor.user.updateUserPreferences({ animationSpeed: editor.user.getAnimationSpeed() === 0 ? 1 : 0, }) }, checkbox: true, }, { id: 'toggle-edge-scrolling', label: 'action.toggle-edge-scrolling', menuLabel: 'action.toggle-edge-scrolling.menu', readonlyOk: true, onSelect(source) { trackEvent('toggle-edge-scrolling', { source }) editor.user.updateUserPreferences({ edgeScrollSpeed: editor.user.getEdgeScrollSpeed() === 0 ? 1 : 0, }) }, checkbox: true, }, { id: 'toggle-transparent', label: 'action.toggle-transparent', menuLabel: 'action.toggle-transparent.menu', contextMenuLabel: 'action.toggle-transparent.context-menu', readonlyOk: true, onSelect(source) { trackEvent('toggle-transparent', { source }) editor.updateInstanceState( { exportBackground: !editor.getInstanceState().exportBackground, }, { ephemeral: true } ) }, checkbox: true, }, { id: 'toggle-tool-lock', label: 'action.toggle-tool-lock', menuLabel: 'action.toggle-tool-lock.menu', readonlyOk: false, kbd: 'q', onSelect(source) { trackEvent('toggle-tool-lock', { source }) editor.updateInstanceState({ isToolLocked: !editor.getInstanceState().isToolLocked }) }, checkbox: true, }, { id: 'unlock-all', label: 'action.unlock-all', readonlyOk: false, onSelect(source) { trackEvent('unlock-all', { source }) const updates = [] as TLShapePartial[] for (const shape of editor.getCurrentPageShapes()) { if (shape.isLocked) { updates.push({ id: shape.id, type: shape.type, isLocked: false }) } } if (updates.length > 0) { editor.updateShapes(updates) } }, }, { id: 'toggle-focus-mode', label: 'action.toggle-focus-mode', menuLabel: 'action.toggle-focus-mode.menu', readonlyOk: true, kbd: '$.', checkbox: true, onSelect(source) { // this needs to be deferred because it causes the menu // UI to unmount which puts us in a dodgy state requestAnimationFrame(() => { editor.batch(() => { trackEvent('toggle-focus-mode', { source }) clearDialogs() clearToasts() editor.updateInstanceState({ isFocusMode: !editor.getInstanceState().isFocusMode }) }) }) }, }, { id: 'toggle-grid', label: 'action.toggle-grid', menuLabel: 'action.toggle-grid.menu', readonlyOk: true, kbd: "$'", onSelect(source) { trackEvent('toggle-grid-mode', { source }) editor.updateInstanceState({ isGridMode: !editor.getInstanceState().isGridMode }) }, checkbox: true, }, { id: 'toggle-debug-mode', label: 'action.toggle-debug-mode', menuLabel: 'action.toggle-debug-mode.menu', readonlyOk: true, onSelect(source) { trackEvent('toggle-debug-mode', { source }) editor.updateInstanceState({ isDebugMode: !editor.getInstanceState().isDebugMode, }) }, checkbox: true, }, { id: 'print', label: 'action.print', kbd: '$p', readonlyOk: true, onSelect(source) { trackEvent('print', { source }) printSelectionOrPages() }, }, { id: 'exit-pen-mode', label: 'action.exit-pen-mode', icon: 'cross-2', readonlyOk: true, onSelect(source) { trackEvent('exit-pen-mode', { source }) editor.updateInstanceState({ isPenMode: false }) }, }, { id: 'stop-following', label: 'action.stop-following', icon: 'cross-2', readonlyOk: true, onSelect(source) { trackEvent('stop-following', { source }) editor.stopFollowingUser() }, }, { id: 'back-to-content', label: 'action.back-to-content', icon: 'arrow-left', readonlyOk: true, onSelect(source) { trackEvent('zoom-to-content', { source }) editor.zoomToContent() }, }, { id: 'toggle-lock', label: 'action.toggle-lock', readonlyOk: false, kbd: '!l', onSelect(source) { editor.mark('locking') trackEvent('toggle-lock', { source }) editor.toggleLock(editor.getSelectedShapeIds()) }, }, ] const actions = makeActions(actionItems) if (overrides) { return overrides(editor, actions, undefined) } return actions }, [ editor, trackEvent, overrides, addDialog, insertMedia, exportAs, copyAs, cut, copy, paste, clearDialogs, clearToasts, printSelectionOrPages, ]) return {children} } /** @public */ export function useActions() { const ctx = React.useContext(ActionsContext) if (!ctx) { throw new Error('useTools must be used within a ToolProvider') } return ctx } function asActions>(actions: T) { return actions as Record }