kopia lustrzana https://github.com/Tldraw/Tldraw
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
rodzic
3f33f98434
commit
eabcb22b6e
|
@ -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.
|
||||
|
||||
*/
|
|
@ -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.
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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)",
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Ładowanie…
Reference in New Issue