[feature] transform snaps (#170)

* transform single

* transform
This commit is contained in:
Steve Ruiz 2021-10-18 15:26:34 +01:00 committed by GitHub
parent c56bf3b0eb
commit 6661ab0586
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 206 additions and 29 deletions

View file

@ -1524,10 +1524,11 @@ left past the initial left edge) then swap points on that axis.
* @param bounds
*/
static getBoundsWithCenter(bounds: TLBounds): TLBounds & { midX: number; midY: number } {
const center = Utils.getBoundsCenter(bounds)
return {
...bounds,
midX: bounds.minX + bounds.width / 2,
midY: bounds.minY + bounds.height / 2,
midX: center[0],
midY: center[1],
}
}

View file

@ -44,3 +44,14 @@ describe('When creating with a transform-single session', () => {
expect(tlstate.getShape('rect1')).toBe(undefined)
})
})
describe('When snapping', () => {
it.todo('Does not snap when moving quicky')
it.todo('Snaps only matching edges when moving slowly')
it.todo('Snaps any edge to any edge when moving very slowly')
it.todo('Snaps a clone to its parent')
it.todo('Cleans up snap lines when cancelled')
it.todo('Cleans up snap lines when completed')
it.todo('Cleans up snap lines when starting to clone / not clone')
it.todo('Does not snap rotated shapes')
})

View file

