Skip to content

Commit e999671

Browse files
authored
Add page navigation kbds (tldraw#5586)
This PR adds shortcuts for moving between pages. Option + ArrowLeft or Option + ArrowUp will go to the prev page (eg from Page 2 to Page 1), Option + ArrowRight or Option + ArrowDown will go to the next page (from Page 1 to Page 2). If the current page is NOT blank, then going to the next page from the last page will create a new page. ### Change type - [ ] `bugfix` - [ ] `improvement` - [x] `feature` - [ ] `api` - [ ] `other` ### Test plan 1. Create some pages 2. Use the keyboard to navigate between pages 3. On a last page with shapes, create a new page by going "up" from the last page 3. On a last page without shapes, fail to create a new page by going "up" from the last page ### Release notes - Added keyboard shortcuts (option + arrows) for navigating between pages.
1 parent 3ad7908 commit e999671

File tree

5 files changed

+143
-35
lines changed

5 files changed

+143
-35
lines changed

Diff for: apps/examples/e2e/tests/test-kbds.spec.ts

+67-5
Original file line numberDiff line numberDiff line change
@@ -212,11 +212,6 @@ test.describe('Actions on shapes', () => {
212212
await setupPageWithShapes(page)
213213

214214
// needs shapes on the canvas
215-
await page.keyboard.press('Control+Shift+c')
216-
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
217-
name: 'copy-as',
218-
data: { format: 'svg', source: 'kbd' },
219-
})
220215

221216
// select-all — Cmd+A
222217
await page.keyboard.press('Control+a')
@@ -316,6 +311,14 @@ test.describe('Actions on shapes', () => {
316311
data: { operation: 'bottom', source: 'kbd' },
317312
})
318313

314+
// Copy as SVG — this should have a clipboard error
315+
316+
// await page.keyboard.press('Control+Shift+c')
317+
// expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
318+
// name: 'copy-as',
319+
// data: { format: 'svg', source: 'kbd' },
320+
// })
321+
319322
// delete — backspace
320323
await page.keyboard.press('Control+a') // selected
321324
await page.keyboard.press('Backspace')
@@ -340,6 +343,65 @@ test.describe('Actions on shapes', () => {
340343
data: { source: 'kbd' },
341344
})
342345

346+
// Next, previous pages — Alt+ArrowLeft, Alt+ArrowRight
347+
348+
// Try a previous page move. We can't go lower since we're on the first page.
349+
// So the most recent event should be the previous delete
350+
351+
await page.keyboard.press('Alt+ArrowLeft')
352+
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
353+
name: 'delete-shapes',
354+
data: { source: 'kbd' },
355+
})
356+
357+
// Next page. Since there's only one page and the page is empty, nothing should happen.
358+
359+
await page.keyboard.press('Alt+ArrowRight')
360+
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
361+
name: 'delete-shapes',
362+
data: { source: 'kbd' },
363+
})
364+
365+
// make something on the new page and delete it
366+
await page.keyboard.press('r')
367+
await page.mouse.click(100, 100)
368+
369+
// If there's something on the page, we can create the next page by moving up
370+
await page.keyboard.press('Alt+ArrowRight')
371+
372+
// We'll also have a change page here... but the most recent will be the new page
373+
374+
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
375+
name: 'new-page',
376+
data: { source: 'kbd' },
377+
})
378+
379+
// We can go back down...
380+
await page.keyboard.press('Alt+ArrowLeft')
381+
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
382+
name: 'change-page',
383+
data: { source: 'kbd', direction: 'prev' },
384+
})
385+
386+
// We can go up again
387+
await page.keyboard.press('Alt+ArrowRight')
388+
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
389+
name: 'change-page',
390+
data: { source: 'kbd', direction: 'next' },
391+
})
392+
393+
// We can back down and up with the up and down keys too...
394+
await page.keyboard.press('Alt+ArrowUp')
395+
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
396+
name: 'change-page',
397+
data: { source: 'kbd', direction: 'prev' },
398+
})
399+
await page.keyboard.press('Alt+ArrowDown')
400+
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
401+
name: 'change-page',
402+
data: { source: 'kbd', direction: 'next' },
403+
})
404+
343405
/* ---------------------- Misc ---------------------- */
344406

345407
// toggle lock

Diff for: packages/tldraw/api-report.api.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -2990,7 +2990,9 @@ export interface TLUiEventMap {
29902990
locale: string;
29912991
};
29922992
// (undocumented)
2993-
'change-page': null;
2993+
'change-page': {
2994+
direction?: 'next' | 'prev';
2995+
};
29942996
// (undocumented)
29952997
'change-user-name': null;
29962998
// (undocumented)

Diff for: packages/tldraw/src/lib/ui/context/actions.tsx

