Add support for locking shapes (#1447)

Add support for locking shapes. 

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

Solves #1445 

### Change Type

- [x] `minor` — New Feature

### Test Plan

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

- [x] Unit Tests
- [ ] Webdriver tests

### Release Notes

- Add support for locking shapes.

---------

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

View file

@ -79,6 +79,7 @@
"action.toggle-focus-mode": "Toggle focus mode",
"action.toggle-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",

View file

@ -360,14 +360,13 @@ export class App extends EventEmitter<TLEventMap> {
new (...args: any): TLShapeUtil<T>;
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<TLEventMap> {
stretchShapes(operation: 'horizontal' | 'vertical', ids?: TLShapeId[]): this;
static styles: TLStyleCollections;
textMeasure: TextManager;
// (undocumented)
toggleLock(ids?: TLShapeId[]): this;
undo(): HistoryManager<this>;
// (undocumented)
ungroupShapes(ids?: TLShapeId[]): this;
@ -2033,11 +2034,11 @@ export class TLFrameUtil extends TLBoxUtil<TLFrameShape> {
// (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<T extends TLUnknownShape = TLUnknownShape> {
canCrop: TLShapeUtilFlag<T>;
canDropShapes(shape: T, shapes: TLShape[]): boolean;
canEdit: TLShapeUtilFlag<T>;
canReceiveNewChildrenOfType(type: TLShape['type']): boolean;
canReceiveNewChildrenOfType(shape: T, type: TLShape['type']): boolean;
canResize: TLShapeUtilFlag<T>;
canScroll: TLShapeUtilFlag<T>;
canUnmount: TLShapeUtilFlag<T>;

View file

@ -2221,6 +2221,18 @@ export class App extends EventEmitter<TLEventMap> {
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<TLEventMap> {
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<TLEventMap> {
* @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<TLEventMap> {
}
)
/** @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<TLEventMap> {
* @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<TLEventMap> {
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<TLEventMap> {
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<TLEventMap> {
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))))

View file

@ -148,16 +148,16 @@ export class TLFrameUtil extends TLBoxUtil<TLFrameShape> {
)
}
providesBackgroundForChildren(): boolean {
override canReceiveNewChildrenOfType = (shape: TLShape, _type: TLShape['type']) => {
return !shape.isLocked
}
override providesBackgroundForChildren(): boolean {
return true
}
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 } => {

View file

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

View file

@ -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<TLShapeId>(erasingIdsSet)
for (const shape of shapesArray) {
// Skip groups
if (shape.type === 'group') continue
// Avoid testing masked shapes, unless the pointer is inside the mask

View file

@ -24,6 +24,7 @@ export class Brushing extends StateNode {
brush = new Box2d()
initialSelectedIds: TLShapeId[] = []
excludedShapeIds = new Set<TLShapeId>()
// 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)

View file

@ -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])

View file

@ -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)

View file

@ -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
}

View file

@ -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

View file

@ -1,15 +1,175 @@
// import { TestApp } from '../TestApp'
import { createCustomShapeId } from '@tldraw/tlschema'
import { TestApp } from '../TestApp'
let app: TestApp
// let app: TestApp
const ids = {
lockedShapeA: createCustomShapeId('boxA'),
unlockedShapeA: createCustomShapeId('boxB'),
unlockedShapeB: createCustomShapeId('boxC'),
lockedShapeB: createCustomShapeId('boxD'),
lockedGroup: createCustomShapeId('lockedGroup'),
groupedBoxA: createCustomShapeId('grouppedBoxA'),
groupedBoxB: createCustomShapeId('grouppedBoxB'),
lockedFrame: createCustomShapeId('lockedFrame'),
}
// beforeEach(() => {
// 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])
})
})

File diff suppressed because one or more lines are too long

View file

@ -24,7 +24,14 @@ export const ContextMenu = function ContextMenu({ children }: { children: any })
const app = useApp()
const 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()

View file

@ -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) {

View file

@ -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<MenuSchema>(() => {
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 (

View file

@ -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

View file

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

View file

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