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:
parent
82b6287ab3
commit
e2ddbb16f6
21 changed files with 608 additions and 52 deletions
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue