Removing frames and adding elements to frames (#2219)

- Add simple frame removing - it just drops the frame and parent
children to frames parent.
- Select children after removing the frame.
- Add children to the frame if we resize the frame so that it encloses
them.

Describe what your pull request does. If appropriate, add GIFs or images
showing the before and after.

### Change Type

- [ ] `patch` — Bug fix
- [x] `minor` — New feature
- [ ] `major` — Breaking change
- [ ] `dependencies` — Changes to package dependencies[^1]
- [ ] `documentation` — Changes to the documentation only[^2]
- [ ] `tests` — Changes to any test code only[^2]
- [ ] `internal` — Any other changes that don't affect the published
package[^2]
- [ ] I don't know

[^1]: publishes a `patch` release, for devDependencies use `internal`
[^2]: will not publish a new version

### Test Plan

1. Add a step-by-step description of how to test your PR here.
2.

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

- Add a brief release note for your PR here.

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
Co-authored-by: Taha <98838967+Taha-Hassan-Git@users.noreply.github.com>
This commit is contained in:
Mitja Bezenšek 2023-11-29 13:01:57 +01:00 committed by GitHub
parent 82b6287ab3
commit e2ddbb16f6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 608 additions and 52 deletions

View file

@ -53,6 +53,7 @@
"action.paste": "Paste", "action.paste": "Paste",
"action.print": "Print", "action.print": "Print",
"action.redo": "Redo", "action.redo": "Redo",
"action.remove-frame": "Remove frame",
"action.rotate-ccw": "Rotate counterclockwise", "action.rotate-ccw": "Rotate counterclockwise",
"action.rotate-cw": "Rotate clockwise", "action.rotate-cw": "Rotate clockwise",
"action.save-copy": "Save a copy", "action.save-copy": "Save a copy",

View file

@ -147,6 +147,8 @@ export abstract class BaseBoxShapeTool extends StateNode {
// (undocumented) // (undocumented)
static initial: string; static initial: string;
// (undocumented) // (undocumented)
onCreate?: (_shape: null | TLShape) => null | void;
// (undocumented)
abstract shapeType: string; abstract shapeType: string;
} }
@ -885,6 +887,7 @@ export class Editor extends EventEmitter<TLEventMap> {
registerExternalContentHandler<T extends TLExternalContent['type']>(type: T, handler: ((info: T extends TLExternalContent['type'] ? TLExternalContent & { registerExternalContentHandler<T extends TLExternalContent['type']>(type: T, handler: ((info: T extends TLExternalContent['type'] ? TLExternalContent & {
type: T; type: T;
} : TLExternalContent) => void) | null): this; } : TLExternalContent) => void) | null): this;
removeFrame(ids: TLShapeId[]): this;
renamePage(page: TLPage | TLPageId, name: string, historyOptions?: TLCommandHistoryOptions): this; renamePage(page: TLPage | TLPageId, name: string, historyOptions?: TLCommandHistoryOptions): this;
// @deprecated (undocumented) // @deprecated (undocumented)
get renderingBounds(): Box2d; get renderingBounds(): Box2d;

View file

@ -1083,6 +1083,45 @@
"isProtected": false, "isProtected": false,
"isAbstract": false "isAbstract": false
}, },
{
"kind": "Property",
"canonicalReference": "@tldraw/editor!BaseBoxShapeTool#onCreate:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "onCreate?: "
},
{
"kind": "Content",
"text": "(_shape: null | "
},
{
"kind": "Reference",
"text": "TLShape",
"canonicalReference": "@tldraw/tlschema!TLShape:type"
},
{
"kind": "Content",
"text": ") => null | void"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": true,
"releaseTag": "Public",
"name": "onCreate",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 4
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{ {
"kind": "Property", "kind": "Property",
"canonicalReference": "@tldraw/editor!BaseBoxShapeTool#shapeType:member", "canonicalReference": "@tldraw/editor!BaseBoxShapeTool#shapeType:member",
@ -16351,6 +16390,59 @@
"isAbstract": false, "isAbstract": false,
"name": "registerExternalContentHandler" "name": "registerExternalContentHandler"
}, },
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#removeFrame:member(1)",
"docComment": "/**\n * Remove a frame.\n *\n * @param ids - Ids of the frames you wish to remove.\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "removeFrame(ids: "
},
{
"kind": "Reference",
"text": "TLShapeId",
"canonicalReference": "@tldraw/tlschema!TLShapeId:type"
},
{
"kind": "Content",
"text": "[]"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "this"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 4,
"endIndex": 5
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "ids",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "removeFrame"
},
{ {
"kind": "Method", "kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#renamePage:member(1)", "canonicalReference": "@tldraw/editor!Editor#renamePage:member(1)",
@ -19385,7 +19477,7 @@
{ {
"kind": "Method", "kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#updateCurrentPageState:member(1)", "canonicalReference": "@tldraw/editor!Editor#updateCurrentPageState:member(1)",
"docComment": "/**\n * Update this instance's page state.\n *\n * @param partial - The partial of the page state object containing the changes.\n *\n * @param historyOptions - The history options for the change.\n *\n * @example\n * ```ts\n * editor.updateInstancePageState({ id: 'page1', editingShapeId: 'shape:123' })\n * editor.updateInstancePageState({ id: 'page1', editingShapeId: 'shape:123' }, { ephemeral: true })\n * ```\n *\n * @public\n */\n", "docComment": "/**\n * Update this instance's page state.\n *\n * @param partial - The partial of the page state object containing the changes.\n *\n * @param historyOptions - The history options for the change.\n *\n * @example\n * ```ts\n * editor.updateCurrentPageState({ id: 'page1', editingShapeId: 'shape:123' })\n * editor.updateCurrentPageState({ id: 'page1', editingShapeId: 'shape:123' }, { ephemeral: true })\n * ```\n *\n * @public\n */\n",
"excerptTokens": [ "excerptTokens": [
{ {
"kind": "Content", "kind": "Content",

View file

@ -1315,6 +1315,11 @@ input,
stroke-width: calc(1px * var(--tl-scale)); stroke-width: calc(1px * var(--tl-scale));
} }
.tl-frame__creating {
stroke: var(--color-selected);
fill: none;
}
.tl-frame__hitarea { .tl-frame__hitarea {
border-style: solid; border-style: solid;
border-width: calc(8px * var(--tl-scale)); border-width: calc(8px * var(--tl-scale));

View file

@ -1467,8 +1467,8 @@ export class Editor extends EventEmitter<TLEventMap> {
* *
* @example * @example
* ```ts * ```ts
* editor.updateInstancePageState({ id: 'page1', editingShapeId: 'shape:123' }) * editor.updateCurrentPageState({ id: 'page1', editingShapeId: 'shape:123' })
* editor.updateInstancePageState({ id: 'page1', editingShapeId: 'shape:123' }, { ephemeral: true }) * editor.updateCurrentPageState({ id: 'page1', editingShapeId: 'shape:123' }, { ephemeral: true })
* ``` * ```
* *
* @param partial - The partial of the page state object containing the changes. * @param partial - The partial of the page state object containing the changes.
@ -5159,6 +5159,8 @@ export class Editor extends EventEmitter<TLEventMap> {
reparentShapes(shapes: TLShapeId[] | TLShape[], parentId: TLParentId, insertIndex?: string) { reparentShapes(shapes: TLShapeId[] | TLShape[], parentId: TLParentId, insertIndex?: string) {
const ids = const ids =
typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : shapes.map((s) => (s as TLShape).id) typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : shapes.map((s) => (s as TLShape).id)
if (ids.length === 0) return this
const changes: TLShapePartial[] = [] const changes: TLShapePartial[] = []
const parentTransform = isPageId(parentId) const parentTransform = isPageId(parentId)
@ -7317,6 +7319,36 @@ export class Editor extends EventEmitter<TLEventMap> {
return this return this
} }
/**
* Remove a frame.
*
* @param ids - Ids of the frames you wish to remove.
*
* @public
*/
removeFrame(ids: TLShapeId[]): this {
const frames = compact(
ids
.map((id) => this.getShape<TLFrameShape>(id))
.filter((f) => f && this.isShapeOfType<TLFrameShape>(f, 'frame'))
)
if (!frames.length) return this
const allChildren: TLShapeId[] = []
this.batch(() => {
frames.map((frame) => {
const children = this.getSortedChildIdsForParent(frame.id)
if (children.length) {
this.reparentShapes(children, frame.parentId, frame.index)
allChildren.push(...children)
}
})
this.setSelectedShapes(allChildren)
this.deleteShapes(ids)
})
return this
}
/** /**
* Update a shape using a partial of the shape. * Update a shape using a partial of the shape.
* *

View file

@ -1,3 +1,4 @@
import { TLShape } from '@tldraw/tlschema'
import { StateNode } from '../StateNode' import { StateNode } from '../StateNode'
import { Idle } from './children/Idle' import { Idle } from './children/Idle'
import { Pointing } from './children/Pointing' import { Pointing } from './children/Pointing'
@ -9,4 +10,6 @@ export abstract class BaseBoxShapeTool extends StateNode {
static override children = () => [Idle, Pointing] static override children = () => [Idle, Pointing]
abstract override shapeType: string abstract override shapeType: string
onCreate?: (_shape: TLShape | null) => void | null
} }

View file

@ -49,6 +49,7 @@ export class Pointing extends StateNode {
isCreating: true, isCreating: true,
creationCursorOffset: { x: 1, y: 1 }, creationCursorOffset: { x: 1, y: 1 },
onInteractionEnd: this.parent.id, onInteractionEnd: this.parent.id,
onCreate: (this.parent as BaseBoxShapeTool).onCreate,
}) })
} }
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,8 +1,53 @@
import { BaseBoxShapeTool } from '@tldraw/editor' import { BaseBoxShapeTool, TLShape, TLShapeId } from '@tldraw/editor'
/** @public */ /** @public */
export class FrameShapeTool extends BaseBoxShapeTool { export class FrameShapeTool extends BaseBoxShapeTool {
static override id = 'frame' static override id = 'frame'
static override initial = 'idle' static override initial = 'idle'
override shapeType = 'frame' override shapeType = 'frame'
override onCreate = (shape: TLShape | null): void => {
if (!shape) return
const bounds = this.editor.getShapePageBounds(shape)!
const shapesToAddToFrame: TLShapeId[] = []
const ancestorIds = this.editor.getShapeAncestors(shape).map((shape) => shape.id)
this.editor.getCurrentPageShapes().map((pageShape) => {
// We don't want to frame the frame itself
if (pageShape.id === shape.id) return
if (pageShape.isLocked) return
const pageShapeBounds = this.editor.getShapePageBounds(pageShape)
if (!pageShapeBounds) return
// Frame shape encloses page shape
if (bounds.contains(pageShapeBounds)) {
if (canEnclose(pageShape, ancestorIds, shape)) {
shapesToAddToFrame.push(pageShape.id)
}
}
})
this.editor.reparentShapes(shapesToAddToFrame, shape.id)
if (this.editor.getInstanceState().isToolLocked) {
this.editor.setCurrentTool('frame')
} else {
this.editor.setCurrentTool('select.idle')
}
}
}
/** @internal */
function canEnclose(shape: TLShape, ancestorIds: TLShapeId[], frame: TLShape): boolean {
// We don't want to pull in shapes that are ancestors of the frame (can create a cycle)
if (ancestorIds.includes(shape.id)) {
return false
}
// We only want to pull in shapes that are siblings of the frame
if (shape.parentId === frame.parentId) {
return true
}
return false
} }

View file

@ -7,6 +7,7 @@ import {
TLFrameShape, TLFrameShape,
TLGroupShape, TLGroupShape,
TLOnResizeEndHandler, TLOnResizeEndHandler,
TLOnResizeHandler,
TLShape, TLShape,
TLShapeId, TLShapeId,
canonicalizeRotation, canonicalizeRotation,
@ -14,8 +15,11 @@ import {
frameShapeProps, frameShapeProps,
getDefaultColorTheme, getDefaultColorTheme,
last, last,
resizeBox,
toDomPrecision, toDomPrecision,
useValue,
} from '@tldraw/editor' } from '@tldraw/editor'
import classNames from 'classnames'
import { useDefaultColorTheme } from '../shared/ShapeFill' import { useDefaultColorTheme } from '../shared/ShapeFill'
import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans' import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans'
import { FrameHeading } from './components/FrameHeading' import { FrameHeading } from './components/FrameHeading'
@ -54,23 +58,40 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
const theme = useDefaultColorTheme() const theme = useDefaultColorTheme()
// eslint-disable-next-line react-hooks/rules-of-hooks
const isCreating = useValue(
'is creating this shape',
() => {
const resizingState = this.editor.getStateDescendant('select.resizing')
if (!resizingState) return false
if (!resizingState.getIsActive()) return false
const info = (resizingState as typeof resizingState & { info: { isCreating: boolean } })
?.info
if (!info) return false
return info.isCreating && this.editor.getOnlySelectedShape()?.id === shape.id
},
[shape.id]
)
return ( return (
<> <>
<SVGContainer> <SVGContainer>
<rect <rect
className="tl-frame__body" className={classNames('tl-frame__body', { 'tl-frame__creating': isCreating })}
width={bounds.width} width={bounds.width}
height={bounds.height} height={bounds.height}
fill={theme.solid} fill={theme.solid}
stroke={theme.text} stroke={theme.text}
/> />
</SVGContainer> </SVGContainer>
{isCreating ? null : (
<FrameHeading <FrameHeading
id={shape.id} id={shape.id}
name={shape.props.name} name={shape.props.name}
width={bounds.width} width={bounds.width}
height={bounds.height} height={bounds.height}
/> />
)}
</> </>
) )
} }
@ -230,4 +251,8 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
this.editor.reparentShapes(shapesToReparent, this.editor.getCurrentPageId()) this.editor.reparentShapes(shapesToReparent, this.editor.getCurrentPageId())
} }
} }
override onResize: TLOnResizeHandler<any> = (shape, info) => {
return resizeBox(shape, info)
}
} }

View file

@ -37,9 +37,12 @@ export class Pointing extends StateNode {
...info, ...info,
target: 'shape', target: 'shape',
shape: this.shape, shape: this.shape,
isCreating: true,
editAfterComplete: true,
onInteractionEnd: 'note', onInteractionEnd: 'note',
isCreating: true,
onCreate: () => {
this.editor.setEditingShape(this.shape.id)
this.editor.setCurrentTool('select.editing_shape')
},
}) })
} }
} }

