Stickies: parenting (of frames and stickies) (#3297)

This PR adds parenting behaviour to stickies, and also changes how
parenting works on the whole.

- Rescaled timings for drag and drop manager. It should feel tighter,
closer to figma.


https://github.com/tldraw/tldraw/assets/15892272/c36c5bba-9964-403e-a5cd-6dcb35e1fe27


- We now 'kick out' any occluded children from their parents. We
manually do this after many interactions and actions.
- We now only parent shapes if their geometry overlaps *as well* as your
cursor dragging over.
- We now hint parents when translating a child inside it.
- We hide the hint if your selected shape will be kicked out of a
frame/note when you let go.
- We now *only* hint if a child will successfully drop inside a parent.
- Removed `shouldHint` option from `onDragShapesOver`. The editor
handles that now.

I will add more gifs demonstrating all these cases.

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [x] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [ ] `bugfix` — Bug fix
- [x] `feature` — New feature
- [x] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### 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>
pull/3328/head
Lu Wilson 2024-04-03 17:15:19 +01:00 zatwierdzone przez GitHub
rodzic df7e3c4d31
commit 3f6b385880
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
28 zmienionych plików z 833 dodań i 232 usunięć

Wyświetl plik

@ -814,7 +814,6 @@ export class Editor extends EventEmitter<TLEventMap> {
pointerVelocity: Vec;
};
interrupt(): this;
isAncestorSelected(shape: TLShape | TLShapeId): boolean;
isIn(path: string): boolean;
isInAny(...paths: string[]): boolean;
isPointInShape(shape: TLShape | TLShapeId, point: VecLike, opts?: {
@ -1446,6 +1445,9 @@ export class Polygon2d extends Polyline2d {
});
}
// @public (undocumented)
export function polygonIntersectsPolyline(polygon: VecLike[], polyline: VecLike[]): boolean;
// @public (undocumented)
export function polygonsIntersect(a: VecLike[], b: VecLike[]): boolean;
@ -1655,9 +1657,7 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
onDoubleClickEdge?: TLOnDoubleClickHandler<Shape>;
onDoubleClickHandle?: TLOnDoubleClickHandleHandler<Shape>;
onDragShapesOut?: TLOnDragHandler<Shape>;
onDragShapesOver?: TLOnDragHandler<Shape, {
shouldHint: boolean;
}>;
onDragShapesOver?: TLOnDragHandler<Shape>;
onDropShapesOver?: TLOnDragHandler<Shape>;
onEditEnd?: TLOnEditEndHandler<Shape>;
onHandleDrag?: TLOnHandleDragHandler<Shape>;

Wyświetl plik

@ -14578,64 +14578,6 @@
"isAbstract": false,
"name": "interrupt"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#isAncestorSelected:member(1)",
"docComment": "/**\n * Determine whether or not any of a shape's ancestors are selected.\n *\n * @param id - The id of the shape to check.\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "isAncestorSelected(shape: "
},
{
"kind": "Reference",
"text": "TLShape",
"canonicalReference": "@tldraw/tlschema!TLShape:type"
},
{
"kind": "Content",
"text": " | "
},
{
"kind": "Reference",
"text": "TLShapeId",
"canonicalReference": "@tldraw/tlschema!TLShapeId:type"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "boolean"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "shape",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 4
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "isAncestorSelected"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#isIn:member(1)",
@ -28079,6 +28021,77 @@
},
"implementsTokenRanges": []
},
{
"kind": "Function",
"canonicalReference": "@tldraw/editor!polygonIntersectsPolyline:function(1)",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export declare function polygonIntersectsPolyline(polygon: "
},
{
"kind": "Reference",
"text": "VecLike",
"canonicalReference": "@tldraw/editor!VecLike:type"
},
{
"kind": "Content",
"text": "[]"
},
{
"kind": "Content",
"text": ", polyline: "
},
{
"kind": "Reference",
"text": "VecLike",
"canonicalReference": "@tldraw/editor!VecLike:type"
},
{
"kind": "Content",
"text": "[]"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "boolean"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/editor/src/lib/primitives/intersect.ts",
"returnTypeTokenRange": {
"startIndex": 7,
"endIndex": 8
},
"releaseTag": "Public",
"overloadIndex": 1,
"parameters": [
{
"parameterName": "polygon",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"isOptional": false
},
{
"parameterName": "polyline",
"parameterTypeTokenRange": {
"startIndex": 4,
"endIndex": 6
},
"isOptional": false
}
],
"name": "polygonIntersectsPolyline"
},
{
"kind": "Function",
"canonicalReference": "@tldraw/editor!polygonsIntersect:function(1)",
@ -31678,7 +31691,7 @@
{
"kind": "Property",
"canonicalReference": "@tldraw/editor!ShapeUtil#onDragShapesOver:member",
"docComment": "/**\n * A callback called when some other shapes are dragged over this one.\n *\n * @param shape - The shape.\n *\n * @param shapes - The shapes that are being dragged over this one.\n *\n * @returns An object specifying whether the shape should hint that it can receive the dragged shapes.\n *\n * @example\n * ```ts\n * onDragShapesOver = (shape, shapes) => {\n * \treturn { shouldHint: true }\n * }\n * ```\n *\n * @public\n */\n",
"docComment": "/**\n * A callback called when some other shapes are dragged over this one.\n *\n * @param shape - The shape.\n *\n * @param shapes - The shapes that are being dragged over this one.\n *\n * @example\n * ```ts\n * onDragShapesOver = (shape, shapes) => {\n * \tthis.editor.reparentShapes(shapes, shape.id)\n * }\n * ```\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
@ -31691,7 +31704,7 @@
},
{
"kind": "Content",
"text": "<Shape, {\n shouldHint: boolean;\n }>"
"text": "<Shape>"
},
{
"kind": "Content",

Wyświetl plik

@ -301,6 +301,7 @@ export {
intersectPolygonBounds,
intersectPolygonPolygon,
linesIntersect,
polygonIntersectsPolyline,
polygonsIntersect,
} from './lib/primitives/intersect'
export {

Wyświetl plik

@ -1517,21 +1517,6 @@ export class Editor extends EventEmitter<TLEventMap> {
}
)
/**
* Determine whether or not any of a shape's ancestors are selected.
*
* @param id - The id of the shape to check.
*
* @public
*/
isAncestorSelected(shape: TLShape | TLShapeId): boolean {
const id = typeof shape === 'string' ? shape : shape?.id ?? null
const _shape = this.getShape(id)
if (!_shape) return false
const selectedShapeIds = this.getSelectedShapeIds()
return !!this.findShapeAncestor(_shape, (parent) => selectedShapeIds.includes(parent.id))
}
/**
* Select one or more shapes.
*
@ -4053,7 +4038,13 @@ export class Editor extends EventEmitter<TLEventMap> {
/** @internal */
@computed private _getShapeMaskCache(): ComputedCache<Vec[], TLShape> {
return this.store.createComputedCache('pageMaskCache', (shape) => {
if (isPageId(shape.parentId)) return undefined
// todo: Consider adding a flag for this hardcoded behaviour
if (
isPageId(shape.parentId) ||
shape.type === 'note' ||
this.findShapeAncestor(shape, (v) => v.type === 'note')
)
return undefined
const frameAncestors = this.getShapeAncestors(shape.id).filter((shape) =>
this.isShapeOfType<TLFrameShape>(shape, 'frame')
@ -5027,6 +5018,8 @@ export class Editor extends EventEmitter<TLEventMap> {
const shape = currentPageShapesSorted[i]
if (
// don't allow dropping on selected shapes
this.getSelectedShapeIds().includes(shape.id) ||
// only allow shapes that can receive children
!this.getShapeUtil(shape).canDropShapes(shape, droppingShapes) ||
// don't allow dropping a shape on itself or one of it's children

Wyświetl plik

@ -334,16 +334,15 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
*
* ```ts
* onDragShapesOver = (shape, shapes) => {
* return { shouldHint: true }
* this.editor.reparentShapes(shapes, shape.id)
* }
* ```
*
* @param shape - The shape.
* @param shapes - The shapes that are being dragged over this one.
* @returns An object specifying whether the shape should hint that it can receive the dragged shapes.
* @public
*/
onDragShapesOver?: TLOnDragHandler<Shape, { shouldHint: boolean }>
onDragShapesOver?: TLOnDragHandler<Shape>
/**
* A callback called when some other shapes are dragged out of this one.

Wyświetl plik

@ -316,3 +316,19 @@ export function polygonsIntersect(a: VecLike[], b: VecLike[]) {
}
return false
}
/** @public */
export function polygonIntersectsPolyline(polygon: VecLike[], polyline: VecLike[]) {
let a: VecLike, b: VecLike, c: VecLike, d: VecLike
for (let i = 0, n = polygon.length; i < n; i++) {
a = polygon[i]
b = polygon[(i + 1) % n]
for (let j = 1, m = polyline.length; j < m; j++) {
c = polyline[j - 1]
d = polyline[j]
if (linesIntersect(a, b, c, d)) return true
}
}
return false
}

Wyświetl plik

@ -94,7 +94,6 @@ import { TLOnBeforeUpdateHandler } from '@tldraw/editor';
import { TLOnDoubleClickHandler } from '@tldraw/editor';
import { TLOnEditEndHandler } from '@tldraw/editor';
import { TLOnHandleDragHandler } from '@tldraw/editor';
import { TLOnResizeEndHandler } from '@tldraw/editor';
import { TLOnResizeHandler } from '@tldraw/editor';
import { TLOnTranslateHandler } from '@tldraw/editor';
import { TLOnTranslateStartHandler } from '@tldraw/editor';
@ -659,14 +658,10 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
// (undocumented)
onDragShapesOut: (_shape: TLFrameShape, shapes: TLShape[]) => void;
// (undocumented)
onDragShapesOver: (frame: TLFrameShape, shapes: TLShape[]) => {
shouldHint: boolean;
};
onDragShapesOver: (frame: TLFrameShape, shapes: TLShape[]) => void;
// (undocumented)
onResize: TLOnResizeHandler<any>;
// (undocumented)
onResizeEnd: TLOnResizeEndHandler<TLFrameShape>;
// (undocumented)
static props: {
w: Validator<number>;
h: Validator<number>;
@ -974,9 +969,15 @@ export class ImageShapeUtil extends BaseBoxShapeUtil<TLImageShape> {
// @public (undocumented)
export function isGifAnimated(file: Blob): Promise<boolean>;
// @internal (undocumented)
export function isShapeOccluded(editor: Editor, occluder: TLShape, shape: TLShapeId): boolean;
// @public (undocumented)
export function KeyboardShortcutsMenuItem(): JSX_2.Element | null;
// @internal (undocumented)
export function kickoutOccludedShapes(editor: Editor, shapeIds: TLShapeId[]): void;
// @public (undocumented)
export const LABEL_FONT_SIZES: Record<TLDefaultSizeStyle, number>;
@ -1096,9 +1097,13 @@ export class NoteShapeTool extends StateNode {
// @public (undocumented)
export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
// (undocumented)
canDropShapes: (shape: TLNoteShape, _shapes: TLShape[]) => boolean;
// (undocumented)
canEdit: () => boolean;
// (undocumented)
canReceiveNewChildrenOfType: (shape: TLNoteShape, type: string) => boolean;
// (undocumented)
component(shape: TLNoteShape): JSX_2.Element;
// (undocumented)
doesAutoEditOnKeyStroke: () => boolean;
@ -1169,6 +1174,10 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
typeName: "shape";
} | undefined;
// (undocumented)
onDragShapesOut: (note: TLNoteShape, shapes: TLShape[]) => void;
// (undocumented)
onDragShapesOver: (note: TLNoteShape, shapes: TLShape[]) => void;
// (undocumented)
onEditEnd: TLOnEditEndHandler<TLNoteShape>;
// (undocumented)
static props: {

Wyświetl plik

@ -7611,7 +7611,7 @@
},
{
"kind": "Content",
"text": "[]) => {\n shouldHint: boolean;\n }"
"text": "[]) => void"
},
{
"kind": "Content",
@ -7665,50 +7665,6 @@
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "tldraw!FrameShapeUtil#onResizeEnd:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "onResizeEnd: "
},
{
"kind": "Reference",
"text": "TLOnResizeEndHandler",
"canonicalReference": "@tldraw/editor!TLOnResizeEndHandler:type"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "TLFrameShape",
"canonicalReference": "@tldraw/tlschema!TLFrameShape:type"
},
{
"kind": "Content",
"text": ">"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "onResizeEnd",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 5
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "tldraw!FrameShapeUtil.props:member",
@ -12824,6 +12780,54 @@
"name": "NoteShapeUtil",
"preserveMemberOrder": false,
"members": [
{
"kind": "Property",
"canonicalReference": "tldraw!NoteShapeUtil#canDropShapes:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "canDropShapes: "
},
{
"kind": "Content",
"text": "(shape: "
},
{
"kind": "Reference",
"text": "TLNoteShape",
"canonicalReference": "@tldraw/tlschema!TLNoteShape:type"
},
{
"kind": "Content",
"text": ", _shapes: "
},
{
"kind": "Reference",
"text": "TLShape",
"canonicalReference": "@tldraw/tlschema!TLShape:type"
},
{
"kind": "Content",
"text": "[]) => boolean"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "canDropShapes",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 6
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "tldraw!NoteShapeUtil#canEdit:member",
@ -12854,6 +12858,45 @@
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "tldraw!NoteShapeUtil#canReceiveNewChildrenOfType:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "canReceiveNewChildrenOfType: "
},
{
"kind": "Content",
"text": "(shape: "
},
{
"kind": "Reference",
"text": "TLNoteShape",
"canonicalReference": "@tldraw/tlschema!TLNoteShape:type"
},
{
"kind": "Content",
"text": ", type: string) => boolean"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "canReceiveNewChildrenOfType",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 4
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{
"kind": "Method",
"canonicalReference": "tldraw!NoteShapeUtil#component:member(1)",
@ -13435,6 +13478,102 @@
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "tldraw!NoteShapeUtil#onDragShapesOut:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "onDragShapesOut: "
},
{
"kind": "Content",
"text": "(note: "
},
{
"kind": "Reference",
"text": "TLNoteShape",
"canonicalReference": "@tldraw/tlschema!TLNoteShape:type"
},
{
"kind": "Content",
"text": ", shapes: "
},
{
"kind": "Reference",
"text": "TLShape",
"canonicalReference": "@tldraw/tlschema!TLShape:type"
},
{
"kind": "Content",
"text": "[]) => void"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "onDragShapesOut",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 6
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "tldraw!NoteShapeUtil#onDragShapesOver:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "onDragShapesOver: "
},
{
"kind": "Content",
"text": "(note: "
},
{
"kind": "Reference",
"text": "TLNoteShape",
"canonicalReference": "@tldraw/tlschema!TLNoteShape:type"
},
{
"kind": "Content",
"text": ", shapes: "
},
{
"kind": "Reference",
"text": "TLShape",
"canonicalReference": "@tldraw/tlschema!TLShape:type"
},
{
"kind": "Content",
"text": "[]) => void"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "onDragShapesOver",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 6
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "tldraw!NoteShapeUtil#onEditEnd:member",

Wyświetl plik

@ -41,6 +41,7 @@ export { EraserTool } from './lib/tools/EraserTool/EraserTool'
export { HandTool } from './lib/tools/HandTool/HandTool'
export { LaserTool } from './lib/tools/LaserTool/LaserTool'
export { SelectTool } from './lib/tools/SelectTool/SelectTool'
export { isShapeOccluded, kickoutOccludedShapes } from './lib/tools/SelectTool/selectHelpers'
export { ZoomTool } from './lib/tools/ZoomTool/ZoomTool'
// UI
export { useEditableText } from './lib/shapes/shared/useEditableText'

Wyświetl plik

@ -306,15 +306,20 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
// If no bound shapes are in the selection, unbind any bound shapes
const selectedShapeIds = this.editor.getSelectedShapeIds()
if (
(startBindingId &&
(selectedShapeIds.includes(startBindingId) ||
this.editor.isAncestorSelected(startBindingId))) ||
(endBindingId &&
(selectedShapeIds.includes(endBindingId) || this.editor.isAncestorSelected(endBindingId)))
) {
return
const shapesToCheck = new Set<string>()
if (startBindingId) {
// Add shape and all ancestors to set
shapesToCheck.add(startBindingId)
this.editor.getShapeAncestors(startBindingId).forEach((a) => shapesToCheck.add(a.id))
}
if (endBindingId) {
// Add shape and all ancestors to set
shapesToCheck.add(endBindingId)
this.editor.getShapeAncestors(endBindingId).forEach((a) => shapesToCheck.add(a.id))
}
// If any of the shapes are selected, return
for (const id of selectedShapeIds) {
if (shapesToCheck.has(id)) return
}
let result = shape

Wyświetl plik

@ -6,10 +6,8 @@ import {
SvgExportContext,
TLFrameShape,
TLGroupShape,
TLOnResizeEndHandler,
TLOnResizeHandler,
TLShape,
TLShapeId,
canonicalizeRotation,
frameShapeMigrations,
frameShapeProps,
@ -207,15 +205,10 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
return !shape.isLocked
}
override onDragShapesOver = (frame: TLFrameShape, shapes: TLShape[]): { shouldHint: boolean } => {
override onDragShapesOver = (frame: TLFrameShape, shapes: TLShape[]) => {
if (!shapes.every((child) => child.parentId === frame.id)) {
this.editor.reparentShapes(
shapes.map((shape) => shape.id),
frame.id
)
return { shouldHint: true }
this.editor.reparentShapes(shapes, frame.id)
}
return { shouldHint: false }
}
override onDragShapesOut = (_shape: TLFrameShape, shapes: TLShape[]): void => {
@ -232,24 +225,6 @@ export class FrameShapeUtil extends BaseBoxShapeUtil<TLFrameShape> {
}
}
override onResizeEnd: TLOnResizeEndHandler<TLFrameShape> = (shape) => {
const bounds = this.editor.getShapePageBounds(shape)!
const children = this.editor.getSortedChildIdsForParent(shape.id)
const shapesToReparent: TLShapeId[] = []
for (const childId of children) {
const childBounds = this.editor.getShapePageBounds(childId)!
if (!bounds.includes(childBounds)) {
shapesToReparent.push(childId)
}
}
if (shapesToReparent.length > 0) {
this.editor.reparentShapes(shapesToReparent, this.editor.getCurrentPageId())
}
}
override onResize: TLOnResizeHandler<any> = (shape, info) => {
return resizeBox(shape, info)
}

Wyświetl plik

@ -204,17 +204,17 @@ describe('Grid placement helpers', () => {
})
})
it('Does note create a new sticky note in a sticky pit if a note is already there', () => {
it('Does not create a new sticky note in a sticky pit if a note is already there', () => {
editor
.createShape({ type: 'note', x: 0, y: 0 })
.createShape({ type: 'note', x: 228, y: 8 }) // make a shape kinda there already!
.createShape({ type: 'note', x: 330, y: 8 }) // make a shape kinda there already!
.setCurrentTool('note')
.pointerMove(324, 104)
.pointerMove(300, 104)
.click()
.expectShapeToMatch({
...editor.getLastCreatedShape(),
// outta da pit
x: 224,
x: 200,
y: 4,
})
})

Wyświetl plik

@ -4,9 +4,11 @@ import {
Rectangle2d,
ShapeUtil,
SvgExportContext,
TLGroupShape,
TLHandle,
TLNoteShape,
TLOnEditEndHandler,
TLShape,
TLShapeId,
Vec,
getDefaultColorTheme,
@ -26,6 +28,7 @@ import { SvgTextLabel } from '../shared/SvgTextLabel'
import { TextLabel } from '../shared/TextLabel'
import { FONT_FAMILIES, LABEL_FONT_SIZES, TEXT_PROPS } from '../shared/default-shape-constants'
import { getFontDefForExport } from '../shared/defaultStyleDefs'
import { useForceSolid } from '../shared/useForceSolid'
import {
ADJACENT_NOTE_MARGIN,
@ -47,6 +50,36 @@ export class NoteShapeUtil extends ShapeUtil<TLNoteShape> {
override hideResizeHandles = () => true
override hideSelectionBoundsFg = () => false
override canReceiveNewChildrenOfType = (shape: TLNoteShape, type: string) => {
return !shape.isLocked && type !== 'frame'
}
override canDropShapes = (shape: TLNoteShape, _shapes: TLShape[]): boolean => {
return !shape.isLocked
}
override onDragShapesOver = (note: TLNoteShape, shapes: TLShape[]) => {
if (!shapes.every((child) => child.parentId === note.id)) {
const shapesWithoutFrames = shapes.filter(
(shape) => !this.editor.isShapeOfType(shape, 'frame')
)
this.editor.reparentShapes(shapesWithoutFrames, note.id)
}
}
override onDragShapesOut = (note: TLNoteShape, shapes: TLShape[]) => {
const parent = this.editor.getShape(note.parentId)
const isInGroup = parent && this.editor.isShapeOfType<TLGroupShape>(parent, 'group')
// If sticky is in a group, keep the shape in that group
if (isInGroup) {
this.editor.reparentShapes(shapes, parent.id)
} else {
this.editor.reparentShapes(shapes, this.editor.getCurrentPageId())
}
}
getDefaultProps(): TLNoteShape['props'] {
return {
color: 'black',

Wyświetl plik

@ -183,7 +183,7 @@ it('Creates an adjacent note when dragging the clone handle', () => {
const handles = editor.getShapeHandles(shapeB.id)!
const handle = handles[1]
const handle = handles[0]
editor.select(shapeB.id)
editor.pointerDown(handle.x, handle.y, {

Wyświetl plik

@ -0,0 +1,77 @@
import { TLShapeId } from '@tldraw/editor'
import { TestEditor } from '../../../test/TestEditor'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
})
afterEach(() => {
editor?.dispose()
})
function clickCreate(tool: string, [x, y]: [number, number]): TLShapeId {
editor.setCurrentTool('note')
editor.pointerDown(x, y)
editor.pointerUp(x, y)
const shapes = editor.getSelectedShapes()
const noteId = shapes[0].id
return noteId
}
function dragCreate(
tool: string,
{
from,
to,
}: {
from: [number, number]
to: [number, number]
}
): TLShapeId {
editor.setCurrentTool(tool)
editor.pointerDown(...from)
editor.pointerMove(...to)
editor.pointerUp(...to)
const shapes = editor.getSelectedShapes()
const rectId = shapes[0].id
return rectId
}
describe('note parenting', () => {
it('accepts shapes as children', () => {
const noteId = clickCreate('note', [0, 0])
const rectId = dragCreate('geo', { from: [-50, -50], to: [50, 50] })
expect(editor.getShape(rectId)!.parentId).toBe(noteId)
})
it("doesn't accept frames as children", () => {
clickCreate('note', [0, 0])
const frameId = dragCreate('frame', { from: [-50, -50], to: [50, 50] })
expect(editor.getShape(frameId)!.parentId).toBe(editor.getCurrentPageId())
})
it('parents shapes when you drag them onto the note', () => {
const noteId = clickCreate('note', [0, 0])
const rectId = dragCreate('geo', { from: [200, 200], to: [300, 300] })
expect(editor.getShape(rectId)!.parentId).toBe(editor.getCurrentPageId())
editor.pointerDown(250, 250)
editor.pointerMove(0, 0)
jest.advanceTimersByTime(200)
expect(editor.getShape(rectId)!.parentId).toBe(noteId)
editor.pointerUp()
})
it("doesn't parent frames when you drag them onto the note", () => {
clickCreate('note', [0, 0])
const frameId = dragCreate('frame', { from: [200, 200], to: [300, 300] })
expect(editor.getShape(frameId)!.parentId).toBe(editor.getCurrentPageId())
editor.pointerDown(250, 250)
editor.pointerMove(0, 0)
jest.advanceTimersByTime(200)
expect(editor.getShape(frameId)!.parentId).toBe(editor.getCurrentPageId())
editor.pointerUp()
})
})

Wyświetl plik

@ -1,6 +1,8 @@
import { Editor, TLShape, TLShapeId, Vec, compact } from '@tldraw/editor'
import { isShapeOccluded } from './selectHelpers'
const LAG_DURATION = 100
const INITIAL_POINTER_LAG_DURATION = 20
const FAST_POINTER_LAG_DURATION = 100
/** @public */
export class DragAndDropManager {
@ -16,6 +18,12 @@ export class DragAndDropManager {
updateDroppingNode(movingShapes: TLShape[], cb: () => void) {
if (this.first) {
this.editor.setHintingShapes(
movingShapes
.map((s) => this.editor.findShapeAncestor(s, (v) => v.type !== 'group'))
.filter((s) => s) as TLShape[]
)
this.prevDroppingShapeId =
this.editor.getDroppingOverShape(this.editor.inputs.originPagePoint, movingShapes)?.id ??
null
@ -23,10 +31,10 @@ export class DragAndDropManager {
}
if (this.droppingNodeTimer === null) {
this.setDragTimer(movingShapes, LAG_DURATION * 10, cb)
this.setDragTimer(movingShapes, INITIAL_POINTER_LAG_DURATION, cb)
} else if (this.editor.inputs.pointerVelocity.len() > 0.5) {
clearInterval(this.droppingNodeTimer)
this.setDragTimer(movingShapes, LAG_DURATION, cb)
this.setDragTimer(movingShapes, FAST_POINTER_LAG_DURATION, cb)
}
}
@ -46,6 +54,7 @@ export class DragAndDropManager {
// is the next dropping shape id different than the last one?
if (nextDroppingShapeId === this.prevDroppingShapeId) {
this.hintParents(movingShapes)
return
}
@ -64,24 +73,42 @@ export class DragAndDropManager {
}
if (nextDroppingShape) {
const res = this.editor
this.editor
.getShapeUtil(nextDroppingShape)
.onDragShapesOver?.(nextDroppingShape, movingShapes)
if (res && res.shouldHint) {
this.editor.setHintingShapes([nextDroppingShape.id])
}
} else {
// If we're dropping onto the page, then clear hinting ids
this.editor.setHintingShapes([])
}
this.hintParents(movingShapes)
cb?.()
// next -> curr
this.prevDroppingShapeId = nextDroppingShapeId
}
hintParents(movingShapes: TLShape[]) {
// Group moving shapes by their ancestor
const shapesGroupedByAncestor = new Map<TLShapeId, TLShape[]>()
for (const shape of movingShapes) {
const ancestor = this.editor.findShapeAncestor(shape, (v) => v.type !== 'group')
if (!ancestor) continue
const shapes = shapesGroupedByAncestor.get(ancestor.id) ?? []
shapes.push(shape)
shapesGroupedByAncestor.set(ancestor.id, shapes)
}
// Only hint an ancestor if some shapes will drop into it on pointer up
const hintingShapes = []
for (const [ancestorId, shapes] of shapesGroupedByAncestor) {
const ancestor = this.editor.getShape(ancestorId)
if (!ancestor) continue
if (shapes.some((shape) => !isShapeOccluded(this.editor, ancestor, shape.id))) {
hintingShapes.push(ancestor.id)
}
}
this.editor.setHintingShapes(hintingShapes)
}
dropShapes(shapes: TLShape[]) {
const { prevDroppingShapeId } = this

Wyświetl plik

@ -11,6 +11,7 @@ import {
Vec,
structuredClone,
} from '@tldraw/editor'
import { kickoutOccludedShapes } from '../selectHelpers'
import { MIN_CROP_SIZE } from './Crop/crop-constants'
import { CursorTypeMap } from './PointingResizeHandle'
@ -206,6 +207,7 @@ export class Cropping extends StateNode {
private complete() {
this.updateShapes()
kickoutOccludedShapes(this.editor, [this.snapshot.shape.id])
if (this.info.onInteractionEnd) {
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)
} else {

Wyświetl plik

@ -16,6 +16,7 @@ import {
sortByIndex,
structuredClone,
} from '@tldraw/editor'
import { kickoutOccludedShapes } from '../selectHelpers'
export class DraggingHandle extends StateNode {
static override id = 'dragging_handle'
@ -203,6 +204,7 @@ export class DraggingHandle extends StateNode {
private complete() {
this.editor.snaps.clearIndicators()
kickoutOccludedShapes(this.editor, [this.shapeId])
const { onInteractionEnd } = this.info
if (this.editor.getInstanceState().isToolLocked && onInteractionEnd) {

Wyświetl plik

@ -19,6 +19,7 @@ import { getHitShapeOnCanvasPointerDown } from '../../selection-logic/getHitShap
import { getShouldEnterCropMode } from '../../selection-logic/getShouldEnterCropModeOnPointerDown'
import { selectOnCanvasPointerUp } from '../../selection-logic/selectOnCanvasPointerUp'
import { updateHoveredId } from '../../selection-logic/updateHoveredId'
import { kickoutOccludedShapes } from '../selectHelpers'
const SKIPPED_KEYS_FOR_AUTO_EDITING = [
'Delete',
@ -268,6 +269,7 @@ export class Idle extends StateNode {
if (change) {
this.editor.mark('double click edge')
this.editor.updateShapes([change])
kickoutOccludedShapes(this.editor, [onlySelectedShape.id])
return
}
}

Wyświetl plik

@ -25,6 +25,9 @@ export class PointingShape extends StateNode {
this.hitShape = info.shape
const outermostSelectingShape = this.editor.getOutermostSelectableShape(info.shape)
const selectedAncestor = this.editor.findShapeAncestor(outermostSelectingShape, (parent) =>
selectedShapeIds.includes(parent.id)
)
if (
// If the shape has an onClick handler
@ -33,7 +36,9 @@ export class PointingShape extends StateNode {
outermostSelectingShape.id === focusedGroupId ||
// ...or if the shape is within the selection
selectedShapeIds.includes(outermostSelectingShape.id) ||
this.editor.isAncestorSelected(outermostSelectingShape.id) ||
// ...or if an ancestor of the shape is selected (except note shapes)...
// todo: Consider adding a flag for this hardcoded behaviour
(selectedAncestor && selectedAncestor.type !== 'note') ||
// ...or if the current point is NOT within the selection bounds
(selectedShapeIds.length > 1 && selectionBounds?.containsPoint(currentPagePoint))
) {

Wyświetl plik

@ -19,6 +19,7 @@ import {
compact,
moveCameraWhenCloseToEdge,
} from '@tldraw/editor'
import { kickoutOccludedShapes } from '../selectHelpers'
type ResizingInfo = TLPointerEventInfo & {
target: 'selection'
@ -111,6 +112,8 @@ export class Resizing extends StateNode {
}
private complete() {
kickoutOccludedShapes(this.editor, this.snapshot.selectedShapeIds)
this.handleResizeEnd()
if (this.info.isCreating && this.info.onCreate) {

Wyświetl plik

@ -10,6 +10,7 @@ import {
shortAngleDist,
snapAngle,
} from '@tldraw/editor'
import { kickoutOccludedShapes } from '../selectHelpers'
import { CursorTypeMap } from './PointingResizeHandle'
const ONE_DEGREE = Math.PI / 180
@ -128,6 +129,10 @@ export class Rotating extends StateNode {
snapshot: this.snapshot,
stage: 'end',
})
kickoutOccludedShapes(
this.editor,
this.snapshot.shapeSnapshots.map((s) => s.shape.id)
)
if (this.info.onInteractionEnd) {
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)
} else {

Wyświetl plik

@ -22,6 +22,7 @@ import {
getAvailableNoteAdjacentPositions,
} from '../../../shapes/note/noteHelpers'
import { DragAndDropManager } from '../DragAndDropManager'
import { kickoutOccludedShapes } from '../selectHelpers'
export class Translating extends StateNode {
static override id = 'translating'
@ -174,6 +175,10 @@ export class Translating extends StateNode {
protected complete() {
this.updateShapes()
this.dragAndDropManager.dropShapes(this.snapshot.movingShapes)
kickoutOccludedShapes(
this.editor,
this.snapshot.movingShapes.map((s) => s.id)
)
this.handleEnd()
if (this.editor.getInstanceState().isToolLocked && this.info.onInteractionEnd) {

Wyświetl plik

@ -0,0 +1,71 @@
import {
Editor,
TLShape,
TLShapeId,
polygonIntersectsPolyline,
polygonsIntersect,
} from '@tldraw/editor'
/** @internal */
export function kickoutOccludedShapes(editor: Editor, shapeIds: TLShapeId[]) {
const shapes = shapeIds.map((id) => editor.getShape(id)).filter((s) => s) as TLShape[]
const effectedParents: TLShape[] = shapes
.map((shape) => {
const parent = editor.getShape(shape.parentId)
if (!parent) return shape
return parent
})
.filter((shape) => shape.type === 'frame' || shape.type === 'note')
const kickedOutChildren: TLShapeId[] = []
for (const parent of effectedParents) {
const childIds = editor.getSortedChildIdsForParent(parent.id)
// Get the bounds of the parent shape
const parentPageBounds = editor.getShapePageBounds(parent)
if (!parentPageBounds) continue
// For each child, check whether its bounds overlap with the parent's bounds
for (const childId of childIds) {
if (isShapeOccluded(editor, parent, childId)) {
kickedOutChildren.push(childId)
}
}
}
// now kick out the children
// TODO: make this reparent to the parent's parent?
editor.reparentShapes(kickedOutChildren, editor.getCurrentPageId())
}
/** @internal */
export function isShapeOccluded(editor: Editor, occluder: TLShape, shape: TLShapeId) {
const occluderPageBounds = editor.getShapePageBounds(occluder)
if (!occluderPageBounds) return false
const shapePageBounds = editor.getShapePageBounds(shape)
if (!shapePageBounds) return true
// If the shape's bounds are completely inside the occluder, it's not occluded
if (occluderPageBounds.contains(shapePageBounds)) {
return false
}
// If the shape's bounds are completely outside the occluder, it's occluded
if (!occluderPageBounds.includes(shapePageBounds)) {
return true
}
// If we've made it this far, the shape's bounds must intersect the edge of the occluder
// In this case, we need to look at the shape's geometry for a more fine-grained check
const shapeGeometry = editor.getShapeGeometry(shape)
const occluderCornersInShapeSpace = occluderPageBounds.corners.map((v) => {
return editor.getPointInShapeSpace(shape, v)
})
if (shapeGeometry.isClosed) {
return !polygonsIntersect(occluderCornersInShapeSpace, shapeGeometry.vertices)
}
return !polygonIntersectsPolyline(occluderCornersInShapeSpace, shapeGeometry.vertices)
}

Wyświetl plik

@ -22,6 +22,7 @@ import {
} from '@tldraw/editor'
import React from 'react'
import { STYLES } from '../../../styles'
import { kickoutOccludedShapes } from '../../../tools/SelectTool/selectHelpers'
import { useUiEvents } from '../../context/events'
import { useRelevantStyles } from '../../hooks/useRelevantStyles'
import { useTranslation } from '../../hooks/useTranslation/useTranslation'
@ -101,6 +102,7 @@ export function CommonStylePickerSet({
theme: TLDefaultColorTheme
}) {
const msg = useTranslation()
const editor = useEditor()
const handleValueChange = useStyleChangeCallback()
@ -163,7 +165,13 @@ export function CommonStylePickerSet({
style={DefaultSizeStyle}
items={STYLES.size}
value={size}
onValueChange={handleValueChange}
onValueChange={(style, value, squashing) => {
handleValueChange(style, value, squashing)
const selectedShapeIds = editor.getSelectedShapeIds()
if (selectedShapeIds.length > 0) {
kickoutOccludedShapes(editor, selectedShapeIds)
}
}}
theme={theme}
/>
)}

Wyświetl plik

@ -20,6 +20,7 @@ import {
useEditor,
} from '@tldraw/editor'
import * as React from 'react'
import { kickoutOccludedShapes } from '../../tools/SelectTool/selectHelpers'
import { getEmbedInfo } from '../../utils/embeds/embeds'
import { fitFrameToContent, removeFrame } from '../../utils/frames/frames'
import { EditLinkDialog } from '../components/EditLinkDialog'
@ -321,24 +322,28 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('toggle-auto-size', { source })
editor.mark('toggling auto size')
const shapes = editor
.getSelectedShapes()
.filter(
(shape): shape is TLTextShape =>
editor.isShapeOfType<TLTextShape>(shape, 'text') && shape.props.autoSize === false
)
editor.updateShapes(
editor
.getSelectedShapes()
.filter(
(shape): shape is TLTextShape =>
editor.isShapeOfType<TLTextShape>(shape, 'text') && shape.props.autoSize === false
)
.map((shape) => {
return {
id: shape.id,
type: shape.type,
props: {
...shape.props,
w: 8,
autoSize: true,
},
}
})
shapes.map((shape) => {
return {
id: shape.id,
type: shape.type,
props: {
...shape.props,
w: 8,
autoSize: true,
},
}
})
)
kickoutOccludedShapes(
editor,
shapes.map((shape) => shape.id)
)
},
},
@ -602,7 +607,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('align-shapes', { operation: 'left', source })
editor.mark('align left')
editor.alignShapes(editor.getSelectedShapeIds(), 'left')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.alignShapes(selectedShapeIds, 'left')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -619,7 +626,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('align-shapes', { operation: 'center-horizontal', source })
editor.mark('align center horizontal')
editor.alignShapes(editor.getSelectedShapeIds(), 'center-horizontal')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.alignShapes(selectedShapeIds, 'center-horizontal')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -633,7 +642,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('align-shapes', { operation: 'right', source })
editor.mark('align right')
editor.alignShapes(editor.getSelectedShapeIds(), 'right')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.alignShapes(selectedShapeIds, 'right')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -650,7 +661,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('align-shapes', { operation: 'center-vertical', source })
editor.mark('align center vertical')
editor.alignShapes(editor.getSelectedShapeIds(), 'center-vertical')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.alignShapes(selectedShapeIds, 'center-vertical')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -664,7 +677,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('align-shapes', { operation: 'top', source })
editor.mark('align top')
editor.alignShapes(editor.getSelectedShapeIds(), 'top')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.alignShapes(selectedShapeIds, 'top')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -678,7 +693,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('align-shapes', { operation: 'bottom', source })
editor.mark('align bottom')
editor.alignShapes(editor.getSelectedShapeIds(), 'bottom')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.alignShapes(selectedShapeIds, 'bottom')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -695,7 +712,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('distribute-shapes', { operation: 'horizontal', source })
editor.mark('distribute horizontal')
editor.distributeShapes(editor.getSelectedShapeIds(), 'horizontal')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.distributeShapes(selectedShapeIds, 'horizontal')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -712,7 +731,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('distribute-shapes', { operation: 'vertical', source })
editor.mark('distribute vertical')
editor.distributeShapes(editor.getSelectedShapeIds(), 'vertical')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.distributeShapes(selectedShapeIds, 'vertical')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -728,7 +749,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('stretch-shapes', { operation: 'horizontal', source })
editor.mark('stretch horizontal')
editor.stretchShapes(editor.getSelectedShapeIds(), 'horizontal')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.stretchShapes(selectedShapeIds, 'horizontal')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -744,7 +767,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('stretch-shapes', { operation: 'vertical', source })
editor.mark('stretch vertical')
editor.stretchShapes(editor.getSelectedShapeIds(), 'vertical')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.stretchShapes(selectedShapeIds, 'vertical')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -760,7 +785,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('flip-shapes', { operation: 'horizontal', source })
editor.mark('flip horizontal')
editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.flipShapes(selectedShapeIds, 'horizontal')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -773,7 +800,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('flip-shapes', { operation: 'vertical', source })
editor.mark('flip vertical')
editor.flipShapes(editor.getSelectedShapeIds(), 'vertical')
const selectedShapeIds = editor.getSelectedShapeIds()
editor.flipShapes(selectedShapeIds, 'vertical')
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -786,7 +815,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('pack-shapes', { source })
editor.mark('pack')
editor.packShapes(editor.getSelectedShapeIds(), 16)
const selectedShapeIds = editor.getSelectedShapeIds()
editor.packShapes(selectedShapeIds, 16)
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -802,7 +833,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('stack-shapes', { operation: 'vertical', source })
editor.mark('stack-vertical')
editor.stackShapes(editor.getSelectedShapeIds(), 'vertical', 16)
const selectedShapeIds = editor.getSelectedShapeIds()
editor.stackShapes(selectedShapeIds, 'vertical', 16)
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -818,7 +851,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
trackEvent('stack-shapes', { operation: 'horizontal', source })
editor.mark('stack-horizontal')
editor.stackShapes(editor.getSelectedShapeIds(), 'horizontal', 16)
const selectedShapeIds = editor.getSelectedShapeIds()
editor.stackShapes(selectedShapeIds, 'horizontal', 16)
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -970,10 +1005,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
editor.mark('rotate-cw')
const offset = editor.getSelectionRotation() % (HALF_PI / 2)
const dontUseOffset = approximately(offset, 0) || approximately(offset, HALF_PI / 2)
editor.rotateShapesBy(
editor.getSelectedShapeIds(),
HALF_PI / 2 - (dontUseOffset ? 0 : offset)
)
const selectedShapeIds = editor.getSelectedShapeIds()
editor.rotateShapesBy(selectedShapeIds, HALF_PI / 2 - (dontUseOffset ? 0 : offset))
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{
@ -988,10 +1022,9 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
editor.mark('rotate-ccw')
const offset = editor.getSelectionRotation() % (HALF_PI / 2)
const offsetCloseToZero = approximately(offset, 0)
editor.rotateShapesBy(
editor.getSelectedShapeIds(),
offsetCloseToZero ? -(HALF_PI / 2) : -offset
)
const selectedShapeIds = editor.getSelectedShapeIds()
editor.rotateShapesBy(selectedShapeIds, offsetCloseToZero ? -(HALF_PI / 2) : -offset)
kickoutOccludedShapes(editor, selectedShapeIds)
},
},
{

Wyświetl plik

@ -1,5 +1,6 @@
import {
DefaultFillStyle,
GeoShapeGeoStyle,
TLArrowShape,
TLFrameShape,
TLShapeId,
@ -275,8 +276,8 @@ describe('frame shapes', () => {
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)
editor.pointerMove(80, 50)
editor.pointerUp(80, 50)
const parentAfter = editor.getShape(rectId)?.parentId
expect(parentAfter).toBe(frameId)
})
@ -405,7 +406,7 @@ describe('frame shapes', () => {
expect(editor.getOnlySelectedShape()!.id).toBe(boxAid)
expect(editor.getOnlySelectedShape()!.parentId).toBe(frameId)
expect(editor.getHintingShapeIds()).toHaveLength(0)
expect(editor.getHintingShapeIds()).toHaveLength(1)
// box A should still be beneath box B
expect(editor.getShape(boxAid)!.index.localeCompare(editor.getShape(boxBid)!.index)).toBe(-1)
})
@ -1024,6 +1025,65 @@ function dragCreateFrame({
return frameId
}
function dragCreateRect({
down,
move,
up,
}: {
down: [number, number]
move: [number, number]
up: [number, number]
}): TLShapeId {
editor.setCurrentTool('geo')
editor.pointerDown(...down)
editor.pointerMove(...move)
editor.pointerUp(...up)
const shapes = editor.getSelectedShapes()
const rectId = shapes[0].id
return rectId
}
function dragCreateTriangle({
down,
move,
up,
}: {
down: [number, number]
move: [number, number]
up: [number, number]
}): TLShapeId {
editor.setCurrentTool('geo')
const originalStyle = editor.getStyleForNextShape(GeoShapeGeoStyle)
editor.setStyleForNextShapes(GeoShapeGeoStyle, 'triangle')
editor.pointerDown(...down)
editor.pointerMove(...move)
editor.pointerUp(...up)
const shapes = editor.getSelectedShapes()
editor.selectNone()
editor.setStyleForNextShapes(GeoShapeGeoStyle, originalStyle)
const rectId = shapes[0].id
editor.select(shapes[0].id)
return rectId
}
function dragCreateLine({
down,
move,
up,
}: {
down: [number, number]
move: [number, number]
up: [number, number]
}): TLShapeId {
editor.setCurrentTool('line')
editor.pointerDown(...down)
editor.pointerMove(...move)
editor.pointerUp(...up)
const shapes = editor.getSelectedShapes()
const lineId = shapes[0].id
return lineId
}
function createRect({ pos, size }: { pos: [number, number]; size: [number, number] }) {
const rectId: TLShapeId = createShapeId()
editor.createShapes([
@ -1037,3 +1097,117 @@ function createRect({ pos, size }: { pos: [number, number]; size: [number, numbe
])
return rectId
}
describe('Unparenting behavior', () => {
it("unparents a shape when it's completely dragged out of a frame, even when the pointer doesn't move across the edge of the frame", () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateRect({ down: [80, 50], move: [120, 60], up: [120, 60] })
const [frame, rect] = editor.getLastCreatedShapes(2)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerDown(110, 50)
editor.pointerMove(140, 50)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerUp(140, 50)
expect(editor.getShape(rect.id)!.parentId).toBe(editor.getCurrentPageId())
})
it("doesn't unparent a shape when it's partially dragged out of a frame, when the pointer doesn't move across the edge of the frame", () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateRect({ down: [80, 50], move: [120, 60], up: [120, 60] })
const [frame, rect] = editor.getLastCreatedShapes(2)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerDown(110, 50)
editor.pointerMove(120, 50)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerUp(120, 50)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
})
it('unparents a shape when the pointer drags across the edge of a frame, even if its geometry overlaps with the frame', () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateRect({ down: [80, 50], move: [120, 60], up: [120, 60] })
const [frame, rect] = editor.getLastCreatedShapes(2)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerDown(90, 50)
editor.pointerMove(110, 50)
jest.advanceTimersByTime(200)
expect(editor.getShape(rect.id)!.parentId).toBe(editor.getCurrentPageId())
editor.pointerUp(110, 50)
expect(editor.getShape(rect.id)!.parentId).toBe(editor.getCurrentPageId())
})
it("unparents a shape when it's rotated out of a frame", () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateRect({ down: [95, 10], move: [200, 20], up: [200, 20] })
const [frame, rect] = editor.getLastCreatedShapes(2)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerDown(200, 20, {
target: 'selection',
handle: 'top_right_rotate',
})
editor.pointerMove(200, 200)
expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
editor.pointerUp(200, 200)
expect(editor.getShape(rect.id)!.parentId).toBe(editor.getCurrentPageId())
})
it("unparents shapes if they're resized out of a frame", () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateRect({ down: [10, 10], move: [20, 20], up: [20, 20] })
dragCreateRect({ down: [80, 80], move: [90, 90], up: [90, 90] })
const [frame, rect1, rect2] = editor.getLastCreatedShapes(3)
editor.select(rect1.id, rect2.id)
editor.pointerDown(90, 90, { target: 'selection', handle: 'top_right' })
expect(editor.getShape(rect2.id)!.parentId).toBe(frame.id)
editor.pointerMove(200, 200)
expect(editor.getShape(rect2.id)!.parentId).toBe(frame.id)
editor.pointerUp(200, 200)
expect(editor.getShape(rect2.id)!.parentId).toBe(editor.getCurrentPageId())
})
it("unparents a shape if its geometry doesn't overlap with the frame", () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateTriangle({ down: [80, 80], move: [120, 120], up: [120, 120] })
const [frame, triangle] = editor.getLastCreatedShapes(2)
expect(editor.getShape(triangle.id)!.parentId).toBe(frame.id)
editor.pointerDown(85, 85)
editor.pointerMove(95, 95)
expect(editor.getShape(triangle.id)!.parentId).toBe(frame.id)
editor.pointerUp(95, 95)
expect(editor.getShape(triangle.id)!.parentId).toBe(editor.getCurrentPageId())
})
it("only parents on pointer up if the shape's geometry overlaps with the frame", () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateTriangle({ down: [120, 120], move: [160, 160], up: [160, 160] })
const [frame, triangle] = editor.getLastCreatedShapes(2)
expect(editor.getShape(triangle.id)!.parentId).toBe(editor.getCurrentPageId())
editor.pointerDown(125, 125)
editor.pointerMove(95, 95)
jest.advanceTimersByTime(200)
expect(editor.getShape(triangle.id)!.parentId).toBe(frame.id)
expect(editor.getHintingShapeIds()).toHaveLength(0)
editor.pointerUp(95, 95)
expect(editor.getShape(triangle.id)!.parentId).toBe(editor.getCurrentPageId())
})
it('unparents an occluded shape after dragging a handle out of a frame', () => {
dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
dragCreateLine({ down: [90, 90], move: [120, 120], up: [120, 120] })
const [frame, line] = editor.getLastCreatedShapes(2)
expect(editor.getShape(line.id)!.parentId).toBe(frame.id)
editor.pointerDown(90, 90)
editor.pointerMove(110, 110)
expect(editor.getShape(line.id)!.parentId).toBe(frame.id)
editor.pointerUp(110, 110)
expect(editor.getShape(line.id)!.parentId).toBe(editor.getCurrentPageId())
})
})

Wyświetl plik

@ -2048,7 +2048,7 @@ describe('Note shape grid helper positions / pits', () => {
it('Snaps multiple notes to the pit using the note under the cursor', () => {
editor.createShape({ type: 'note' })
editor.createShape({ type: 'note', x: 500, y: 500 })
editor.createShape({ type: 'note', x: 700, y: 500 })
editor.createShape({ type: 'note', x: 700, y: 500, parentId: editor.getCurrentPageId() })
const [shapeB, shapeC] = editor.getLastCreatedShapes(2)
const pit = { x: 320, y: 100 } // right of shapeA
@ -2093,6 +2093,9 @@ describe('Note shape grid helper positions / pits', () => {
editor.createShape({ type: 'note', x: 501, y: 501 })
const [shapeB, shapeC] = editor.getLastCreatedShapes(2)
// For the purposes of this test, let's leave the stickies unparented
editor.reparentShapes([shapeC], editor.getCurrentPageId())
const pit = { x: 320, y: 100 } // right of shapeA
editor.select(shapeB, shapeC)