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.print": "Print",
"action.redo": "Redo",
"action.remove-frame": "Remove frame",
"action.rotate-ccw": "Rotate counterclockwise",
"action.rotate-cw": "Rotate clockwise",
"action.save-copy": "Save a copy",

View file

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

View file

@ -1083,6 +1083,45 @@
"isProtected": 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",
"canonicalReference": "@tldraw/editor!BaseBoxShapeTool#shapeType:member",
@ -16351,6 +16390,59 @@
"isAbstract": false,
"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",
"canonicalReference": "@tldraw/editor!Editor#renamePage:member(1)",
@ -19385,7 +19477,7 @@
{
"kind": "Method",
"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": [
{
"kind": "Content",

View file

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

View file

@ -1467,8 +1467,8 @@ export class Editor extends EventEmitter<TLEventMap> {
*
* @example
* ```ts
* editor.updateInstancePageState({ id: 'page1', editingShapeId: 'shape:123' })
* editor.updateInstancePageState({ id: 'page1', editingShapeId: 'shape:123' }, { ephemeral: true })
* editor.updateCurrentPageState({ id: 'page1', editingShapeId: 'shape:123' })
* editor.updateCurrentPageState({ id: 'page1', editingShapeId: 'shape:123' }, { ephemeral: true })
* ```
*
* @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) {
const ids =
typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : shapes.map((s) => (s as TLShape).id)
if (ids.length === 0) return this
const changes: TLShapePartial[] = []
const parentTransform = isPageId(parentId)
@ -7317,6 +7319,36 @@ export class Editor extends EventEmitter<TLEventMap> {
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.
*

View file

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

View file

@ -49,6 +49,7 @@ export class Pointing extends StateNode {
isCreating: true,
creationCursorOffset: { x: 1, y: 1 },
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 */
export class FrameShapeTool extends BaseBoxShapeTool {
static override id = 'frame'
static override initial = 'idle'
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,
TLGroupShape,
TLOnResizeEndHandler,
TLOnResizeHandler,
TLShape,
TLShapeId,
canonicalizeRotation,
@ -14,8 +15,11 @@ import {
frameShapeProps,
getDefaultColorTheme,
last,
resizeBox,
toDomPrecision,
useValue,
} from '@tldraw/editor'
import classNames from 'classnames'
import { useDefaultColorTheme } from '../shared/ShapeFill'
import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans'
import { FrameHeading } from './components/FrameHeading'
@ -54,23 +58,40 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
// eslint-disable-next-line react-hooks/rules-of-hooks
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 (
<>
<SVGContainer>
<rect
className="tl-frame__body"
className={classNames('tl-frame__body', { 'tl-frame__creating': isCreating })}
width={bounds.width}
height={bounds.height}
fill={theme.solid}
stroke={theme.text}
/>
</SVGContainer>
{isCreating ? null : (
<FrameHeading
id={shape.id}
name={shape.props.name}
width={bounds.width}
height={bounds.height}
/>
)}
</>
)
}
@ -230,4 +251,8 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
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,
target: 'shape',
shape: this.shape,
isCreating: true,
editAfterComplete: true,
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)
if (!this.shape) return
const { shape } = this
this.editor.setCurrentTool('select.resizing', {
...info,
target: 'selection',
handle: 'right',
isCreating: true,
creationCursorOffset: { x: 1, y: 1 },
editAfterComplete: true,
onInteractionEnd: 'text',
onCreate: () => {
this.editor.setEditingShape(shape.id)
this.editor.setCurrentTool('select.editing_shape')
},
})
}
}

View file

@ -16,13 +16,14 @@ import {
Vec2d,
VecLike,
areAnglesCompatible,
compact,
} from '@tldraw/editor'
type ResizingInfo = TLPointerEventInfo & {
target: 'selection'
handle: SelectionEdge | SelectionCorner
isCreating?: boolean
editAfterComplete?: boolean
onCreate?: (shape: TLShape | null) => void
creationCursorOffset?: VecLike
onInteractionEnd?: string
}
@ -34,41 +35,39 @@ export class Resizing extends StateNode {
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,
// 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
creationCursorOffset = { x: 0, y: 0 } as VecLike
editAfterComplete = false
private snapshot = {} as any as Snapshot
override onEnter: TLEnterEventHandler = (info: ResizingInfo) => {
const {
isCreating = false,
editAfterComplete = false,
creationCursorOffset = { x: 0, y: 0 },
} = info
const { isCreating = false, creationCursorOffset = { x: 0, y: 0 } } = info
this.info = info
this.didHoldCommand = false
this.parent.setCurrentToolIdMask(info.onInteractionEnd)
this.editAfterComplete = editAfterComplete
this.creationCursorOffset = creationCursorOffset
if (info.isCreating) {
this.snapshot = this._createSnapshot()
if (isCreating) {
this.markId = `creating:${this.editor.getOnlySelectedShape()!.id}`
this.editor.updateInstanceState(
{ cursor: { type: 'cross', rotation: 0 } },
{ 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.updateShapes()
}
@ -109,10 +108,8 @@ export class Resizing extends StateNode {
private complete() {
this.handleResizeEnd()
const onlySelectedShape = this.editor.getOnlySelectedShape()
if (this.editAfterComplete && onlySelectedShape) {
this.editor.setEditingShape(onlySelectedShape.id)
this.editor.setCurrentTool('select.editing_shape')
if (this.info.isCreating && this.info.onCreate) {
this.info.onCreate?.(this.editor.getOnlySelectedShape())
return
}
@ -164,6 +161,7 @@ export class Resizing extends StateNode {
private updateShapes() {
const { altKey, shiftKey } = this.editor.inputs
const {
frames,
shapeSnapshots,
selectionBounds,
cursorHandleOffset,
@ -316,6 +314,48 @@ export class Resizing extends StateNode {
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 frames: { id: TLShapeId; children: TLShape[] }[] = []
selectedShapeIds.forEach((id) => {
const shape = this.editor.getShape(id)
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))
if (
this.editor.isShapeOfType<TLFrameShape>(shape, 'frame') &&
@ -419,6 +469,7 @@ export class Resizing extends StateNode {
selectedShapeIds,
canShapesDeform,
initialSelectionPageBounds: this.editor.getSelectionPageBounds()!,
frames,
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -57,6 +57,7 @@ export const DEFAULT_TRANSLATION = {
'action.paste': 'Paste',
'action.print': 'Print',
'action.redo': 'Redo',
'action.remove-frame': 'Remove frame',
'action.rotate-ccw': 'Rotate counterclockwise',
'action.rotate-cw': 'Rotate clockwise',
'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'
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', () => {
editor.createShapes([
{ 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,
})
})
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', () => {
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())
})
})
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
}