2023-04-25 11:01:25 +00:00
|
|
|
import {
|
|
|
|
ANIMATION_MEDIUM_MS,
|
|
|
|
App,
|
|
|
|
DEFAULT_BOOKMARK_HEIGHT,
|
|
|
|
DEFAULT_BOOKMARK_WIDTH,
|
|
|
|
getEmbedInfo,
|
|
|
|
openWindow,
|
|
|
|
TLBookmarkShapeDef,
|
|
|
|
TLEmbedShapeDef,
|
|
|
|
TLShapeId,
|
|
|
|
TLShapePartial,
|
|
|
|
TLTextShape,
|
|
|
|
useApp,
|
|
|
|
} from '@tldraw/editor'
|
|
|
|
import { approximately, Box2d, TAU, Vec2d } from '@tldraw/primitives'
|
|
|
|
import { compact } from '@tldraw/utils'
|
|
|
|
import * as React from 'react'
|
|
|
|
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'
|
2023-05-11 22:14:58 +00:00
|
|
|
import { TLUiEventSource, useEvents } from './useEventsProvider'
|
2023-04-25 11:01:25 +00:00
|
|
|
import { useExportAs } from './useExportAs'
|
|
|
|
import { useInsertMedia } from './useInsertMedia'
|
|
|
|
import { usePrint } from './usePrint'
|
|
|
|
import { useToasts } from './useToastsProvider'
|
|
|
|
import { TLTranslationKey } from './useTranslation/TLTranslationKey'
|
|
|
|
|
|
|
|
/** @public */
|
|
|
|
export interface ActionItem {
|
|
|
|
icon?: TLUiIconType
|
|
|
|
id: string
|
|
|
|
kbd?: string
|
|
|
|
title?: string
|
|
|
|
label?: TLTranslationKey
|
|
|
|
menuLabel?: TLTranslationKey
|
|
|
|
shortcutsLabel?: TLTranslationKey
|
|
|
|
contextMenuLabel?: TLTranslationKey
|
|
|
|
readonlyOk: boolean
|
|
|
|
checkbox?: boolean
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect: (source: TLUiEventSource) => Promise<void> | void
|
2023-04-25 11:01:25 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/** @public */
|
|
|
|
export type ActionsContextType = Record<string, ActionItem>
|
|
|
|
|
|
|
|
/** @public */
|
|
|
|
export const ActionsContext = React.createContext<ActionsContextType>({})
|
|
|
|
|
|
|
|
/** @public */
|
|
|
|
export type ActionsProviderProps = {
|
|
|
|
overrides?: (app: App, actions: ActionsContextType, helpers: undefined) => ActionsContextType
|
|
|
|
children: any
|
|
|
|
}
|
|
|
|
|
|
|
|
function makeActions(actions: ActionItem[]) {
|
|
|
|
return Object.fromEntries(actions.map((action) => [action.id, action])) as ActionsContextType
|
|
|
|
}
|
|
|
|
|
|
|
|
/** @public */
|
|
|
|
export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|
|
|
const app = useApp()
|
|
|
|
|
|
|
|
// const saveFile = useSaveFile()
|
|
|
|
// const saveFileAs = useSaveFileAs()
|
|
|
|
// const newFile = useNewFile()
|
|
|
|
// const openFile = useOpenFile()
|
|
|
|
|
|
|
|
const { addDialog, clearDialogs } = useDialogs()
|
|
|
|
const { clearToasts } = useToasts()
|
|
|
|
|
|
|
|
const insertMedia = useInsertMedia()
|
|
|
|
const printSelectionOrPages = usePrint()
|
|
|
|
const { cut, copy } = useMenuClipboardEvents()
|
|
|
|
const copyAs = useCopyAs()
|
|
|
|
const exportAs = useExportAs()
|
|
|
|
|
2023-05-11 22:14:58 +00:00
|
|
|
const trackEvent = useEvents()
|
|
|
|
|
2023-04-25 11:01:25 +00:00
|
|
|
// should this be a useMemo? looks like it doesn't actually deref any reactive values
|
|
|
|
const actions = React.useMemo<ActionsContextType>(() => {
|
|
|
|
const actions = makeActions([
|
|
|
|
{
|
|
|
|
id: 'edit-link',
|
|
|
|
label: 'action.edit-link',
|
|
|
|
icon: 'link',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'edit-link')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('edit-link')
|
|
|
|
addDialog({ component: EditLinkDialog })
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'insert-embed',
|
|
|
|
label: 'action.insert-embed',
|
|
|
|
readonlyOk: false,
|
|
|
|
kbd: '$i',
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'insert-embed')
|
2023-04-25 11:01:25 +00:00
|
|
|
addDialog({ component: EmbedDialog })
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'insert-media',
|
|
|
|
label: 'action.insert-media',
|
|
|
|
kbd: '$u',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'insert-media')
|
2023-04-25 11:01:25 +00:00
|
|
|
insertMedia()
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'undo',
|
|
|
|
label: 'action.undo',
|
|
|
|
icon: 'undo',
|
|
|
|
kbd: '$z',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'undo')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.undo()
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'redo',
|
|
|
|
label: 'action.redo',
|
|
|
|
icon: 'redo',
|
|
|
|
kbd: '$!z',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'redo')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.redo()
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'export-as-svg',
|
|
|
|
label: 'action.export-as-svg',
|
|
|
|
menuLabel: 'action.export-as-svg.short',
|
|
|
|
contextMenuLabel: 'action.export-as-svg.short',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'export-as', { format: 'svg' })
|
2023-04-25 11:01:25 +00:00
|
|
|
exportAs(app.selectedIds, 'svg')
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'export-as-png',
|
|
|
|
label: 'action.export-as-png',
|
|
|
|
menuLabel: 'action.export-as-png.short',
|
|
|
|
contextMenuLabel: 'action.export-as-png.short',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'export-as', { format: 'png' })
|
2023-04-25 11:01:25 +00:00
|
|
|
exportAs(app.selectedIds, 'png')
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'export-as-json',
|
|
|
|
label: 'action.export-as-json',
|
|
|
|
menuLabel: 'action.export-as-json.short',
|
|
|
|
contextMenuLabel: 'action.export-as-json.short',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'export-as', { format: 'json' })
|
2023-04-25 11:01:25 +00:00
|
|
|
exportAs(app.selectedIds, '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,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'copy-as', { format: 'svg' })
|
2023-04-25 11:01:25 +00:00
|
|
|
copyAs(app.selectedIds, 'svg')
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'copy-as-png',
|
|
|
|
label: 'action.copy-as-png',
|
|
|
|
menuLabel: 'action.copy-as-png.short',
|
|
|
|
contextMenuLabel: 'action.copy-as-png.short',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'copy-as', { format: 'png' })
|
2023-04-25 11:01:25 +00:00
|
|
|
copyAs(app.selectedIds, 'png')
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'copy-as-json',
|
|
|
|
label: 'action.copy-as-json',
|
|
|
|
menuLabel: 'action.copy-as-json.short',
|
|
|
|
contextMenuLabel: 'action.copy-as-json.short',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'copy-as', { format: 'json' })
|
2023-04-25 11:01:25 +00:00
|
|
|
copyAs(app.selectedIds, 'json')
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'toggle-auto-size',
|
|
|
|
label: 'action.toggle-auto-size',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'toggle-auto-size')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark()
|
|
|
|
app.updateShapes(
|
|
|
|
app.selectedShapes
|
|
|
|
.filter((shape) => shape && shape.type === 'text' && shape.props.autoSize === false)
|
|
|
|
.map((shape: TLTextShape) => {
|
|
|
|
return {
|
|
|
|
id: shape.id,
|
|
|
|
type: shape.type,
|
|
|
|
props: {
|
|
|
|
...shape.props,
|
|
|
|
w: 8,
|
|
|
|
autoSize: true,
|
|
|
|
},
|
|
|
|
} as TLTextShape
|
|
|
|
})
|
|
|
|
)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'open-embed-link',
|
|
|
|
label: 'action.open-embed-link',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'open-embed-link')
|
2023-04-25 11:01:25 +00:00
|
|
|
const ids = app.selectedIds
|
|
|
|
const warnMsg = 'No embed shapes selected'
|
|
|
|
if (ids.length !== 1) {
|
|
|
|
console.error(warnMsg)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
const shape = app.getShapeById(ids[0])
|
|
|
|
if (!shape || !TLEmbedShapeDef.is(shape)) {
|
|
|
|
console.error(warnMsg)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
openWindow(shape.props.url, '_blank')
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'convert-to-bookmark',
|
|
|
|
label: 'action.convert-to-bookmark',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'convert-to-bookmark')
|
2023-04-25 11:01:25 +00:00
|
|
|
const ids = app.selectedIds
|
|
|
|
const shapes = ids.map((id) => app.getShapeById(id))
|
|
|
|
|
|
|
|
const createList: TLShapePartial[] = []
|
|
|
|
const deleteList: TLShapeId[] = []
|
|
|
|
for (const shape of shapes) {
|
|
|
|
if (!shape || !TLEmbedShapeDef.is(shape) || !shape.props.url) continue
|
|
|
|
|
|
|
|
const newPos = new Vec2d(shape.x, shape.y)
|
|
|
|
newPos.rot(-shape.rotation)
|
|
|
|
newPos.add(
|
|
|
|
new Vec2d(
|
|
|
|
shape.props.w / 2 - DEFAULT_BOOKMARK_WIDTH / 2,
|
|
|
|
shape.props.h / 2 - DEFAULT_BOOKMARK_HEIGHT / 2
|
|
|
|
)
|
|
|
|
)
|
|
|
|
newPos.rot(shape.rotation)
|
|
|
|
|
|
|
|
createList.push({
|
|
|
|
id: app.createShapeId(),
|
|
|
|
type: 'bookmark',
|
|
|
|
rotation: shape.rotation,
|
|
|
|
x: newPos.x,
|
|
|
|
y: newPos.y,
|
|
|
|
props: {
|
|
|
|
url: shape.props.url,
|
|
|
|
opacity: '1',
|
|
|
|
},
|
|
|
|
})
|
|
|
|
deleteList.push(shape.id)
|
|
|
|
}
|
|
|
|
|
|
|
|
app.mark('convert shapes to bookmark')
|
|
|
|
app.deleteShapes(deleteList)
|
|
|
|
app.createShapes(createList)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'convert-to-embed',
|
|
|
|
label: 'action.convert-to-embed',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'convert-to-embed')
|
2023-04-25 11:01:25 +00:00
|
|
|
const ids = app.selectedIds
|
|
|
|
const shapes = compact(ids.map((id) => app.getShapeById(id)))
|
|
|
|
|
|
|
|
const createList: TLShapePartial[] = []
|
|
|
|
const deleteList: TLShapeId[] = []
|
|
|
|
for (const shape of shapes) {
|
|
|
|
if (!TLBookmarkShapeDef.is(shape)) continue
|
|
|
|
|
|
|
|
const { url } = shape.props
|
|
|
|
|
|
|
|
const embedInfo = getEmbedInfo(shape.props.url)
|
|
|
|
|
|
|
|
if (!embedInfo) continue
|
|
|
|
if (!embedInfo.definition) continue
|
|
|
|
|
|
|
|
const { width, height, doesResize } = 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)
|
|
|
|
|
|
|
|
createList.push({
|
|
|
|
id: app.createShapeId(),
|
|
|
|
type: 'embed',
|
|
|
|
x: newPos.x,
|
|
|
|
y: newPos.y,
|
|
|
|
rotation: shape.rotation,
|
|
|
|
props: {
|
|
|
|
url: url,
|
|
|
|
w: width,
|
|
|
|
h: height,
|
|
|
|
doesResize,
|
|
|
|
},
|
|
|
|
})
|
|
|
|
deleteList.push(shape.id)
|
|
|
|
}
|
|
|
|
|
|
|
|
app.mark('convert shapes to embed')
|
|
|
|
app.deleteShapes(deleteList)
|
|
|
|
app.createShapes(createList)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'duplicate',
|
|
|
|
kbd: '$d',
|
|
|
|
label: 'action.duplicate',
|
|
|
|
icon: 'duplicate',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
2023-04-25 11:01:25 +00:00
|
|
|
if (app.currentToolId !== 'select') return
|
2023-05-11 22:14:58 +00:00
|
|
|
trackEvent(source, 'duplicate-shapes')
|
2023-04-25 11:01:25 +00:00
|
|
|
const ids = app.selectedIds
|
|
|
|
const commonBounds = Box2d.Common(compact(ids.map((id) => app.getPageBoundsById(id))))
|
|
|
|
const offset = app.canMoveCamera
|
|
|
|
? {
|
|
|
|
x: commonBounds.width + 10,
|
|
|
|
y: 0,
|
|
|
|
}
|
|
|
|
: {
|
|
|
|
x: 16 / app.zoomLevel,
|
|
|
|
y: 16 / app.zoomLevel,
|
|
|
|
}
|
|
|
|
app.mark('duplicate shapes')
|
|
|
|
app.duplicateShapes(ids, offset)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'ungroup',
|
|
|
|
label: 'action.ungroup',
|
|
|
|
kbd: '$!g',
|
|
|
|
icon: 'ungroup',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'ungroup-shapes')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('ungroup')
|
|
|
|
app.ungroupShapes(app.selectedIds)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'group',
|
|
|
|
label: 'action.group',
|
|
|
|
kbd: '$g',
|
|
|
|
icon: 'group',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'group-shapes')
|
2023-04-25 11:01:25 +00:00
|
|
|
if (app.selectedShapes.length === 1 && app.selectedShapes[0].type === 'group') {
|
|
|
|
app.mark('ungroup')
|
|
|
|
app.ungroupShapes(app.selectedIds)
|
|
|
|
} else {
|
|
|
|
app.mark('group')
|
|
|
|
app.groupShapes(app.selectedIds)
|
|
|
|
}
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'align-left',
|
|
|
|
label: 'action.align-left',
|
|
|
|
kbd: '?A',
|
|
|
|
icon: 'align-left',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'align-shapes', { operation: 'left' })
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('align left')
|
|
|
|
app.alignShapes('left', app.selectedIds)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'align-center-horizontal',
|
|
|
|
label: 'action.align-center-horizontal',
|
|
|
|
contextMenuLabel: 'action.align-center-horizontal.short',
|
|
|
|
kbd: '?H',
|
|
|
|
icon: 'align-center-horizontal',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'align-shapes', { operation: 'center-horizontal' })
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('align center horizontal')
|
|
|
|
app.alignShapes('center-horizontal', app.selectedIds)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'align-right',
|
|
|
|
label: 'action.align-right',
|
|
|
|
kbd: '?D',
|
|
|
|
icon: 'align-right',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'align-shapes', { operation: 'right' })
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('align right')
|
|
|
|
app.alignShapes('right', app.selectedIds)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'align-center-vertical',
|
|
|
|
label: 'action.align-center-vertical',
|
|
|
|
contextMenuLabel: 'action.align-center-vertical.short',
|
|
|
|
kbd: '?V',
|
|
|
|
icon: 'align-center-vertical',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'align-shapes', { operation: 'center-vertical' })
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('align center vertical')
|
|
|
|
app.alignShapes('center-vertical', app.selectedIds)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'align-top',
|
|
|
|
label: 'action.align-top',
|
|
|
|
icon: 'align-top',
|
|
|
|
kbd: '?W',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'align-shapes', { operation: 'top' })
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('align top')
|
|
|
|
app.alignShapes('top', app.selectedIds)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'align-bottom',
|
|
|
|
label: 'action.align-bottom',
|
|
|
|
icon: 'align-bottom',
|
|
|
|
kbd: '?S',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'align-shapes', { operation: 'bottom' })
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('align bottom')
|
|
|
|
app.alignShapes('bottom', app.selectedIds)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'distribute-horizontal',
|
|
|
|
label: 'action.distribute-horizontal',
|
|
|
|
contextMenuLabel: 'action.distribute-horizontal.short',
|
|
|
|
icon: 'distribute-horizontal',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'distribute-shapes', { operation: 'horizontal' })
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('distribute horizontal')
|
|
|
|
app.distributeShapes('horizontal', app.selectedIds)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'distribute-vertical',
|
|
|
|
label: 'action.distribute-vertical',
|
|
|
|
contextMenuLabel: 'action.distribute-vertical.short',
|
|
|
|
icon: 'distribute-vertical',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'distribute-shapes', { operation: 'vertical' })
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('distribute vertical')
|
|
|
|
app.distributeShapes('vertical', app.selectedIds)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'stretch-horizontal',
|
|
|
|
label: 'action.stretch-horizontal',
|
|
|
|
contextMenuLabel: 'action.stretch-horizontal.short',
|
|
|
|
icon: 'stretch-horizontal',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'stretch-shapes', { operation: 'horizontal' })
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('stretch horizontal')
|
|
|
|
app.stretchShapes('horizontal', app.selectedIds)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'stretch-vertical',
|
|
|
|
label: 'action.stretch-vertical',
|
|
|
|
contextMenuLabel: 'action.stretch-vertical.short',
|
|
|
|
icon: 'stretch-vertical',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'stretch-shapes', { operation: 'vertical' })
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('stretch vertical')
|
|
|
|
app.stretchShapes('vertical', app.selectedIds)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'flip-horizontal',
|
|
|
|
label: 'action.flip-horizontal',
|
|
|
|
contextMenuLabel: 'action.flip-horizontal.short',
|
|
|
|
kbd: '!h',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'flip-shapes', { operation: 'horizontal' })
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('flip horizontal')
|
|
|
|
app.flipShapes('horizontal', app.selectedIds)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'flip-vertical',
|
|
|
|
label: 'action.flip-vertical',
|
|
|
|
contextMenuLabel: 'action.flip-vertical.short',
|
|
|
|
kbd: '!v',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'flip-shapes', { operation: 'vertical' })
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('flip vertical')
|
|
|
|
app.flipShapes('vertical', app.selectedIds)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'pack',
|
|
|
|
label: 'action.pack',
|
|
|
|
icon: 'pack',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'pack-shapes')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('pack')
|
|
|
|
app.packShapes(app.selectedIds)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'stack-vertical',
|
|
|
|
label: 'action.stack-vertical',
|
|
|
|
contextMenuLabel: 'action.stack-vertical.short',
|
|
|
|
icon: 'stack-vertical',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'stack-shapes', { operation: 'vertical' })
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('stack-vertical')
|
|
|
|
app.stackShapes('vertical', app.selectedIds)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'stack-horizontal',
|
|
|
|
label: 'action.stack-horizontal',
|
|
|
|
contextMenuLabel: 'action.stack-horizontal.short',
|
|
|
|
icon: 'stack-horizontal',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'stack-shapes', { operation: 'horizontal' })
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('stack-horizontal')
|
|
|
|
app.stackShapes('horizontal', app.selectedIds)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'bring-to-front',
|
|
|
|
label: 'action.bring-to-front',
|
|
|
|
kbd: ']',
|
|
|
|
icon: 'bring-to-front',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'reorder-shapes', { operation: 'toFront' })
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('bring to front')
|
|
|
|
app.bringToFront()
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'bring-forward',
|
|
|
|
label: 'action.bring-forward',
|
|
|
|
icon: 'bring-forward',
|
|
|
|
kbd: '?]',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'reorder-shapes', { operation: 'forward' })
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('bring forward')
|
|
|
|
app.bringForward()
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'send-backward',
|
|
|
|
label: 'action.send-backward',
|
|
|
|
icon: 'send-backward',
|
|
|
|
kbd: '?[',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'reorder-shapes', { operation: 'backward' })
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('send backward')
|
|
|
|
app.sendBackward()
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'send-to-back',
|
|
|
|
label: 'action.send-to-back',
|
|
|
|
icon: 'send-to-back',
|
|
|
|
kbd: '[',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'reorder-shapes', { operation: 'toBack' })
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('send to back')
|
|
|
|
app.sendToBack()
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'cut',
|
|
|
|
label: 'action.cut',
|
|
|
|
kbd: '$x',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'cut')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('cut')
|
|
|
|
cut()
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'copy',
|
|
|
|
label: 'action.copy',
|
|
|
|
kbd: '$c',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'copy')
|
2023-04-25 11:01:25 +00:00
|
|
|
copy()
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'paste',
|
|
|
|
label: 'action.paste',
|
|
|
|
kbd: '$v',
|
|
|
|
readonlyOk: false,
|
|
|
|
onSelect() {
|
|
|
|
// must be inlined with a custom menu item
|
|
|
|
// the kbd listed here should have no effect
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'select-all',
|
|
|
|
label: 'action.select-all',
|
|
|
|
kbd: '$a',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'select-all-shapes')
|
2023-04-25 11:01:25 +00:00
|
|
|
if (app.currentToolId !== 'select') {
|
|
|
|
app.cancel()
|
|
|
|
app.setSelectedTool('select')
|
|
|
|
}
|
|
|
|
|
|
|
|
app.mark('select all kbd')
|
|
|
|
app.selectAll()
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'select-none',
|
|
|
|
label: 'action.select-none',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'select-none-shapes')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('select none')
|
|
|
|
app.selectNone()
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'delete',
|
|
|
|
label: 'action.delete',
|
|
|
|
kbd: '⌫',
|
|
|
|
icon: 'trash',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
2023-04-25 11:01:25 +00:00
|
|
|
if (app.currentToolId !== 'select') return
|
2023-05-11 22:14:58 +00:00
|
|
|
trackEvent(source, 'delete-shapes')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('delete')
|
|
|
|
app.deleteShapes()
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'rotate-cw',
|
|
|
|
label: 'action.rotate-cw',
|
|
|
|
icon: 'rotate-cw',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
2023-04-25 11:01:25 +00:00
|
|
|
if (app.selectedIds.length === 0) return
|
2023-05-11 22:14:58 +00:00
|
|
|
trackEvent(source, 'rotate-cw')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('rotate-cw')
|
|
|
|
const offset = app.selectionRotation % (TAU / 2)
|
|
|
|
const dontUseOffset = approximately(offset, 0) || approximately(offset, TAU / 2)
|
|
|
|
app.rotateShapesBy(app.selectedIds, TAU / 2 - (dontUseOffset ? 0 : offset))
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'rotate-ccw',
|
|
|
|
label: 'action.rotate-ccw',
|
|
|
|
icon: 'rotate-ccw',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
2023-04-25 11:01:25 +00:00
|
|
|
if (app.selectedIds.length === 0) return
|
2023-05-11 22:14:58 +00:00
|
|
|
trackEvent(source, 'rotate-ccw')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.mark('rotate-ccw')
|
|
|
|
const offset = app.selectionRotation % (TAU / 2)
|
|
|
|
const offsetCloseToZero = approximately(offset, 0)
|
|
|
|
app.rotateShapesBy(app.selectedIds, offsetCloseToZero ? -(TAU / 2) : -offset)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'zoom-in',
|
|
|
|
label: 'action.zoom-in',
|
|
|
|
kbd: '$=',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'zoom-in')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.zoomIn(app.viewportScreenCenter, { duration: ANIMATION_MEDIUM_MS })
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'zoom-out',
|
|
|
|
label: 'action.zoom-out',
|
|
|
|
kbd: '$-',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'zoom-out')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.zoomOut(app.viewportScreenCenter, { duration: ANIMATION_MEDIUM_MS })
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'zoom-to-100',
|
|
|
|
label: 'action.zoom-to-100',
|
|
|
|
icon: 'reset-zoom',
|
|
|
|
kbd: '!0',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'reset-zoom')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.resetZoom(app.viewportScreenCenter, { duration: ANIMATION_MEDIUM_MS })
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'zoom-to-fit',
|
|
|
|
label: 'action.zoom-to-fit',
|
|
|
|
kbd: '!1',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'zoom-to-fit')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.zoomToFit({ duration: ANIMATION_MEDIUM_MS })
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'zoom-to-selection',
|
|
|
|
label: 'action.zoom-to-selection',
|
|
|
|
kbd: '!2',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'zoom-to-selection')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.zoomToSelection({ duration: ANIMATION_MEDIUM_MS })
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'toggle-snap-mode',
|
|
|
|
label: 'action.toggle-snap-mode',
|
|
|
|
menuLabel: 'action.toggle-snap-mode.menu',
|
|
|
|
readonlyOk: false,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'toggle-snap-mode')
|
|
|
|
app.setSnapMode(!app.isSnapMode)
|
2023-04-25 11:01:25 +00:00
|
|
|
},
|
|
|
|
checkbox: true,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'toggle-dark-mode',
|
|
|
|
label: 'action.toggle-dark-mode',
|
|
|
|
menuLabel: 'action.toggle-dark-mode.menu',
|
|
|
|
kbd: '$/',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'toggle-dark-mode')
|
|
|
|
app.setDarkMode(!app.isDarkMode)
|
2023-04-25 11:01:25 +00:00
|
|
|
},
|
|
|
|
checkbox: true,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'toggle-transparent',
|
|
|
|
label: 'action.toggle-transparent',
|
|
|
|
menuLabel: 'action.toggle-transparent.menu',
|
|
|
|
contextMenuLabel: 'action.toggle-transparent.context-menu',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'toggle-transparent')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.updateInstanceState(
|
|
|
|
{
|
|
|
|
exportBackground: !app.instanceState.exportBackground,
|
|
|
|
},
|
|
|
|
true
|
|
|
|
)
|
|
|
|
},
|
|
|
|
checkbox: true,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'toggle-tool-lock',
|
|
|
|
label: 'action.toggle-tool-lock',
|
|
|
|
menuLabel: 'action.toggle-tool-lock.menu',
|
|
|
|
readonlyOk: false,
|
|
|
|
kbd: 'q',
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'toggle-tool-lock')
|
|
|
|
app.setToolLocked(!app.isToolLocked)
|
2023-04-25 11:01:25 +00:00
|
|
|
},
|
|
|
|
checkbox: true,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'toggle-focus-mode',
|
|
|
|
label: 'action.toggle-focus-mode',
|
|
|
|
menuLabel: 'action.toggle-focus-mode.menu',
|
|
|
|
readonlyOk: true,
|
|
|
|
kbd: '$.',
|
|
|
|
checkbox: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
2023-04-25 11:01:25 +00:00
|
|
|
// this needs to be deferred because it causes the menu
|
|
|
|
// UI to unmount which puts us in a dodgy state
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
app.batch(() => {
|
2023-05-11 22:14:58 +00:00
|
|
|
trackEvent(source, 'toggle-focus-mode')
|
2023-04-25 11:01:25 +00:00
|
|
|
clearDialogs()
|
|
|
|
clearToasts()
|
2023-05-11 22:14:58 +00:00
|
|
|
app.setFocusMode(!app.isFocusMode)
|
2023-04-25 11:01:25 +00:00
|
|
|
})
|
|
|
|
})
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'toggle-grid',
|
|
|
|
label: 'action.toggle-grid',
|
|
|
|
menuLabel: 'action.toggle-grid.menu',
|
|
|
|
readonlyOk: true,
|
|
|
|
kbd: "$'",
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'toggle-grid-mode')
|
|
|
|
app.setGridMode(!app.isGridMode)
|
2023-04-25 11:01:25 +00:00
|
|
|
},
|
|
|
|
checkbox: true,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'toggle-debug-mode',
|
|
|
|
label: 'action.toggle-debug-mode',
|
|
|
|
menuLabel: 'action.toggle-debug-mode.menu',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'toggle-debug-mode')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.updateInstanceState(
|
|
|
|
{
|
|
|
|
isDebugMode: !app.instanceState.isDebugMode,
|
|
|
|
},
|
|
|
|
true
|
|
|
|
)
|
|
|
|
},
|
|
|
|
checkbox: true,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'print',
|
|
|
|
label: 'action.print',
|
|
|
|
kbd: '$p',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'print')
|
2023-04-25 11:01:25 +00:00
|
|
|
printSelectionOrPages()
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'exit-pen-mode',
|
|
|
|
label: 'action.exit-pen-mode',
|
|
|
|
icon: 'cross-2',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'exit-pen-mode')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.setPenMode(false)
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'stop-following',
|
|
|
|
label: 'action.stop-following',
|
|
|
|
icon: 'cross-2',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'stop-following')
|
2023-04-25 11:01:25 +00:00
|
|
|
app.stopFollowingUser()
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
id: 'back-to-content',
|
|
|
|
label: 'action.back-to-content',
|
|
|
|
icon: 'arrow-left',
|
|
|
|
readonlyOk: true,
|
2023-05-11 22:14:58 +00:00
|
|
|
onSelect(source) {
|
|
|
|
trackEvent(source, 'zoom-to-content')
|
|
|
|
app.zoomToContent()
|
2023-04-25 11:01:25 +00:00
|
|
|
},
|
|
|
|
},
|
|
|
|
])
|
|
|
|
|
|
|
|
if (overrides) {
|
|
|
|
return overrides(app, actions, undefined)
|
|
|
|
}
|
|
|
|
|
|
|
|
return actions
|
|
|
|
}, [
|
2023-05-11 22:14:58 +00:00
|
|
|
trackEvent,
|
2023-04-25 11:01:25 +00:00
|
|
|
overrides,
|
|
|
|
app,
|
|
|
|
addDialog,
|
|
|
|
insertMedia,
|
|
|
|
exportAs,
|
|
|
|
copyAs,
|
|
|
|
cut,
|
|
|
|
copy,
|
|
|
|
clearDialogs,
|
|
|
|
clearToasts,
|
|
|
|
printSelectionOrPages,
|
|
|
|
])
|
|
|
|
|
|
|
|
return <ActionsContext.Provider value={asActions(actions)}>{children}</ActionsContext.Provider>
|
|
|
|
}
|
|
|
|
|
|
|
|
/** @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<T extends Record<string, ActionItem>>(actions: T) {
|
|
|
|
return actions as Record<keyof typeof actions, ActionItem>
|
|
|
|
}
|