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>
pull/2270/head
Mitja Bezenšek 2023-11-29 13:01:57 +01:00 zatwierdzone przez GitHub
rodzic 82b6287ab3
commit e2ddbb16f6
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
21 zmienionych plików z 608 dodań i 52 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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>
<FrameHeading
id={shape.id}
name={shape.props.name}
width={bounds.width}
height={bounds.height}
/>
{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)
}
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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
if (isCreating) {
this.markId = `creating:${this.editor.getOnlySelectedShape()!.id}`
} else {
this.markId = 'translating'
this.editor.mark(this.markId)
}
this.markId = isCreating ? `creating:${this.editor.getOnlySelectedShape()!.id}` : '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')
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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