diff --git a/apps/dotcom/client/public/tla/locales-compiled/en.json b/apps/dotcom/client/public/tla/locales-compiled/en.json
index ab03a9f6e38a..8d91a07fe682 100644
--- a/apps/dotcom/client/public/tla/locales-compiled/en.json
+++ b/apps/dotcom/client/public/tla/locales-compiled/en.json
@@ -699,6 +699,12 @@
"value": "."
}
],
+ "858ba4765e": [
+ {
+ "type": 0,
+ "value": "Member"
+ }
+ ],
"85a082de1b": [
{
"type": 0,
@@ -1277,12 +1283,6 @@
"value": "Legal summary"
}
],
- "e3afed0047": [
- {
- "type": 0,
- "value": "Admin"
- }
- ],
"e404559826": [
{
"type": 0,
diff --git a/apps/dotcom/client/public/tla/locales/en.json b/apps/dotcom/client/public/tla/locales/en.json
index f192987782bc..5df2b62dd2b4 100644
--- a/apps/dotcom/client/public/tla/locales/en.json
+++ b/apps/dotcom/client/public/tla/locales/en.json
@@ -314,6 +314,9 @@
"815e116a2e": {
"translation": "Have a bug, issue, or idea for tldraw? Let us know! Fill out this form and we will follow up over email if needed. You can also chat with us on Discord or submit an issue on GitHub."
},
+ "858ba4765e": {
+ "translation": "Member"
+ },
"85a082de1b": {
"translation": "Please refresh the page to get the latest version of tldraw."
},
@@ -549,9 +552,6 @@
"e2ae80a510": {
"translation": "Legal summary"
},
- "e3afed0047": {
- "translation": "Admin"
- },
"e404559826": {
"translation": "Terms of service"
},
diff --git a/apps/dotcom/client/src/tla/components/dialogs/GroupSettingsDialog.tsx b/apps/dotcom/client/src/tla/components/dialogs/GroupSettingsDialog.tsx
index c1c119e5ea68..5d9b7434ac68 100644
--- a/apps/dotcom/client/src/tla/components/dialogs/GroupSettingsDialog.tsx
+++ b/apps/dotcom/client/src/tla/components/dialogs/GroupSettingsDialog.tsx
@@ -33,7 +33,7 @@ const messages = defineMessages({
copyInviteLink: { defaultMessage: 'Copy invite link' },
members: { defaultMessage: 'Members' },
owner: { defaultMessage: 'Owner' },
- admin: { defaultMessage: 'Admin' },
+ member: { defaultMessage: 'Member' },
you: { defaultMessage: 'you' },
dangerZone: { defaultMessage: 'Danger zone' },
leaveGroup: { defaultMessage: 'Leave group…' },
@@ -62,7 +62,7 @@ export function GroupSettingsDialog({ groupId, onClose }: GroupSettingsDialogPro
const namePlaceholderMsg = useMsg(messages.namePlaceholder)
const ownerMsg = useMsg(messages.owner)
- const adminMsg = useMsg(messages.admin)
+ const memberMsg = useMsg(messages.member)
const youMsg = useMsg(messages.you)
// Get group data
@@ -83,7 +83,7 @@ export function GroupSettingsDialog({ groupId, onClose }: GroupSettingsDialogPro
// Leaving is allowed for everyone except the last owner — a group invariant
// (it must always keep at least one owner), not a capability.
const canLeave = role !== 'owner' || ownersCount > 1
- const roleLabels: Record = { owner: ownerMsg, admin: adminMsg }
+ const roleLabels: Record = { owner: ownerMsg, member: memberMsg }
const handleCopyInviteLink = async () => {
if (copiedInviteLink) return
diff --git a/apps/dotcom/sync-worker/src/routes/tla/acceptInvite.ts b/apps/dotcom/sync-worker/src/routes/tla/acceptInvite.ts
index 513d6b8a9b2a..e137e9402a38 100644
--- a/apps/dotcom/sync-worker/src/routes/tla/acceptInvite.ts
+++ b/apps/dotcom/sync-worker/src/routes/tla/acceptInvite.ts
@@ -119,7 +119,7 @@ export async function acceptInvite(request: IRequest, env: Environment): Promise
userId: auth.userId,
userColor: user.color || '#000000',
userName: user.name,
- role: 'admin',
+ role: 'member',
index,
createdAt: Date.now(),
updatedAt: Date.now(),
diff --git a/apps/dotcom/zero-cache/migrations/033_rename_group_admin_role_to_member.sql b/apps/dotcom/zero-cache/migrations/033_rename_group_admin_role_to_member.sql
new file mode 100644
index 000000000000..f4f53117306c
--- /dev/null
+++ b/apps/dotcom/zero-cache/migrations/033_rename_group_admin_role_to_member.sql
@@ -0,0 +1,13 @@
+-- Rename the non-owner group role from 'admin' to 'member'.
+-- The 'admin' label was misleading: the role confers no admin-level
+-- capabilities, it's a standard group member.
+
+-- Drop the existing CHECK constraint. It was created as an inline column
+-- constraint in 023_groups.sql, so Postgres auto-named it "group_user_role_check".
+ALTER TABLE public."group_user" DROP CONSTRAINT IF EXISTS "group_user_role_check";
+
+-- Rewrite existing rows to the new role value.
+UPDATE public."group_user" SET "role" = 'member' WHERE "role" = 'admin';
+
+-- Re-add the CHECK constraint with the new allowed values.
+ALTER TABLE public."group_user" ADD CONSTRAINT "group_user_role_check" CHECK ("role" IN ('member', 'owner'));
diff --git a/packages/dotcom-shared/src/mutators.test.ts b/packages/dotcom-shared/src/mutators.test.ts
index 025499275048..d458415b000f 100644
--- a/packages/dotcom-shared/src/mutators.test.ts
+++ b/packages/dotcom-shared/src/mutators.test.ts
@@ -73,7 +73,7 @@ function makeGroupUser(
return {
createdAt: 1,
updatedAt: 1,
- role: 'admin',
+ role: 'member',
userName: 'Test',
userColor: '#000',
index: 'a1' as IndexKey,
@@ -386,7 +386,7 @@ describe('file mutations', () => {
file: [] as TlaFile[],
file_state: [] as TlaFileState[],
group: [makeGroup({ id: groupId })],
- group_user: [makeGroupUser({ userId, groupId, role: 'admin' })],
+ group_user: [makeGroupUser({ userId, groupId, role: 'member' })],
group_file: [] as TlaGroupFile[],
}
}
@@ -651,14 +651,14 @@ describe('group mutations', () => {
await expectValid(() => m.updateGroup(tx, { id: groupId, name: 'Renamed' }))
})
- it('admin cannot update group name', async () => {
+ it('member cannot update group name', async () => {
const groupId = 'group_aaa11112222bbb'
const s = {
user: [makeUser({ id: userId, flags: 'groups_backend' })],
file: [],
file_state: [],
group: [makeGroup({ id: groupId })],
- group_user: [makeGroupUser({ userId, groupId, role: 'admin' })],
+ group_user: [makeGroupUser({ userId, groupId, role: 'member' })],
group_file: [],
}
const { tx } = createMockTx(s)
@@ -692,7 +692,7 @@ describe('group mutations', () => {
file: [],
file_state: [],
group: [makeGroup({ id: groupId })],
- group_user: [makeGroupUser({ userId, groupId, role: 'admin' })],
+ group_user: [makeGroupUser({ userId, groupId, role: 'member' })],
group_file: [],
}
const { tx } = createMockTx(s)
@@ -714,7 +714,7 @@ describe('membership', () => {
group: [makeGroup({ id: groupId })],
group_user: [
makeGroupUser({ userId, groupId, role: 'owner' }),
- makeGroupUser({ userId: memberId, groupId, role: 'admin' }),
+ makeGroupUser({ userId: memberId, groupId, role: 'member' }),
],
group_file: [],
}
@@ -724,15 +724,15 @@ describe('membership', () => {
expect(s.group_user.find((gu) => gu.userId === memberId)?.role).toBe('owner')
})
- it('admin cannot set member roles', async () => {
+ it('member cannot set member roles', async () => {
const s = {
user: [makeUser({ id: userId, flags: 'groups_backend' })],
file: [],
file_state: [],
group: [makeGroup({ id: groupId })],
group_user: [
- makeGroupUser({ userId, groupId, role: 'admin' }),
- makeGroupUser({ userId: memberId, groupId, role: 'admin' }),
+ makeGroupUser({ userId, groupId, role: 'member' }),
+ makeGroupUser({ userId: memberId, groupId, role: 'member' }),
],
group_file: [],
}
@@ -743,7 +743,7 @@ describe('membership', () => {
)
})
- it('cannot demote last owner to admin', async () => {
+ it('cannot demote last owner to member', async () => {
const s = {
user: [makeUser({ id: userId, flags: 'groups_backend' })],
file: [],
@@ -751,14 +751,14 @@ describe('membership', () => {
group: [makeGroup({ id: groupId })],
group_user: [
makeGroupUser({ userId, groupId, role: 'owner' }),
- makeGroupUser({ userId: memberId, groupId, role: 'admin' }),
+ makeGroupUser({ userId: memberId, groupId, role: 'member' }),
],
group_file: [],
}
const { tx } = createMockTx(s)
const m = createMutators(userId)
await expectForbidden(() =>
- m.setGroupMemberRole(tx, { groupId, targetUserId: userId, role: 'admin' })
+ m.setGroupMemberRole(tx, { groupId, targetUserId: userId, role: 'member' })
)
})
@@ -784,7 +784,7 @@ describe('membership', () => {
group: [makeGroup({ id: groupId })],
group_user: [
makeGroupUser({ userId: 'user_owner12345678ab', groupId, role: 'owner' }),
- makeGroupUser({ userId, groupId, role: 'admin' }),
+ makeGroupUser({ userId, groupId, role: 'member' }),
],
group_file: [],
}
@@ -847,13 +847,13 @@ describe('file operations across groups', () => {
await expectForbidden(() => m.moveFileToGroup(tx, { fileId, groupId: groupB }))
})
- it('admin can remove file from group', async () => {
+ it('member can remove file from group', async () => {
const s = {
user: [makeUser({ id: userId, flags: 'groups_backend' })],
file: [makeFile({ id: fileId, owningGroupId: groupA })],
file_state: [makeFileState({ userId, fileId })],
group: [makeGroup({ id: groupA })],
- group_user: [makeGroupUser({ userId, groupId: groupA, role: 'admin' })],
+ group_user: [makeGroupUser({ userId, groupId: groupA, role: 'member' })],
group_file: [makeGroupFile({ fileId, groupId: groupA })],
}
const { tx } = createMockTx(s)
@@ -959,13 +959,13 @@ describe('regenerateGroupInviteSecret', () => {
const userId = 'user_aaaa11112222bbbb'
const groupId = 'group_aaa11112222bbb'
- it('admin can regenerate invite secret', async () => {
+ it('member can regenerate invite secret', async () => {
const s = {
user: [makeUser({ id: userId, flags: 'groups_backend' })],
file: [],
file_state: [],
group: [makeGroup({ id: groupId, inviteSecret: 'old_secret_1234567' })],
- group_user: [makeGroupUser({ userId, groupId, role: 'admin' })],
+ group_user: [makeGroupUser({ userId, groupId, role: 'member' })],
group_file: [],
}
const { tx } = createMockTx(s, { location: 'server' })
@@ -976,18 +976,18 @@ describe('regenerateGroupInviteSecret', () => {
})
it('non-member cannot regenerate invite secret', async () => {
- const nonAdminId = 'user_nonadmin1234567'
+ const nonMemberId = 'user_nonmember123456'
const s = {
- user: [makeUser({ id: nonAdminId, flags: 'groups_backend' })],
+ user: [makeUser({ id: nonMemberId, flags: 'groups_backend' })],
file: [],
file_state: [],
group: [makeGroup({ id: groupId })],
- // No group_user for nonAdminId — not a member at all
+ // No group_user for nonMemberId — not a member at all
group_user: [],
group_file: [],
}
const { tx } = createMockTx(s, { location: 'server' })
- const m = createMutators(nonAdminId)
+ const m = createMutators(nonMemberId)
await expectForbidden(() => m.regenerateGroupInviteSecret(tx, { id: groupId }))
})
})
diff --git a/packages/dotcom-shared/src/roles.test.ts b/packages/dotcom-shared/src/roles.test.ts
index 464354122c1d..653460f44ea3 100644
--- a/packages/dotcom-shared/src/roles.test.ts
+++ b/packages/dotcom-shared/src/roles.test.ts
@@ -24,29 +24,29 @@ describe('can', () => {
}
})
- it('grants admins the non-administrative capabilities only', () => {
- const adminCapabilities = capabilities.filter((capability) => can('admin', capability))
- expect(adminCapabilities).toEqual(['accessFiles', 'addFiles', 'removeFiles', 'manageInvites'])
+ it('grants members the non-administrative capabilities only', () => {
+ const memberCapabilities = capabilities.filter((capability) => can('member', capability))
+ expect(memberCapabilities).toEqual(['accessFiles', 'addFiles', 'removeFiles', 'manageInvites'])
})
- it('denies admins the administrative capabilities', () => {
- expect(can('admin', 'editGroup')).toBe(false)
- expect(can('admin', 'editMembers')).toBe(false)
- expect(can('admin', 'deleteGroup')).toBe(false)
+ it('denies members the administrative capabilities', () => {
+ expect(can('member', 'editGroup')).toBe(false)
+ expect(can('member', 'editMembers')).toBe(false)
+ expect(can('member', 'deleteGroup')).toBe(false)
})
- it("admins' capabilities are a subset of owners'", () => {
+ it("members' capabilities are a subset of owners'", () => {
for (const capability of capabilities) {
- if (can('admin', capability)) {
+ if (can('member', capability)) {
expect(can('owner', capability)).toBe(true)
}
}
})
it('denies unknown, empty, or missing roles every capability', () => {
- // `member` is included deliberately: the rename from `admin` is still
- // pending, so it is not yet a valid role.
- const notRoles = [null, undefined, '', 'member', 'nope', 'OWNER', 'toString', '__proto__']
+ // `admin` is included deliberately: it was renamed to `member`, so it is no
+ // longer a valid role.
+ const notRoles = [null, undefined, '', 'admin', 'nope', 'OWNER', 'toString', '__proto__']
for (const role of notRoles) {
for (const capability of capabilities) {
expect(can(role, capability)).toBe(false)
@@ -58,11 +58,11 @@ describe('can', () => {
describe('isRole', () => {
it('accepts known role strings', () => {
expect(isRole('owner')).toBe(true)
- expect(isRole('admin')).toBe(true)
+ expect(isRole('member')).toBe(true)
})
it('rejects unknown, empty, or missing values', () => {
- for (const value of [null, undefined, '', 'member', 'nope', 'toString', '__proto__']) {
+ for (const value of [null, undefined, '', 'admin', 'nope', 'toString', '__proto__']) {
expect(isRole(value)).toBe(false)
}
})
diff --git a/packages/dotcom-shared/src/roles.ts b/packages/dotcom-shared/src/roles.ts
index 27944d1d4251..81a13877a8db 100644
--- a/packages/dotcom-shared/src/roles.ts
+++ b/packages/dotcom-shared/src/roles.ts
@@ -9,22 +9,16 @@ import { Capability } from './capabilities'
*
* The role is stored in the DB as a plain string (`group_user.role`);
* capabilities are never persisted — they're derived from that string here.
- *
- * NOTE: `'admin'` is in the process of being renamed to `'member'`. No
- * authorization logic branches on that name — the only role literals left in
- * logic are the last-owner invariant, which checks `'owner'` (not renamed). So
- * the rename is relabeling this key, the `acceptInvite` default, the display
- * labels, and a data migration of stored values.
*/
/**
* What each role can do — the single source of truth. The role name is the key,
* and {@link Role} is derived from these keys. Today the only difference between
- * `admin` and `owner` is the three administrative capabilities at the end of the
+ * `member` and `owner` is the three administrative capabilities at the end of the
* owner list.
*/
const roles = {
- admin: ['accessFiles', 'addFiles', 'removeFiles', 'manageInvites'],
+ member: ['accessFiles', 'addFiles', 'removeFiles', 'manageInvites'],
owner: [
'accessFiles',
'addFiles',
diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts
index e6afd9bdbb8a..cf596aa5406c 100644
--- a/packages/editor/src/lib/editor/Editor.ts
+++ b/packages/editor/src/lib/editor/Editor.ts
@@ -4029,16 +4029,34 @@ export class Editor extends EventEmitter {
this.once('stop-camera-animation', cancel)
+ const dirZ = direction.z ?? 0
+
const moveCamera = (elapsed: number) => {
const { x: cx, y: cy, z: cz } = this.getCamera()
- const movementVec = Vec.Mul(direction, (currentSpeed * elapsed) / cz)
+
+ // Pan movement from x/y direction
+ const dx = (direction.x * (currentSpeed * elapsed)) / cz
+ const dy = (direction.y * (currentSpeed * elapsed)) / cz
+
+ let newCx = cx + dx
+ let newCy = cy + dy
+ let newCz = cz
+
+ // animate zoom if z direction is passed in
+ if (dirZ !== 0) {
+ newCz = cz * (1 + dirZ * currentSpeed * elapsed)
+ // Adjust x/y to keep the viewport center fixed while zooming
+ const center = this.getViewportScreenCenter()
+ newCx += center.x / newCz - center.x / cz
+ newCy += center.y / newCz - center.y / cz
+ }
// Apply friction
currentSpeed *= 1 - friction
if (currentSpeed < speedThreshold) {
cancel()
} else {
- this._setCamera(new Vec(cx + movementVec.x, cy + movementVec.y, cz))
+ this._setCamera(new Vec(newCx, newCy, newCz))
}
}
@@ -11332,7 +11350,10 @@ export class Editor extends EventEmitter {
}
if (slideSpeed > 0) {
- this.slideCamera({ speed: slideSpeed, direction: slideDirection })
+ this.slideCamera({
+ speed: slideSpeed,
+ direction: { x: slideDirection.x, y: slideDirection.y, z: 0 },
+ })
}
} else {
if (info.button === STYLUS_ERASER_BUTTON) {
diff --git a/packages/editor/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.test.ts b/packages/editor/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.test.ts
index 1a35053521f5..cb703d29f36c 100644
--- a/packages/editor/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.test.ts
+++ b/packages/editor/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.test.ts
@@ -129,4 +129,18 @@ describe(CollaboratorsManager, () => {
expect(manager.getVisibleCollaborators()).toHaveLength(1)
})
+
+ it('shows newly-joined collaborators that have not recorded any activity yet', () => {
+ // A peer who has joined but not moved their pointer broadcasts the default
+ // `lastActivityTimestamp` of 0. They should still be treated as active so
+ // they appear in the people menu / face pile. See issue #9017.
+ const zero = createPresence('zero')
+ zero.lastActivityTimestamp = 0
+ const nullish = createPresence('nullish')
+ nullish.lastActivityTimestamp = null
+ const { editor } = createEditor([zero, nullish])
+ const manager = new CollaboratorsManager(editor)
+
+ expect(manager.getVisibleCollaborators()).toHaveLength(2)
+ })
})
diff --git a/packages/editor/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts b/packages/editor/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts
index d70b023ec732..8ed2331cefa2 100644
--- a/packages/editor/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts
+++ b/packages/editor/src/lib/editor/managers/CollaboratorsManager/CollaboratorsManager.ts
@@ -93,9 +93,12 @@ export class CollaboratorsManager {
return collaborators.filter((presence) => {
const { lastActivityTimestamp, userId, chatMessage } = presence
- // Treat a missing `lastActivityTimestamp` as "active right now" (elapsed = 0)
- // so newly-joined peers aren't immediately classified as idle/inactive.
- const elapsed = Math.max(0, now - (lastActivityTimestamp ?? now))
+ // Treat a missing or zero `lastActivityTimestamp` as "active right now"
+ // (elapsed = 0) so newly-joined peers aren't immediately classified as
+ // idle/inactive. The broadcast default for peers who haven't moved their
+ // pointer yet is `0` (e.g. someone on a touch device who joins and just
+ // watches), so a plain `?? now` would leave them hidden. See issue #9017.
+ const elapsed = lastActivityTimestamp ? Math.max(0, now - lastActivityTimestamp) : 0
if (elapsed > collaboratorInactiveTimeoutMs) {
// Inactive: If they're inactive, only show if we're following them or they're highlighted
diff --git a/packages/tldraw/src/lib/tools/HandTool/HandTool.ts b/packages/tldraw/src/lib/tools/HandTool/HandTool.ts
index 6e3e20063303..366808cd29e9 100644
--- a/packages/tldraw/src/lib/tools/HandTool/HandTool.ts
+++ b/packages/tldraw/src/lib/tools/HandTool/HandTool.ts
@@ -1,6 +1,7 @@
import { EASINGS, StateNode, TLClickEventInfo, TLStateNodeConstructor } from '@tldraw/editor'
import { Dragging } from './childStates/Dragging'
import { Idle } from './childStates/Idle'
+import { OneFingerZooming } from './childStates/OneFingerZooming'
import { Pointing } from './childStates/Pointing'
/** @public */
@@ -9,15 +10,27 @@ export class HandTool extends StateNode {
static override initial = 'idle'
static override isLockable = false
static override children(): TLStateNodeConstructor[] {
- return [Idle, Pointing, Dragging]
+ return [Idle, Pointing, Dragging, OneFingerZooming]
}
override onDoubleClick(info: TLClickEventInfo) {
- if (info.phase === 'settle-up') {
- const currentScreenPoint = this.editor.inputs.getCurrentScreenPoint()
- this.editor.zoomIn(currentScreenPoint, {
- animation: { duration: 220, easing: EASINGS.easeOutQuint },
- })
+ switch (info.phase) {
+ case 'settle-down': {
+ // A double-tap whose second press is still held down: begin one-finger
+ // drag-to-zoom. This is a touch gesture, so only enter it on a coarse pointer.
+ if (this.editor.getInstanceState().isCoarsePointer) {
+ this.transition('one_finger_zooming', info)
+ }
+ break
+ }
+ case 'settle-up': {
+ // A double-tap whose second press was released: zoom in by one step.
+ const currentScreenPoint = this.editor.inputs.getCurrentScreenPoint()
+ this.editor.zoomIn(currentScreenPoint, {
+ animation: { duration: 220, easing: EASINGS.easeOutQuint },
+ })
+ break
+ }
}
}
}
diff --git a/packages/tldraw/src/lib/tools/HandTool/childStates/Dragging.ts b/packages/tldraw/src/lib/tools/HandTool/childStates/Dragging.ts
index af2859d09919..1f78556a755f 100644
--- a/packages/tldraw/src/lib/tools/HandTool/childStates/Dragging.ts
+++ b/packages/tldraw/src/lib/tools/HandTool/childStates/Dragging.ts
@@ -44,7 +44,10 @@ export class Dragging extends StateNode {
const velocityAtPointerUp = Math.min(pointerVelocity.len(), 2)
if (velocityAtPointerUp > 0.1) {
- this.editor.slideCamera({ speed: velocityAtPointerUp, direction: pointerVelocity })
+ this.editor.slideCamera({
+ speed: velocityAtPointerUp,
+ direction: { x: pointerVelocity.x, y: pointerVelocity.y, z: 0 },
+ })
}
this.parent.transition('idle')
diff --git a/packages/tldraw/src/lib/tools/HandTool/childStates/OneFingerZooming.ts b/packages/tldraw/src/lib/tools/HandTool/childStates/OneFingerZooming.ts
new file mode 100644
index 000000000000..393d7af22fa4
--- /dev/null
+++ b/packages/tldraw/src/lib/tools/HandTool/childStates/OneFingerZooming.ts
@@ -0,0 +1,79 @@
+import { StateNode, TLPointerEventInfo, Vec, clamp, last } from '@tldraw/editor'
+
+export class OneFingerZooming extends StateNode {
+ static override id = 'one_finger_zooming'
+
+ private anchorScreenPoint = new Vec()
+ private initialCamera = new Vec()
+ private initialZoom = 1
+ private originScreenY = 0
+
+ override onEnter(_info: TLPointerEventInfo) {
+ const camera = this.editor.getCamera()
+ this.initialCamera = Vec.From(camera)
+ this.initialZoom = camera.z
+ this.anchorScreenPoint = this.editor.inputs.getOriginScreenPoint().clone()
+ this.originScreenY = this.editor.inputs.getCurrentScreenPoint().y
+ this.editor.setCursor({ type: 'grab', rotation: 0 })
+ }
+
+ override onPointerMove(_info: TLPointerEventInfo) {
+ this.editor.menus.clearOpenMenus()
+
+ const currentScreenY = this.editor.inputs.getCurrentScreenPoint().y
+
+ // Dragging up = zoom out, dragging down = zoom in (same as google maps default)
+ // we won't respect the user's zoom direction preference because it only applies
+ // to mouse input
+ const dy = (this.originScreenY - currentScreenY) * -1
+
+ // ~200px of drag ≈ 2x zoom change.
+ const zoomFactor = Math.pow(2, dy / 200)
+ const newZoom = this.clampZoom(this.initialZoom * zoomFactor)
+
+ const { x: cx, y: cy } = this.initialCamera
+ const { x: sx, y: sy } = this.anchorScreenPoint
+ this.editor.setCamera(
+ new Vec(
+ cx + sx / newZoom - sx / this.initialZoom,
+ cy + sy / newZoom - sy / this.initialZoom,
+ newZoom
+ ),
+ { immediate: true }
+ )
+ }
+
+ override onPointerUp(_info: TLPointerEventInfo) {
+ this.complete()
+ }
+
+ override onCancel() {
+ this.parent.transition('idle')
+ }
+
+ override onInterrupt() {
+ this.parent.transition('idle')
+ }
+
+ private complete() {
+ const pointerVelocity = this.editor.inputs.getPointerVelocity()
+ const velocityAtPointerUp = Math.min(pointerVelocity.len(), 2)
+
+ if (velocityAtPointerUp > 0.1) {
+ // Use velocity y-sign for momentum direction (positive y = moving down = zoom in)
+ this.editor.slideCamera({
+ speed: velocityAtPointerUp,
+ direction: { x: 0, y: 0, z: Math.sign(pointerVelocity.y) * 0.01 },
+ })
+ }
+ this.parent.transition('idle')
+ }
+
+ private clampZoom(zoom: number): number {
+ const { zoomSteps } = this.editor.getCameraOptions()
+ const baseZoom = this.editor.getBaseZoom()
+ const zoomMin = zoomSteps[0] * baseZoom
+ const zoomMax = last(zoomSteps)! * baseZoom
+ return clamp(zoom, zoomMin, zoomMax)
+ }
+}
diff --git a/packages/tldraw/src/lib/ui/hooks/useKeyboardShortcuts.ts b/packages/tldraw/src/lib/ui/hooks/useKeyboardShortcuts.ts
index 41d2b48ae8fb..c31a72db04b5 100644
--- a/packages/tldraw/src/lib/ui/hooks/useKeyboardShortcuts.ts
+++ b/packages/tldraw/src/lib/ui/hooks/useKeyboardShortcuts.ts
@@ -141,12 +141,25 @@ export function useKeyboardShortcuts() {
const body = editor.getContainerDocument().body
+ // Track which registration each physically-held key first triggered, keyed by
+ // `event.code`. While a key is held down, releasing a modifier should not let the
+ // auto-repeat keydown events trigger an adjacent shortcut (e.g. releasing shift while
+ // still holding shift+q shouldn't start firing the plain `q` shortcut). The same
+ // registration is still allowed to repeat (e.g. holding `=` to keep zooming).
+ const heldKeyRegistrations = new Map()
+
const handleKeyDown = (e: KeyboardEvent) => {
if (shouldSkipEvent(e)) return
+ const code = e.code
for (const reg of registry) {
if (!reg.onKeyDown) continue
for (const p of reg.parsed) {
if (matchesEvent(e, p)) {
+ const prev = code ? heldKeyRegistrations.get(code) : undefined
+ // The held key already triggered a different shortcut; don't fall back to
+ // this one (or anything else) just because a modifier was released.
+ if (prev && prev !== reg) return
+ if (code) heldKeyRegistrations.set(code, reg)
reg.onKeyDown(e)
break
}
@@ -155,6 +168,10 @@ export function useKeyboardShortcuts() {
}
const handleKeyUp = (e: KeyboardEvent) => {
+ // Always release the held-key tracking, even for events we'd otherwise skip (e.g. the
+ // key was released after focus moved into a text input), so a stale entry can't block
+ // later shortcuts.
+ if (e.code) heldKeyRegistrations.delete(e.code)
if (shouldSkipEvent(e)) return
for (const reg of registry) {
if (!reg.onKeyUp) continue
diff --git a/packages/tldraw/src/test/HandTool.test.ts b/packages/tldraw/src/test/HandTool.test.ts
index 57d53152dd93..daca2888ffc2 100644
--- a/packages/tldraw/src/test/HandTool.test.ts
+++ b/packages/tldraw/src/test/HandTool.test.ts
@@ -1,3 +1,4 @@
+import { Vec } from '@tldraw/editor'
import { vi } from 'vitest'
import { HandTool } from '../lib/tools/HandTool/HandTool'
import { TestEditor } from './TestEditor'
@@ -18,6 +19,43 @@ afterEach(() => {
vi.useFakeTimers()
describe(HandTool, () => {
+ it('Uses one-finger zoom when coarse pointer double-taps, holds, and drags', () => {
+ editor.setCurrentTool('hand')
+ editor.updateInstanceState({ isCoarsePointer: true })
+
+ // Double-tap, holding the second press down.
+ editor.pointerDown(0, 0).pointerUp(0, 0)
+ editor.pointerDown(0, 0)
+ // On the second press we're still just pointing — the zoom hasn't started yet.
+ editor.expectToBeIn('hand.pointing')
+
+ // One-finger zoom begins when the multi-click timer settles with the pointer
+ // still held down (the `settle-down` phase).
+ vi.advanceTimersByTime(editor.options.multiClickDurationMs)
+ editor.expectToBeIn('hand.one_finger_zooming')
+
+ const before = editor.getCamera().z
+ editor.pointerMove(0, 100).pointerUp(0, 100).forceTick()
+ expect(editor.getCamera().z).toBeGreaterThan(before)
+ })
+
+ it('Zooms in (not one-finger zoom) when a coarse pointer double-taps and releases', () => {
+ editor.setCurrentTool('hand')
+ editor.updateInstanceState({ isCoarsePointer: true })
+
+ // Double-tap and release the second press before the timer settles.
+ editor.pointerDown(0, 0).pointerUp(0, 0)
+ editor.pointerDown(0, 0).pointerUp(0, 0)
+ // Releasing means we never enter the one-finger zoom drag state.
+ editor.expectToBeIn('hand.idle')
+
+ // The `settle-up` phase zooms in by one step.
+ const before = editor.getZoomLevel()
+ vi.advanceTimersByTime(editor.options.multiClickDurationMs)
+ vi.advanceTimersByTime(600) // let the zoom animation finish
+ expect(editor.getZoomLevel()).toBeGreaterThan(before)
+ })
+
it('Double taps to zoom in', () => {
editor.setCurrentTool('hand')
expect(editor.getZoomLevel()).toBe(1)
@@ -129,6 +167,16 @@ describe('When in the dragging state', () => {
editor.pointerUp()
})
+ it('Does not zoom when momentum panning on release', () => {
+ editor.setCurrentTool('hand')
+ editor.pointerDown(0, 0)
+ editor.pointerMove(50, 50)
+ editor.inputs.setPointerVelocity(new Vec(1, 1))
+ editor.pointerUp().forceTick()
+
+ expect(editor.getCamera().z).toBe(1)
+ })
+
// it('Moves the camera with inertia on pointer up', () => {
// Can't test this—x is set to Inifnity in tests
// editor.setCurrentTool('hand')
diff --git a/packages/tldraw/src/test/commands/setCamera.test.ts b/packages/tldraw/src/test/commands/setCamera.test.ts
index 281765835532..3abb52907abe 100644
--- a/packages/tldraw/src/test/commands/setCamera.test.ts
+++ b/packages/tldraw/src/test/commands/setCamera.test.ts
@@ -428,6 +428,18 @@ describe('CameraOptions.panSpeed', () => {
expect(editor.getCamera()).toMatchObject({ x: 5, y: 10, z: 1 })
})
+ it('Does not zoom when spacebar panning momentum is applied on release', () => {
+ editor
+ .dispatch({ ...keyBoardEvent, key: ' ', code: 'Space' })
+ .pointerDown(0, 0)
+ .pointerMove(50, 50)
+
+ editor.inputs.setPointerVelocity(new Vec(1, 1))
+ editor.pointerUp().forceTick()
+
+ expect(editor.getCamera().z).toBe(1)
+ })
+
it('Does not affect edge scroll panning', () => {
const shapeId = createShapeId()
const viewportScreenBounds = editor.getViewportScreenBounds()
diff --git a/packages/tldraw/src/test/ui/keyboardShortcuts.test.tsx b/packages/tldraw/src/test/ui/keyboardShortcuts.test.tsx
index f55e85d097cd..e08e5cbb00d1 100644
--- a/packages/tldraw/src/test/ui/keyboardShortcuts.test.tsx
+++ b/packages/tldraw/src/test/ui/keyboardShortcuts.test.tsx
@@ -1,3 +1,5 @@
+import { act } from '@testing-library/react'
+import { Editor } from '@tldraw/editor'
import { useEffect } from 'react'
import { Tldraw } from '../../lib/Tldraw'
import { useActions } from '../../lib/ui/context/actions'
@@ -7,7 +9,10 @@ import {
parseKbd,
} from '../../lib/ui/hooks/useKeyboardShortcuts'
import { useTools } from '../../lib/ui/hooks/useTools'
-import { renderTldrawComponent } from '../testutils/renderTldrawComponent'
+import {
+ renderTldrawComponent,
+ renderTldrawComponentWithEditor,
+} from '../testutils/renderTldrawComponent'
// These kbds are intentionally not registered in the shortcut registry, they're handled by
// useNativeClipboardEvents / the upload asset action instead. See SKIP_KBDS in useKeyboardShortcuts.
@@ -85,3 +90,93 @@ describe('default keyboard shortcuts', () => {
expect(collisions).toEqual([])
})
})
+
+async function setupFocusedEditor() {
+ const { editor } = await renderTldrawComponentWithEditor(
+ (onMount) => ,
+ { waitForPatterns: false }
+ )
+
+ // Shortcuts only register while the editor is focused.
+ act(() => {
+ editor.updateInstanceState({ isFocused: true })
+ })
+
+ return { editor }
+}
+
+function keydown(editor: Editor, init: KeyboardEventInit) {
+ act(() => {
+ editor
+ .getContainerDocument()
+ .body.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ...init }))
+ })
+}
+
+function keyup(editor: Editor, init: KeyboardEventInit) {
+ act(() => {
+ editor
+ .getContainerDocument()
+ .body.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, ...init }))
+ })
+}
+
+describe('keyboard shortcuts with a held key', () => {
+ it('fires the plain shortcut on a fresh key press', async () => {
+ const { editor } = await setupFocusedEditor()
+ expect(editor.getInstanceState().isToolLocked).toBe(false)
+
+ // Plain `q` toggles tool lock.
+ keydown(editor, { key: 'q', code: 'KeyQ' })
+ expect(editor.getInstanceState().isToolLocked).toBe(true)
+ })
+
+ it('does not fall back to the plain shortcut when a modifier is released mid-hold', async () => {
+ const { editor } = await setupFocusedEditor()
+ expect(editor.getInstanceState().isToolLocked).toBe(false)
+
+ // Press shift+q (copy-hovered-styles), which does not toggle tool lock.
+ keydown(editor, { key: 'q', code: 'KeyQ', shiftKey: true })
+ expect(editor.getInstanceState().isToolLocked).toBe(false)
+
+ // Release shift but keep holding q. The auto-repeat keydown events should not start
+ // firing the adjacent plain `q` shortcut (toggle tool lock).
+ keydown(editor, { key: 'q', code: 'KeyQ', shiftKey: false, repeat: true })
+ keydown(editor, { key: 'q', code: 'KeyQ', shiftKey: false, repeat: true })
+ expect(editor.getInstanceState().isToolLocked).toBe(false)
+ })
+
+ it('fires the plain shortcut again after the held key is released and pressed fresh', async () => {
+ const { editor } = await setupFocusedEditor()
+
+ keydown(editor, { key: 'q', code: 'KeyQ', shiftKey: true })
+ keydown(editor, { key: 'q', code: 'KeyQ', shiftKey: false, repeat: true })
+ expect(editor.getInstanceState().isToolLocked).toBe(false)
+
+ // Releasing the physical key clears the held-key tracking, so a fresh press works again.
+ keyup(editor, { key: 'q', code: 'KeyQ' })
+ keydown(editor, { key: 'q', code: 'KeyQ' })
+ expect(editor.getInstanceState().isToolLocked).toBe(true)
+ })
+
+ it('releases the held key even when the keyup lands on a text input', async () => {
+ const { editor } = await setupFocusedEditor()
+
+ keydown(editor, { key: 'q', code: 'KeyQ', shiftKey: true })
+ expect(editor.getInstanceState().isToolLocked).toBe(false)
+
+ // The key is released while focus is inside a text input, so the keyup is otherwise
+ // skipped. It must still clear the held-key tracking.
+ const body = editor.getContainerDocument().body
+ const input = editor.getContainerDocument().createElement('input')
+ body.appendChild(input)
+ act(() => {
+ input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'q', code: 'KeyQ' }))
+ })
+ body.removeChild(input)
+
+ // A fresh plain `q` press now works rather than being blocked by a stale entry.
+ keydown(editor, { key: 'q', code: 'KeyQ' })
+ expect(editor.getInstanceState().isToolLocked).toBe(true)
+ })
+})