+45-1
Original file line numberDiff line numberDiff line change
@@ -966,7 +966,7 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
966966
{
967967
id: 'delete',
968968
label: 'action.delete',
969-
kbd: '⌫,del,backspace',
969+
kbd: '⌫,del',
970970
icon: 'trash',
971971
onSelect(source) {
972972
if (!canApplySelectionAction()) return
@@ -1410,6 +1410,50 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
14101410
editor.setCurrentTool('geo')
14111411
},
14121412
},
1413+
{
1414+
id: 'change-page-prev',
1415+
kbd: '?left,?up',
1416+
onSelect: async (source) => {
1417+
// will select whatever the most recent geo tool was
1418+
const pages = editor.getPages()
1419+
const currentPageIndex = pages.findIndex((page) => page.id === editor.getCurrentPageId())
1420+
if (currentPageIndex < 1) return
1421+
trackEvent('change-page', { source, direction: 'prev' })
1422+
editor.setCurrentPage(pages[currentPageIndex - 1].id)
1423+
},
1424+
},
1425+
{
1426+
id: 'change-page-next',
1427+
kbd: '?right,?down',
1428+
onSelect: async (source) => {
1429+
// will select whatever the most recent geo tool was
1430+
const pages = editor.getPages()
1431+
const currentPageIndex = pages.findIndex((page) => page.id === editor.getCurrentPageId())
1432+
1433+
// If we're on the last page...
1434+
if (currentPageIndex === -1 || currentPageIndex >= pages.length - 1) {
1435+
// if the current page is blank or if we're in readonly mode, do nothing
1436+
if (editor.getCurrentPageShapes().length <= 0 || editor.getIsReadonly()) {
1437+
return
1438+
}
1439+
// Otherwise, create a new page
1440+
trackEvent('new-page', { source })
1441+
editor.run(() => {
1442+
editor.markHistoryStoppingPoint('creating page')
1443+
const newPageId = PageRecordType.createId()
1444+
editor.createPage({
1445+
name: helpers.msg('page-menu.new-page-initial-name'),
1446+
id: newPageId,
1447+
})
1448+
editor.setCurrentPage(newPageId)
1449+
})
1450+
return
1451+
}
1452+
1453+
editor.setCurrentPage(pages[currentPageIndex + 1].id)
1454+
trackEvent('change-page', { source, direction: 'next' })
1455+
},
1456+
},
14131457
]
14141458

14151459
if (showCollaborationUi) {

Diff for: packages/tldraw/src/lib/ui/context/events.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export interface TLUiEventMap {
3030
undo: null
3131
redo: null
3232
'change-language': { locale: string }
33-
'change-page': null
33+
'change-page': { direction?: 'prev' | 'next' }
3434
'delete-page': null
3535
'duplicate-page': null
3636
'move-page': null

Diff for: packages/tldraw/src/lib/ui/hooks/useKeyboardShortcuts.ts

+27-27
Original file line numberDiff line numberDiff line change
@@ -140,39 +140,39 @@ export function useKeyboardShortcuts() {
140140
}, [actions, tools, isReadonlyMode, editor, isFocused])
141141
}
142142

143-
// Shift is !
144-
// Alt is ?
145-
// Cmd / control is $
146-
// so cmd+shift+u would be $!u
147-
143+
// The "raw" kbd here will look something like "a" or a combination of keys "del,backspace",
144+
// or modifier keys (using ! for shift, $ for cmd, and ? for alt). We need to first split them
145+
// up by comma, then parse each key to get the actual key and modifiers.
148146
function getHotkeysStringFromKbd(kbd: string) {
149147
return getKeys(kbd)
150148
.map((kbd) => {
151149
let str = ''
152-
const chars = kbd.split('')
153-
if (chars.length === 1) {
154-
str = chars[0]
150+
151+
const shift = kbd.includes('!')
152+
const alt = kbd.includes('?')
153+
const cmd = kbd.includes('$')
154+
155+
// remove the modifiers; the remaining string are the actual key
156+
const k = kbd.replace(/[!?$]/g, '')
157+
158+
if (shift && alt && cmd) {
159+
str = `cmd+shift+alt+${k},ctrl+shift+alt+${k}`
160+
} else if (shift && cmd) {
161+
str = `cmd+shift+${k},ctrl+shift+${k}`
162+
} else if (alt && cmd) {
163+
str = `cmd+alt+${k},ctrl+alt+${k}`
164+
} else if (alt && shift) {
165+
str = `shift+alt+${k}`
166+
} else if (shift) {
167+
str = `shift+${k}`
168+
} else if (alt) {
169+
str = `alt+${k}`
170+
} else if (cmd) {
171+
str = `cmd+${k},ctrl+${k}`
155172
} else {
156-
if (chars[0] === '!') {
157-
str = `shift+${chars[1]}`
158-
} else if (chars[0] === '?') {
159-
if (chars.length === 3 && chars[1] === '!') {
160-
str = `alt+shift+${chars[2]}`
161-
} else {
162-
str = `alt+${chars[1]}`
163-
}
164-
} else if (chars[0] === '$') {
165-
if (chars[1] === '!') {
166-
str = `cmd+shift+${chars[2]},ctrl+shift+${chars[2]}`
167-
} else if (chars[1] === '?') {
168-
str = `cmd+⌥+${chars[2]},ctrl+alt+${chars[2]}`
169-
} else {
170-
str = `cmd+${chars[1]},ctrl+${chars[1]}`
171-
}
172-
} else {
173-
str = kbd
174-
}
173+
str = k
175174
}
175+
176176
return str
177177
})
178178
.join(',')

0 commit comments

Comments
 (0)