Skip to content

Commit 1ed9660

Browse files
feat(editor): add selectLockedShapes option (tldraw#8860)
Adds a new `selectLockedShapes` option to `TldrawOptions` that, when enabled, lets users select locked shapes with a left click (and via brush or scribble selection). The editor's existing lock guards still prevent moving, resizing, editing, or deleting locked shapes, so the option enables inspection without unlocking. When `selectLockedShapes: true`: - `getHitShapeOnCanvasPointerDown` passes `hitLocked: true` - `Idle.onPointerDown` no longer routes left clicks on locked shapes to `pointing_canvas` - `Brushing` and `ScribbleBrushing` include locked shapes - Hover detection (`updateHoveredShapeId`) treats locked shapes as hoverable - `selectOnCanvasPointerUp` allows locked shapes through When `selectLockedShapes` is `false` (default), behavior is unchanged. Closes tldraw#8548 ### Change type - [ ] `bugfix` - [ ] `improvement` - [x] `feature` - [ ] `api` - [ ] `other` ### Test plan 1. Set `<Tldraw options={{ selectLockedShapes: true }} />` in an example app. 2. Create a shape, lock it, and click it — it should become selected and show selection bounds. 3. Try to drag or resize it — nothing should move (lock still applies). 4. Brush over the locked shape with the select tool — it should be included in the brush selection. 5. Alt-drag (scribble select) over the locked shape — it should also be included. 6. With the option set to `false` (default), confirm left-clicking a locked shape still falls through to the canvas, matching the previous behavior. - [x] Unit tests - [ ] End to end tests ### API changes - Add a new `selectLockedShapes` option to `TldrawOptions` (and to `defaultTldrawOptions`). When `false` (default), left-clicking a locked shape is treated as a click on the canvas; only right-click selects it. When `true`, locked shapes can be selected via left-click and included in brush and scribble selections, but the editor's lock guards still prevent moving, resizing, editing, or deleting them. ### Release notes - Added a `selectLockedShapes` option to `TldrawOptions`. When enabled, locked shapes can be selected with left click and brush/scribble selection while remaining protected from edits, moves, and deletes. --------- Co-authored-by: angrycaptain19 <53473467+angrycaptain19@users.noreply.github.com>
1 parent d49babc commit 1ed9660

10 files changed

Lines changed: 154 additions & 25 deletions

File tree

apps/examples/src/examples/editor-api/locked-shapes/LockedShapesExample.tsx

Lines changed: 65 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useState } from 'react'
12
import { createShapeId, Tldraw, TldrawUiButton, TLShapeId, toRichText, useEditor } from 'tldraw'
23
import 'tldraw/tldraw.css'
34

