Add support for locking shapes (#1447)

Add support for locking shapes. 

How it works right now:
- You can lock / unlock shapes from the context menu.
- You can also lock shapes with `⇧⌘L` keyboard shortcut.
- You cannot select locked shapes: clicking on the shape, double click
to edit, select all, brush select,... should not work.
- You cannot change props of locked shapes.
- You cannot delete locked shapes.
- If a shape is grouped or within the frame the same rules apply.
- If you delete a group, that contains locked shape it will also delete
those shapes. This seems to be what other apps use as well.

Solves #1445 

### Change Type

- [x] `minor` — New Feature

### Test Plan

1. Insert a shape
2. Right click on it and lock it.
3. Test that you cannot select it, change its properties, delete it.
4. Do the same with locked groups.
5. Do the same with locked frames.

- [x] Unit Tests
- [ ] Webdriver tests

### Release Notes

- Add support for locking shapes.

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Mitja Bezenšek 2023-06-01 20:13:38 +02:00 committed by GitHub
parent a3c39cde4b
commit d738c28c19
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 328 additions and 68 deletions

View file

@ -79,6 +79,7 @@
"action.toggle-focus-mode": "Toggle focus mode", "action.toggle-focus-mode": "Toggle focus mode",
"action.toggle-grid.menu": "Show grid", "action.toggle-grid.menu": "Show grid",
"action.toggle-grid": "Toggle grid", "action.toggle-grid": "Toggle grid",
"action.toggle-lock": "Lock / Unlock",
"action.toggle-snap-mode.menu": "Always snap", "action.toggle-snap-mode.menu": "Always snap",
"action.toggle-snap-mode": "Toggle always snap", "action.toggle-snap-mode": "Toggle always snap",
"action.toggle-tool-lock.menu": "Tool lock", "action.toggle-tool-lock.menu": "Tool lock",

View file

@ -360,14 +360,13 @@ export class App extends EventEmitter<TLEventMap> {
new (...args: any): TLShapeUtil<T>; new (...args: any): TLShapeUtil<T>;
type: string; type: string;
}): shape is T; }): shape is T;
isShapeOrAncestorLocked(shape?: TLShape): boolean;
// (undocumented) // (undocumented)
get isSnapMode(): boolean; get isSnapMode(): boolean;
// (undocumented) // (undocumented)
get isToolLocked(): boolean; get isToolLocked(): boolean;
isWithinSelection(id: TLShapeId): boolean; isWithinSelection(id: TLShapeId): boolean;
get locale(): string; get locale(): string;
// (undocumented)
lockShapes(_ids?: TLShapeId[]): this;
mark(reason?: string, onUndo?: boolean, onRedo?: boolean): string; mark(reason?: string, onUndo?: boolean, onRedo?: boolean): string;
moveShapesToPage(ids: TLShapeId[], pageId: TLPageId): this; moveShapesToPage(ids: TLShapeId[], pageId: TLPageId): this;
nudgeShapes(ids: TLShapeId[], direction: Vec2dModel, major?: boolean, ephemeral?: boolean): this; nudgeShapes(ids: TLShapeId[], direction: Vec2dModel, major?: boolean, ephemeral?: boolean): this;
@ -505,6 +504,8 @@ export class App extends EventEmitter<TLEventMap> {
stretchShapes(operation: 'horizontal' | 'vertical', ids?: TLShapeId[]): this; stretchShapes(operation: 'horizontal' | 'vertical', ids?: TLShapeId[]): this;
static styles: TLStyleCollections; static styles: TLStyleCollections;
textMeasure: TextManager; textMeasure: TextManager;
// (undocumented)
toggleLock(ids?: TLShapeId[]): this;
undo(): HistoryManager<this>; undo(): HistoryManager<this>;
// (undocumented) // (undocumented)
ungroupShapes(ids?: TLShapeId[]): this; ungroupShapes(ids?: TLShapeId[]): this;
@ -2033,11 +2034,11 @@ export class TLFrameUtil extends TLBoxUtil<TLFrameShape> {
// (undocumented) // (undocumented)
canBind: () => boolean; canBind: () => boolean;
// (undocumented) // (undocumented)
canDropShapes: (_shape: TLFrameShape, _shapes: TLShape[]) => boolean; canDropShapes: (shape: TLFrameShape, _shapes: TLShape[]) => boolean;
// (undocumented) // (undocumented)
canEdit: () => boolean; canEdit: () => boolean;
// (undocumented) // (undocumented)
canReceiveNewChildrenOfType: (_type: TLShape['type']) => boolean; canReceiveNewChildrenOfType: (shape: TLShape, _type: TLShape['type']) => boolean;
// (undocumented) // (undocumented)
defaultProps(): TLFrameShape['props']; defaultProps(): TLFrameShape['props'];
// (undocumented) // (undocumented)
@ -2493,7 +2494,7 @@ export abstract class TLShapeUtil<T extends TLUnknownShape = TLUnknownShape> {
canCrop: TLShapeUtilFlag<T>; canCrop: TLShapeUtilFlag<T>;
canDropShapes(shape: T, shapes: TLShape[]): boolean; canDropShapes(shape: T, shapes: TLShape[]): boolean;
canEdit: TLShapeUtilFlag<T>; canEdit: TLShapeUtilFlag<T>;
canReceiveNewChildrenOfType(type: TLShape['type']): boolean; canReceiveNewChildrenOfType(shape: T, type: TLShape['type']): boolean;
canResize: TLShapeUtilFlag<T>; canResize: TLShapeUtilFlag<T>;
canScroll: TLShapeUtilFlag<T>; canScroll: TLShapeUtilFlag<T>;
canUnmount: TLShapeUtilFlag<T>; canUnmount: TLShapeUtilFlag<T>;

View file

@ -2221,6 +2221,18 @@ export class App extends EventEmitter<TLEventMap> {
return this.viewportPageBounds.includes(pageBounds) return this.viewportPageBounds.includes(pageBounds)
} }
/**
* Check whether a shape or its parent is locked.
*
* @param id - The id of the shape to check.
* @public
*/
isShapeOrAncestorLocked(shape?: TLShape): boolean {
if (shape === undefined) return false
if (shape.isLocked) return true
return this.isShapeOrAncestorLocked(this.getParentShape(shape))
}
private computeUnorderedRenderingShapes( private computeUnorderedRenderingShapes(
ids: TLParentId[], ids: TLParentId[],
{ {
@ -2940,7 +2952,7 @@ export class App extends EventEmitter<TLEventMap> {
for (let i = shapes.length - 1; i >= 0; i--) { for (let i = shapes.length - 1; i >= 0; i--) {
const shape = shapes[i] const shape = shapes[i]
const util = this.getShapeUtil(shape) const util = this.getShapeUtil(shape)
if (!util.canReceiveNewChildrenOfType(shapeType)) continue if (!util.canReceiveNewChildrenOfType(shape, shapeType)) continue
const maskedPageBounds = this.getMaskedPageBoundsById(shape.id) const maskedPageBounds = this.getMaskedPageBoundsById(shape.id)
if ( if (
maskedPageBounds && maskedPageBounds &&
@ -4897,17 +4909,21 @@ export class App extends EventEmitter<TLEventMap> {
* @public * @public
*/ */
updateShapes(partials: (TLShapePartial | null | undefined)[], squashing = false) { updateShapes(partials: (TLShapePartial | null | undefined)[], squashing = false) {
let compactedPartials = compact(partials)
if (this.animatingShapes.size > 0) { if (this.animatingShapes.size > 0) {
let partial: TLShapePartial | null | undefined compactedPartials.forEach((p) => this.animatingShapes.delete(p.id))
for (let i = 0; i < partials.length; i++) {
partial = partials[i]
if (partial) {
this.animatingShapes.delete(partial.id)
}
}
} }
this._updateShapes(partials, squashing) compactedPartials = compactedPartials.filter((p) => {
const shape = this.getShapeById(p.id)
if (!shape) return false
// Only allow changes to unlocked shapes or changes to the isLocked property (otherwise we cannot unlock a shape)
if (this.isShapeOrAncestorLocked(shape) && !Object.hasOwn(p, 'isLocked')) return false
return true
})
this._updateShapes(compactedPartials, squashing)
return this return this
} }
@ -5001,6 +5017,11 @@ export class App extends EventEmitter<TLEventMap> {
} }
) )
/** @internal */
private _getUnlockedShapeIds(ids: TLShapeId[]): TLShapeId[] {
return ids.filter((id) => !this.getShapeById(id)?.isLocked)
}
/** /**
* Delete shapes. * Delete shapes.
* *
@ -5015,7 +5036,7 @@ export class App extends EventEmitter<TLEventMap> {
* @public * @public
*/ */
deleteShapes(ids: TLShapeId[] = this.selectedIds) { deleteShapes(ids: TLShapeId[] = this.selectedIds) {
this._deleteShapes(ids) this._deleteShapes(this._getUnlockedShapeIds(ids))
return this return this
} }
@ -6003,9 +6024,34 @@ export class App extends EventEmitter<TLEventMap> {
return this return this
} }
lockShapes(_ids: TLShapeId[] = this.pageState.selectedIds): this { toggleLock(ids: TLShapeId[] = this.selectedIds): this {
if (this.isReadOnly) return this if (this.isReadOnly || ids.length === 0) return this
// todo
let allLocked = true,
allUnlocked = true
const shapes: TLShape[] = []
for (const id of ids) {
const shape = this.getShapeById(id)
if (shape) {
shapes.push(shape)
if (shape.isLocked) {
allUnlocked = false
} else {
allLocked = false
}
}
}
if (allUnlocked) {
this.updateShapes(shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true })))
this.setSelectedIds([])
} else if (allLocked) {
this.updateShapes(
shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: false }))
)
} else {
this.updateShapes(shapes.map((shape) => ({ id: shape.id, type: shape.type, isLocked: true })))
}
return this return this
} }
@ -7241,7 +7287,7 @@ export class App extends EventEmitter<TLEventMap> {
const ids = this.getSortedChildIds(this.currentPageId) const ids = this.getSortedChildIds(this.currentPageId)
// page might have no shapes // page might have no shapes
if (ids.length <= 0) return this if (ids.length <= 0) return this
this.setSelectedIds(ids) this.setSelectedIds(this._getUnlockedShapeIds(ids))
return this return this
} }
@ -8915,7 +8961,7 @@ export class App extends EventEmitter<TLEventMap> {
if (ids.length <= 1) return this if (ids.length <= 1) return this
const shapes = compact(ids.map((id) => this.getShapeById(id))) const shapes = compact(this._getUnlockedShapeIds(ids).map((id) => this.getShapeById(id)))
const sortedShapeIds = shapes.sort(sortByIndex).map((s) => s.id) const sortedShapeIds = shapes.sort(sortByIndex).map((s) => s.id)
const pageBounds = Box2d.Common(compact(shapes.map((id) => this.getPageBounds(id)))) const pageBounds = Box2d.Common(compact(shapes.map((id) => this.getPageBounds(id))))

View file

@ -148,16 +148,16 @@ export class TLFrameUtil extends TLBoxUtil<TLFrameShape> {
) )
} }
providesBackgroundForChildren(): boolean { override canReceiveNewChildrenOfType = (shape: TLShape, _type: TLShape['type']) => {
return !shape.isLocked
}
override providesBackgroundForChildren(): boolean {
return true return true
} }
override canReceiveNewChildrenOfType = (_type: TLShape['type']) => { override canDropShapes = (shape: TLFrameShape, _shapes: TLShape[]): boolean => {
return true return !shape.isLocked
}
override canDropShapes = (_shape: TLFrameShape, _shapes: TLShape[]): boolean => {
return true
} }
override onDragShapesOver = (frame: TLFrameShape, shapes: TLShape[]): { shouldHint: boolean } => { override onDragShapesOver = (frame: TLFrameShape, shapes: TLShape[]): { shouldHint: boolean } => {

View file

@ -316,7 +316,7 @@ export abstract class TLShapeUtil<T extends TLUnknownShape = TLUnknownShape> {
* @param type - The shape type. * @param type - The shape type.
* @public * @public
*/ */
canReceiveNewChildrenOfType(type: TLShape['type']) { canReceiveNewChildrenOfType(shape: T, type: TLShape['type']) {
return false return false
} }

View file

@ -21,8 +21,9 @@ export class Erasing extends StateNode {
this.app.shapesArray this.app.shapesArray
.filter( .filter(
(shape) => (shape) =>
(shape.type === 'frame' || shape.type === 'group') && this.app.isShapeOrAncestorLocked(shape) ||
this.app.isPointInShape(originPagePoint, shape) ((shape.type === 'group' || shape.type === 'frame') &&
this.app.isPointInShape(originPagePoint, shape))
) )
.map((shape) => shape.id) .map((shape) => shape.id)
) )
@ -94,7 +95,6 @@ export class Erasing extends StateNode {
const erasing = new Set<TLShapeId>(erasingIdsSet) const erasing = new Set<TLShapeId>(erasingIdsSet)
for (const shape of shapesArray) { for (const shape of shapesArray) {
// Skip groups
if (shape.type === 'group') continue if (shape.type === 'group') continue
// Avoid testing masked shapes, unless the pointer is inside the mask // Avoid testing masked shapes, unless the pointer is inside the mask

View file

@ -24,6 +24,7 @@ export class Brushing extends StateNode {
brush = new Box2d() brush = new Box2d()
initialSelectedIds: TLShapeId[] = [] initialSelectedIds: TLShapeId[] = []
excludedShapeIds = new Set<TLShapeId>()
// The shape that the brush started on // The shape that the brush started on
initialStartShape: TLShape | null = null initialStartShape: TLShape | null = null
@ -36,6 +37,12 @@ export class Brushing extends StateNode {
return return
} }
this.excludedShapeIds = new Set(
this.app.shapesArray
.filter((shape) => shape.type === 'group' || this.app.isShapeOrAncestorLocked(shape))
.map((shape) => shape.id)
)
this.info = info this.info = info
this.initialSelectedIds = this.app.selectedIds.slice() this.initialSelectedIds = this.app.selectedIds.slice()
this.initialStartShape = this.app.getShapesAtPoint(currentPagePoint)[0] this.initialStartShape = this.app.getShapesAtPoint(currentPagePoint)[0]
@ -104,12 +111,11 @@ export class Brushing extends StateNode {
// We'll be testing the corners of the brush against the shapes // We'll be testing the corners of the brush against the shapes
const { corners } = this.brush const { corners } = this.brush
const { excludedShapeIds } = this
testAllShapes: for (let i = 0, n = shapesArray.length; i < n; i++) { testAllShapes: for (let i = 0, n = shapesArray.length; i < n; i++) {
shape = shapesArray[i] shape = shapesArray[i]
if (excludedShapeIds.has(shape.id)) continue testAllShapes
// don't select groups directly, only via their children
if (shape.type === 'group') continue testAllShapes
if (results.has(shape.id)) continue testAllShapes if (results.has(shape.id)) continue testAllShapes
pageBounds = this.app.getPageBounds(shape) pageBounds = this.app.getPageBounds(shape)

View file

@ -66,7 +66,11 @@ export class EditingShape extends StateNode {
// If the user has clicked onto a different shape of the same type // If the user has clicked onto a different shape of the same type
// which is available to edit, select it and begin editing it. // which is available to edit, select it and begin editing it.
if (shape.type === editingShape.type && util.canEdit?.(shape)) { if (
shape.type === editingShape.type &&
util.canEdit?.(shape) &&
!this.app.isShapeOrAncestorLocked(shape)
) {
this.app.setEditingId(shape.id) this.app.setEditingId(shape.id)
this.app.setHoveredId(shape.id) this.app.setHoveredId(shape.id)
this.app.setSelectedIds([shape.id]) this.app.setSelectedIds([shape.id])

View file

@ -64,13 +64,13 @@ export class Idle extends StateNode {
this.parent.transition('brushing', info) this.parent.transition('brushing', info)
return return
} }
switch (info.target) { switch (info.target) {
case 'canvas': { case 'canvas': {
this.parent.transition('pointing_canvas', info) this.parent.transition('pointing_canvas', info)
break break
} }
case 'shape': { case 'shape': {
if (this.app.isShapeOrAncestorLocked(info.shape)) break
this.parent.transition('pointing_shape', info) this.parent.transition('pointing_shape', info)
break break
} }
@ -157,12 +157,15 @@ export class Idle extends StateNode {
} }
// For corners OR edges // For corners OR edges
if (util.canCrop(onlySelectedShape)) { if (
util.canCrop(onlySelectedShape) &&
!this.app.isShapeOrAncestorLocked(onlySelectedShape)
) {
this.parent.transition('crop', info) this.parent.transition('crop', info)
return return
} }
if (util.canEdit(onlySelectedShape)) { if (this.shouldStartEditingShape(onlySelectedShape)) {
this.startEditingShape(onlySelectedShape, info) this.startEditingShape(onlySelectedShape, info)
} }
} }
@ -181,7 +184,7 @@ export class Idle extends StateNode {
if (change) { if (change) {
this.app.updateShapes([change]) this.app.updateShapes([change])
return return
} else if (util.canCrop(shape)) { } else if (util.canCrop(shape) && !this.app.isShapeOrAncestorLocked(shape)) {
// crop on double click // crop on double click
this.app.mark('select and crop') this.app.mark('select and crop')
this.app.select(info.shape?.id) this.app.select(info.shape?.id)
@ -190,7 +193,7 @@ export class Idle extends StateNode {
} }
} }
// If the shape can edit, then begin editing // If the shape can edit, then begin editing
if (util.canEdit(shape)) { if (this.shouldStartEditingShape(shape)) {
this.startEditingShape(shape, info) this.startEditingShape(shape, info)
} else { } else {
// If the shape's double click handler has not created a change, // If the shape's double click handler has not created a change,
@ -212,7 +215,7 @@ export class Idle extends StateNode {
} else { } else {
// If the shape's double click handler has not created a change, // If the shape's double click handler has not created a change,
// and if the shape can edit, then begin editing the shape. // and if the shape can edit, then begin editing the shape.
if (util.canEdit(shape)) { if (this.shouldStartEditingShape(shape)) {
this.startEditingShape(shape, info) this.startEditingShape(shape, info)
} }
} }
@ -327,12 +330,12 @@ export class Idle extends StateNode {
} }
} }
private shouldStartEditingShape(): boolean { private shouldStartEditingShape(shape: TLShape | null = this.app.onlySelectedShape): boolean {
const { onlySelectedShape } = this.app if (!shape) return false
if (!onlySelectedShape) return false if (this.app.isShapeOrAncestorLocked(shape) && shape.type !== 'embed') return false
const util = this.app.getShapeUtil(onlySelectedShape) const util = this.app.getShapeUtil(shape)
return util.canEdit(onlySelectedShape) return util.canEdit(shape)
} }
private shouldEnterCropMode( private shouldEnterCropMode(
@ -341,6 +344,7 @@ export class Idle extends StateNode {
): boolean { ): boolean {
const singleShape = this.app.onlySelectedShape const singleShape = this.app.onlySelectedShape
if (!singleShape) return false if (!singleShape) return false
if (this.app.isShapeOrAncestorLocked(singleShape)) return false
const shapeUtil = this.app.getShapeUtil(singleShape) const shapeUtil = this.app.getShapeUtil(singleShape)
// Should the Ctrl key be pressed to enter crop mode // Should the Ctrl key be pressed to enter crop mode
@ -352,6 +356,7 @@ export class Idle extends StateNode {
} }
private startEditingShape(shape: TLShape, info: TLClickEventInfo | TLKeyboardEventInfo) { private startEditingShape(shape: TLShape, info: TLClickEventInfo | TLKeyboardEventInfo) {
if (this.app.isShapeOrAncestorLocked(shape) && shape.type !== 'embed') return
this.app.mark('editing shape') this.app.mark('editing shape')
this.app.setEditingId(shape.id) this.app.setEditingId(shape.id)
this.parent.transition('editing_shape', info) this.parent.transition('editing_shape', info)

View file

@ -107,7 +107,8 @@ export class ScribbleBrushing extends StateNode {
shape.type === 'group' || shape.type === 'group' ||
this.newlySelectedIds.has(shape.id) || this.newlySelectedIds.has(shape.id) ||
(shape.type === 'frame' && (shape.type === 'frame' &&
util.hitTestPoint(shape, this.app.getPointInShapeSpace(shape, originPagePoint))) util.hitTestPoint(shape, this.app.getPointInShapeSpace(shape, originPagePoint))) ||
this.app.isShapeOrAncestorLocked(shape)
) { ) {
continue continue
} }

View file

@ -33,6 +33,7 @@ export const SelectionFg = track(function SelectionFg() {
let bounds = app.selectionBounds let bounds = app.selectionBounds
const shapes = app.selectedShapes const shapes = app.selectedShapes
const onlyShape = shapes.length === 1 ? shapes[0] : null const onlyShape = shapes.length === 1 ? shapes[0] : null
const isLockedShape = onlyShape && app.isShapeOrAncestorLocked(onlyShape)
// if all shapes have an expandBy for the selection outline, we can expand by the l // if all shapes have an expandBy for the selection outline, we can expand by the l
const expandOutlineBy = onlyShape const expandOutlineBy = onlyShape
@ -115,13 +116,15 @@ export const SelectionFg = track(function SelectionFg() {
!isCoarsePointer && !isCoarsePointer &&
!(isTinyX || isTinyY) && !(isTinyX || isTinyY) &&
(shouldDisplayControls || showCropHandles) && (shouldDisplayControls || showCropHandles) &&
(onlyShape ? !app.getShapeUtil(onlyShape).hideRotateHandle(onlyShape) : true) (onlyShape ? !app.getShapeUtil(onlyShape).hideRotateHandle(onlyShape) : true) &&
!isLockedShape
const showMobileRotateHandle = const showMobileRotateHandle =
isCoarsePointer && isCoarsePointer &&
(!isSmallX || !isSmallY) && (!isSmallX || !isSmallY) &&
(shouldDisplayControls || showCropHandles) && (shouldDisplayControls || showCropHandles) &&
(onlyShape ? !app.getShapeUtil(onlyShape).hideRotateHandle(onlyShape) : true) (onlyShape ? !app.getShapeUtil(onlyShape).hideRotateHandle(onlyShape) : true) &&
!isLockedShape
const showResizeHandles = const showResizeHandles =
shouldDisplayControls && shouldDisplayControls &&
@ -129,7 +132,8 @@ export const SelectionFg = track(function SelectionFg() {
? app.getShapeUtil(onlyShape).canResize(onlyShape) && ? app.getShapeUtil(onlyShape).canResize(onlyShape) &&
!app.getShapeUtil(onlyShape).hideResizeHandles(onlyShape) !app.getShapeUtil(onlyShape).hideResizeHandles(onlyShape)
: true) && : true) &&
!showCropHandles !showCropHandles &&
!isLockedShape
const hideAlternateCornerHandles = isTinyX || isTinyY const hideAlternateCornerHandles = isTinyX || isTinyY
const showOnlyOneHandle = isTinyX && isTinyY const showOnlyOneHandle = isTinyX && isTinyY

View file

@ -1,15 +1,175 @@
// import { TestApp } from '../TestApp' import { createCustomShapeId } from '@tldraw/tlschema'
import { TestApp } from '../TestApp'
let app: TestApp
// let app: TestApp const ids = {
lockedShapeA: createCustomShapeId('boxA'),
unlockedShapeA: createCustomShapeId('boxB'),
unlockedShapeB: createCustomShapeId('boxC'),
lockedShapeB: createCustomShapeId('boxD'),
lockedGroup: createCustomShapeId('lockedGroup'),
groupedBoxA: createCustomShapeId('grouppedBoxA'),
groupedBoxB: createCustomShapeId('grouppedBoxB'),
lockedFrame: createCustomShapeId('lockedFrame'),
}
// beforeEach(() => { beforeEach(() => {
// app = new TestApp() app = new TestApp()
// }) app.selectAll()
app.deleteShapes()
app.createShapes([
{
id: ids.lockedShapeA,
type: 'geo',
x: 0,
y: 0,
isLocked: true,
},
{
id: ids.lockedShapeB,
type: 'geo',
x: 100,
y: 100,
isLocked: true,
},
{
id: ids.unlockedShapeA,
type: 'geo',
x: 200,
y: 200,
isLocked: false,
},
{
id: ids.unlockedShapeB,
type: 'geo',
x: 300,
y: 300,
isLocked: false,
},
{
id: ids.lockedGroup,
type: 'group',
x: 800,
y: 800,
isLocked: true,
},
{
id: ids.groupedBoxA,
type: 'geo',
x: 1000,
y: 1000,
parentId: ids.lockedGroup,
isLocked: false,
},
{
id: ids.groupedBoxB,
type: 'geo',
x: 1200,
y: 1200,
parentId: ids.lockedGroup,
isLocked: false,
},
{
id: ids.lockedFrame,
type: 'frame',
x: 1600,
y: 1600,
isLocked: true,
},
])
})
describe('Locking', () => { describe('Locking', () => {
it.todo('Locks all selected shapes if the selection includes any unlocked shapes') it('Can lock shapes', () => {
app.setSelectedIds([ids.unlockedShapeA])
app.toggleLock()
expect(app.getShapeById(ids.unlockedShapeA)!.isLocked).toBe(true)
// Locking deselects the shape
expect(app.selectedIds).toEqual([])
})
})
describe('Locked shapes', () => {
it('Cannot be deleted', () => {
const numberOfShapesBefore = app.shapesArray.length
app.deleteShapes([ids.lockedShapeA])
expect(app.shapesArray.length).toBe(numberOfShapesBefore)
})
it('Cannot be changed', () => {
const xBefore = app.getShapeById(ids.lockedShapeA)!.x
app.updateShapes([{ id: ids.lockedShapeA, type: 'geo', x: 100 }])
expect(app.getShapeById(ids.lockedShapeA)!.x).toBe(xBefore)
})
it('Cannot be moved', () => {
const shape = app.getShapeById(ids.lockedShapeA)
app.pointerDown(150, 150, { target: 'shape', shape })
app.expectToBeIn('select.idle')
app.pointerMove(10, 10)
app.expectToBeIn('select.idle')
app.pointerUp()
app.expectToBeIn('select.idle')
})
it('Cannot be selected with select all', () => {
app.selectAll()
expect(app.selectedIds).toEqual([ids.unlockedShapeA, ids.unlockedShapeB])
})
it('Cannot be selected by clicking', () => {
const shape = app.getShapeById(ids.lockedShapeA)!
app
.pointerDown(10, 10, { target: 'shape', shape })
.expectToBeIn('select.idle')
.pointerUp()
.expectToBeIn('select.idle')
expect(app.selectedIds).not.toContain(shape.id)
})
it('Cannot be edited', () => {
const shape = app.getShapeById(ids.lockedShapeA)!
const shapeCount = app.shapesArray.length
// We create a new shape and we edit that one
app.doubleClick(10, 10, { target: 'shape', shape }).expectToBeIn('select.editing_shape')
expect(app.shapesArray.length).toBe(shapeCount + 1)
expect(app.selectedIds).not.toContain(shape.id)
})
it('Cannot be grouped', () => {
const shapeCount = app.shapesArray.length
const parentBefore = app.getShapeById(ids.lockedShapeA)!.parentId
app.groupShapes([ids.lockedShapeA, ids.unlockedShapeA, ids.unlockedShapeB])
expect(app.shapesArray.length).toBe(shapeCount + 1)
const parentAfter = app.getShapeById(ids.lockedShapeA)!.parentId
expect(parentAfter).toBe(parentBefore)
})
it('Locked frames do not accept new shapes', () => {
const frame = app.getShapeById(ids.lockedFrame)!
const frameUtil = app.getShapeUtil(frame)
expect(frameUtil.canReceiveNewChildrenOfType(frame, 'box')).toBe(false)
const shape = app.getShapeById(ids.lockedShapeA)!
expect(frameUtil.canDropShapes(frame, [shape])).toBe(false)
})
}) })
describe('Unlocking', () => { describe('Unlocking', () => {
it.todo('Unlocks all selected shapes if the selection includes only locked shapes') it('Can unlock shapes', () => {
app.setSelectedIds([ids.lockedShapeA, ids.lockedShapeB])
let lockedStatus = [ids.lockedShapeA, ids.lockedShapeB].map(
(id) => app.getShapeById(id)!.isLocked
)
expect(lockedStatus).toStrictEqual([true, true])
app.toggleLock()
lockedStatus = [ids.lockedShapeA, ids.lockedShapeB].map((id) => app.getShapeById(id)!.isLocked)
expect(lockedStatus).toStrictEqual([false, false])
})
}) })

File diff suppressed because one or more lines are too long

View file

@ -24,7 +24,14 @@ export const ContextMenu = function ContextMenu({ children }: { children: any })
const app = useApp() const app = useApp()
const contextMenuSchema = useContextMenuSchema() const contextMenuSchema = useContextMenuSchema()
const [_, handleOpenChange] = useMenuIsOpen('context menu') const cb = (isOpen: boolean) => {
if (isOpen) return
if (shouldDeselect(app)) {
app.setSelectedIds([])
}
}
const [_, handleOpenChange] = useMenuIsOpen('context menu', cb)
// If every item in the menu is readonly, then we don't want to show the menu // If every item in the menu is readonly, then we don't want to show the menu
const isReadonly = useReadonly() const isReadonly = useReadonly()
@ -53,6 +60,12 @@ export const ContextMenu = function ContextMenu({ children }: { children: any })
) )
} }
function shouldDeselect(app: App) {
const { onlySelectedShape } = app
if (!onlySelectedShape) return false
return app.isShapeOrAncestorLocked(onlySelectedShape)
}
function ContextMenuContent() { function ContextMenuContent() {
const app = useApp() const app = useApp()
const msg = useTranslation() const msg = useTranslation()

View file

@ -925,6 +925,16 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
app.zoomToContent() app.zoomToContent()
}, },
}, },
{
id: 'toggle-lock',
label: 'action.toggle-lock',
readonlyOk: false,
kbd: '!$l',
onSelect(source) {
trackEvent('toggle-lock', { source })
app.toggleLock()
},
},
]) ])
if (overrides) { if (overrides) {

View file

@ -89,19 +89,22 @@ export const ContextMenuSchemaProvider = track(function ContextMenuSchemaProvide
const allowUngroup = useAllowUngroup() const allowUngroup = useAllowUngroup()
const hasClipboardWrite = Boolean(window.navigator.clipboard?.write) const hasClipboardWrite = Boolean(window.navigator.clipboard?.write)
const showEditLink = useHasLinkShapeSelected() const showEditLink = useHasLinkShapeSelected()
const { onlySelectedShape } = app
const isShapeLocked = onlySelectedShape && app.isShapeOrAncestorLocked(onlySelectedShape)
const contextMenuSchema = useMemo<MenuSchema>(() => { const contextMenuSchema = useMemo<MenuSchema>(() => {
let contextMenuSchema: ContextMenuSchemaContextType = compactMenuItems([ let contextMenuSchema: ContextMenuSchemaContextType = compactMenuItems([
menuGroup( menuGroup(
'selection', 'selection',
oneEmbedSelected && menuItem(actions['open-embed-link']), oneEmbedSelected && menuItem(actions['open-embed-link']),
oneEmbedSelected && menuItem(actions['convert-to-bookmark']), oneEmbedSelected && !isShapeLocked && menuItem(actions['convert-to-bookmark']),
oneEmbeddableBookmarkSelected && menuItem(actions['convert-to-embed']), oneEmbeddableBookmarkSelected && menuItem(actions['convert-to-embed']),
showAutoSizeToggle && menuItem(actions['toggle-auto-size']), showAutoSizeToggle && menuItem(actions['toggle-auto-size']),
showEditLink && menuItem(actions['edit-link']), showEditLink && !isShapeLocked && menuItem(actions['edit-link']),
oneSelected && menuItem(actions['duplicate']), oneSelected && !isShapeLocked && menuItem(actions['duplicate']),
allowGroup && menuItem(actions['group']), allowGroup && !isShapeLocked && menuItem(actions['group']),
allowUngroup && menuItem(actions['ungroup']) allowUngroup && !isShapeLocked && menuItem(actions['ungroup']),
oneSelected && menuItem(actions['toggle-lock'])
), ),
menuGroup( menuGroup(
'modify', 'modify',
@ -132,6 +135,7 @@ export const ContextMenuSchemaProvider = track(function ContextMenuSchemaProvide
menuItem(actions['stretch-vertical']) menuItem(actions['stretch-vertical'])
), ),
onlyFlippableShapeSelected && onlyFlippableShapeSelected &&
!isShapeLocked &&
menuGroup( menuGroup(
'flip', 'flip',
menuItem(actions['flip-horizontal']), menuItem(actions['flip-horizontal']),
@ -146,6 +150,7 @@ export const ContextMenuSchemaProvider = track(function ContextMenuSchemaProvide
) )
), ),
oneSelected && oneSelected &&
!isShapeLocked &&
menuSubmenu( menuSubmenu(
'reorder', 'reorder',
'context-menu.reorder', 'context-menu.reorder',
@ -157,11 +162,11 @@ export const ContextMenuSchemaProvider = track(function ContextMenuSchemaProvide
menuItem(actions['send-to-back']) menuItem(actions['send-to-back'])
) )
), ),
oneSelected && menuCustom('MOVE_TO_PAGE_MENU', { readonlyOk: false }) oneSelected && !isShapeLocked && menuCustom('MOVE_TO_PAGE_MENU', { readonlyOk: false })
), ),
menuGroup( menuGroup(
'clipboard-group', 'clipboard-group',
oneSelected && menuItem(actions['cut']), oneSelected && !isShapeLocked && menuItem(actions['cut']),
oneSelected && menuItem(actions['copy']), oneSelected && menuItem(actions['copy']),
showMenuPaste && menuItem(actions['paste']) showMenuPaste && menuItem(actions['paste'])
), ),
@ -203,7 +208,7 @@ export const ContextMenuSchemaProvider = track(function ContextMenuSchemaProvide
menuItem(actions['select-all']), menuItem(actions['select-all']),
oneSelected && menuItem(actions['select-none']) oneSelected && menuItem(actions['select-none'])
), ),
oneSelected && menuGroup('delete-group', menuItem(actions['delete'])), oneSelected && !isShapeLocked && menuGroup('delete-group', menuItem(actions['delete'])),
]) ])
if (overrides) { if (overrides) {
@ -237,6 +242,7 @@ export const ContextMenuSchemaProvider = track(function ContextMenuSchemaProvide
oneEmbedSelected, oneEmbedSelected,
oneEmbeddableBookmarkSelected, oneEmbeddableBookmarkSelected,
isTransparentBg, isTransparentBg,
isShapeLocked,
]) ])
return ( return (

View file

@ -77,6 +77,7 @@ export interface TLUiEventMap {
'toggle-dark-mode': null 'toggle-dark-mode': null
'toggle-focus-mode': null 'toggle-focus-mode': null
'toggle-debug-mode': null 'toggle-debug-mode': null
'toggle-lock': null
'toggle-reduce-motion': null 'toggle-reduce-motion': null
'exit-pen-mode': null 'exit-pen-mode': null
'stop-following': null 'stop-following': null

View file

@ -83,6 +83,7 @@ export type TLTranslationKey =
| 'action.toggle-focus-mode' | 'action.toggle-focus-mode'
| 'action.toggle-grid.menu' | 'action.toggle-grid.menu'
| 'action.toggle-grid' | 'action.toggle-grid'
| 'action.toggle-lock'
| 'action.toggle-snap-mode.menu' | 'action.toggle-snap-mode.menu'
| 'action.toggle-snap-mode' | 'action.toggle-snap-mode'
| 'action.toggle-tool-lock.menu' | 'action.toggle-tool-lock.menu'

View file

@ -83,6 +83,7 @@ export const DEFAULT_TRANSLATION = {
'action.toggle-focus-mode': 'Toggle focus mode', 'action.toggle-focus-mode': 'Toggle focus mode',
'action.toggle-grid.menu': 'Show grid', 'action.toggle-grid.menu': 'Show grid',
'action.toggle-grid': 'Toggle grid', 'action.toggle-grid': 'Toggle grid',
'action.toggle-lock': 'Lock / Unlock',
'action.toggle-snap-mode.menu': 'Always snap', 'action.toggle-snap-mode.menu': 'Always snap',
'action.toggle-snap-mode': 'Toggle always snap', 'action.toggle-snap-mode': 'Toggle always snap',
'action.toggle-tool-lock.menu': 'Tool lock', 'action.toggle-tool-lock.menu': 'Tool lock',