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',