@ -1,9 +1,19 @@
import { TLBoundsCorner, TLBoundsEdge, Utils } from '@tldraw/core'
import { TLBoundsCorner, TLSnapLine, TLBoundsEdge, Utils, TLBoundsWithCenter } from '@tldraw/core'
import { Vec } from '@tldraw/vec'
import { SessionType, TLDrawShape, TLDrawStatus } from '~types'
import type { Session } from '~types'
import type { Data } from '~types'
import { TLDR } from '~state/tldr'
import { SNAP_DISTANCE } from '~state/constants'
type SnapInfo =
| {
state: 'empty'
}
| {
state: 'ready'
bounds: TLBoundsWithCenter[]
}
export class TransformSingleSession implements Session {
type = SessionType.TransformSingle
@ -14,6 +24,9 @@ export class TransformSingleSession implements Session {
scaleY = 1
isCreate: boolean
snapshot: TransformSingleSnapshot
snapInfo: SnapInfo = { state: 'empty' }
prevPoint = [0, 0]
speed = 1
constructor(
data: Data,
@ -27,12 +40,17 @@ export class TransformSingleSession implements Session {
this.isCreate = isCreate
}
start = () => void null
start = (data: Data) => {
this.createSnapInfo(data)
return void null
}
update = (data: Data, point: number[], shiftKey = false, altKey = false, metaKey = false) => {
const { transformType } = this
const { initialShapeBounds, initialShape, id } = this.snapshot
const { currentPageId, initialShapeBounds, initialShape, id } = this.snapshot
const delta = Vec.sub(point, this.origin)
const shapes = {} as Record<string, Partial<TLDrawShape>>
@ -40,15 +58,55 @@ export class TransformSingleSession implements Session {
const utils = TLDR.getShapeUtils(shape)
const newBounds = Utils.getTransformedBoundingBox(
let newBounds = Utils.getTransformedBoundingBox(
initialShapeBounds,
transformType,
Vec.sub(point, this.origin),
delta,
shape.rotation,
shiftKey || shape.isAspectRatioLocked || utils.isAspectRatioLocked
)
const change = TLDR.getShapeUtils(shape).transformSingle(shape, newBounds, {
// Should we snap?
const speed = Vec.dist(point, this.prevPoint)
this.prevPoint = point
const speedChange = speed - this.speed
this.speed = this.speed + speedChange * (speedChange > 1 ? 0.5 : 0.15)
let snapLines: TLSnapLine[] = []
const { zoom } = data.document.pageStates[currentPageId].camera
if (
!metaKey &&
!initialShape.rotation && // not now anyway
this.speed * zoom < 5 &&
this.snapInfo.state === 'ready'
) {
const snapResult = Utils.getSnapPoints(
Utils.getBoundsWithCenter(newBounds),
this.snapInfo.bounds,
SNAP_DISTANCE / zoom,
this.speed * zoom < 0.45
)
if (snapResult) {
snapLines = snapResult.snapLines
newBounds = Utils.getTransformedBoundingBox(
initialShapeBounds,
transformType,
Vec.sub(delta, snapResult.offset),
shape.rotation,
shiftKey || shape.isAspectRatioLocked || utils.isAspectRatioLocked
)
}
}
const afterShape = TLDR.getShapeUtils(shape).transformSingle(shape, newBounds, {
initialShape,
type: this.transformType,
scaleX: newBounds.scaleX,
@ -56,11 +114,14 @@ export class TransformSingleSession implements Session {
transformOrigin: [0.5, 0.5],
})
if (change) {
shapes[shape.id] = change
if (afterShape) {
shapes[shape.id] = afterShape
}
return {
appState: {
snapLines,
},
document: {
pages: {
[data.appState.currentPageId]: {
@ -83,6 +144,9 @@ export class TransformSingleSession implements Session {
}
return {
appState: {
snapLines: [],
},
document: {
pages: {
[data.appState.currentPageId]: {
@ -115,6 +179,9 @@ export class TransformSingleSession implements Session {
return {
id: 'transform_single',
before: {
appState: {
snapLines: [],
},
document: {
pages: {
[data.appState.currentPageId]: {
@ -131,6 +198,9 @@ export class TransformSingleSession implements Session {
},
},
after: {
appState: {
snapLines: [],
},
document: {
pages: {
[data.appState.currentPageId]: {
@ -148,30 +218,40 @@ export class TransformSingleSession implements Session {
},
}
}
private createSnapInfo = async (data: Data) => {
const { initialShape } = this.snapshot
const { currentPageId } = data.appState
const page = data.document.pages[currentPageId]
this.snapInfo = {
state: 'ready',
bounds: Object.values(page.shapes)
.filter((shape) => shape.id !== initialShape.id)
.map((shape) => Utils.getBoundsWithCenter(TLDR.getRotatedBounds(shape))),
}
}
}
export function getTransformSingleSnapshot(
data: Data,
transformType: TLBoundsEdge | TLBoundsCorner
) {
const shape = TLDR.getShape(
data,
TLDR.getSelectedIds(data, data.appState.currentPageId)[0],
data.appState.currentPageId
)
const { currentPageId } = data.appState
const shape = TLDR.getShape(data, TLDR.getSelectedIds(data, currentPageId)[0], currentPageId)
if (!shape) {
throw Error('You must have one shape selected.')
}
const bounds = TLDR.getBounds(shape)
return {
id: shape.id,
currentPageId,
hasUnlockedShape: !shape.isLocked,
type: transformType,
initialShape: Utils.deepClone(shape),
initialShapeBounds: bounds,
initialShapeBounds: TLDR.getBounds(shape),
commonBounds: TLDR.getRotatedBounds(shape),
}
}

View file

@ -1,9 +1,20 @@
import { TLBoundsCorner, TLBoundsEdge, Utils } from '@tldraw/core'
import { Vec } from '@tldraw/vec'
import type { TLSnapLine, TLBoundsWithCenter } from '@tldraw/core'
import { Session, SessionType, TLDrawShape, TLDrawStatus } from '~types'
import type { Data } from '~types'
import { TLDR } from '~state/tldr'
import type { Command } from 'rko'
import { SNAP_DISTANCE } from '~state/constants'
type SnapInfo =
| {
state: 'empty'
}
| {
state: 'ready'
bounds: TLBoundsWithCenter[]
}
export class TransformSession implements Session {
static type = SessionType.Transform
@ -15,6 +26,9 @@ export class TransformSession implements Session {
snapshot: TransformSnapshot
isCreate: boolean
initialSelectedIds: string[]
snapInfo: SnapInfo = { state: 'empty' }
prevPoint = [0, 0]
speed = 1
constructor(
data: Data,
@ -29,7 +43,10 @@ export class TransformSession implements Session {
this.initialSelectedIds = TLDR.getSelectedIds(data, data.appState.currentPageId)
}
start = () => void null
start = (data: Data) => {
this.createSnapInfo(data)
return void null
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
update = (data: Data, point: number[], shiftKey = false, altKey = false, metaKey = false) => {
@ -42,22 +59,61 @@ export class TransformSession implements Session {
const pageState = TLDR.getPageState(data, data.appState.currentPageId)
const newBoundingBox = Utils.getTransformedBoundingBox(
const delta = Vec.sub(point, this.origin)
let newBounds = Utils.getTransformedBoundingBox(
initialBounds,
transformType,
Vec.sub(point, this.origin),
delta,
pageState.boundsRotation,
shiftKey || isAllAspectRatioLocked
)
// Should we snap?
const speed = Vec.dist(point, this.prevPoint)
this.prevPoint = point
const speedChange = speed - this.speed
this.speed = this.speed + speedChange * (speedChange > 1 ? 0.5 : 0.15)
let snapLines: TLSnapLine[] = []
const { currentPageId } = data.appState
const { zoom } = data.document.pageStates[currentPageId].camera
if (!metaKey && this.speed * zoom < 5 && this.snapInfo.state === 'ready') {
const snapResult = Utils.getSnapPoints(
Utils.getBoundsWithCenter(newBounds),
this.snapInfo.bounds,
SNAP_DISTANCE / zoom,
this.speed * zoom < 0.45
)
if (snapResult) {
snapLines = snapResult.snapLines
newBounds = Utils.getTransformedBoundingBox(
initialBounds,
transformType,
Vec.sub(delta, snapResult.offset),
pageState.boundsRotation,
shiftKey || isAllAspectRatioLocked
)
}
}
// Now work backward to calculate a new bounding box for each of the shapes.
this.scaleX = newBoundingBox.scaleX
this.scaleY = newBoundingBox.scaleY
this.scaleX = newBounds.scaleX
this.scaleY = newBounds.scaleY
shapeBounds.forEach(({ id, initialShape, initialShapeBounds, transformOrigin }) => {
const newShapeBounds = Utils.getRelativeTransformedBoundingBox(
newBoundingBox,
newBounds,
initialBounds,
initialShapeBounds,
this.scaleX < 0,
@ -78,6 +134,9 @@ export class TransformSession implements Session {
})
return {
appState: {
snapLines,
},
document: {
pages: {
[data.appState.currentPageId]: {
@ -100,6 +159,9 @@ export class TransformSession implements Session {
}
return {
appState: {
snapLines: [],
},
document: {
pages: {
[data.appState.currentPageId]: {
@ -145,6 +207,9 @@ export class TransformSession implements Session {
return {
id: 'transform',
before: {
appState: {
snapLines: [],
},
document: {
pages: {
[data.appState.currentPageId]: {
@ -161,6 +226,9 @@ export class TransformSession implements Session {
},
},
after: {
appState: {
snapLines: [],
},
document: {
pages: {
[data.appState.currentPageId]: {
@ -178,11 +246,26 @@ export class TransformSession implements Session {
},
}
}
private createSnapInfo = async (data: Data) => {
const { initialShapeIds } = this.snapshot
const { currentPageId } = data.appState
const page = data.document.pages[currentPageId]
this.snapInfo = {
state: 'ready',
bounds: Object.values(page.shapes)
.filter((shape) => !initialShapeIds.includes(shape.id))
.map((shape) => Utils.getBoundsWithCenter(TLDR.getRotatedBounds(shape))),
}
}
}
export function getTransformSnapshot(data: Data, transformType: TLBoundsEdge | TLBoundsCorner) {
const initialShapes = TLDR.getSelectedBranchSnapshot(data, data.appState.currentPageId)
const initialShapeIds = initialShapes.map((shape) => shape.id)
const hasUnlockedShapes = initialShapes.length > 0
const isAllAspectRatioLocked = initialShapes.every(
@ -205,6 +288,7 @@ export function getTransformSnapshot(data: Data, transformType: TLBoundsEdge | T
type: transformType,
hasUnlockedShapes,
isAllAspectRatioLocked,
initialShapeIds,
initialShapes,
initialBounds: commonBounds,
shapeBounds: initialShapes.map((shape) => {

View file

@ -335,4 +335,5 @@ describe('When snapping', () => {
it.todo('Cleans up snap lines when cancelled')
it.todo('Cleans up snap lines when completed')
it.todo('Cleans up snap lines when starting to clone / not clone')
it.todo('Snaps the rotated bounding box of rotated shapes')
})

View file

@ -137,9 +137,9 @@ export class TranslateSession implements Session {
this.snapLines = []
if (!metaKey && this.speed < 4 && this.snapInfo.state === 'ready') {
const { zoom } = data.document.pageStates[currentPageId].camera
const { zoom } = data.document.pageStates[currentPageId].camera
if (!metaKey && this.speed * zoom < 5 && this.snapInfo.state === 'ready') {
const bounds = Utils.getBoundsWithCenter(
Utils.translateBounds(this.snapshot.commonBounds, delta)
)
@ -153,7 +153,7 @@ export class TranslateSession implements Session {
if (snapResult) {
this.snapLines = snapResult.snapLines
delta = Vec.sub(delta, snapResult?.offset)
delta = Vec.sub(delta, snapResult.offset)
}
}
@ -504,7 +504,7 @@ export class TranslateSession implements Session {
const otherBounds: TLBoundsWithCenter[] = []
Object.values(page.shapes).forEach((shape) => {
const bounds = Utils.getBoundsWithCenter(TLDR.getBounds(shape))
const bounds = Utils.getBoundsWithCenter(TLDR.getRotatedBounds(shape))
allBounds.push(bounds)
if (!selectedIds.includes(shape.id)) {
otherBounds.push(bounds)
@ -655,7 +655,7 @@ export function getTranslateSnapshot(data: Data) {
initialParentChildren[id] = shape.children!
})
const commonBounds = Utils.getCommonBounds(shapesToMove.map(TLDR.getBounds))
const commonBounds = Utils.getCommonBounds(shapesToMove.map(TLDR.getRotatedBounds))
return {
selectedIds,