Allow "kickout" on every shape, not just frames, and add drag-and-drop example (#3406)

> Stacked on top of https://github.com/tldraw/tldraw/pull/3405

> This doesn't need to land for the stickies release. We can merge it
afterwards. It's the last bit of work on my 'parenting' spike.

This PR makes `kickoutOccludedShapes` shape-agnostic. It now runs on any
shape that opts-in to the drag and drop manager by having an
`onDragShapesOut` method. This is more of a bug fix than a feature, but
some people might be relying on the bug.

This PR also adds an example for drag and drop.


https://github.com/tldraw/tldraw/assets/15892272/15b66be4-75bd-4c30-a594-340415bc5896


### Change Type

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

- [ ] `sdk` — Changes the tldraw SDK
- [ ] `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
- [ ] `feature` — New feature
- [ ] `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/3422/head
Lu Wilson 2024-04-09 14:47:08 +01:00 zatwierdzone przez GitHub
rodzic 3f33f98434
commit eabcb22b6e
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
7 zmienionych plików z 341 dodań i 62 usunięć

Wyświetl plik

@ -0,0 +1,151 @@
import {
Circle2d,
Geometry2d,
HTMLContainer,
Rectangle2d,
ShapeUtil,
TLBaseShape,
TLShape,
Tldraw,
} from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this file!
// [1]
type MyGridShape = TLBaseShape<'my-grid-shape', Record<string, never>>
type MyCounterShape = TLBaseShape<'my-counter-shape', Record<string, never>>
// [2]
const SLOT_SIZE = 100
class MyCounterShapeUtil extends ShapeUtil<MyCounterShape> {
static override type = 'my-counter-shape' as const
override canResize = () => false
override hideResizeHandles = () => true
getDefaultProps(): MyCounterShape['props'] {
return {}
}
getGeometry(): Geometry2d {
return new Circle2d({ radius: SLOT_SIZE / 2 - 10, isFilled: true })
}
component() {
return (
<HTMLContainer
style={{
backgroundColor: '#e03131',
border: '1px solid #ff8787',
borderRadius: '50%',
}}
/>
)
}
indicator() {
return <circle r={SLOT_SIZE / 2 - 10} cx={SLOT_SIZE / 2 - 10} cy={SLOT_SIZE / 2 - 10} />
}
}
// [3]
class MyGridShapeUtil extends ShapeUtil<MyGridShape> {
static override type = 'my-grid-shape' as const
getDefaultProps(): MyGridShape['props'] {
return {}
}
getGeometry(): Geometry2d {
return new Rectangle2d({
width: SLOT_SIZE * 5,
height: SLOT_SIZE * 2,
isFilled: true,
})
}
override canResize = () => false
override hideResizeHandles = () => true
// [a]
override canDropShapes = (shape: MyGridShape, shapes: TLShape[]) => {
if (shapes.every((s) => s.type === 'my-counter-shape')) {
return true
}
return false
}
// [b]
override onDragShapesOver = (shape: MyGridShape, shapes: TLShape[]) => {
if (!shapes.every((child) => child.parentId === shape.id)) {
this.editor.reparentShapes(shapes, shape.id)
}
}
// [c]
override onDragShapesOut = (shape: MyGridShape, shapes: TLShape[]) => {
this.editor.reparentShapes(shapes, this.editor.getCurrentPageId())
}
component() {
return (
<HTMLContainer
style={{
backgroundColor: '#efefef',
borderRight: '1px solid #ccc',
borderBottom: '1px solid #ccc',
backgroundSize: `${SLOT_SIZE}px ${SLOT_SIZE}px`,
backgroundImage: `
linear-gradient(to right, #ccc 1px, transparent 1px),
linear-gradient(to bottom, #ccc 1px, transparent 1px)
`,
}}
/>
)
}
indicator() {
return <rect width={SLOT_SIZE * 5} height={SLOT_SIZE * 2} />
}
}
export default function DragAndDropExample() {
return (
<div className="tldraw__editor">
<Tldraw
shapeUtils={[MyGridShapeUtil, MyCounterShapeUtil]}
onMount={(editor) => {
editor.createShape({ type: 'my-grid-shape', x: 100, y: 100 })
editor.createShape({ type: 'my-counter-shape', x: 700, y: 100 })
editor.createShape({ type: 'my-counter-shape', x: 750, y: 200 })
editor.createShape({ type: 'my-counter-shape', x: 770, y: 300 })
}}
/>
</div>
)
}
/*
This example demonstrates how to use the drag-and-drop system.
[1] Define some shape types. For the purposes of this example, we'll define two
shapes: a grid and a counter.
[2] Make a shape util for the first shape. For this example, we'll make a simple
red circle that you drag and drop onto the other shape.
[3] Make the other shape util. In this example, we'll make a grid that you can
place the the circle counters onto.
[a] Use the `canDropShapes` method to specify which shapes can be dropped onto
the grid shape.
[b] Use the `onDragShapesOver` method to reparent counters to the grid shape
when they are dragged on top.
[c] Use the `onDragShapesOut` method to reparent counters back to the page
when they are dragged off.
*/

Wyświetl plik

@ -0,0 +1,12 @@
---
title: Drag and drop
component: ./DragAndDropExample.tsx
category: shapes/tools
priority: 1
---
Shapes that can be dragged and dropped onto each other.
---
You can create custom shapes that can be dragged and dropped onto each other.

Wyświetl plik

@ -832,6 +832,9 @@ export function GeoStylePickerSet({ styles }: {
// @public
export function getEmbedInfo(inputUrl: string): TLEmbedResult;
// @public (undocumented)
export function getOccludedChildren(editor: Editor, parent: TLShape): TLShapeId[];
// @public (undocumented)
export function getSvgAsImage(svgString: string, isSafari: boolean, options: {
type: 'jpeg' | 'png' | 'webp';
@ -966,9 +969,6 @@ 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;

Wyświetl plik

@ -9134,6 +9134,74 @@
],
"name": "getEmbedInfo"
},
{
"kind": "Function",
"canonicalReference": "tldraw!getOccludedChildren:function(1)",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export declare function getOccludedChildren(editor: "
},
{
"kind": "Reference",
"text": "Editor",
"canonicalReference": "@tldraw/editor!Editor:class"
},
{
"kind": "Content",
"text": ", parent: "
},
{
"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": ";"
}
],
"fileUrlPath": "packages/tldraw/src/lib/tools/SelectTool/selectHelpers.ts",
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 7
},
"releaseTag": "Public",
"overloadIndex": 1,
"parameters": [
{
"parameterName": "editor",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
},
{
"parameterName": "parent",
"parameterTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"isOptional": false
}
],
"name": "getOccludedChildren"
},
{
"kind": "Function",
"canonicalReference": "tldraw!getSvgAsImage:function(1)",

Wyświetl plik

@ -41,7 +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 { getOccludedChildren, kickoutOccludedShapes } from './lib/tools/SelectTool/selectHelpers'
export { ZoomTool } from './lib/tools/ZoomTool/ZoomTool'
// UI
export { useEditableText } from './lib/shapes/shared/useEditableText'

Wyświetl plik

@ -1,5 +1,5 @@
import { Editor, TLShape, TLShapeId, Vec, compact } from '@tldraw/editor'
import { isShapeOccluded } from './selectHelpers'
import { getOccludedChildren } from './selectHelpers'
const INITIAL_POINTER_LAG_DURATION = 20
const FAST_POINTER_LAG_DURATION = 100
@ -87,21 +87,25 @@ export class DragAndDropManager {
hintParents(movingShapes: TLShape[]) {
// Group moving shapes by their ancestor
const shapesGroupedByAncestor = new Map<TLShapeId, TLShape[]>()
const shapesGroupedByAncestor = new Map<TLShapeId, TLShapeId[]>()
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)
if (!shapesGroupedByAncestor.has(ancestor.id)) {
shapesGroupedByAncestor.set(ancestor.id, [])
}
shapesGroupedByAncestor.get(ancestor.id)!.push(shape.id)
}
// Only hint an ancestor if some shapes will drop into it on pointer up
const hintingShapes = []
for (const [ancestorId, shapes] of shapesGroupedByAncestor) {
for (const [ancestorId, shapeIds] of shapesGroupedByAncestor) {
const ancestor = this.editor.getShape(ancestorId)
if (!ancestor) continue
if (shapes.some((shape) => !isShapeOccluded(this.editor, ancestor, shape.id))) {
// If all of the ancestor's children would be occluded, then don't hint it
// 1. get the number of fully occluded children
// 2. if that number is less than the number of moving shapes, hint the ancestor
if (getOccludedChildren(this.editor, ancestor).length < shapeIds.length) {
hintingShapes.push(ancestor.id)
}
}

Wyświetl plik

@ -1,71 +1,115 @@
import {
Editor,
Geometry2d,
Mat,
TLShape,
TLShapeId,
Vec,
compact,
pointInPolygon,
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')
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)
}
// const shapes = shapeIds.map((id) => editor.getShape(id)).filter((s) => s) as TLShape[]
const parentsToCheck = new Set<TLShape>()
for (const id of shapeIds) {
// If the shape exists and the shape has an onDragShapesOut
// function, add it to the set
const shape = editor.getShape(id)
if (!shape) continue
if (editor.getShapeUtil(shape).onDragShapesOut) {
parentsToCheck.add(shape)
}
// If the shape's parent is a shape and the shape's parent
// has an onDragShapesOut function, add it to the set
const parent = editor.getShape(shape.parentId)
if (!parent) continue
if (editor.getShapeUtil(parent).onDragShapesOut) {
parentsToCheck.add(parent)
}
}
// now kick out the children
// TODO: make this reparent to the parent's parent?
editor.reparentShapes(kickedOutChildren, editor.getCurrentPageId())
const parentsWithKickedOutChildren = new Map<TLShape, TLShapeId[]>()
for (const parent of parentsToCheck) {
const occludedChildren = getOccludedChildren(editor, parent)
if (occludedChildren.length) {
parentsWithKickedOutChildren.set(parent, occludedChildren)
}
}
// now call onDragShapesOut for each parent
for (const [parent, kickedOutChildrenIds] of parentsWithKickedOutChildren) {
const shapeUtil = editor.getShapeUtil(parent)
const kickedOutChildren = compact(kickedOutChildrenIds.map((id) => editor.getShape(id)))
shapeUtil.onDragShapesOut?.(parent, kickedOutChildren)
}
}
/** @internal */
export function isShapeOccluded(editor: Editor, occluder: TLShape, shape: TLShapeId) {
const occluderPageBounds = editor.getShapePageBounds(occluder)
if (!occluderPageBounds) return false
/** @public */
export function getOccludedChildren(editor: Editor, parent: TLShape) {
const childIds = editor.getSortedChildIdsForParent(parent.id)
if (childIds.length === 0) return []
const parentPageBounds = editor.getShapePageBounds(parent)
if (!parentPageBounds) return []
const shapePageBounds = editor.getShapePageBounds(shape)
if (!shapePageBounds) return true
let parentGeometry: Geometry2d | undefined
let parentPageTransform: Mat | undefined
let parentPageCorners: Vec[] | undefined
// If the shape's bounds are completely inside the occluder, it's not occluded
if (occluderPageBounds.contains(shapePageBounds)) {
return false
const results: TLShapeId[] = []
for (const childId of childIds) {
const shapePageBounds = editor.getShapePageBounds(childId)
if (!shapePageBounds) {
// Not occluded, shape doesn't exist
continue
}
if (!parentPageBounds.includes(shapePageBounds)) {
// Not in shape's bounds, shape is occluded
results.push(childId)
continue
}
// There might be a lot of children; we don't want to do this for all of them,
// but we also don't want to do it at all if we don't have to. ??= to the rescue!
parentGeometry ??= editor.getShapeGeometry(parent)
parentPageTransform ??= editor.getShapePageTransform(parent)
parentPageCorners ??= parentPageTransform.applyToPoints(parentGeometry.vertices)
const parentCornersInShapeSpace = editor
.getShapePageTransform(childId)
.clone()
.invert()
.applyToPoints(parentPageCorners)
// If any of the shape's vertices are inside the occluder, it's not occluded
const { vertices, isClosed } = editor.getShapeGeometry(childId)
if (vertices.some((v) => pointInPolygon(v, parentCornersInShapeSpace))) {
// not occluded, vertices are in the occluder's corners
continue
}
// If any the shape's vertices intersect the edge of the occluder, it's not occluded
if (isClosed) {
if (polygonsIntersect(parentCornersInShapeSpace, vertices)) {
// not occluded, vertices intersect parent's corners
continue
}
} else if (polygonIntersectsPolyline(parentCornersInShapeSpace, vertices)) {
// not occluded, vertices intersect parent's corners
continue
}
// Passed all checks, shape is occluded
results.push(childId)
}
// 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)
return results
}