[improvement] quick actions (#1922)

This PR:

- improves the quick actions bar, enabling undo / redo actions when the
eraser is selected.
- for actions that effect selected shapes, calling the action when the
select tool is not selected will select the select tool and run the
action
- actions that effect selected shapes are hidden from the menu when the
select tool is not selected

### Change Type

- [x] `major`

### Test Plan

1. Select the eraser tool, the undo / redo buttons should still be
there.

1. Select two shapes
2. Select the draw tool
3. The menu should not display most options, e.g. cut or paste, but
should display undo / redo
4. Press Shift+H
5. The shapes should not move, but the select tool should be selected
again

### Release Notes

- Improve the menu / kbds behavior when select tool is not active
This commit is contained in:
Steve Ruiz 2023-09-19 16:33:39 +01:00 committed by GitHub
parent 386a2396d1
commit b6ebe1e274
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 387 additions and 251 deletions

View file

@ -41,15 +41,20 @@ export async function setupPage(page: PlaywrightTestArgs['page']) {
}
export async function setupPageWithShapes(page: PlaywrightTestArgs['page']) {
// delete everything
await page.keyboard.press('Control+a')
await page.keyboard.press('Backspace')
// create shapes
await page.keyboard.press('r')
await page.mouse.click(200, 200)
await page.keyboard.press('r')
await page.mouse.click(200, 250)
await page.keyboard.press('r')
await page.mouse.click(250, 300)
await page.evaluate(() => {
editor.selectNone()
})
// deselect everything
await page.evaluate(() => editor.selectNone())
}
export async function cleanupPage(page: PlaywrightTestArgs['page']) {

View file

@ -64,8 +64,21 @@ test.describe('Keyboard Shortcuts', () => {
})
}
})
})
test.describe('Keyboard Shortcuts', () => {
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await setupPage(page)
// Make some shapes
await page.keyboard.press('r')
await page.mouse.click(100, 100)
await page.keyboard.press('r')
await page.mouse.click(250, 250)
await page.keyboard.press('v')
})
test.describe('actions', () => {
test('Zoom in', async () => {
await page.keyboard.press('Control+=')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
@ -134,8 +147,6 @@ test.describe('Keyboard Shortcuts', () => {
// data: { source: 'kbd' },
// })
/* ------------------- Preferences ------------------ */
test('Toggle grid mode', async () => {
await page.keyboard.press("Control+'")
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
@ -159,6 +170,13 @@ test.describe('Keyboard Shortcuts', () => {
data: { source: 'kbd' },
})
})
})
test.describe('Actions on shapes', () => {
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await setupPage(page)
})
/* -------------- Operations on Shapes -------------- */
@ -235,20 +253,6 @@ test.describe('Keyboard Shortcuts', () => {
data: { source: 'kbd' },
})
// delete — backspace
await page.keyboard.press('Backspace')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'delete-shapes',
data: { source: 'kbd' },
})
// delete — ⌫
await page.keyboard.press('Delete')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'delete-shapes',
data: { source: 'kbd' },
})
// align left — Alt+A
await page.keyboard.press('Alt+a')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
@ -277,6 +281,30 @@ test.describe('Keyboard Shortcuts', () => {
data: { operation: 'bottom', source: 'kbd' },
})
// delete — backspace
await page.keyboard.press('Control+a') // selected
await page.keyboard.press('Backspace')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'delete-shapes',
data: { source: 'kbd' },
})
// delete — ⌫
// Make some shapes and select them
await page.keyboard.press('r')
await page.mouse.click(100, 100)
await page.keyboard.press('r')
await page.mouse.click(250, 250)
await page.keyboard.press('v')
await page.keyboard.press('Control+a')
await page.keyboard.press('Delete')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'delete-shapes',
data: { source: 'kbd' },
})
/* ---------------------- Misc ---------------------- */
// toggle lock
@ -306,7 +334,6 @@ test.describe('Keyboard Shortcuts', () => {
})
})
})
})
test.describe('Context menu', async () => {
test.beforeEach(async ({ browser }) => {

View file

@ -97,6 +97,7 @@ test.describe('Shape Tools', () => {
})
test('creates shapes clickable tools', async () => {
await page.keyboard.press('v')
await page.keyboard.press('Control+a')
await page.keyboard.press('Backspace')
expect(await getAllShapeTypes(page)).toEqual([])
@ -124,6 +125,7 @@ test.describe('Shape Tools', () => {
// Reset for next time
await page.mouse.click(0, 0) // to ensure we're not focused
await page.keyboard.press('v') // go to the select tool
await page.keyboard.press('Control+a')
await page.keyboard.press('Backspace')
}
@ -162,6 +164,7 @@ test.describe('Shape Tools', () => {
// Reset for next time
await page.mouse.click(0, 0) // to ensure we're not focused
await page.keyboard.press('v')
await page.keyboard.press('Control+a')
await page.keyboard.press('Backspace')
}

View file

@ -10,13 +10,11 @@ export const DuplicateButton = track(function DuplicateButton() {
const msg = useTranslation()
const action = actions['duplicate']
const noSelected = editor.selectedShapeIds.length <= 0
return (
<Button
icon={action.icon}
onClick={() => action.onSelect('quick-actions')}
disabled={noSelected}
disabled={!(editor.isIn('select') && editor.selectedShapeIds.length > 0)}
title={`${msg(action.label!)} ${kbdStr(action.kbd!)}`}
smallIcon
/>

View file

@ -15,15 +15,13 @@ export const MenuZone = track(function MenuZone() {
const breakpoint = useBreakpoint()
const isReadonly = useReadonly()
const showQuickActions = !isReadonly && !editor.isInAny('hand', 'zoom', 'eraser')
return (
<div className="tlui-menu-zone">
<div className="tlui-menu-zone__controls">
<Menu />
<div className="tlui-menu-zone__divider" />
<PageMenu />
{breakpoint >= 6 && showQuickActions && (
{breakpoint >= 6 && !isReadonly && !editor.isInAny('hand', 'zoom') && (
<>
<div className="tlui-menu-zone__divider" />
<UndoButton />

View file

@ -110,13 +110,12 @@ export const Toolbar = memo(function Toolbar() {
<div className="tlui-toolbar">
<div className="tlui-toolbar__inner">
<div className="tlui-toolbar__left">
{!isReadonly && (
{!isReadonly && breakpoint < 6 && !editor.isInAny('hand', 'zoom') && (
<div
className={classNames('tlui-toolbar__extras', {
'tlui-toolbar__extras__hidden': !showExtraActions,
})}
>
{breakpoint < 6 && (
<div className="tlui-toolbar__extras__controls">
<UndoButton />
<RedoButton />
@ -124,7 +123,6 @@ export const Toolbar = memo(function Toolbar() {
<DuplicateButton />
<ActionsMenu />
</div>
)}
<ToggleToolLockedButton activeToolId={activeToolId} />
</div>
)}

View file

@ -15,13 +15,11 @@ export const TrashButton = track(function TrashButton() {
if (isReadonly) return null
const noSelected = editor.selectedShapeIds.length <= 0
return (
<Button
icon={action.icon}
onClick={() => action.onSelect('quick-actions')}
disabled={noSelected}
disabled={!(editor.isIn('select') && editor.selectedShapeIds.length > 0)}
title={`${msg(action.label!)} ${kbdStr(action.kbd!)}`}
smallIcon
/>

View file

@ -83,6 +83,21 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
// should this be a useMemo? looks like it doesn't actually deref any reactive values
const actions = React.useMemo<TLUiActionsContextType>(() => {
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.selectedShapeIds.length > 0
}
const actions = makeActions([
{
id: 'edit-link',
@ -90,6 +105,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
icon: 'link',
readonlyOk: false,
onSelect(source) {
if (!hasSelectedShapes()) return
if (mustGoBackToSelectToolFirst()) return
trackEvent('edit-link', { source })
editor.mark('edit-link')
addDialog({ component: EditLinkDialog })
@ -209,6 +227,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
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(
@ -273,6 +294,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
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.selectedShapes
@ -314,6 +338,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
label: 'action.convert-to-embed',
readonlyOk: false,
onSelect(source) {
if (!hasSelectedShapes()) return
if (mustGoBackToSelectToolFirst()) return
trackEvent('convert-to-embed', { source })
editor.batch(() => {
@ -369,7 +396,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
icon: 'duplicate',
readonlyOk: false,
onSelect(source) {
if (editor.currentToolId !== 'select') return
if (!hasSelectedShapes()) return
if (mustGoBackToSelectToolFirst()) return
trackEvent('duplicate-shapes', { source })
const ids = editor.selectedShapeIds
const commonBounds = Box2d.Common(compact(ids.map((id) => editor.getShapePageBounds(id))))
@ -393,6 +422,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
icon: 'ungroup',
readonlyOk: false,
onSelect(source) {
if (!hasSelectedShapes()) return
if (mustGoBackToSelectToolFirst()) return
trackEvent('ungroup-shapes', { source })
editor.mark('ungroup')
editor.ungroupShapes(editor.selectedShapeIds)
@ -405,6 +437,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
icon: 'group',
readonlyOk: false,
onSelect(source) {
if (!hasSelectedShapes()) return
if (mustGoBackToSelectToolFirst()) return
trackEvent('group-shapes', { source })
const { onlySelectedShape } = editor
if (onlySelectedShape && editor.isShapeOfType<TLGroupShape>(onlySelectedShape, 'group')) {
@ -423,6 +458,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
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.selectedShapeIds, 'left')
@ -436,6 +474,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
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.selectedShapeIds, 'center-horizontal')
@ -448,6 +489,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
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.selectedShapeIds, 'right')
@ -461,6 +505,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
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.selectedShapeIds, 'center-vertical')
@ -473,6 +520,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
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.selectedShapeIds, 'top')
@ -485,6 +535,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
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.selectedShapeIds, 'bottom')
@ -498,6 +551,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
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.selectedShapeIds, 'horizontal')
@ -511,6 +567,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
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.selectedShapeIds, 'vertical')
@ -523,6 +582,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
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.selectedShapeIds, 'horizontal')
@ -535,6 +597,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
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.selectedShapeIds, 'vertical')
@ -547,6 +612,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
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.selectedShapeIds, 'horizontal')
@ -559,6 +627,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
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.selectedShapeIds, 'vertical')
@ -570,6 +641,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
icon: 'pack',
readonlyOk: false,
onSelect(source) {
if (!hasSelectedShapes()) return
if (mustGoBackToSelectToolFirst()) return
trackEvent('pack-shapes', { source })
editor.mark('pack')
editor.packShapes(editor.selectedShapeIds, 16)
@ -582,6 +656,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
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.selectedShapeIds, 'vertical', 16)
@ -594,6 +671,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
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.selectedShapeIds, 'horizontal', 16)
@ -606,6 +686,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
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.selectedShapeIds)
@ -618,6 +701,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
kbd: '?]',
readonlyOk: false,
onSelect(source) {
if (!hasSelectedShapes()) return
if (mustGoBackToSelectToolFirst()) return
trackEvent('reorder-shapes', { operation: 'forward', source })
editor.mark('bring forward')
editor.bringForward(editor.selectedShapeIds)
@ -630,6 +716,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
kbd: '?[',
readonlyOk: false,
onSelect(source) {
if (!hasSelectedShapes()) return
if (mustGoBackToSelectToolFirst()) return
trackEvent('reorder-shapes', { operation: 'backward', source })
editor.mark('send backward')
editor.sendBackward(editor.selectedShapeIds)
@ -642,6 +731,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
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.selectedShapeIds)
@ -653,6 +745,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
kbd: '$x',
readonlyOk: false,
onSelect(source) {
if (!hasSelectedShapes()) return
if (mustGoBackToSelectToolFirst()) return
editor.mark('cut')
cut(source)
},
@ -663,6 +758,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
kbd: '$c',
readonlyOk: true,
onSelect(source) {
if (!hasSelectedShapes()) return
if (mustGoBackToSelectToolFirst()) return
copy(source)
},
},
@ -688,11 +786,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
readonlyOk: true,
onSelect(source) {
editor.batch(() => {
if (mustGoBackToSelectToolFirst()) return
trackEvent('select-all-shapes', { source })
if (editor.currentToolId !== 'select') {
editor.cancel()
editor.setCurrentTool('select')
}
editor.mark('select all kbd')
editor.selectAll()
@ -704,6 +800,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
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()
@ -716,7 +815,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
icon: 'trash',
readonlyOk: false,
onSelect(source) {
if (editor.currentToolId !== 'select') return
if (!hasSelectedShapes()) return
if (mustGoBackToSelectToolFirst()) return
trackEvent('delete-shapes', { source })
editor.mark('delete')
editor.deleteShapes(editor.selectedShapeIds)
@ -728,7 +829,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
icon: 'rotate-cw',
readonlyOk: false,
onSelect(source) {
if (editor.selectedShapeIds.length === 0) return
if (!hasSelectedShapes()) return
if (mustGoBackToSelectToolFirst()) return
trackEvent('rotate-cw', { source })
editor.mark('rotate-cw')
const offset = editor.selectionRotation % (TAU / 2)
@ -742,7 +845,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
icon: 'rotate-ccw',
readonlyOk: false,
onSelect(source) {
if (editor.selectedShapeIds.length === 0) return
if (!hasSelectedShapes()) return
if (mustGoBackToSelectToolFirst()) return
trackEvent('rotate-ccw', { source })
editor.mark('rotate-ccw')
const offset = editor.selectionRotation % (TAU / 2)
@ -797,6 +902,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
kbd: '!2',
readonlyOk: true,
onSelect(source) {
if (!hasSelectedShapes()) return
if (mustGoBackToSelectToolFirst()) return
trackEvent('zoom-to-selection', { source })
editor.zoomToSelection({ duration: ANIMATION_MEDIUM_MS })
},

View file

@ -1,8 +1,9 @@
import { Editor, TLBookmarkShape, TLEmbedShape, compact, useEditor, useValue } from '@tldraw/editor'
import { Editor, TLBookmarkShape, TLEmbedShape, useEditor, useValue } from '@tldraw/editor'
import React, { useMemo } from 'react'
import { getEmbedInfo } from '../../utils/embeds'
import {
TLUiMenuSchema,
compactMenuItems,
menuCustom,
menuGroup,
menuItem,
@ -108,7 +109,7 @@ export function TLUiMenuSchemaProvider({ overrides, children }: TLUiMenuSchemaPr
)
const menuSchema = useMemo<TLUiMenuSchema>(() => {
const menuSchema = compact([
const menuSchema = compactMenuItems([
menuGroup(
'menu',
menuSubmenu(