View file

@ -41,14 +41,19 @@ export class Pointing extends StateNode {
this.shape = this.editor.getShape(id) this.shape = this.editor.getShape(id)
if (!this.shape) return if (!this.shape) return
const { shape } = this
this.editor.setCurrentTool('select.resizing', { this.editor.setCurrentTool('select.resizing', {
...info, ...info,
target: 'selection', target: 'selection',
handle: 'right', handle: 'right',
isCreating: true, isCreating: true,
creationCursorOffset: { x: 1, y: 1 }, creationCursorOffset: { x: 1, y: 1 },
editAfterComplete: true,
onInteractionEnd: 'text', onInteractionEnd: 'text',
onCreate: () => {
this.editor.setEditingShape(shape.id)
this.editor.setCurrentTool('select.editing_shape')
},
}) })
} }
} }

View file

@ -16,13 +16,14 @@ import {
Vec2d, Vec2d,
VecLike, VecLike,
areAnglesCompatible, areAnglesCompatible,
compact,
} from '@tldraw/editor' } from '@tldraw/editor'
type ResizingInfo = TLPointerEventInfo & { type ResizingInfo = TLPointerEventInfo & {
target: 'selection' target: 'selection'
handle: SelectionEdge | SelectionCorner handle: SelectionEdge | SelectionCorner
isCreating?: boolean isCreating?: boolean
editAfterComplete?: boolean onCreate?: (shape: TLShape | null) => void
creationCursorOffset?: VecLike creationCursorOffset?: VecLike
onInteractionEnd?: string onInteractionEnd?: string
} }
@ -34,41 +35,39 @@ export class Resizing extends StateNode {
markId = '' markId = ''
// A switch to detect when the user is holding ctrl
private didHoldCommand = false
// we transition into the resizing state from the geo pointing state, which starts with a shape of size w: 1, h: 1, // we transition into the resizing state from the geo pointing state, which starts with a shape of size w: 1, h: 1,
// so if the user drags x: +50, y: +50 after mouseDown, the shape will be w: 51, h: 51, which is too many pixels, alas // so if the user drags x: +50, y: +50 after mouseDown, the shape will be w: 51, h: 51, which is too many pixels, alas
// so we allow passing a further offset into this state to negate such issues // so we allow passing a further offset into this state to negate such issues
creationCursorOffset = { x: 0, y: 0 } as VecLike creationCursorOffset = { x: 0, y: 0 } as VecLike
editAfterComplete = false
private snapshot = {} as any as Snapshot private snapshot = {} as any as Snapshot
override onEnter: TLEnterEventHandler = (info: ResizingInfo) => { override onEnter: TLEnterEventHandler = (info: ResizingInfo) => {
const { const { isCreating = false, creationCursorOffset = { x: 0, y: 0 } } = info
isCreating = false,
editAfterComplete = false,
creationCursorOffset = { x: 0, y: 0 },
} = info
this.info = info this.info = info
this.didHoldCommand = false
this.parent.setCurrentToolIdMask(info.onInteractionEnd) this.parent.setCurrentToolIdMask(info.onInteractionEnd)
this.editAfterComplete = editAfterComplete
this.creationCursorOffset = creationCursorOffset this.creationCursorOffset = creationCursorOffset
if (info.isCreating) { this.snapshot = this._createSnapshot()
if (isCreating) {
this.markId = `creating:${this.editor.getOnlySelectedShape()!.id}`
this.editor.updateInstanceState( this.editor.updateInstanceState(
{ cursor: { type: 'cross', rotation: 0 } }, { cursor: { type: 'cross', rotation: 0 } },
{ ephemeral: true } { ephemeral: true }
) )
} else {
this.markId = 'starting resizing'
this.editor.mark(this.markId)
} }
this.snapshot = this._createSnapshot()
this.markId = isCreating
? `creating:${this.editor.getOnlySelectedShape()!.id}`
: 'starting resizing'
if (!isCreating) this.editor.mark(this.markId)
this.handleResizeStart() this.handleResizeStart()
this.updateShapes() this.updateShapes()
} }
@ -109,10 +108,8 @@ export class Resizing extends StateNode {
private complete() { private complete() {
this.handleResizeEnd() this.handleResizeEnd()
const onlySelectedShape = this.editor.getOnlySelectedShape() if (this.info.isCreating && this.info.onCreate) {
if (this.editAfterComplete && onlySelectedShape) { this.info.onCreate?.(this.editor.getOnlySelectedShape())
this.editor.setEditingShape(onlySelectedShape.id)
this.editor.setCurrentTool('select.editing_shape')
return return
} }
@ -164,6 +161,7 @@ export class Resizing extends StateNode {
private updateShapes() { private updateShapes() {
const { altKey, shiftKey } = this.editor.inputs const { altKey, shiftKey } = this.editor.inputs
const { const {
frames,
shapeSnapshots, shapeSnapshots,
selectionBounds, selectionBounds,
cursorHandleOffset, cursorHandleOffset,
@ -316,6 +314,48 @@ export class Resizing extends StateNode {
scaleAxisRotation: selectionRotation, scaleAxisRotation: selectionRotation,
}) })
} }
if (this.editor.inputs.ctrlKey) {
this.didHoldCommand = true
for (const { id, children } of frames) {
if (!children.length) continue
const initial = shapeSnapshots.get(id)!.shape
const current = this.editor.getShape(id)!
if (!(initial && current)) continue
// If the user is holding ctrl, then preseve the position of the frame's children
const dx = current.x - initial.x
const dy = current.y - initial.y
const delta = new Vec2d(dx, dy).rot(-initial.rotation)
if (delta.x !== 0 || delta.y !== 0) {
for (const child of children) {
this.editor.updateShape({
id: child.id,
type: child.type,
x: child.x - delta.x,
y: child.y - delta.y,
})
}
}
}
} else if (this.didHoldCommand) {
this.didHoldCommand = false
for (const { children } of frames) {
if (!children.length) continue
for (const child of children) {
this.editor.updateShape({
id: child.id,
type: child.type,
x: child.x,
y: child.y,
})
}
}
}
} }
// --- // ---
@ -385,9 +425,19 @@ export class Resizing extends StateNode {
const shapeSnapshots = new Map<TLShapeId, ShapeSnapshot>() const shapeSnapshots = new Map<TLShapeId, ShapeSnapshot>()
const frames: { id: TLShapeId; children: TLShape[] }[] = []
selectedShapeIds.forEach((id) => { selectedShapeIds.forEach((id) => {
const shape = this.editor.getShape(id) const shape = this.editor.getShape(id)
if (shape) { if (shape) {
if (shape.type === 'frame') {
frames.push({
id,
children: compact(
this.editor.getSortedChildIdsForParent(shape).map((id) => this.editor.getShape(id))
),
})
}
shapeSnapshots.set(shape.id, this._createShapeSnapshot(shape)) shapeSnapshots.set(shape.id, this._createShapeSnapshot(shape))
if ( if (
this.editor.isShapeOfType<TLFrameShape>(shape, 'frame') && this.editor.isShapeOfType<TLFrameShape>(shape, 'frame') &&
@ -419,6 +469,7 @@ export class Resizing extends StateNode {
selectedShapeIds, selectedShapeIds,
canShapesDeform, canShapesDeform,
initialSelectionPageBounds: this.editor.getSelectionPageBounds()!, initialSelectionPageBounds: this.editor.getSelectionPageBounds()!,
frames,
} }
} }

View file

@ -22,7 +22,7 @@ export class Translating extends StateNode {
info = {} as TLPointerEventInfo & { info = {} as TLPointerEventInfo & {
target: 'shape' target: 'shape'
isCreating?: boolean isCreating?: boolean
editAfterComplete?: boolean onCreate?: () => void
onInteractionEnd?: string onInteractionEnd?: string
} }
@ -34,7 +34,7 @@ export class Translating extends StateNode {
isCloning = false isCloning = false
isCreating = false isCreating = false
editAfterComplete = false onCreate: (shape: TLShape | null) => void = () => void null
dragAndDropManager = new DragAndDropManager(this.editor) dragAndDropManager = new DragAndDropManager(this.editor)
@ -42,19 +42,24 @@ export class Translating extends StateNode {
info: TLPointerEventInfo & { info: TLPointerEventInfo & {
target: 'shape' target: 'shape'
isCreating?: boolean isCreating?: boolean
editAfterComplete?: boolean onCreate?: () => void
onInteractionEnd?: string onInteractionEnd?: string
} }
) => { ) => {
const { isCreating = false, editAfterComplete = false } = info const { isCreating = false, onCreate = () => void null } = info
this.info = info this.info = info
this.parent.setCurrentToolIdMask(info.onInteractionEnd) this.parent.setCurrentToolIdMask(info.onInteractionEnd)
this.isCreating = isCreating this.isCreating = isCreating
this.editAfterComplete = editAfterComplete this.onCreate = onCreate
this.markId = isCreating ? `creating:${this.editor.getOnlySelectedShape()!.id}` : 'translating' if (isCreating) {
this.markId = `creating:${this.editor.getOnlySelectedShape()!.id}`
} else {
this.markId = 'translating'
this.editor.mark(this.markId) this.editor.mark(this.markId)
}
this.isCloning = false this.isCloning = false
this.info = info this.info = info
@ -165,12 +170,8 @@ export class Translating extends StateNode {
if (this.editor.getInstanceState().isToolLocked && this.info.onInteractionEnd) { if (this.editor.getInstanceState().isToolLocked && this.info.onInteractionEnd) {
this.editor.setCurrentTool(this.info.onInteractionEnd) this.editor.setCurrentTool(this.info.onInteractionEnd)
} else { } else {
if (this.editAfterComplete) { if (this.isCreating) {
const onlySelected = this.editor.getOnlySelectedShape() this.onCreate?.(this.editor.getOnlySelectedShape())
if (onlySelected) {
this.editor.setEditingShape(onlySelected.id)
this.editor.setCurrentTool('select.editing_shape')
}
} else { } else {
this.parent.transition('idle') this.parent.transition('idle')
} }

View file

@ -5,6 +5,7 @@ import {
TAU, TAU,
TLBookmarkShape, TLBookmarkShape,
TLEmbedShape, TLEmbedShape,
TLFrameShape,
TLGroupShape, TLGroupShape,
TLShapeId, TLShapeId,
TLShapePartial, TLShapePartial,
@ -455,6 +456,25 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
} }
}, },
}, },
{
id: 'remove-frame',
label: 'action.remove-frame',
kbd: '$!f',
readonlyOk: false,
onSelect(source) {
if (!hasSelectedShapes()) return
trackEvent('remove-frame', { source })
const selectedShapes = editor.getSelectedShapes()
if (
selectedShapes.length > 0 &&
selectedShapes.every((shape) => editor.isShapeOfType<TLFrameShape>(shape, 'frame'))
) {
editor.mark('remove-frame')
editor.removeFrame(selectedShapes.map((shape) => shape.id))
}
},
},
{ {
id: 'align-left', id: 'align-left',
label: 'action.align-left', label: 'action.align-left',

View file

@ -1,4 +1,4 @@
import { Editor, track, useEditor, useValue } from '@tldraw/editor' import { Editor, TLFrameShape, track, useEditor, useValue } from '@tldraw/editor'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { import {
TLUiMenuSchema, TLUiMenuSchema,
@ -55,7 +55,8 @@ export const TLUiContextMenuSchemaProvider = track(function TLUiContextMenuSchem
const onlyFlippableShapeSelected = useOnlyFlippableShape() const onlyFlippableShapeSelected = useOnlyFlippableShape()
const selectedCount = editor.getSelectedShapeIds().length const selectedShapes = editor.getSelectedShapes()
const selectedCount = selectedShapes.length
const oneSelected = selectedCount > 0 const oneSelected = selectedCount > 0
@ -77,6 +78,9 @@ export const TLUiContextMenuSchemaProvider = track(function TLUiContextMenuSchem
const hasClipboardWrite = Boolean(window.navigator.clipboard?.write) const hasClipboardWrite = Boolean(window.navigator.clipboard?.write)
const showEditLink = useHasLinkShapeSelected() const showEditLink = useHasLinkShapeSelected()
const onlySelectedShape = editor.getOnlySelectedShape() const onlySelectedShape = editor.getOnlySelectedShape()
const allowRemoveFrame =
oneSelected &&
selectedShapes.every((shape) => editor.isShapeOfType<TLFrameShape>(shape, 'frame'))
const isShapeLocked = onlySelectedShape && editor.isShapeOrAncestorLocked(onlySelectedShape) const isShapeLocked = onlySelectedShape && editor.isShapeOrAncestorLocked(onlySelectedShape)
const contextTLUiMenuSchema = useMemo<TLUiMenuSchema>(() => { const contextTLUiMenuSchema = useMemo<TLUiMenuSchema>(() => {
@ -88,6 +92,7 @@ export const TLUiContextMenuSchemaProvider = track(function TLUiContextMenuSchem
oneSelected && !isShapeLocked && menuItem(actions['duplicate']), oneSelected && !isShapeLocked && menuItem(actions['duplicate']),
allowGroup && !isShapeLocked && menuItem(actions['group']), allowGroup && !isShapeLocked && menuItem(actions['group']),
allowUngroup && !isShapeLocked && menuItem(actions['ungroup']), allowUngroup && !isShapeLocked && menuItem(actions['ungroup']),
allowRemoveFrame && !isShapeLocked && menuItem(actions['remove-frame']),
oneSelected && menuItem(actions['toggle-lock']) oneSelected && menuItem(actions['toggle-lock'])
), ),
menuGroup( menuGroup(
@ -221,6 +226,7 @@ export const TLUiContextMenuSchemaProvider = track(function TLUiContextMenuSchem
threeStackableItems, threeStackableItems,
allowGroup, allowGroup,
allowUngroup, allowUngroup,
allowRemoveFrame,
hasClipboardWrite, hasClipboardWrite,
showEditLink, showEditLink,
// oneEmbedSelected, // oneEmbedSelected,

View file

@ -27,6 +27,7 @@ export interface TLUiEventMap {
redo: null redo: null
'group-shapes': null 'group-shapes': null
'ungroup-shapes': null 'ungroup-shapes': null
'remove-frame': null
'convert-to-embed': null 'convert-to-embed': null
'convert-to-bookmark': null 'convert-to-bookmark': null
'open-embed-link': null 'open-embed-link': null

View file

@ -57,6 +57,7 @@ export type TLUiTranslationKey =
| 'action.paste' | 'action.paste'
| 'action.print' | 'action.print'
| 'action.redo' | 'action.redo'
| 'action.remove-frame'
| 'action.rotate-ccw' | 'action.rotate-ccw'
| 'action.rotate-cw' | 'action.rotate-cw'
| 'action.save-copy' | 'action.save-copy'

View file

@ -57,6 +57,7 @@ export const DEFAULT_TRANSLATION = {
'action.paste': 'Paste', 'action.paste': 'Paste',
'action.print': 'Print', 'action.print': 'Print',
'action.redo': 'Redo', 'action.redo': 'Redo',
'action.remove-frame': 'Remove frame',
'action.rotate-ccw': 'Rotate counterclockwise', 'action.rotate-ccw': 'Rotate counterclockwise',
'action.rotate-cw': 'Rotate clockwise', 'action.rotate-cw': 'Rotate clockwise',
'action.save-copy': 'Save a copy', 'action.save-copy': 'Save a copy',

View file

@ -1,4 +1,10 @@
import { DefaultFillStyle, TLArrowShape, TLFrameShape, createShapeId } from '@tldraw/editor' import {
DefaultFillStyle,
TLArrowShape,
TLFrameShape,
TLShapeId,
createShapeId,
} from '@tldraw/editor'
import { TestEditor } from './TestEditor' import { TestEditor } from './TestEditor'
let editor: TestEditor let editor: TestEditor
@ -110,6 +116,22 @@ describe('creating frames', () => {
}) })
}) })
it('parents a shape when drag-creating a frame over it', () => {
const rectId: TLShapeId = createRect({ pos: [10, 10], size: [20, 20] })
const frameId = dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
const parent = editor.getShape(rectId)?.parentId
expect(parent).toBe(frameId)
})
it('does not parent a shape when click-creating a frame over it', () => {
const rectId: TLShapeId = createRect({ pos: [10, 10], size: [20, 20] })
editor.setCurrentTool('frame')
editor.pointerDown(0, 0)
editor.pointerUp(0, 0)
const parent = editor.getShape(rectId)?.parentId
expect(parent).toBe('page:page')
})
it('can snap', () => { it('can snap', () => {
editor.createShapes([ editor.createShapes([
{ type: 'geo', id: ids.boxA, x: 0, y: 0, props: { w: 50, h: 50, fill: 'solid' } }, { type: 'geo', id: ids.boxA, x: 0, y: 0, props: { w: 50, h: 50, fill: 'solid' } },
@ -234,6 +256,61 @@ describe('frame shapes', () => {
h: 50, h: 50,
}) })
}) })
it('unparents a shape when resize causes it to be out of bounds', () => {
const rectId: TLShapeId = createRect({ pos: [70, 10], size: [20, 20] })
dragCreateFrame({ down: [10, 10], move: [100, 100], up: [100, 100] })
// resize the frame so the shape is out of bounds
editor.pointerDown(100, 50, { target: 'selection', handle: 'right' })
editor.pointerMove(50, 50)
editor.pointerUp(50, 50)
const parent = editor.getShape(rectId)?.parentId
expect(parent).toBe('page:page')
})
it('doesnt unparent a shape that is only partially out of bounds', () => {
const rectId: TLShapeId = createRect({ pos: [70, 10], size: [20, 20] })
const frameId = dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
const parentBefore = editor.getShape(rectId)?.parentId
expect(parentBefore).toBe(frameId)
// resize the frame so the shape is partially out of bounds
editor.pointerDown(100, 50, { target: 'selection', handle: 'right' })
editor.pointerMove(70, 50)
editor.pointerUp(70, 50)
const parentAfter = editor.getShape(rectId)?.parentId
expect(parentAfter).toBe(frameId)
})
it('does not parent a shape when resizing over it', () => {
const rectId = createRect({ pos: [70, 10], size: [20, 20] })
// create frame next to shape
dragCreateFrame({ down: [10, 10], move: [60, 100], up: [60, 100] })
// resize the frame so the shape is totally covered
editor.pointerDown(60, 50, { target: 'selection', handle: 'right' })
editor.pointerMove(100, 50)
editor.pointerUp(100, 50)
const parent = editor.getShape(rectId)?.parentId
expect(parent).toBe('page:page')
})
it('moves children when resizing a parent frame', () => {
const rectId: TLShapeId = createRect({ size: [20, 20], pos: [10, 10] })
dragCreateFrame({ down: [10, 10], move: [100, 100], up: [100, 100] })
editor.pointerDown(0, 0, { target: 'selection', handle: 'top_left' })
expect(editor.getShape(rectId)?.y).toBe(10)
editor.pointerMove(-50, -50)
editor.pointerUp(-50, -50)
expect(editor.getShape(rectId)?.y).toBe(10)
})
it('does not move children when resizing with cmd key held down', () => {
const rectId: TLShapeId = createRect({ size: [20, 20], pos: [10, 10] })
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
editor.pointerDown(0, 0, { target: 'selection', handle: 'top_left' })
editor.keyDown('Control')
editor.pointerMove(-50, -50)
editor.pointerUp(-50, -50)
expect(editor.getShape(rectId)?.x).toBe(60)
})
it('can have shapes dragged on top and back out', () => { it('can have shapes dragged on top and back out', () => {
editor.setCurrentTool('frame') editor.setCurrentTool('frame')
@ -775,3 +852,79 @@ describe('When dragging a shape inside a group inside a frame', () => {
expect(editor.getShape(ids.box1)!.parentId).toBe(editor.getCurrentPageId()) expect(editor.getShape(ids.box1)!.parentId).toBe(editor.getCurrentPageId())
}) })
}) })
describe('When deleting/removing a frame', () => {
it('deletes a frame and its children', () => {
const rectId: TLShapeId = createRect({ size: [20, 20], pos: [10, 10] })
const frameId = dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
editor.deleteShape(frameId)
expect(editor.getShape(rectId)).toBeUndefined()
})
it('removes a frame but not its children', () => {
const rectId: TLShapeId = createRect({ size: [20, 20], pos: [10, 10] })
const frameId = dragCreateFrame({ down: [10, 10], move: [100, 100], up: [100, 100] })
editor.removeFrame([frameId])
expect(editor.getShape(rectId)).toBeDefined()
})
it('reparents the children of a frame when removing it', () => {
const rectId: TLShapeId = createRect({ size: [20, 20], pos: [10, 10] })
const frame1Id = dragCreateFrame({ down: [10, 10], move: [100, 100], up: [100, 100] })
const frame2Id = dragCreateFrame({ down: [0, 0], move: [110, 110], up: [110, 110] })
editor.removeFrame([frame1Id])
expect(editor.getShape(rectId)?.parentId).toBe(frame2Id)
})
})
describe('When dragging a shape', () => {
it('parents a shape when dragging it into a frame', () => {
const rectId: TLShapeId = createRect({ pos: [70, 10], size: [20, 20] })
// create frame next to shape
const frameId = dragCreateFrame({ down: [0, 0], move: [60, 100], up: [60, 100] })
// drag shape into frame
editor.pointerDown(80, 15)
editor.pointerMove(30, 50)
editor.pointerUp(30, 50)
const parent = editor.getShape(rectId)?.parentId
expect(parent).toBe(frameId)
})
it('Unparents a shape when dragging it out of a frame', () => {
const rectId: TLShapeId = createRect({ pos: [10, 10], size: [20, 20] })
editor.pointerDown(15, 15, { target: 'selection' })
editor.pointerMove(-100, -100)
editor.pointerUp(-100, -100)
const parent = editor.getShape(rectId)?.parentId
expect(parent).toBe('page:page')
})
})
function dragCreateFrame({
down,
move,
up,
}: {
down: [number, number]
move: [number, number]
up: [number, number]
}): TLShapeId {
editor.setCurrentTool('frame')
editor.pointerDown(...down)
editor.pointerMove(...move)
editor.pointerUp(...up)
const shapes = editor.getSelectedShapes()
const frameId = shapes[0].id
return frameId
}
function createRect({ pos, size }: { pos: [number, number]; size: [number, number] }) {
const rectId: TLShapeId = createShapeId()
editor.createShapes([
{
id: rectId,
x: pos[0],
y: pos[1],
props: { w: size[0], h: size[1] },
type: 'geo',
},
])
return rectId
}