@@ -13,7 +14,20 @@ const TEMPLATE_IDS: TLShapeId[] = [
1314
function ControlPanel() {
1415
const editor = useEditor()
1516

16-
// [3] Update locked shapes using ignoreShapeLock option
17+
// [3] Local state mirrors editor.options.selectLockedShapes for the UI.
18+
// The option is readonly at the type level but the editor stores a copy
19+
// internally that the SelectTool reads live on every interaction — so
20+
// mutating the underlying field flips behaviour immediately, without a
21+
// remount.
22+
const [selectLocked, setSelectLocked] = useState(editor.options.selectLockedShapes)
23+
24+
const toggleSelectLocked = () => {
25+
const next = !selectLocked
26+
;(editor.options as { selectLockedShapes: boolean }).selectLockedShapes = next
27+
setSelectLocked(next)
28+
}
29+
30+
// [4] Update locked shapes using ignoreShapeLock option
1731
// Without ignoreShapeLock: true, these updates would be blocked
1832
const handleScatter = () => {
1933
editor.run(
@@ -46,7 +60,21 @@ function ControlPanel() {
4660
}
4761

4862
return (
49-
<div className="tlui-menu">
63+
<div className="tlui-menu" style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
64+
<label
65+
style={{
66+
display: 'flex',
67+
alignItems: 'center',
68+
gap: 6,
69+
fontSize: 13,
70+
cursor: 'pointer',
71+
userSelect: 'none',
72+
}}
73+
title="When on, left-click and brush selection include locked shapes."
74+
>
75+
<input type="checkbox" checked={selectLocked} onChange={toggleSelectLocked} />
76+
Allow selecting locked shapes
77+
</label>
5078
<TldrawUiButton type="normal" onClick={handleScatter}>
5179
Scatter
5280
</TldrawUiButton>
@@ -61,7 +89,7 @@ const components = {
6189
TopPanel: ControlPanel,
6290
}
6391

64-
// [4]
92+
// [5]
6593
export default function LockedShapesExample() {
6694
return (
6795
<div className="tldraw__editor">
@@ -74,7 +102,7 @@ export default function LockedShapesExample() {
74102
return
75103
}
76104

77-
// [5] Create locked template shapes
105+
// [6] Create locked template shapes
78106
const shapeProps = {
79107
geo: 'rectangle' as const,
80108
w: 130,
@@ -92,7 +120,7 @@ export default function LockedShapesExample() {
92120
{ id: TEMPLATE_IDS[3], type: 'geo', x: 250, y: 250, props: shapeProps },
93121
])
94122

95-
// [6] Lock them immediately
123+
// [7] Lock them immediately
96124
editor.toggleLock(TEMPLATE_IDS)
97125
editor.zoomToFit({ animation: { duration: 0 } })
98126
}}
@@ -102,27 +130,46 @@ export default function LockedShapesExample() {
102130
}
103131

104132
/*
105-
This example demonstrates the key distinction between locked shapes and programmatic updates:
133+
This example demonstrates two ways the editor distinguishes user interaction
134+
from programmatic mutation on locked shapes:
106135
107-
Locked shapes prevent ALL user interaction (dragging, deleting, etc.), but programs can still
108-
modify them using the ignoreShapeLock option. This is useful for shapes that should be fixed
109-
in place by the user but need to be repositioned programmatically.
136+
1. `editor.run(fn, { ignoreShapeLock: true })` bypasses the lock guard for
137+
the duration of the callback, so the Scatter / Reset buttons can move
138+
shapes the user can't drag.
139+
2. `editor.options.selectLockedShapes` controls whether locked shapes can be
140+
*selected* (via left-click, brush select, scribble select). The lock
141+
guards on moving, resizing, editing, and deleting still apply — selection
142+
is the only thing this option unlocks.
110143
111144
[1] Pre-defined shape IDs so we can reference them later.
112145
113-
[2] Control panel with action buttons.
146+
[2] Control panel with the new toggle plus the existing action buttons.
147+
148+
[3] Local React state mirrors the live editor option. The option is
149+
`readonly` at the type level (it's intended as initial editor config) but
150+
the editor stores a single mutable copy that the SelectTool reads on every
151+
relevant pointer event. Mutating the field changes behaviour immediately
152+
without remounting. The cast through `{ selectLockedShapes: boolean }`
153+
isolates the type relaxation to one line.
114154
115-
[3] Both buttons use editor.run() with { ignoreShapeLock: true } to bypass the lock constraint.
116-
This option allows programmatic updates even though user interactions on these shapes are blocked.
155+
[4] Both buttons use `editor.run()` with `{ ignoreShapeLock: true }` to
156+
bypass the lock constraint. This option allows programmatic updates even
157+
though user interactions on these shapes are blocked.
117158
118-
[4] The main component sets up the editor.
159+
[5] The main component sets up the editor.
119160
120-
[5] On mount, we create a 2x2 grid of template shapes.
161+
[6] On mount, we create a 2x2 grid of template shapes.
121162
122-
[6] We immediately lock them with toggleLock(). The key behavior: users cannot move or delete
123-
these shapes, but the Scatter/Reset buttons can still reposition them programmatically.
163+
[7] We immediately lock them with `toggleLock()`. The key behavior: users
164+
cannot move or delete these shapes, but the Scatter / Reset buttons can
165+
still reposition them programmatically.
124166
125167
Try it:
126-
- Try dragging any template shape (won't work - they're locked by the user interface)
127-
- Click Scatter or Reset to see how programmatic updates work with ignoreShapeLock: true
168+
- Default: try left-clicking a template shape — nothing happens (locked
169+
shapes aren't selectable). Right-click still selects.
170+
- Flip the "Allow selecting locked shapes" toggle on, then left-click or
171+
brush-select across a template shape — it gets selected. Try to drag it
172+
by the handles — the lock guard still prevents the move.
173+
- Click Scatter or Reset to see how programmatic updates work with
174+
`ignoreShapeLock: true` regardless of the toggle.
128175
*/

packages/editor/api-report.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,7 @@ export const defaultTldrawOptions: {
779779
readonly onClipboardPasteRaw: undefined;
780780
readonly quickZoomPreservesScreenBounds: true;
781781
readonly rightClickPanning: true;
782+
readonly selectLockedShapes: false;
782783
readonly snapThreshold: 8;
783784
readonly spacebarPanning: true;
784785
readonly temporaryAssetPreviewLifetimeMs: 180000;
@@ -3841,6 +3842,7 @@ export interface TldrawOptions {
38413842
onClipboardPasteRaw?(info: TLClipboardPasteRawInfo): false | void;
38423843
readonly quickZoomPreservesScreenBounds: boolean;
38433844
readonly rightClickPanning: boolean;
3845+
readonly selectLockedShapes: boolean;
38443846
readonly snapThreshold: number;
38453847
readonly spacebarPanning: boolean;
38463848
readonly temporaryAssetPreviewLifetimeMs: number;

packages/editor/src/lib/options.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,13 @@ export interface TldrawOptions {
171171
* The distance (in screen pixels) at which shapes snap to guides and other shapes.
172172
*/
173173
readonly snapThreshold: number
174+
/**
175+
* Whether locked shapes can be selected with a left click. When false (default), left-clicking
176+
* a locked shape is treated as a click on the canvas — only right-click selects it. When true,
177+
* locked shapes can be selected via left click and included in brush and scribble selections,
178+
* but the editor's lock guards still prevent moving, resizing, editing, or deleting them.
179+
*/
180+
readonly selectLockedShapes: boolean
174181
/**
175182
* Options for the editor's camera. These are the initial camera options.
176183
* Use {@link Editor.setCameraOptions} to update camera options at runtime.
@@ -335,6 +342,7 @@ export const defaultTldrawOptions = {
335342
rightClickPanning: true,
336343
zoomToFitPadding: 128,
337344
snapThreshold: 8,
345+
selectLockedShapes: false,
338346
camera: DEFAULT_CAMERA_OPTIONS,
339347
text: {},
340348
deepLinks: undefined,

packages/tldraw/src/lib/tools/SelectTool/childStates/Brushing.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,14 @@ export class Brushing extends StateNode {
5252
return
5353
}
5454

55+
const selectLockedShapes = editor.options.selectLockedShapes
5556
this.excludedShapeIds = new Set(
5657
editor
5758
.getCurrentPageShapes()
5859
.filter(
59-
(shape) => editor.isShapeOfType(shape, 'group') || editor.isShapeOrAncestorLocked(shape)
60+
(shape) =>
61+
editor.isShapeOfType(shape, 'group') ||
62+
(!selectLockedShapes && editor.isShapeOrAncestorLocked(shape))
6063
)
6164
.map((shape) => shape.id)
6265
)

packages/tldraw/src/lib/tools/SelectTool/childStates/Idle.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export class Idle extends StateNode {
8787
// Check to see if we hit any shape under the pointer; if so,
8888
// handle this as a pointer down on the shape instead of the canvas
8989
const hitShape = getHitShapeOnCanvasPointerDown(this.editor)
90-
if (hitShape && !hitShape.isLocked) {
90+
if (hitShape && (this.editor.options.selectLockedShapes || !hitShape.isLocked)) {
9191
this.onPointerDown({
9292
...info,
9393
shape: hitShape,
@@ -184,7 +184,7 @@ export class Idle extends StateNode {
184184
case 'shape': {
185185
const { shape } = info
186186

187-
if (this.editor.isShapeOrAncestorLocked(shape)) {
187+
if (!this.editor.options.selectLockedShapes && this.editor.isShapeOrAncestorLocked(shape)) {
188188
this.parent.transition('pointing_canvas', info)
189189
break
190190
}
@@ -242,7 +242,7 @@ export class Idle extends StateNode {
242242
if (
243243
hoveredShape &&
244244
!this.editor.getSelectedShapeIds().includes(hoveredShape.id) &&
245-
!hoveredShape.isLocked
245+
(this.editor.options.selectLockedShapes || !hoveredShape.isLocked)
246246
) {
247247
this.onPointerDown({
248248
...info,

packages/tldraw/src/lib/tools/SelectTool/childStates/ScribbleBrushing.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,12 @@ export class ScribbleBrushing extends StateNode {
123123
for (let i = 0, n = shapes.length; i < n; i++) {
124124
shape = shapes[i]
125125

126-
// If the shape is a group or is already selected or locked, don't select it
126+
// If the shape is a group or is already selected, don't select it.
127+
// Also skip locked shapes unless the selectLockedShapes option is enabled.
127128
if (
128129
editor.isShapeOfType(shape, 'group') ||
129130
newlySelectedShapeIds.has(shape.id) ||
130-
editor.isShapeOrAncestorLocked(shape)
131+
(!editor.options.selectLockedShapes && editor.isShapeOrAncestorLocked(shape))
131132
) {
132133
continue
133134
}

packages/tldraw/src/lib/tools/selection-logic/getHitShapeOnCanvasPointerDown.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export function getHitShapeOnCanvasPointerDown(
1313
editor.getShapeAtPoint(currentPagePoint, {
1414
hitInside: false,
1515
hitLabels,
16+
hitLocked: editor.options.selectLockedShapes,
1617
margin: editor.options.hitTestMargin / zoomLevel,
1718
renderingOnly: true,
1819
}) ??

packages/tldraw/src/lib/tools/selection-logic/selectOnCanvasPointerUp.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ export function selectOnCanvasPointerUp(
99
const { shiftKey, altKey, accelKey } = info
1010
const additiveSelectionKey = shiftKey || accelKey
1111

12+
const selectLockedShapes = editor.options.selectLockedShapes
1213
const hitShape = editor.getShapeAtPoint(currentPagePoint, {
1314
hitInside: false,
1415
margin: editor.options.hitTestMargin / editor.getZoomLevel(),
1516
hitLabels: true,
17+
hitLocked: selectLockedShapes,
1618
renderingOnly: true,
17-
filter: (shape) => !shape.isLocked,
19+
filter: (shape) => selectLockedShapes || !shape.isLocked,
1820
})
1921

2022
// Note at the start: if we select a shape that is inside of a group,

packages/tldraw/src/lib/tools/selection-logic/updateHoveredShapeId.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ function getShapeToHover(editor: Editor): TLShapeId | null {
2525
const hitShape = editor.getShapeAtPoint(editor.inputs.getCurrentPagePoint(), {
2626
hitInside: false,
2727
hitLabels: false,
28+
hitLocked: editor.options.selectLockedShapes,
2829
margin: editor.options.hitTestMargin / editor.getZoomLevel(),
2930
renderingOnly: true,
3031
})

packages/tldraw/src/test/commands/lockShapes.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,3 +297,67 @@ it('works when forced', () => {
297297
)
298298
expect(editor.getShape(myShapeId)).toBeUndefined()
299299
})
300+
301+
describe('When the selectLockedShapes option is enabled', () => {
302+
let lockEditor: TestEditor
303+
304+
beforeEach(() => {
305+
lockEditor = new TestEditor({ options: { selectLockedShapes: true } })
306+
lockEditor.createShapes([
307+
{
308+
id: ids.lockedShapeA,
309+
type: 'geo',
310+
x: 0,
311+
y: 0,
312+
isLocked: true,
313+
props: { fill: 'solid' },
314+
},
315+
{ id: ids.unlockedShapeA, type: 'geo', x: 200, y: 200, isLocked: false },
316+
])
317+
})
318+
319+
it('Can be selected by clicking', () => {
320+
const shape = lockEditor.getShape(ids.lockedShapeA)!
321+
322+
lockEditor
323+
.pointerDown(10, 10, { target: 'shape', shape })
324+
.expectToBeIn('select.pointing_shape')
325+
.pointerUp()
326+
.expectToBeIn('select.idle')
327+
expect(lockEditor.getSelectedShapeIds()).toEqual([ids.lockedShapeA])
328+
})
329+
330+
it('Can be selected by left-clicking on the canvas over the shape', () => {
331+
lockEditor
332+
.pointerDown(10, 10, { target: 'canvas' })
333+
.expectToBeIn('select.pointing_shape')
334+
.pointerUp()
335+
.expectToBeIn('select.idle')
336+
expect(lockEditor.getSelectedShapeIds()).toEqual([ids.lockedShapeA])
337+
})
338+
339+
it('Can be selected by brushing', () => {
340+
lockEditor
341+
.pointerDown(-10, -10, { target: 'canvas' })
342+
.pointerMove(250, 250)
343+
.expectToBeIn('select.brushing')
344+
.pointerUp()
345+
expect(lockEditor.getSelectedShapeIds()).toEqual(
346+
expect.arrayContaining([ids.lockedShapeA, ids.unlockedShapeA])
347+
)
348+
})
349+
350+
it('Still cannot be moved or mutated while selected', () => {
351+
const shape = lockEditor.getShape(ids.lockedShapeA)!
352+
const xBefore = shape.x
353+
354+
lockEditor.pointerDown(10, 10, { target: 'shape', shape }).pointerMove(50, 50).pointerUp()
355+
356+
expect(lockEditor.getShape(ids.lockedShapeA)!.x).toBe(xBefore)
357+
})
358+
359+
it('Still cannot be deleted via deleteShapes', () => {
360+
lockEditor.deleteShapes([ids.lockedShapeA])
361+
expect(lockEditor.getShape(ids.lockedShapeA)).not.toBeUndefined()
362+
})
363+
})

0 commit comments

Comments
 (0)