From d738c28c19dc7749e9f1ada731942408cca501d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mitja=20Bezen=C5=A1ek?= Date: Thu, 1 Jun 2023 20:13:38 +0200 Subject: [PATCH] Add support for locking shapes (#1447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- assets/translations/main.json | 1 + packages/editor/api-report.md | 11 +- packages/editor/src/lib/app/App.ts | 76 ++++++-- .../shapeutils/TLFrameUtil/TLFrameUtil.tsx | 14 +- .../src/lib/app/shapeutils/TLShapeUtil.ts | 2 +- .../TLEraserTool/children/Erasing.ts | 6 +- .../TLSelectTool/children/Brushing.ts | 14 +- .../TLSelectTool/children/EditingShape.ts | 6 +- .../statechart/TLSelectTool/children/Idle.ts | 27 +-- .../TLSelectTool/children/ScribbleBrushing.ts | 3 +- .../editor/src/lib/components/SelectionFg.tsx | 10 +- .../src/lib/test/commands/lockShapes.test.ts | 174 +++++++++++++++++- packages/ui/api-report.md | 2 +- .../ui/src/lib/components/ContextMenu.tsx | 15 +- packages/ui/src/lib/hooks/useActions.tsx | 10 + .../ui/src/lib/hooks/useContextMenuSchema.tsx | 22 ++- .../ui/src/lib/hooks/useEventsProvider.tsx | 1 + .../hooks/useTranslation/TLTranslationKey.ts | 1 + .../useTranslation/defaultTranslation.ts | 1 + 19 files changed, 328 insertions(+), 68 deletions(-) diff --git a/assets/translations/main.json b/assets/translations/main.json index 7e611d15d..8177d47a3 100644 --- a/assets/translations/main.json +++ b/assets/translations/main.json @@ -79,6 +79,7 @@ "action.toggle-focus-mode": "Toggle focus mode", "action.toggle-grid.menu": "Show grid", "action.toggle-grid": "Toggle grid", + "action.toggle-lock": "Lock / Unlock", "action.toggle-snap-mode.menu": "Always snap", "action.toggle-snap-mode": "Toggle always snap", "action.toggle-tool-lock.menu": "Tool lock", diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index a923c6817..93d96f26d 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -360,14 +360,13 @@ export class App extends EventEmitter { new (...args: any): TLShapeUtil; type: string; }): shape is T; + isShapeOrAncestorLocked(shape?: TLShape): boolean; // (undocumented) get isSnapMode(): boolean; // (undocumented) get isToolLocked(): boolean; isWithinSelection(id: TLShapeId): boolean; get locale(): string; - // (undocumented) - lockShapes(_ids?: TLShapeId[]): this; mark(reason?: string, onUndo?: boolean, onRedo?: boolean): string; moveShapesToPage(ids: TLShapeId[], pageId: TLPageId): this; nudgeShapes(ids: TLShapeId[], direction: Vec2dModel, major?: boolean, ephemeral?: boolean): this; @@ -505,6 +504,8 @@ export class App extends EventEmitter { stretchShapes(operation: 'horizontal' | 'vertical', ids?: TLShapeId[]): this; static styles: TLStyleCollections; textMeasure: TextManager; + // (undocumented) + toggleLock(ids?: TLShapeId[]): this; undo(): HistoryManager; // (undocumented) ungroupShapes(ids?: TLShapeId[]): this; @@ -2033,11 +2034,11 @@ export class TLFrameUtil extends TLBoxUtil { // (undocumented) canBind: () => boolean; // (undocumented) - canDropShapes: (_shape: TLFrameShape, _shapes: TLShape[]) => boolean; + canDropShapes: (shape: TLFrameShape, _shapes: TLShape[]) => boolean; // (undocumented) canEdit: () => boolean; // (undocumented) - canReceiveNewChildrenOfType: (_type: TLShape['type']) => boolean; + canReceiveNewChildrenOfType: (shape: TLShape, _type: TLShape['type']) => boolean; // (undocumented) defaultProps(): TLFrameShape['props']; // (undocumented) @@ -2493,7 +2494,7 @@ export abstract class TLShapeUtil { canCrop: TLShapeUtilFlag; canDropShapes(shape: T, shapes: TLShape[]): boolean; canEdit: TLShapeUtilFlag; - canReceiveNewChildrenOfType(type: TLShape['type']): boolean; + canReceiveNewChildrenOfType(shape: T, type: TLShape['type']): boolean; canResize: TLShapeUtilFlag; canScroll: TLShapeUtilFlag; canUnmount: TLShapeUtilFlag; diff --git a/packages/editor/src/lib/app/App.ts b/packages/editor/src/lib/app/App.ts index a4dab6415..8fbd0ecd6 100644 --- a/packages/editor/src/lib/app/App.ts +++ b/packages/editor/src/lib/app/App.ts @@ -2221,6 +2221,18 @@ export class App extends EventEmitter { 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( ids: TLParentId[], { @@ -2940,7 +2952,7 @@ export class App extends EventEmitter { for (let i = shapes.length - 1; i >= 0; i--) { const shape = shapes[i] const util = this.getShapeUtil(shape) - if (!util.canReceiveNewChildrenOfType(shapeType)) continue + if (!util.canReceiveNewChildrenOfType(shape, shapeType)) continue const maskedPageBounds = this.getMaskedPageBoundsById(shape.id) if ( maskedPageBounds && @@ -4897,17 +4909,21 @@ export class App extends EventEmitter { * @public */ updateShapes(partials: (TLShapePartial | null | undefined)[], squashing = false) { + let compactedPartials = compact(partials) if (this.animatingShapes.size > 0) { - let partial: TLShapePartial | null | undefined - for (let i = 0; i < partials.length; i++) { - partial = partials[i] - if (partial) { - this.animatingShapes.delete(partial.id) - } - } + compactedPartials.forEach((p) => this.animatingShapes.delete(p.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 } @@ -5001,6 +5017,11 @@ export class App extends EventEmitter { } ) + /** @internal */ + private _getUnlockedShapeIds(ids: TLShapeId[]): TLShapeId[] { + return ids.filter((id) => !this.getShapeById(id)?.isLocked) + } + /** * Delete shapes. * @@ -5015,7 +5036,7 @@ export class App extends EventEmitter { * @public */ deleteShapes(ids: TLShapeId[] = this.selectedIds) { - this._deleteShapes(ids) + this._deleteShapes(this._getUnlockedShapeIds(ids)) return this } @@ -6003,9 +6024,34 @@ export class App extends EventEmitter { return this } - lockShapes(_ids: TLShapeId[] = this.pageState.selectedIds): this { - if (this.isReadOnly) return this - // todo + toggleLock(ids: TLShapeId[] = this.selectedIds): this { + if (this.isReadOnly || ids.length === 0) return this + + 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 } @@ -7241,7 +7287,7 @@ export class App extends EventEmitter { const ids = this.getSortedChildIds(this.currentPageId) // page might have no shapes if (ids.length <= 0) return this - this.setSelectedIds(ids) + this.setSelectedIds(this._getUnlockedShapeIds(ids)) return this } @@ -8915,7 +8961,7 @@ export class App extends EventEmitter { 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 pageBounds = Box2d.Common(compact(shapes.map((id) => this.getPageBounds(id)))) diff --git a/packages/editor/src/lib/app/shapeutils/TLFrameUtil/TLFrameUtil.tsx b/packages/editor/src/lib/app/shapeutils/TLFrameUtil/TLFrameUtil.tsx index e78b9361f..b1584420f 100644 --- a/packages/editor/src/lib/app/shapeutils/TLFrameUtil/TLFrameUtil.tsx +++ b/packages/editor/src/lib/app/shapeutils/TLFrameUtil/TLFrameUtil.tsx @@ -148,16 +148,16 @@ export class TLFrameUtil extends TLBoxUtil { ) } - providesBackgroundForChildren(): boolean { + override canReceiveNewChildrenOfType = (shape: TLShape, _type: TLShape['type']) => { + return !shape.isLocked + } + + override providesBackgroundForChildren(): boolean { return true } - override canReceiveNewChildrenOfType = (_type: TLShape['type']) => { - return true - } - - override canDropShapes = (_shape: TLFrameShape, _shapes: TLShape[]): boolean => { - return true + override canDropShapes = (shape: TLFrameShape, _shapes: TLShape[]): boolean => { + return !shape.isLocked } override onDragShapesOver = (frame: TLFrameShape, shapes: TLShape[]): { shouldHint: boolean } => { diff --git a/packages/editor/src/lib/app/shapeutils/TLShapeUtil.ts b/packages/editor/src/lib/app/shapeutils/TLShapeUtil.ts index 0ca6adccf..4579dd740 100644 --- a/packages/editor/src/lib/app/shapeutils/TLShapeUtil.ts +++ b/packages/editor/src/lib/app/shapeutils/TLShapeUtil.ts @@ -316,7 +316,7 @@ export abstract class TLShapeUtil { * @param type - The shape type. * @public */ - canReceiveNewChildrenOfType(type: TLShape['type']) { + canReceiveNewChildrenOfType(shape: T, type: TLShape['type']) { return false } diff --git a/packages/editor/src/lib/app/statechart/TLEraserTool/children/Erasing.ts b/packages/editor/src/lib/app/statechart/TLEraserTool/children/Erasing.ts index 591d9db85..b898cce93 100644 --- a/packages/editor/src/lib/app/statechart/TLEraserTool/children/Erasing.ts +++ b/packages/editor/src/lib/app/statechart/TLEraserTool/children/Erasing.ts @@ -21,8 +21,9 @@ export class Erasing extends StateNode { this.app.shapesArray .filter( (shape) => - (shape.type === 'frame' || shape.type === 'group') && - this.app.isPointInShape(originPagePoint, shape) + this.app.isShapeOrAncestorLocked(shape) || + ((shape.type === 'group' || shape.type === 'frame') && + this.app.isPointInShape(originPagePoint, shape)) ) .map((shape) => shape.id) ) @@ -94,7 +95,6 @@ export class Erasing extends StateNode { const erasing = new Set(erasingIdsSet) for (const shape of shapesArray) { - // Skip groups if (shape.type === 'group') continue // Avoid testing masked shapes, unless the pointer is inside the mask diff --git a/packages/editor/src/lib/app/statechart/TLSelectTool/children/Brushing.ts b/packages/editor/src/lib/app/statechart/TLSelectTool/children/Brushing.ts index 6667edaf9..1f15cac96 100644 --- a/packages/editor/src/lib/app/statechart/TLSelectTool/children/Brushing.ts +++ b/packages/editor/src/lib/app/statechart/TLSelectTool/children/Brushing.ts @@ -24,6 +24,7 @@ export class Brushing extends StateNode { brush = new Box2d() initialSelectedIds: TLShapeId[] = [] + excludedShapeIds = new Set() // The shape that the brush started on initialStartShape: TLShape | null = null @@ -36,6 +37,12 @@ export class Brushing extends StateNode { 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.initialSelectedIds = this.app.selectedIds.slice() 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 const { corners } = this.brush + const { excludedShapeIds } = this + testAllShapes: for (let i = 0, n = shapesArray.length; i < n; i++) { shape = shapesArray[i] - - // don't select groups directly, only via their children - if (shape.type === 'group') continue testAllShapes - + if (excludedShapeIds.has(shape.id)) continue testAllShapes if (results.has(shape.id)) continue testAllShapes pageBounds = this.app.getPageBounds(shape) diff --git a/packages/editor/src/lib/app/statechart/TLSelectTool/children/EditingShape.ts b/packages/editor/src/lib/app/statechart/TLSelectTool/children/EditingShape.ts index 55760519a..90061a5c2 100644 --- a/packages/editor/src/lib/app/statechart/TLSelectTool/children/EditingShape.ts +++ b/packages/editor/src/lib/app/statechart/TLSelectTool/children/EditingShape.ts @@ -66,7 +66,11 @@ export class EditingShape extends StateNode { // If the user has clicked onto a different shape of the same type // 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.setHoveredId(shape.id) this.app.setSelectedIds([shape.id]) diff --git a/packages/editor/src/lib/app/statechart/TLSelectTool/children/Idle.ts b/packages/editor/src/lib/app/statechart/TLSelectTool/children/Idle.ts index 18aa74657..558dbf7a2 100644 --- a/packages/editor/src/lib/app/statechart/TLSelectTool/children/Idle.ts +++ b/packages/editor/src/lib/app/statechart/TLSelectTool/children/Idle.ts @@ -64,13 +64,13 @@ export class Idle extends StateNode { this.parent.transition('brushing', info) return } - switch (info.target) { case 'canvas': { this.parent.transition('pointing_canvas', info) break } case 'shape': { + if (this.app.isShapeOrAncestorLocked(info.shape)) break this.parent.transition('pointing_shape', info) break } @@ -157,12 +157,15 @@ export class Idle extends StateNode { } // For corners OR edges - if (util.canCrop(onlySelectedShape)) { + if ( + util.canCrop(onlySelectedShape) && + !this.app.isShapeOrAncestorLocked(onlySelectedShape) + ) { this.parent.transition('crop', info) return } - if (util.canEdit(onlySelectedShape)) { + if (this.shouldStartEditingShape(onlySelectedShape)) { this.startEditingShape(onlySelectedShape, info) } } @@ -181,7 +184,7 @@ export class Idle extends StateNode { if (change) { this.app.updateShapes([change]) return - } else if (util.canCrop(shape)) { + } else if (util.canCrop(shape) && !this.app.isShapeOrAncestorLocked(shape)) { // crop on double click this.app.mark('select and crop') this.app.select(info.shape?.id) @@ -190,7 +193,7 @@ export class Idle extends StateNode { } } // If the shape can edit, then begin editing - if (util.canEdit(shape)) { + if (this.shouldStartEditingShape(shape)) { this.startEditingShape(shape, info) } else { // If the shape's double click handler has not created a change, @@ -212,7 +215,7 @@ export class Idle extends StateNode { } else { // If the shape's double click handler has not created a change, // and if the shape can edit, then begin editing the shape. - if (util.canEdit(shape)) { + if (this.shouldStartEditingShape(shape)) { this.startEditingShape(shape, info) } } @@ -327,12 +330,12 @@ export class Idle extends StateNode { } } - private shouldStartEditingShape(): boolean { - const { onlySelectedShape } = this.app - if (!onlySelectedShape) return false + private shouldStartEditingShape(shape: TLShape | null = this.app.onlySelectedShape): boolean { + if (!shape) return false + if (this.app.isShapeOrAncestorLocked(shape) && shape.type !== 'embed') return false - const util = this.app.getShapeUtil(onlySelectedShape) - return util.canEdit(onlySelectedShape) + const util = this.app.getShapeUtil(shape) + return util.canEdit(shape) } private shouldEnterCropMode( @@ -341,6 +344,7 @@ export class Idle extends StateNode { ): boolean { const singleShape = this.app.onlySelectedShape if (!singleShape) return false + if (this.app.isShapeOrAncestorLocked(singleShape)) return false const shapeUtil = this.app.getShapeUtil(singleShape) // 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) { + if (this.app.isShapeOrAncestorLocked(shape) && shape.type !== 'embed') return this.app.mark('editing shape') this.app.setEditingId(shape.id) this.parent.transition('editing_shape', info) diff --git a/packages/editor/src/lib/app/statechart/TLSelectTool/children/ScribbleBrushing.ts b/packages/editor/src/lib/app/statechart/TLSelectTool/children/ScribbleBrushing.ts index 51efab035..190d55936 100644 --- a/packages/editor/src/lib/app/statechart/TLSelectTool/children/ScribbleBrushing.ts +++ b/packages/editor/src/lib/app/statechart/TLSelectTool/children/ScribbleBrushing.ts @@ -107,7 +107,8 @@ export class ScribbleBrushing extends StateNode { shape.type === 'group' || this.newlySelectedIds.has(shape.id) || (shape.type === 'frame' && - util.hitTestPoint(shape, this.app.getPointInShapeSpace(shape, originPagePoint))) + util.hitTestPoint(shape, this.app.getPointInShapeSpace(shape, originPagePoint))) || + this.app.isShapeOrAncestorLocked(shape) ) { continue } diff --git a/packages/editor/src/lib/components/SelectionFg.tsx b/packages/editor/src/lib/components/SelectionFg.tsx index c4531989b..e03deb898 100644 --- a/packages/editor/src/lib/components/SelectionFg.tsx +++ b/packages/editor/src/lib/components/SelectionFg.tsx @@ -33,6 +33,7 @@ export const SelectionFg = track(function SelectionFg() { let bounds = app.selectionBounds const shapes = app.selectedShapes 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 const expandOutlineBy = onlyShape @@ -115,13 +116,15 @@ export const SelectionFg = track(function SelectionFg() { !isCoarsePointer && !(isTinyX || isTinyY) && (shouldDisplayControls || showCropHandles) && - (onlyShape ? !app.getShapeUtil(onlyShape).hideRotateHandle(onlyShape) : true) + (onlyShape ? !app.getShapeUtil(onlyShape).hideRotateHandle(onlyShape) : true) && + !isLockedShape const showMobileRotateHandle = isCoarsePointer && (!isSmallX || !isSmallY) && (shouldDisplayControls || showCropHandles) && - (onlyShape ? !app.getShapeUtil(onlyShape).hideRotateHandle(onlyShape) : true) + (onlyShape ? !app.getShapeUtil(onlyShape).hideRotateHandle(onlyShape) : true) && + !isLockedShape const showResizeHandles = shouldDisplayControls && @@ -129,7 +132,8 @@ export const SelectionFg = track(function SelectionFg() { ? app.getShapeUtil(onlyShape).canResize(onlyShape) && !app.getShapeUtil(onlyShape).hideResizeHandles(onlyShape) : true) && - !showCropHandles + !showCropHandles && + !isLockedShape const hideAlternateCornerHandles = isTinyX || isTinyY const showOnlyOneHandle = isTinyX && isTinyY diff --git a/packages/editor/src/lib/test/commands/lockShapes.test.ts b/packages/editor/src/lib/test/commands/lockShapes.test.ts index 5f65bb3b6..92f15f9cc 100644 --- a/packages/editor/src/lib/test/commands/lockShapes.test.ts +++ b/packages/editor/src/lib/test/commands/lockShapes.test.ts @@ -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(() => { -// app = new TestApp() -// }) +beforeEach(() => { + 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', () => { - 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', () => { - 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]) + }) }) diff --git a/packages/ui/api-report.md b/packages/ui/api-report.md index a436a9bd7..208ce111c 100644 --- a/packages/ui/api-report.md +++ b/packages/ui/api-report.md @@ -720,7 +720,7 @@ export type TLTranslation = { }; // @public (undocumented) -export type TLTranslationKey = 'action.align-bottom' | 'action.align-center-horizontal.short' | 'action.align-center-horizontal' | 'action.align-center-vertical.short' | 'action.align-center-vertical' | 'action.align-left' | 'action.align-right' | 'action.align-top' | 'action.back-to-content' | 'action.bring-forward' | 'action.bring-to-front' | 'action.convert-to-bookmark' | 'action.convert-to-embed' | 'action.copy-as-json.short' | 'action.copy-as-json' | 'action.copy-as-png.short' | 'action.copy-as-png' | 'action.copy-as-svg.short' | 'action.copy-as-svg' | 'action.copy' | 'action.cut' | 'action.delete' | 'action.distribute-horizontal.short' | 'action.distribute-horizontal' | 'action.distribute-vertical.short' | 'action.distribute-vertical' | 'action.duplicate' | 'action.edit-link' | 'action.exit-pen-mode' | 'action.export-as-json.short' | 'action.export-as-json' | 'action.export-as-png.short' | 'action.export-as-png' | 'action.export-as-svg.short' | 'action.export-as-svg' | 'action.flip-horizontal.short' | 'action.flip-horizontal' | 'action.flip-vertical.short' | 'action.flip-vertical' | 'action.fork-project' | 'action.group' | 'action.insert-embed' | 'action.insert-media' | 'action.leave-shared-project' | 'action.new-project' | 'action.new-shared-project' | 'action.open-embed-link' | 'action.open-file' | 'action.pack' | 'action.paste' | 'action.print' | 'action.redo' | 'action.rotate-ccw' | 'action.rotate-cw' | 'action.save-copy' | 'action.select-all' | 'action.select-none' | 'action.send-backward' | 'action.send-to-back' | 'action.share-project' | 'action.stack-horizontal.short' | 'action.stack-horizontal' | 'action.stack-vertical.short' | 'action.stack-vertical' | 'action.stop-following' | 'action.stretch-horizontal.short' | 'action.stretch-horizontal' | 'action.stretch-vertical.short' | 'action.stretch-vertical' | 'action.toggle-auto-size' | 'action.toggle-dark-mode.menu' | 'action.toggle-dark-mode' | 'action.toggle-debug-mode.menu' | 'action.toggle-debug-mode' | 'action.toggle-focus-mode.menu' | 'action.toggle-focus-mode' | 'action.toggle-grid.menu' | 'action.toggle-grid' | 'action.toggle-reduce-motion.menu' | 'action.toggle-reduce-motion' | 'action.toggle-snap-mode.menu' | 'action.toggle-snap-mode' | 'action.toggle-tool-lock.menu' | 'action.toggle-tool-lock' | 'action.toggle-transparent.context-menu' | 'action.toggle-transparent.menu' | 'action.toggle-transparent' | 'action.undo' | 'action.ungroup' | 'action.zoom-in' | 'action.zoom-out' | 'action.zoom-to-100' | 'action.zoom-to-fit' | 'action.zoom-to-selection' | 'actions-menu.title' | 'align-style.end' | 'align-style.justify' | 'align-style.middle' | 'align-style.start' | 'arrowheadEnd-style.arrow' | 'arrowheadEnd-style.bar' | 'arrowheadEnd-style.diamond' | 'arrowheadEnd-style.dot' | 'arrowheadEnd-style.inverted' | 'arrowheadEnd-style.none' | 'arrowheadEnd-style.pipe' | 'arrowheadEnd-style.square' | 'arrowheadEnd-style.triangle' | 'arrowheadStart-style.arrow' | 'arrowheadStart-style.bar' | 'arrowheadStart-style.diamond' | 'arrowheadStart-style.dot' | 'arrowheadStart-style.inverted' | 'arrowheadStart-style.none' | 'arrowheadStart-style.pipe' | 'arrowheadStart-style.square' | 'arrowheadStart-style.triangle' | 'color-style.black' | 'color-style.blue' | 'color-style.green' | 'color-style.grey' | 'color-style.light-blue' | 'color-style.light-green' | 'color-style.light-red' | 'color-style.light-violet' | 'color-style.orange' | 'color-style.red' | 'color-style.violet' | 'color-style.yellow' | 'context-menu.arrange' | 'context-menu.copy-as' | 'context-menu.export-as' | 'context-menu.move-to-page' | 'context-menu.reorder' | 'context.pages.new-page' | 'dash-style.dashed' | 'dash-style.dotted' | 'dash-style.draw' | 'dash-style.solid' | 'debug-panel.more' | 'edit-link-dialog.cancel' | 'edit-link-dialog.clear' | 'edit-link-dialog.detail' | 'edit-link-dialog.invalid-url' | 'edit-link-dialog.save' | 'edit-link-dialog.title' | 'edit-link-dialog.url' | 'edit-pages-dialog.move-down' | 'edit-pages-dialog.move-up' | 'embed-dialog.back' | 'embed-dialog.cancel' | 'embed-dialog.create' | 'embed-dialog.instruction' | 'embed-dialog.invalid-url' | 'embed-dialog.title' | 'embed-dialog.url' | 'file-system.confirm-clear.cancel' | 'file-system.confirm-clear.continue' | 'file-system.confirm-clear.description' | 'file-system.confirm-clear.dont-show-again' | 'file-system.confirm-clear.title' | 'file-system.confirm-open.cancel' | 'file-system.confirm-open.description' | 'file-system.confirm-open.dont-show-again' | 'file-system.confirm-open.open' | 'file-system.confirm-open.title' | 'file-system.file-open-error.file-format-version-too-new' | 'file-system.file-open-error.generic-corrupted-file' | 'file-system.file-open-error.not-a-tldraw-file' | 'file-system.file-open-error.title' | 'file-system.shared-document-file-open-error.description' | 'file-system.shared-document-file-open-error.title' | 'fill-style.none' | 'fill-style.pattern' | 'fill-style.semi' | 'fill-style.solid' | 'focus-mode.toggle-focus-mode' | 'font-style.draw' | 'font-style.mono' | 'font-style.sans' | 'font-style.serif' | 'geo-style.arrow-down' | 'geo-style.arrow-left' | 'geo-style.arrow-right' | 'geo-style.arrow-up' | 'geo-style.check-box' | 'geo-style.diamond' | 'geo-style.ellipse' | 'geo-style.hexagon' | 'geo-style.octagon' | 'geo-style.oval' | 'geo-style.pentagon' | 'geo-style.rectangle' | 'geo-style.rhombus-2' | 'geo-style.rhombus' | 'geo-style.star' | 'geo-style.trapezoid' | 'geo-style.triangle' | 'geo-style.x-box' | 'help-menu.about' | 'help-menu.discord' | 'help-menu.github' | 'help-menu.keyboard-shortcuts' | 'help-menu.title' | 'help-menu.twitter' | 'menu.copy-as' | 'menu.edit' | 'menu.export-as' | 'menu.file' | 'menu.language' | 'menu.preferences' | 'menu.title' | 'menu.view' | 'navigation-zone.toggle-minimap' | 'navigation-zone.zoom' | 'opacity-style.0.1' | 'opacity-style.0.25' | 'opacity-style.0.5' | 'opacity-style.0.75' | 'opacity-style.1' | 'page-menu.create-new-page' | 'page-menu.edit-done' | 'page-menu.edit-start' | 'page-menu.go-to-page' | 'page-menu.max-page-count-reached' | 'page-menu.new-page-initial-name' | 'page-menu.submenu.delete' | 'page-menu.submenu.duplicate-page' | 'page-menu.submenu.move-down' | 'page-menu.submenu.move-up' | 'page-menu.submenu.rename' | 'page-menu.submenu.title' | 'page-menu.title' | 'people-menu.change-color' | 'people-menu.change-name' | 'people-menu.follow' | 'people-menu.following' | 'people-menu.invite' | 'people-menu.leading' | 'people-menu.title' | 'people-menu.user' | 'share-menu.copy-link-note' | 'share-menu.copy-link' | 'share-menu.copy-readonly-link-note' | 'share-menu.copy-readonly-link' | 'share-menu.create-snapshot-link' | 'share-menu.fork-note' | 'share-menu.offline-note' | 'share-menu.project-too-large' | 'share-menu.readonly-link' | 'share-menu.save-note' | 'share-menu.share-project' | 'share-menu.snapshot-link-note' | 'share-menu.title' | 'share-menu.upload-failed' | 'sharing.confirm-leave.cancel' | 'sharing.confirm-leave.description' | 'sharing.confirm-leave.dont-show-again' | 'sharing.confirm-leave.leave' | 'sharing.confirm-leave.title' | 'shortcuts-dialog.edit' | 'shortcuts-dialog.file' | 'shortcuts-dialog.preferences' | 'shortcuts-dialog.title' | 'shortcuts-dialog.tools' | 'shortcuts-dialog.transform' | 'shortcuts-dialog.view' | 'size-style.l' | 'size-style.m' | 'size-style.s' | 'size-style.xl' | 'spline-style.cubic' | 'spline-style.line' | 'style-panel.align' | 'style-panel.arrowhead-end' | 'style-panel.arrowhead-start' | 'style-panel.arrowheads' | 'style-panel.color' | 'style-panel.dash' | 'style-panel.fill' | 'style-panel.font' | 'style-panel.geo' | 'style-panel.mixed' | 'style-panel.opacity' | 'style-panel.position' | 'style-panel.size' | 'style-panel.spline' | 'style-panel.title' | 'style-panel.vertical-align' | 'toast.close' | 'toast.error.copy-fail.desc' | 'toast.error.copy-fail.title' | 'toast.error.export-fail.desc' | 'toast.error.export-fail.title' | 'tool-panel.drawing' | 'tool-panel.more' | 'tool-panel.shapes' | 'tool.arrow-down' | 'tool.arrow-left' | 'tool.arrow-right' | 'tool.arrow-up' | 'tool.arrow' | 'tool.asset' | 'tool.check-box' | 'tool.diamond' | 'tool.draw' | 'tool.ellipse' | 'tool.embed' | 'tool.eraser' | 'tool.frame' | 'tool.hand' | 'tool.hexagon' | 'tool.highlight' | 'tool.laser' | 'tool.line' | 'tool.note' | 'tool.octagon' | 'tool.oval' | 'tool.pentagon' | 'tool.rectangle' | 'tool.rhombus' | 'tool.select' | 'tool.star' | 'tool.text' | 'tool.trapezoid' | 'tool.triangle' | 'tool.x-box' | 'vscode.file-open.backup-failed' | 'vscode.file-open.backup-saved' | 'vscode.file-open.backup' | 'vscode.file-open.desc' | 'vscode.file-open.dont-show-again' | 'vscode.file-open.open'; +export type TLTranslationKey = 'action.align-bottom' | 'action.align-center-horizontal.short' | 'action.align-center-horizontal' | 'action.align-center-vertical.short' | 'action.align-center-vertical' | 'action.align-left' | 'action.align-right' | 'action.align-top' | 'action.back-to-content' | 'action.bring-forward' | 'action.bring-to-front' | 'action.convert-to-bookmark' | 'action.convert-to-embed' | 'action.copy-as-json.short' | 'action.copy-as-json' | 'action.copy-as-png.short' | 'action.copy-as-png' | 'action.copy-as-svg.short' | 'action.copy-as-svg' | 'action.copy' | 'action.cut' | 'action.delete' | 'action.distribute-horizontal.short' | 'action.distribute-horizontal' | 'action.distribute-vertical.short' | 'action.distribute-vertical' | 'action.duplicate' | 'action.edit-link' | 'action.exit-pen-mode' | 'action.export-as-json.short' | 'action.export-as-json' | 'action.export-as-png.short' | 'action.export-as-png' | 'action.export-as-svg.short' | 'action.export-as-svg' | 'action.flip-horizontal.short' | 'action.flip-horizontal' | 'action.flip-vertical.short' | 'action.flip-vertical' | 'action.fork-project' | 'action.group' | 'action.insert-embed' | 'action.insert-media' | 'action.leave-shared-project' | 'action.new-project' | 'action.new-shared-project' | 'action.open-embed-link' | 'action.open-file' | 'action.pack' | 'action.paste' | 'action.print' | 'action.redo' | 'action.rotate-ccw' | 'action.rotate-cw' | 'action.save-copy' | 'action.select-all' | 'action.select-none' | 'action.send-backward' | 'action.send-to-back' | 'action.share-project' | 'action.stack-horizontal.short' | 'action.stack-horizontal' | 'action.stack-vertical.short' | 'action.stack-vertical' | 'action.stop-following' | 'action.stretch-horizontal.short' | 'action.stretch-horizontal' | 'action.stretch-vertical.short' | 'action.stretch-vertical' | 'action.toggle-auto-size' | 'action.toggle-dark-mode.menu' | 'action.toggle-dark-mode' | 'action.toggle-debug-mode.menu' | 'action.toggle-debug-mode' | 'action.toggle-focus-mode.menu' | 'action.toggle-focus-mode' | 'action.toggle-grid.menu' | 'action.toggle-grid' | 'action.toggle-lock' | 'action.toggle-reduce-motion.menu' | 'action.toggle-reduce-motion' | 'action.toggle-snap-mode.menu' | 'action.toggle-snap-mode' | 'action.toggle-tool-lock.menu' | 'action.toggle-tool-lock' | 'action.toggle-transparent.context-menu' | 'action.toggle-transparent.menu' | 'action.toggle-transparent' | 'action.undo' | 'action.ungroup' | 'action.zoom-in' | 'action.zoom-out' | 'action.zoom-to-100' | 'action.zoom-to-fit' | 'action.zoom-to-selection' | 'actions-menu.title' | 'align-style.end' | 'align-style.justify' | 'align-style.middle' | 'align-style.start' | 'arrowheadEnd-style.arrow' | 'arrowheadEnd-style.bar' | 'arrowheadEnd-style.diamond' | 'arrowheadEnd-style.dot' | 'arrowheadEnd-style.inverted' | 'arrowheadEnd-style.none' | 'arrowheadEnd-style.pipe' | 'arrowheadEnd-style.square' | 'arrowheadEnd-style.triangle' | 'arrowheadStart-style.arrow' | 'arrowheadStart-style.bar' | 'arrowheadStart-style.diamond' | 'arrowheadStart-style.dot' | 'arrowheadStart-style.inverted' | 'arrowheadStart-style.none' | 'arrowheadStart-style.pipe' | 'arrowheadStart-style.square' | 'arrowheadStart-style.triangle' | 'color-style.black' | 'color-style.blue' | 'color-style.green' | 'color-style.grey' | 'color-style.light-blue' | 'color-style.light-green' | 'color-style.light-red' | 'color-style.light-violet' | 'color-style.orange' | 'color-style.red' | 'color-style.violet' | 'color-style.yellow' | 'context-menu.arrange' | 'context-menu.copy-as' | 'context-menu.export-as' | 'context-menu.move-to-page' | 'context-menu.reorder' | 'context.pages.new-page' | 'dash-style.dashed' | 'dash-style.dotted' | 'dash-style.draw' | 'dash-style.solid' | 'debug-panel.more' | 'edit-link-dialog.cancel' | 'edit-link-dialog.clear' | 'edit-link-dialog.detail' | 'edit-link-dialog.invalid-url' | 'edit-link-dialog.save' | 'edit-link-dialog.title' | 'edit-link-dialog.url' | 'edit-pages-dialog.move-down' | 'edit-pages-dialog.move-up' | 'embed-dialog.back' | 'embed-dialog.cancel' | 'embed-dialog.create' | 'embed-dialog.instruction' | 'embed-dialog.invalid-url' | 'embed-dialog.title' | 'embed-dialog.url' | 'file-system.confirm-clear.cancel' | 'file-system.confirm-clear.continue' | 'file-system.confirm-clear.description' | 'file-system.confirm-clear.dont-show-again' | 'file-system.confirm-clear.title' | 'file-system.confirm-open.cancel' | 'file-system.confirm-open.description' | 'file-system.confirm-open.dont-show-again' | 'file-system.confirm-open.open' | 'file-system.confirm-open.title' | 'file-system.file-open-error.file-format-version-too-new' | 'file-system.file-open-error.generic-corrupted-file' | 'file-system.file-open-error.not-a-tldraw-file' | 'file-system.file-open-error.title' | 'file-system.shared-document-file-open-error.description' | 'file-system.shared-document-file-open-error.title' | 'fill-style.none' | 'fill-style.pattern' | 'fill-style.semi' | 'fill-style.solid' | 'focus-mode.toggle-focus-mode' | 'font-style.draw' | 'font-style.mono' | 'font-style.sans' | 'font-style.serif' | 'geo-style.arrow-down' | 'geo-style.arrow-left' | 'geo-style.arrow-right' | 'geo-style.arrow-up' | 'geo-style.check-box' | 'geo-style.diamond' | 'geo-style.ellipse' | 'geo-style.hexagon' | 'geo-style.octagon' | 'geo-style.oval' | 'geo-style.pentagon' | 'geo-style.rectangle' | 'geo-style.rhombus-2' | 'geo-style.rhombus' | 'geo-style.star' | 'geo-style.trapezoid' | 'geo-style.triangle' | 'geo-style.x-box' | 'help-menu.about' | 'help-menu.discord' | 'help-menu.github' | 'help-menu.keyboard-shortcuts' | 'help-menu.title' | 'help-menu.twitter' | 'menu.copy-as' | 'menu.edit' | 'menu.export-as' | 'menu.file' | 'menu.language' | 'menu.preferences' | 'menu.title' | 'menu.view' | 'navigation-zone.toggle-minimap' | 'navigation-zone.zoom' | 'opacity-style.0.1' | 'opacity-style.0.25' | 'opacity-style.0.5' | 'opacity-style.0.75' | 'opacity-style.1' | 'page-menu.create-new-page' | 'page-menu.edit-done' | 'page-menu.edit-start' | 'page-menu.go-to-page' | 'page-menu.max-page-count-reached' | 'page-menu.new-page-initial-name' | 'page-menu.submenu.delete' | 'page-menu.submenu.duplicate-page' | 'page-menu.submenu.move-down' | 'page-menu.submenu.move-up' | 'page-menu.submenu.rename' | 'page-menu.submenu.title' | 'page-menu.title' | 'people-menu.change-color' | 'people-menu.change-name' | 'people-menu.follow' | 'people-menu.following' | 'people-menu.invite' | 'people-menu.leading' | 'people-menu.title' | 'people-menu.user' | 'share-menu.copy-link-note' | 'share-menu.copy-link' | 'share-menu.copy-readonly-link-note' | 'share-menu.copy-readonly-link' | 'share-menu.create-snapshot-link' | 'share-menu.fork-note' | 'share-menu.offline-note' | 'share-menu.project-too-large' | 'share-menu.readonly-link' | 'share-menu.save-note' | 'share-menu.share-project' | 'share-menu.snapshot-link-note' | 'share-menu.title' | 'share-menu.upload-failed' | 'sharing.confirm-leave.cancel' | 'sharing.confirm-leave.description' | 'sharing.confirm-leave.dont-show-again' | 'sharing.confirm-leave.leave' | 'sharing.confirm-leave.title' | 'shortcuts-dialog.edit' | 'shortcuts-dialog.file' | 'shortcuts-dialog.preferences' | 'shortcuts-dialog.title' | 'shortcuts-dialog.tools' | 'shortcuts-dialog.transform' | 'shortcuts-dialog.view' | 'size-style.l' | 'size-style.m' | 'size-style.s' | 'size-style.xl' | 'spline-style.cubic' | 'spline-style.line' | 'style-panel.align' | 'style-panel.arrowhead-end' | 'style-panel.arrowhead-start' | 'style-panel.arrowheads' | 'style-panel.color' | 'style-panel.dash' | 'style-panel.fill' | 'style-panel.font' | 'style-panel.geo' | 'style-panel.mixed' | 'style-panel.opacity' | 'style-panel.position' | 'style-panel.size' | 'style-panel.spline' | 'style-panel.title' | 'style-panel.vertical-align' | 'toast.close' | 'toast.error.copy-fail.desc' | 'toast.error.copy-fail.title' | 'toast.error.export-fail.desc' | 'toast.error.export-fail.title' | 'tool-panel.drawing' | 'tool-panel.more' | 'tool-panel.shapes' | 'tool.arrow-down' | 'tool.arrow-left' | 'tool.arrow-right' | 'tool.arrow-up' | 'tool.arrow' | 'tool.asset' | 'tool.check-box' | 'tool.diamond' | 'tool.draw' | 'tool.ellipse' | 'tool.embed' | 'tool.eraser' | 'tool.frame' | 'tool.hand' | 'tool.hexagon' | 'tool.highlight' | 'tool.laser' | 'tool.line' | 'tool.note' | 'tool.octagon' | 'tool.oval' | 'tool.pentagon' | 'tool.rectangle' | 'tool.rhombus' | 'tool.select' | 'tool.star' | 'tool.text' | 'tool.trapezoid' | 'tool.triangle' | 'tool.x-box' | 'vscode.file-open.backup-failed' | 'vscode.file-open.backup-saved' | 'vscode.file-open.backup' | 'vscode.file-open.desc' | 'vscode.file-open.dont-show-again' | 'vscode.file-open.open'; // @public (undocumented) export type TLTranslationLocale = TLTranslations[number]['locale']; diff --git a/packages/ui/src/lib/components/ContextMenu.tsx b/packages/ui/src/lib/components/ContextMenu.tsx index 853fa84b9..856982fa5 100644 --- a/packages/ui/src/lib/components/ContextMenu.tsx +++ b/packages/ui/src/lib/components/ContextMenu.tsx @@ -24,7 +24,14 @@ export const ContextMenu = function ContextMenu({ children }: { children: any }) const app = useApp() 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 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() { const app = useApp() const msg = useTranslation() diff --git a/packages/ui/src/lib/hooks/useActions.tsx b/packages/ui/src/lib/hooks/useActions.tsx index e05d41110..e3be9fe02 100644 --- a/packages/ui/src/lib/hooks/useActions.tsx +++ b/packages/ui/src/lib/hooks/useActions.tsx @@ -925,6 +925,16 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) { app.zoomToContent() }, }, + { + id: 'toggle-lock', + label: 'action.toggle-lock', + readonlyOk: false, + kbd: '!$l', + onSelect(source) { + trackEvent('toggle-lock', { source }) + app.toggleLock() + }, + }, ]) if (overrides) { diff --git a/packages/ui/src/lib/hooks/useContextMenuSchema.tsx b/packages/ui/src/lib/hooks/useContextMenuSchema.tsx index 3000edb3b..3d51aeb2b 100644 --- a/packages/ui/src/lib/hooks/useContextMenuSchema.tsx +++ b/packages/ui/src/lib/hooks/useContextMenuSchema.tsx @@ -89,19 +89,22 @@ export const ContextMenuSchemaProvider = track(function ContextMenuSchemaProvide const allowUngroup = useAllowUngroup() const hasClipboardWrite = Boolean(window.navigator.clipboard?.write) const showEditLink = useHasLinkShapeSelected() + const { onlySelectedShape } = app + const isShapeLocked = onlySelectedShape && app.isShapeOrAncestorLocked(onlySelectedShape) const contextMenuSchema = useMemo(() => { let contextMenuSchema: ContextMenuSchemaContextType = compactMenuItems([ menuGroup( 'selection', 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']), showAutoSizeToggle && menuItem(actions['toggle-auto-size']), - showEditLink && menuItem(actions['edit-link']), - oneSelected && menuItem(actions['duplicate']), - allowGroup && menuItem(actions['group']), - allowUngroup && menuItem(actions['ungroup']) + showEditLink && !isShapeLocked && menuItem(actions['edit-link']), + oneSelected && !isShapeLocked && menuItem(actions['duplicate']), + allowGroup && !isShapeLocked && menuItem(actions['group']), + allowUngroup && !isShapeLocked && menuItem(actions['ungroup']), + oneSelected && menuItem(actions['toggle-lock']) ), menuGroup( 'modify', @@ -132,6 +135,7 @@ export const ContextMenuSchemaProvider = track(function ContextMenuSchemaProvide menuItem(actions['stretch-vertical']) ), onlyFlippableShapeSelected && + !isShapeLocked && menuGroup( 'flip', menuItem(actions['flip-horizontal']), @@ -146,6 +150,7 @@ export const ContextMenuSchemaProvider = track(function ContextMenuSchemaProvide ) ), oneSelected && + !isShapeLocked && menuSubmenu( 'reorder', 'context-menu.reorder', @@ -157,11 +162,11 @@ export const ContextMenuSchemaProvider = track(function ContextMenuSchemaProvide menuItem(actions['send-to-back']) ) ), - oneSelected && menuCustom('MOVE_TO_PAGE_MENU', { readonlyOk: false }) + oneSelected && !isShapeLocked && menuCustom('MOVE_TO_PAGE_MENU', { readonlyOk: false }) ), menuGroup( 'clipboard-group', - oneSelected && menuItem(actions['cut']), + oneSelected && !isShapeLocked && menuItem(actions['cut']), oneSelected && menuItem(actions['copy']), showMenuPaste && menuItem(actions['paste']) ), @@ -203,7 +208,7 @@ export const ContextMenuSchemaProvider = track(function ContextMenuSchemaProvide menuItem(actions['select-all']), oneSelected && menuItem(actions['select-none']) ), - oneSelected && menuGroup('delete-group', menuItem(actions['delete'])), + oneSelected && !isShapeLocked && menuGroup('delete-group', menuItem(actions['delete'])), ]) if (overrides) { @@ -237,6 +242,7 @@ export const ContextMenuSchemaProvider = track(function ContextMenuSchemaProvide oneEmbedSelected, oneEmbeddableBookmarkSelected, isTransparentBg, + isShapeLocked, ]) return ( diff --git a/packages/ui/src/lib/hooks/useEventsProvider.tsx b/packages/ui/src/lib/hooks/useEventsProvider.tsx index f3abf0326..5d1ee70ca 100644 --- a/packages/ui/src/lib/hooks/useEventsProvider.tsx +++ b/packages/ui/src/lib/hooks/useEventsProvider.tsx @@ -77,6 +77,7 @@ export interface TLUiEventMap { 'toggle-dark-mode': null 'toggle-focus-mode': null 'toggle-debug-mode': null + 'toggle-lock': null 'toggle-reduce-motion': null 'exit-pen-mode': null 'stop-following': null diff --git a/packages/ui/src/lib/hooks/useTranslation/TLTranslationKey.ts b/packages/ui/src/lib/hooks/useTranslation/TLTranslationKey.ts index cfcd8bada..6f956baac 100644 --- a/packages/ui/src/lib/hooks/useTranslation/TLTranslationKey.ts +++ b/packages/ui/src/lib/hooks/useTranslation/TLTranslationKey.ts @@ -83,6 +83,7 @@ export type TLTranslationKey = | 'action.toggle-focus-mode' | 'action.toggle-grid.menu' | 'action.toggle-grid' + | 'action.toggle-lock' | 'action.toggle-snap-mode.menu' | 'action.toggle-snap-mode' | 'action.toggle-tool-lock.menu' diff --git a/packages/ui/src/lib/hooks/useTranslation/defaultTranslation.ts b/packages/ui/src/lib/hooks/useTranslation/defaultTranslation.ts index 10a00c30a..aaf8d99d3 100644 --- a/packages/ui/src/lib/hooks/useTranslation/defaultTranslation.ts +++ b/packages/ui/src/lib/hooks/useTranslation/defaultTranslation.ts @@ -83,6 +83,7 @@ export const DEFAULT_TRANSLATION = { 'action.toggle-focus-mode': 'Toggle focus mode', 'action.toggle-grid.menu': 'Show grid', 'action.toggle-grid': 'Toggle grid', + 'action.toggle-lock': 'Lock / Unlock', 'action.toggle-snap-mode.menu': 'Always snap', 'action.toggle-snap-mode': 'Toggle always snap', 'action.toggle-tool-lock.menu': 'Tool lock',