init: the first public commit for AFFiNE

This commit is contained in:
DarkSky
2022-07-22 15:49:21 +08:00
commit e3e3741393
1451 changed files with 108124 additions and 0 deletions
@@ -0,0 +1,581 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {
ArrowBinding,
ArrowShape,
TDShape,
TDBinding,
TDStatus,
SessionType,
TDShapeType,
TldrawPatch,
TldrawCommand,
} from '@toeverything/components/board-types';
import { Vec } from '@tldraw/vec';
import { TLDR, deepCopy } from '@toeverything/components/board-state';
import { shapeUtils } from '@toeverything/components/board-shapes';
import { BaseSession } from './base-session';
import type { TldrawApp } from '@toeverything/components/board-state';
import { Utils } from '@tldraw/core';
export class ArrowSession extends BaseSession {
type = SessionType.Arrow;
performanceMode: undefined;
status = TDStatus.TranslatingHandle;
newStartBindingId = Utils.uniqueId();
draggedBindingId = Utils.uniqueId();
didBind = false;
initialShape: ArrowShape;
handleId: 'start' | 'end';
bindableShapeIds: string[];
initialBinding?: TDBinding;
startBindingShapeId?: string;
isCreate: boolean;
constructor(
app: TldrawApp,
shapeId: string,
handleId: 'start' | 'end',
isCreate = false
) {
super(app);
this.isCreate = isCreate;
const { currentPageId } = app.state.appState;
const page = app.state.document.pages[currentPageId];
this.handleId = handleId;
this.initialShape = deepCopy(page.shapes[shapeId] as ArrowShape);
this.bindableShapeIds = TLDR.get_bindable_shape_ids(app.state).filter(
id =>
!(
id === this.initialShape.id ||
id === this.initialShape.parentId
)
);
// TODO: find out why this the oppositeHandleBindingId is sometimes missing
const oppositeHandleBindingId =
this.initialShape.handles[handleId === 'start' ? 'end' : 'start']
?.bindingId;
if (oppositeHandleBindingId) {
const oppositeToId = page.bindings[oppositeHandleBindingId]?.toId;
if (oppositeToId) {
this.bindableShapeIds = this.bindableShapeIds.filter(
id => id !== oppositeToId
);
}
}
const { originPoint } = this.app;
if (this.isCreate) {
// If we're creating a new shape, should we bind its first point?
// The method may return undefined, which is correct if there is no
// bindable shape under the pointer.
this.startBindingShapeId = this.bindableShapeIds
.map(id => page.shapes[id])
.filter(shape =>
Utils.pointInBounds(
originPoint,
TLDR.get_shape_util(shape).getBounds(shape)
)
)
.sort((a, b) => {
// TODO - We should be smarter here, what's the right logic?
return b.childIndex - a.childIndex;
})[0]?.id;
if (this.startBindingShapeId) {
this.bindableShapeIds.splice(
this.bindableShapeIds.indexOf(this.startBindingShapeId),
1
);
}
} else {
// If we're editing an existing line, is there a binding already
// for the dragging handle?
const initialBindingId =
this.initialShape.handles[this.handleId].bindingId;
if (initialBindingId) {
this.initialBinding = page.bindings[initialBindingId];
} else {
// If not, explicitly set this handle to undefined, so that it gets deleted on undo
this.initialShape.handles[this.handleId].bindingId = undefined;
}
}
}
start = (): TldrawPatch | undefined => void null;
update = (): TldrawPatch | undefined => {
const { initialShape } = this;
const {
currentPoint,
shiftKey,
altKey,
metaKey,
currentGrid,
settings: { showGrid },
} = this.app;
const shape = this.app.getShape<ArrowShape>(initialShape.id);
if (shape.isLocked) return;
const { handles } = initialShape;
const handleId = this.handleId as keyof typeof handles;
// If the handle can bind, then we need to search bindable shapes for
// a binding.
if (!handles[handleId].canBind) return;
// Find the delta (in shape space)
let delta = Vec.sub(
currentPoint,
Vec.add(handles[handleId].point, initialShape.point)
);
if (shiftKey) {
const A = altKey
? Vec.med(handles.start.point, handles.end.point)
: handles[handleId === 'start' ? 'end' : 'start'].point;
const B = handles[handleId].point;
const C = Vec.add(B, delta);
const angle = Vec.angle(A, C);
const adjusted = Vec.rotWith(
C,
A,
Utils.snapAngleToSegments(angle, 24) - angle
);
delta = Vec.add(delta, Vec.sub(adjusted, C));
}
const nextPoint = Vec.add(handles[handleId].point, delta);
const handleChanges: Partial<any> = {
[handleId]: {
...handles[handleId],
point: showGrid
? Vec.snap(nextPoint, currentGrid)
: Vec.toFixed(nextPoint),
bindingId: undefined,
},
};
if (altKey) {
// If the user is holding alt key, apply the inverse delta
// to the oppoosite handle.
const oppositeHandleId = handleId === 'start' ? 'end' : 'start';
const nextPoint = Vec.sub(handles[oppositeHandleId].point, delta);
handleChanges[oppositeHandleId] = {
...handles[oppositeHandleId],
point: showGrid
? Vec.snap(nextPoint, currentGrid)
: Vec.toFixed(nextPoint),
bindingId: undefined,
};
}
const utils = shapeUtils[TDShapeType.Arrow];
const handleChange = utils.onHandleChange?.(
initialShape,
handleChanges
);
// If the handle changed produced no change, bail here
if (!handleChange) return;
// If nothing changes, we want these to be the same object reference as
// before. If it does change, we'll redefine this later on. And if we've
// made it this far, the shape should be a new object reference that
// incorporates the changes we've made due to the handle movement.
const next: {
shape: ArrowShape;
bindings: Record<string, TDBinding | undefined>;
} = {
shape: Utils.deepMerge(shape, handleChange),
bindings: {},
};
let draggedBinding: ArrowBinding | undefined;
const draggingHandle = next.shape.handles[this.handleId];
const oppositeHandle =
next.shape.handles[this.handleId === 'start' ? 'end' : 'start'];
// START BINDING
// If we have a start binding shape id, the recompute the binding
// point based on the current end handle position
if (this.startBindingShapeId) {
let nextStartBinding: ArrowBinding | undefined;
const startTarget = this.app.page.shapes[this.startBindingShapeId];
const startTargetUtils = TLDR.get_shape_util(startTarget);
const center = startTargetUtils.getCenter(startTarget);
const startHandle = next.shape.handles.start;
const endHandle = next.shape.handles.end;
const rayPoint = Vec.add(startHandle.point, next.shape.point);
if (Vec.isEqual(rayPoint, center)) rayPoint[1]++; // Fix bug where ray and center are identical
const rayOrigin = center;
const isInsideShape = startTargetUtils.hitTestPoint(
startTarget,
currentPoint
);
const rayDirection = Vec.uni(Vec.sub(rayPoint, rayOrigin));
const hasStartBinding =
this.app.getBinding(this.newStartBindingId) !== undefined;
// Don't bind the start handle if both handles are inside of the target shape.
if (
!metaKey &&
!startTargetUtils.hitTestPoint(
startTarget,
Vec.add(next.shape.point, endHandle.point)
)
) {
nextStartBinding = this.find_binding_point(
shape,
startTarget,
'start',
this.newStartBindingId,
center,
rayOrigin,
rayDirection,
isInsideShape
);
}
if (nextStartBinding && !hasStartBinding) {
// Bind the arrow's start handle to the start target
this.didBind = true;
next.bindings[this.newStartBindingId] = nextStartBinding;
next.shape = Utils.deepMerge(next.shape, {
handles: {
start: {
bindingId: nextStartBinding.id,
},
},
});
} else if (!nextStartBinding && hasStartBinding) {
// Remove the start binding
this.didBind = false;
next.bindings[this.newStartBindingId] = undefined;
next.shape = Utils.deepMerge(initialShape, {
handles: {
start: {
bindingId: undefined,
},
},
});
}
}
// DRAGGED POINT BINDING
if (!metaKey) {
const rayOrigin = Vec.add(oppositeHandle.point, next.shape.point);
const rayPoint = Vec.add(draggingHandle.point, next.shape.point);
const rayDirection = Vec.uni(Vec.sub(rayPoint, rayOrigin));
const startPoint = Vec.add(
next.shape.point!,
next.shape.handles!.start.point!
);
const endPoint = Vec.add(
next.shape.point!,
next.shape.handles!.end.point!
);
const targets = this.bindableShapeIds
.map(id => this.app.page.shapes[id])
.sort((a, b) => b.childIndex - a.childIndex)
.filter(shape => {
const utils = TLDR.get_shape_util(shape);
return ![startPoint, endPoint].every(point =>
utils.hitTestPoint(shape, point)
);
});
for (const target of targets) {
draggedBinding = this.find_binding_point(
shape,
target,
this.handleId,
this.draggedBindingId,
rayPoint,
rayOrigin,
rayDirection,
altKey
);
if (draggedBinding) break;
}
}
if (draggedBinding) {
// Create the dragged point binding
this.didBind = true;
next.bindings[this.draggedBindingId] = draggedBinding;
next.shape = Utils.deepMerge(next.shape, {
handles: {
[this.handleId]: {
bindingId: this.draggedBindingId,
},
},
});
} else {
// Remove the dragging point binding
this.didBind = this.didBind || false;
const currentBindingId = shape.handles[this.handleId].bindingId;
if (currentBindingId !== undefined) {
next.bindings[currentBindingId] = undefined;
next.shape = Utils.deepMerge(next.shape, {
handles: {
[this.handleId]: {
bindingId: undefined,
},
},
});
}
}
const change = TLDR.get_shape_util<ArrowShape>(
next.shape
).onHandleChange?.(next.shape, next.shape.handles);
return {
document: {
pages: {
[this.app.currentPageId]: {
shapes: {
[shape.id]: { ...next.shape, ...(change ?? {}) },
},
bindings: next.bindings,
},
},
pageStates: {
[this.app.currentPageId]: {
bindingId: next.shape.handles[handleId].bindingId,
},
},
},
};
};
cancel = (): TldrawPatch | undefined => {
const {
initialShape,
initialBinding,
newStartBindingId,
draggedBindingId,
} = this;
const currentShape = TLDR.on_session_complete(
this.app.page.shapes[initialShape.id]
) as ArrowShape;
const isDeleting =
this.isCreate ||
Vec.dist(
currentShape.handles.start.point,
currentShape.handles.end.point
) < 4;
const afterBindings: Record<string, TDBinding | undefined> = {};
afterBindings[draggedBindingId] = undefined;
if (initialBinding) {
afterBindings[initialBinding.id] = isDeleting
? undefined
: initialBinding;
}
if (newStartBindingId) {
afterBindings[newStartBindingId] = undefined;
}
return {
document: {
pages: {
[this.app.currentPageId]: {
shapes: {
[initialShape.id]: isDeleting
? undefined
: initialShape,
},
bindings: afterBindings,
},
},
pageStates: {
[this.app.currentPageId]: {
selectedIds: isDeleting ? [] : [initialShape.id],
bindingId: undefined,
hoveredId: undefined,
editingId: undefined,
},
},
},
};
};
complete = (): TldrawPatch | TldrawCommand | undefined => {
const {
initialShape,
initialBinding,
newStartBindingId,
startBindingShapeId,
handleId,
} = this;
const currentShape = TLDR.on_session_complete(
this.app.page.shapes[initialShape.id]
) as ArrowShape;
const currentBindingId = currentShape.handles[handleId].bindingId;
const length = Vec.dist(
currentShape.handles.start.point,
currentShape.handles.end.point
);
if (!(currentBindingId || initialBinding) && length < 4)
return this.cancel();
const beforeBindings: Partial<Record<string, TDBinding>> = {};
const afterBindings: Partial<Record<string, TDBinding>> = {};
if (initialBinding) {
beforeBindings[initialBinding.id] = this.isCreate
? undefined
: initialBinding;
afterBindings[initialBinding.id] = undefined;
}
if (currentBindingId) {
beforeBindings[currentBindingId] = undefined;
afterBindings[currentBindingId] =
this.app.page.bindings[currentBindingId];
}
if (startBindingShapeId) {
beforeBindings[newStartBindingId] = undefined;
afterBindings[newStartBindingId] =
this.app.page.bindings[newStartBindingId];
}
return {
id: 'arrow',
before: {
document: {
pages: {
[this.app.currentPageId]: {
shapes: {
[initialShape.id]: this.isCreate
? undefined
: initialShape,
},
bindings: beforeBindings,
},
},
pageStates: {
[this.app.currentPageId]: {
selectedIds: this.isCreate ? [] : [initialShape.id],
bindingId: undefined,
hoveredId: undefined,
editingId: undefined,
},
},
},
},
after: {
document: {
pages: {
[this.app.currentPageId]: {
shapes: {
[initialShape.id]: currentShape,
},
bindings: afterBindings,
},
},
pageStates: {
[this.app.currentPageId]: {
selectedIds: [initialShape.id],
bindingId: undefined,
hoveredId: undefined,
editingId: undefined,
},
},
},
},
};
};
private find_binding_point = (
shape: ArrowShape,
target: TDShape,
handleId: 'start' | 'end',
bindingId: string,
point: number[],
origin: number[],
direction: number[],
bindAnywhere: boolean
) => {
const util = TLDR.get_shape_util<TDShape>(target.type);
const bindingPoint = util.getBindingPoint(
target,
shape,
point, // fix dead center bug
origin,
direction,
bindAnywhere
);
// Not all shapes will produce a binding point
if (!bindingPoint) return;
return {
id: bindingId,
type: 'arrow',
fromId: shape.id,
toId: target.id,
handleId: handleId,
point: Vec.toFixed(bindingPoint.point),
distance: bindingPoint.distance,
};
};
}
@@ -0,0 +1,10 @@
import { BaseSessionType } from '@toeverything/components/board-types';
import type { TldrawApp } from '@toeverything/components/board-state';
export abstract class BaseSession extends BaseSessionType {
app: TldrawApp;
constructor(app: TldrawApp) {
super();
this.app = app;
}
}
@@ -0,0 +1,149 @@
import { TLBounds, Utils } from '@tldraw/core';
import {
SessionType,
TldrawPatch,
TDStatus,
TldrawCommand,
} from '@toeverything/components/board-types';
import type { TldrawApp } from '@toeverything/components/board-state';
import { BaseSession } from './base-session';
export class BrushSession extends BaseSession {
type = SessionType.Brush;
performanceMode: undefined;
status = TDStatus.Brushing;
initialSelectedIds: Set<string>;
shapesToTest: {
id: string;
bounds: TLBounds;
selectId: string;
}[];
constructor(app: TldrawApp) {
super(app);
const { currentPageId } = app;
this.initialSelectedIds = new Set(this.app.selectedIds);
this.shapesToTest = this.app.shapes
.filter(
shape =>
!(
shape.isLocked ||
shape.isHidden ||
shape.parentId !== currentPageId ||
this.initialSelectedIds.has(shape.id) ||
this.initialSelectedIds.has(shape.parentId)
)
)
.map(shape => ({
id: shape.id,
bounds: this.app.getShapeUtil(shape).getBounds(shape),
selectId: shape.id, //TLDR.getTopParentId(data, shape.id, currentPageId),
}));
this.update();
}
start = (): TldrawPatch | undefined => void null;
update = (): TldrawPatch | undefined => {
const {
initialSelectedIds,
shapesToTest,
app: { metaKey, settings, originPoint, currentPoint },
} = this;
// Create a bounding box between the origin and the new point
const brush = Utils.getBoundsFromPoints([originPoint, currentPoint]);
// Decide weather to select by intersecting or by overlapping
// Using a xor to revers the behaviour if the ctrl key is pressed
// Do it only if the user choose to enable cad like selection
const selectByContain = settings.isCadSelectMode
? !metaKey && originPoint[0] < currentPoint[0]
: metaKey;
// Find ids of brushed shapes
const hits = new Set<string>();
const selectedIds = new Set(initialSelectedIds);
shapesToTest.forEach(({ id, selectId }) => {
const shape = this.app.getShape(id);
if (!hits.has(selectId)) {
const util = this.app.getShapeUtil(shape);
if (
selectByContain
? Utils.boundsContain(brush, util.getBounds(shape))
: util.hitTestBounds(shape, brush)
) {
hits.add(selectId);
// When brushing a shape, select its top group parent.
if (!selectedIds.has(selectId)) {
selectedIds.add(selectId);
}
} else if (selectedIds.has(selectId)) {
selectedIds.delete(selectId);
}
}
});
const currentSelectedIds = this.app.selectedIds;
const didChange =
selectedIds.size !== currentSelectedIds.length ||
currentSelectedIds.some(id => !selectedIds.has(id));
const afterSelectedIds = didChange
? Array.from(selectedIds.values())
: currentSelectedIds;
return {
appState: {
selectByContain,
},
document: {
pageStates: {
[this.app.currentPageId]: {
brush,
selectedIds: afterSelectedIds,
},
},
},
};
};
cancel = (): TldrawPatch | undefined => {
return {
appState: {
selectByContain: false,
},
document: {
pageStates: {
[this.app.currentPageId]: {
brush: null,
selectedIds: Array.from(
this.initialSelectedIds.values()
),
},
},
},
};
};
complete = (): TldrawPatch | TldrawCommand | undefined => {
return {
appState: {
selectByContain: false,
},
document: {
pageStates: {
[this.app.currentPageId]: {
brush: null,
selectedIds: [...this.app.selectedIds],
},
},
},
};
};
}
@@ -0,0 +1,305 @@
import { Utils } from '@tldraw/core';
import { Vec } from '@tldraw/vec';
import {
SessionType,
TDStatus,
TldrawPatch,
TldrawCommand,
DrawShape,
} from '@toeverything/components/board-types';
import type { TldrawApp } from '@toeverything/components/board-state';
import { BaseSession } from './base-session';
export class DrawSession extends BaseSession {
type = SessionType.Draw;
performanceMode: undefined;
status = TDStatus.Creating;
topLeft: number[];
points: number[][];
initialShape: DrawShape;
lastAdjustedPoint: number[];
shiftedPoints: number[][] = [];
shapeId: string;
isLocked?: boolean;
isExtending: boolean;
lockedDirection?: 'horizontal' | 'vertical';
constructor(app: TldrawApp, id: string) {
super(app);
const { originPoint } = this.app;
this.shapeId = id;
this.initialShape = this.app.getShape<DrawShape>(id);
this.topLeft = [...this.initialShape.point];
const currentPoint = [0, 0, originPoint[2] ?? 0.5];
const delta = Vec.sub(originPoint, this.topLeft);
const initialPoints = this.initialShape.points.map(pt =>
Vec.sub(pt, delta).concat(pt[2])
);
this.isExtending = initialPoints.length > 0;
const newPoints: number[][] = [];
if (this.isExtending) {
const prevPoint = initialPoints[initialPoints.length - 1];
newPoints.push(prevPoint, prevPoint);
// Continuing with shift
const len = Math.ceil(Vec.dist(prevPoint, currentPoint) / 16);
for (let i = 0; i < len; i++) {
const t = i / (len - 1);
newPoints.push(
Vec.lrp(prevPoint, currentPoint, t).concat(prevPoint[2])
);
}
} else {
newPoints.push(currentPoint);
}
// Add a first point but don't update the shape yet. We'll update
// when the draw session ends; if the user hasn't added additional
// points, this single point will be interpreted as a "dot" shape.
this.points = [...initialPoints, ...newPoints];
this.shiftedPoints = this.points.map(pt =>
Vec.add(pt, delta).concat(pt[2])
);
this.lastAdjustedPoint = this.points[this.points.length - 1];
}
start = () => {
const currentPoint = this.app.originPoint;
const newAdjustedPoint = [0, 0, currentPoint[2] ?? 0.5];
// Add the new adjusted point to the points array
this.points.push(newAdjustedPoint);
const topLeft = [
Math.min(this.topLeft[0], currentPoint[0]),
Math.min(this.topLeft[1], currentPoint[1]),
];
const delta = Vec.sub(topLeft, currentPoint);
this.topLeft = topLeft;
this.shiftedPoints = this.points.map(pt =>
Vec.toFixed(Vec.sub(pt, delta)).concat(pt[2])
);
return {
document: {
pages: {
[this.app.currentPageId]: {
shapes: {
[this.shapeId]: {
point: this.topLeft,
points: this.shiftedPoints,
},
},
},
},
pageStates: {
[this.app.currentPageId]: {
selectedIds: [this.shapeId],
},
},
},
};
};
update = (): TldrawPatch | undefined => {
const { shapeId } = this;
const { currentPoint, originPoint, shiftKey } = this.app;
// Even if we're not locked yet, we base the future locking direction
// on the first dimension to reach a threshold, or the bigger dimension
// once one or both dimensions have reached the threshold.
if (!this.lockedDirection && this.points.length > 1) {
const bounds = Utils.getBoundsFromPoints(this.points);
if (bounds.width > 8 || bounds.height > 8) {
this.lockedDirection =
bounds.width > bounds.height ? 'horizontal' : 'vertical';
}
}
// Drawing while holding shift will "lock" the pen to either the
// x or y axis, depending on the locking direction.
if (shiftKey) {
if (!this.isLocked && this.points.length > 2) {
// If we're locking before knowing what direction we're in, set it
// early based on the bigger dimension.
if (!this.lockedDirection) {
const bounds = Utils.getBoundsFromPoints(this.points);
this.lockedDirection =
bounds.width > bounds.height
? 'horizontal'
: 'vertical';
}
this.isLocked = true;
// Start locking
const returning = [...this.lastAdjustedPoint];
if (this.lockedDirection === 'vertical') {
returning[0] = 0;
} else {
returning[1] = 0;
}
this.points.push(returning.concat(currentPoint[2]));
}
} else if (this.isLocked) {
this.isLocked = false;
}
if (this.isLocked) {
if (this.lockedDirection === 'vertical') {
currentPoint[0] = originPoint[0];
} else {
currentPoint[1] = originPoint[1];
}
}
const change = this.addPoint(currentPoint);
if (!change) {
return;
}
return {
document: {
pages: {
[this.app.currentPageId]: {
shapes: {
[shapeId]: change,
},
},
},
pageStates: {
[this.app.currentPageId]: {
selectedIds: [shapeId],
},
},
},
};
};
cancel = (): TldrawPatch | undefined => {
const { shapeId } = this;
const pageId = this.app.currentPageId;
return {
document: {
pages: {
[pageId]: {
shapes: {
[shapeId]: this.isExtending
? this.initialShape
: undefined,
},
},
},
pageStates: {
[pageId]: {
selectedIds: [],
},
},
},
};
};
complete = (): TldrawPatch | TldrawCommand | undefined => {
const { shapeId } = this;
const pageId = this.app.currentPageId;
const shape = this.app.getShape<DrawShape>(shapeId);
return {
id: 'create_draw',
before: {
document: {
pages: {
[pageId]: {
shapes: {
[shapeId]: this.isExtending
? this.initialShape
: undefined,
},
},
},
pageStates: {
[pageId]: {
selectedIds: [],
},
},
},
},
after: {
document: {
pages: {
[pageId]: {
shapes: {
[shapeId]: {
...shape,
point: Vec.toFixed(shape.point),
points: shape.points.map(pt =>
Vec.toFixed(pt)
),
isComplete: true,
},
},
},
},
pageStates: {
[this.app.currentPageId]: {
selectedIds: [],
},
},
},
},
};
};
addPoint = (currentPoint: number[]) => {
const { originPoint } = this.app;
// The new adjusted point
const newAdjustedPoint = Vec.toFixed(
Vec.sub(currentPoint, originPoint)
).concat(currentPoint[2]);
// Don't add duplicate points.
if (Vec.isEqual(this.lastAdjustedPoint, newAdjustedPoint)) return;
// Add the new adjusted point to the points array
this.points.push(newAdjustedPoint);
// The new adjusted point is now the previous adjusted point.
this.lastAdjustedPoint = newAdjustedPoint;
// Does the input point create a new top left?
const prevTopLeft = [...this.topLeft];
const topLeft = [
Math.min(this.topLeft[0], currentPoint[0]),
Math.min(this.topLeft[1], currentPoint[1]),
];
const delta = Vec.sub(topLeft, originPoint);
// Time to shift some points!
let points: number[][];
if (prevTopLeft[0] !== topLeft[0] || prevTopLeft[1] !== topLeft[1]) {
this.topLeft = topLeft;
// If we have a new top left, then we need to iterate through
// the "unshifted" points array and shift them based on the
// offset between the new top left and the original top left.
points = this.points.map(pt =>
Vec.toFixed(Vec.sub(pt, delta)).concat(pt[2])
);
} else {
// If the new top left is the same as the previous top left,
// we don't need to shift anything: we just shift the new point
// and add it to the shifted points array.
points = [
...this.shiftedPoints,
Vec.sub(newAdjustedPoint, delta).concat(newAdjustedPoint[2]),
];
}
this.shiftedPoints = points;
return {
point: this.topLeft,
points,
};
};
}
@@ -0,0 +1,274 @@
import { Vec } from '@tldraw/vec';
import {
SessionType,
TDStatus,
TDShape,
PagePartial,
TDBinding,
TldrawPatch,
TldrawCommand,
} from '@toeverything/components/board-types';
import type { TldrawApp } from '@toeverything/components/board-state';
import { BaseSession } from './base-session';
export class EraseSession extends BaseSession {
type = SessionType.Draw;
performanceMode: undefined;
status = TDStatus.Creating;
isLocked?: boolean;
lockedDirection?: 'horizontal' | 'vertical';
erasedShapes = new Set<TDShape>();
erasedBindings = new Set<TDBinding>();
initialSelectedShapes: TDShape[];
erasableShapes: Set<TDShape>;
prevPoint: number[];
constructor(app: TldrawApp) {
super(app);
this.prevPoint = [...app.originPoint];
this.initialSelectedShapes = this.app.selectedIds.map(id =>
this.app.getShape(id)
);
this.erasableShapes = new Set(
this.app.shapes.filter(shape => !shape.isLocked)
);
}
start = (): TldrawPatch | undefined => void null;
update = (): TldrawPatch | undefined => {
const { page, shiftKey, originPoint, currentPoint } = this.app;
if (shiftKey) {
if (!this.isLocked && Vec.dist(originPoint, currentPoint) > 4) {
// If we're locking before knowing what direction we're in, set it
// early based on the bigger dimension.
if (!this.lockedDirection) {
const delta = Vec.sub(currentPoint, originPoint);
this.lockedDirection =
delta[0] > delta[1] ? 'horizontal' : 'vertical';
}
this.isLocked = true;
}
} else if (this.isLocked) {
this.isLocked = false;
}
if (this.isLocked) {
if (this.lockedDirection === 'vertical') {
currentPoint[0] = originPoint[0];
} else {
currentPoint[1] = originPoint[1];
}
}
const newPoint = Vec.toFixed(
Vec.add(originPoint, Vec.sub(currentPoint, originPoint))
);
const deletedShapeIds = new Set<string>([]);
this.erasableShapes.forEach(shape => {
if (this.erasedShapes.has(shape)) return;
if (
this.app
.getShapeUtil(shape)
.hitTestLineSegment(shape, this.prevPoint, newPoint)
) {
this.erasedShapes.add(shape);
deletedShapeIds.add(shape.id);
if (shape.children !== undefined) {
for (const childId of shape.children) {
this.erasedShapes.add(this.app.getShape(childId));
deletedShapeIds.add(childId);
}
}
}
});
// Erase bindings that reference deleted shapes
Object.values(page.bindings).forEach(binding => {
for (const id of [binding.toId, binding.fromId]) {
if (deletedShapeIds.has(id)) {
this.erasedBindings.add(binding);
}
}
});
this.erasedShapes.forEach(shape => {
// Has the shape been deleted? If so, pull it from the list.
if (!this.app.getShape(shape.id)) {
this.erasedShapes.delete(shape);
this.erasableShapes.delete(shape);
deletedShapeIds.delete(shape.id);
}
});
const erasedShapes = Array.from(this.erasedShapes.values());
this.prevPoint = newPoint;
return {
document: {
pages: {
[page.id]: {
shapes: Object.fromEntries(
erasedShapes.map(shape => [
shape.id,
{ isGhost: true },
])
),
},
},
},
};
};
cancel = (): TldrawPatch | undefined => {
const { page } = this.app;
this.erasedShapes.forEach(shape => {
if (!this.app.getShape(shape.id)) {
this.erasedShapes.delete(shape);
this.erasableShapes.delete(shape);
}
});
const erasedShapes = Array.from(this.erasedShapes.values());
return {
document: {
pages: {
[page.id]: {
shapes: Object.fromEntries(
erasedShapes.map(shape => [
shape.id,
{ isGhost: false },
])
),
},
},
pageStates: {
[page.id]: {
selectedIds: this.initialSelectedShapes.map(
shape => shape.id
),
},
},
},
};
};
complete = (): TldrawPatch | TldrawCommand | undefined => {
const { page } = this.app;
this.erasedShapes.forEach(shape => {
if (!this.app.getShape(shape.id)) {
this.erasedShapes.delete(shape);
this.erasableShapes.delete(shape);
}
});
this.erasedBindings.forEach(binding => {
if (!this.app.getBinding(binding.id)) {
this.erasedBindings.delete(binding);
}
});
const erasedShapes = Array.from(this.erasedShapes.values());
const erasedBindings = Array.from(this.erasedBindings.values());
const erasedShapeIds = erasedShapes.map(shape => shape.id);
const erasedBindingIds = erasedBindings.map(binding => binding.id);
const before: PagePartial = {
shapes: Object.fromEntries(
erasedShapes.map(shape => [shape.id, shape])
),
bindings: Object.fromEntries(
erasedBindings.map(binding => [binding.id, binding])
),
};
const after: PagePartial = {
shapes: Object.fromEntries(
erasedShapes.map(shape => [shape.id, undefined])
),
bindings: Object.fromEntries(
erasedBindings.map(binding => [binding.id, undefined])
),
};
// Remove references on any shape's handles to any deleted bindings
this.app.shapes.forEach(shape => {
if (shape.handles && !after.shapes[shape.id]) {
Object.values(shape.handles).forEach(handle => {
if (
handle.bindingId &&
erasedBindingIds.includes(handle.bindingId)
) {
// Save the binding reference in the before patch
before.shapes[shape.id] = {
...before.shapes[shape.id],
handles: {
...before.shapes[shape.id]?.handles,
[handle.id]: handle,
},
};
// Save the binding reference in the before patch
if (!erasedShapeIds.includes(shape.id)) {
after.shapes[shape.id] = {
...after.shapes[shape.id],
handles: {
...after.shapes[shape.id]?.handles,
[handle.id]: {
...handle,
bindingId: undefined,
},
},
};
}
}
});
}
});
return {
id: 'erase',
before: {
document: {
pages: {
[page.id]: before,
},
pageStates: {
[page.id]: {
selectedIds: this.initialSelectedShapes
.filter(shape => !!this.app.getShape(shape.id))
.map(shape => shape.id),
},
},
},
},
after: {
document: {
pages: {
[page.id]: after,
},
pageStates: {
[page.id]: {
selectedIds: this.initialSelectedShapes
.filter(shape => !!this.app.getShape(shape.id))
.filter(
shape => !erasedShapeIds.includes(shape.id)
)
.map(shape => shape.id),
},
},
},
},
};
};
}
@@ -0,0 +1,270 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { TLPageState, TLBounds, Utils } from '@tldraw/core';
import { Vec } from '@tldraw/vec';
import {
Patch,
TDShape,
TDStatus,
SessionType,
TDShapeType,
TldrawPatch,
TldrawCommand,
} from '@toeverything/components/board-types';
import { BaseSession } from './base-session';
import type { TldrawApp } from '@toeverything/components/board-state';
export class GridSession extends BaseSession {
type = SessionType.Grid;
performanceMode: undefined;
status = TDStatus.Translating;
shape: TDShape;
bounds: TLBounds;
initialSelectedIds: string[];
initialSiblings?: string[];
grid: Record<string, string> = {};
columns = 1;
rows = 1;
isCopying = false;
constructor(app: TldrawApp, id: string) {
super(app);
this.shape = this.app.getShape(id);
this.grid['0_0'] = this.shape.id;
this.bounds = this.app.getShapeBounds(id);
this.initialSelectedIds = [...this.app.selectedIds];
if (this.shape.parentId !== this.app.currentPageId) {
this.initialSiblings = this.app
.getShape(this.shape.parentId)
.children?.filter(id => id !== this.shape.id);
}
}
start = (): TldrawPatch | undefined => void null;
update = (): TldrawPatch | undefined => {
const { currentPageId, altKey, shiftKey, currentPoint } = this.app;
const nextShapes: Patch<Record<string, TDShape>> = {};
const nextPageState: Patch<TLPageState> = {};
const center = Utils.getBoundsCenter(this.bounds);
const offset = Vec.sub(currentPoint, center);
if (shiftKey) {
if (Math.abs(offset[0]) < Math.abs(offset[1])) {
offset[0] = 0;
} else {
offset[1] = 0;
}
}
// use the distance from center to determine the grid
const gapX = this.bounds.width + 32;
const gapY = this.bounds.height + 32;
const columns = Math.ceil(offset[0] / gapX);
const rows = Math.ceil(offset[1] / gapY);
const minX = Math.min(columns, 0);
const minY = Math.min(rows, 0);
const maxX = Math.max(columns, 1);
const maxY = Math.max(rows, 1);
const inGrid = new Set<string>();
const isCopying = altKey;
if (isCopying !== this.isCopying) {
// Recreate shapes copying
Object.values(this.grid)
.filter(id => id !== this.shape.id)
.forEach(id => (nextShapes[id] = undefined));
this.grid = { '0_0': this.shape.id };
this.isCopying = isCopying;
}
// Go through grid, adding items in positions
// that aren't already filled.
for (let x = minX; x < maxX; x++) {
for (let y = minY; y < maxY; y++) {
const position = `${x}_${y}`;
inGrid.add(position);
if (this.grid[position]) continue;
if (x === 0 && y === 0) continue;
const clone = this.get_clone(
Vec.add(this.shape.point, [x * gapX, y * gapY]),
isCopying
);
nextShapes[clone.id] = clone;
this.grid[position] = clone.id;
}
}
// Remove any other items from the grid
Object.entries(this.grid).forEach(([position, id]) => {
if (!inGrid.has(position)) {
nextShapes[id] = undefined;
delete this.grid[position];
}
});
if (Object.values(nextShapes).length === 0) return;
// Add shapes to parent id
if (this.initialSiblings) {
nextShapes[this.shape.parentId] = {
children: [
...this.initialSiblings,
...Object.values(this.grid),
],
};
}
return {
document: {
pages: {
[currentPageId]: {
shapes: nextShapes,
},
},
pageStates: {
[currentPageId]: nextPageState,
},
},
};
};
cancel = (): TldrawPatch | undefined => {
const { currentPageId } = this.app;
const nextShapes: Record<string, Partial<TDShape> | undefined> = {};
// Delete clones
Object.values(this.grid).forEach(id => {
nextShapes[id] = undefined;
// TODO: Remove from parent if grouped
});
// Put back the initial shape
nextShapes[this.shape.id] = {
...nextShapes[this.shape.id],
point: this.shape.point,
};
if (this.initialSiblings) {
nextShapes[this.shape.parentId] = {
children: [...this.initialSiblings, this.shape.id],
};
}
return {
document: {
pages: {
[currentPageId]: {
shapes: nextShapes,
},
},
pageStates: {
[currentPageId]: {
selectedIds: [this.shape.id],
},
},
},
};
};
complete = (): TldrawPatch | TldrawCommand | undefined => {
const { currentPageId } = this.app;
const beforeShapes: Patch<Record<string, TDShape>> = {};
const afterShapes: Patch<Record<string, TDShape>> = {};
const afterSelectedIds: string[] = [];
Object.values(this.grid).forEach(id => {
beforeShapes[id] = undefined;
afterShapes[id] = this.app.getShape(id);
afterSelectedIds.push(id);
// TODO: Add shape to parent if grouped
});
beforeShapes[this.shape.id] = this.shape;
// Add shapes to parent id
if (this.initialSiblings) {
beforeShapes[this.shape.parentId] = {
children: [...this.initialSiblings, this.shape.id],
};
afterShapes[this.shape.parentId] = {
children: [
...this.initialSiblings,
...Object.values(this.grid),
],
};
}
// If no new shapes have been created, bail
if (afterSelectedIds.length === 1) return;
return {
id: 'grid',
before: {
document: {
pages: {
[currentPageId]: {
shapes: beforeShapes,
},
},
pageStates: {
[currentPageId]: {
selectedIds: [],
hoveredId: undefined,
},
},
},
},
after: {
document: {
pages: {
[currentPageId]: {
shapes: afterShapes,
},
},
pageStates: {
[currentPageId]: {
selectedIds: afterSelectedIds,
hoveredId: undefined,
},
},
},
},
};
};
private get_clone = (point: number[], copy: boolean) => {
const clone = {
...this.shape,
id: Utils.uniqueId(),
point,
};
// if (!copy) {
// if (clone.type === TDShapeType.Sticky) {
// clone.text = '';
// }
// }
return clone;
};
}
@@ -0,0 +1,141 @@
import { Vec } from '@tldraw/vec';
import {
SessionType,
ShapesWithProp,
TldrawCommand,
TldrawPatch,
TDStatus,
} from '@toeverything/components/board-types';
import { TLDR } from '@toeverything/components/board-state';
import { BaseSession } from './base-session';
import type { TldrawApp } from '@toeverything/components/board-state';
export class HandleSession extends BaseSession {
type = SessionType.Handle;
performanceMode: undefined;
status = TDStatus.TranslatingHandle;
commandId: string;
topLeft: number[];
shiftKey = false;
initialShape: ShapesWithProp<'handles'>;
handleId: string;
constructor(
app: TldrawApp,
shapeId: string,
handleId: string,
commandId = 'move_handle'
) {
super(app);
const { originPoint } = app;
this.topLeft = [...originPoint];
this.handleId = handleId;
this.initialShape = this.app.getShape(shapeId);
this.commandId = commandId;
}
start = (): TldrawPatch | undefined => void null;
update = (): TldrawPatch | undefined => {
const {
initialShape,
app: { currentPageId, currentPoint },
} = this;
const shape = this.app.getShape<ShapesWithProp<'handles'>>(
initialShape.id
);
if (shape.isLocked) return void null;
const handles = shape.handles;
const handleId = this.handleId as keyof typeof handles;
const delta = Vec.sub(currentPoint, handles[handleId].point);
const handleChanges = {
[handleId]: {
...handles[handleId],
point: Vec.sub(
Vec.add(handles[handleId].point, delta),
shape.point
),
},
};
// First update the handle's next point
const change = TLDR.get_shape_util(shape).onHandleChange?.(
shape,
handleChanges
);
if (!change) return;
return {
document: {
pages: {
[currentPageId]: {
shapes: {
[shape.id]: change,
},
},
},
},
};
};
cancel = (): TldrawPatch | undefined => {
const {
initialShape,
app: { currentPageId },
} = this;
return {
document: {
pages: {
[currentPageId]: {
shapes: {
[initialShape.id]: initialShape,
},
},
},
},
};
};
complete = (): TldrawPatch | TldrawCommand | undefined => {
const {
initialShape,
app: { currentPageId },
} = this;
return {
id: this.commandId,
before: {
document: {
pages: {
[currentPageId]: {
shapes: {
[initialShape.id]: initialShape,
},
},
},
},
},
after: {
document: {
pages: {
[currentPageId]: {
shapes: {
[initialShape.id]: TLDR.on_session_complete(
this.app.getShape(this.initialShape.id)
),
},
},
},
},
},
};
};
}
@@ -0,0 +1,64 @@
import { ExceptFirst, SessionType } from '@toeverything/components/board-types';
import { ArrowSession } from './arrow-session';
import { BrushSession } from './brush-session';
import { DrawSession } from './draw-session';
import { HandleSession } from './handle-session';
import { RotateSession } from './rotate-session';
import { TransformSession } from './transform-session';
import { TransformSingleSession } from './transform-single-session';
import { TranslateSession } from './translate-session';
import { EraseSession } from './erase-session';
import { GridSession } from './grid-session';
import { LaserSession } from './laser-session';
export type TldrawSession =
| ArrowSession
| BrushSession
| DrawSession
| HandleSession
| RotateSession
| TransformSession
| TransformSingleSession
| TranslateSession
| EraseSession
| GridSession;
export interface SessionsMap {
[SessionType.Arrow]: typeof ArrowSession;
[SessionType.Brush]: typeof BrushSession;
[SessionType.Draw]: typeof DrawSession;
[SessionType.Laser]: typeof LaserSession;
[SessionType.Erase]: typeof EraseSession;
[SessionType.Handle]: typeof HandleSession;
[SessionType.Rotate]: typeof RotateSession;
[SessionType.Transform]: typeof TransformSession;
[SessionType.TransformSingle]: typeof TransformSingleSession;
[SessionType.Translate]: typeof TranslateSession;
[SessionType.Grid]: typeof GridSession;
}
export type SessionOfType<K extends SessionType> = SessionsMap[K];
export type SessionArgsOfType<K extends SessionType> = ExceptFirst<
ConstructorParameters<SessionOfType<K>>
>;
export const sessions: { [K in SessionType]: SessionsMap[K] } = {
[SessionType.Arrow]: ArrowSession,
[SessionType.Brush]: BrushSession,
[SessionType.Draw]: DrawSession,
[SessionType.Laser]: LaserSession,
[SessionType.Erase]: EraseSession,
[SessionType.Handle]: HandleSession,
[SessionType.Rotate]: RotateSession,
[SessionType.Transform]: TransformSession,
[SessionType.TransformSingle]: TransformSingleSession,
[SessionType.Translate]: TranslateSession,
[SessionType.Grid]: GridSession,
};
export const getSession = <K extends SessionType>(
type: K
): SessionOfType<K> => {
return sessions[type];
};
@@ -0,0 +1,106 @@
import { Vec } from '@tldraw/vec';
import {
SessionType,
TDStatus,
TldrawPatch,
TldrawCommand,
} from '@toeverything/components/board-types';
import type { TldrawApp } from '@toeverything/components/board-state';
import { BaseSession } from './base-session';
export class LaserSession extends BaseSession {
type = SessionType.Draw;
performanceMode: any = undefined;
status = TDStatus.Creating;
prevPoint: number[];
prevEraseShapesSize = 0;
constructor(app: TldrawApp) {
super(app);
this.prevPoint = [...app.originPoint];
this.interval = this.loop();
}
interval: any;
timestamp1 = 0;
timestamp2 = 0;
prevErasePoint: number[] = [];
loop = () => {
const now = Date.now();
const elapsed1 = now - this.timestamp1;
const elapsed2 = now - this.timestamp2;
const { laserLine } = this.app.appState;
let next = [...laserLine];
let didUpdate = false;
if (elapsed1 > 16 && this.prevErasePoint !== this.prevPoint) {
didUpdate = true;
next = [...laserLine, this.prevPoint];
this.prevErasePoint = this.prevPoint;
}
if (elapsed2 > 32) {
if (next.length > 1) {
didUpdate = true;
next.splice(0, Math.ceil(next.length * 0.1));
this.timestamp2 = now;
}
}
if (didUpdate) {
this.app.patchState(
{
appState: {
laserLine: next,
},
},
'eraseline'
);
}
this.interval = requestAnimationFrame(this.loop);
};
start = (): TldrawPatch | undefined => void null;
update = (): TldrawPatch | undefined => {
const { originPoint, currentPoint } = this.app;
const newPoint = Vec.toFixed(
Vec.add(originPoint, Vec.sub(currentPoint, originPoint))
);
this.prevPoint = newPoint;
return {};
};
cancel = (): TldrawPatch | undefined => {
cancelAnimationFrame(this.interval);
return {
appState: {
laserLine: [],
},
};
};
complete = (): TldrawPatch | TldrawCommand | undefined => {
const { page } = this.app;
cancelAnimationFrame(this.interval);
return {
before: {
appState: {
laserLine: [],
},
},
after: {
appState: {
laserLine: [],
},
},
};
};
}
@@ -0,0 +1,177 @@
import { Utils } from '@tldraw/core';
import { Vec } from '@tldraw/vec';
import {
SessionType,
TldrawCommand,
TldrawPatch,
TDShape,
TDStatus,
} from '@toeverything/components/board-types';
import { TLDR } from '@toeverything/components/board-state';
import { BaseSession } from './base-session';
import type { TldrawApp } from '@toeverything/components/board-state';
export class RotateSession extends BaseSession {
type = SessionType.Rotate;
status = TDStatus.Transforming;
performanceMode: undefined;
delta = [0, 0];
commonBoundsCenter: number[];
initialAngle: number;
initialShapes: {
shape: TDShape;
center: number[];
}[];
changes: Record<string, Partial<TDShape>> = {};
constructor(app: TldrawApp) {
super(app);
const {
app: { currentPageId, pageState, originPoint },
} = this;
const initialShapes = TLDR.get_selected_branch_snapshot(
app.state,
currentPageId
).filter(shape => !shape.isLocked);
if (initialShapes.length === 0) {
throw Error('No selected shapes!');
}
if (app.rotationInfo.selectedIds === pageState.selectedIds) {
if (app.rotationInfo.center === undefined) {
throw Error('We should have a center for rotation!');
}
this.commonBoundsCenter = app.rotationInfo.center;
} else {
this.commonBoundsCenter = Utils.getBoundsCenter(
Utils.getCommonBounds(initialShapes.map(TLDR.get_bounds))
);
app.rotationInfo.selectedIds = pageState.selectedIds;
app.rotationInfo.center = this.commonBoundsCenter;
}
this.initialShapes = initialShapes
.filter(shape => shape.children === undefined)
.map(shape => {
return {
shape,
center: this.app.getShapeUtil(shape).getCenter(shape),
};
});
this.initialAngle = Vec.angle(this.commonBoundsCenter, originPoint);
}
start = (): TldrawPatch | undefined => void null;
update = (): TldrawPatch | undefined => {
const {
commonBoundsCenter,
initialShapes,
app: { currentPageId, currentPoint, shiftKey },
} = this;
const shapes: Record<string, Partial<TDShape>> = {};
let directionDelta =
Vec.angle(commonBoundsCenter, currentPoint) - this.initialAngle;
if (shiftKey) {
directionDelta = Utils.snapAngleToSegments(directionDelta, 24); // 15 degrees
}
// Update the shapes
initialShapes.forEach(({ center, shape }) => {
const { rotation = 0 } = shape;
let shapeDelta = 0;
if (shiftKey) {
const snappedRotation = Utils.snapAngleToSegments(rotation, 24);
shapeDelta = snappedRotation - rotation;
}
const change = TLDR.get_rotated_shape_mutation(
shape,
center,
commonBoundsCenter,
shiftKey ? directionDelta + shapeDelta : directionDelta
);
if (change) {
shapes[shape.id] = change;
}
});
this.changes = shapes;
return {
document: {
pages: {
[currentPageId]: {
shapes,
},
},
},
};
};
cancel = (): TldrawPatch | undefined => {
const {
initialShapes,
app: { currentPageId },
} = this;
const shapes: Record<string, TDShape> = {};
initialShapes.forEach(({ shape }) => (shapes[shape.id] = shape));
return {
document: {
pages: {
[currentPageId]: {
shapes,
},
},
},
};
};
complete = (): TldrawPatch | TldrawCommand | undefined => {
const {
initialShapes,
app: { currentPageId },
} = this;
const beforeShapes = {} as Record<string, Partial<TDShape>>;
const afterShapes = this.changes;
initialShapes.forEach(({ shape: { id, point, rotation, handles } }) => {
beforeShapes[id] = { point, rotation, handles };
});
return {
id: 'rotate',
before: {
document: {
pages: {
[currentPageId]: {
shapes: beforeShapes,
},
},
},
},
after: {
document: {
pages: {
[currentPageId]: {
shapes: afterShapes,
},
},
},
},
};
};
}
@@ -0,0 +1,384 @@
import { TLBounds, TLBoundsCorner, TLBoundsEdge, Utils } from '@tldraw/core';
import { Vec } from '@tldraw/vec';
import type { TLSnapLine, TLBoundsWithCenter } from '@tldraw/core';
import {
SessionType,
TldrawCommand,
TldrawPatch,
TDShape,
TDStatus,
SLOW_SPEED,
SNAP_DISTANCE,
} from '@toeverything/components/board-types';
import { TLDR } from '@toeverything/components/board-state';
import { BaseSession } from './base-session';
import type { TldrawApp } from '@toeverything/components/board-state';
type SnapInfo =
| {
state: 'empty';
}
| {
state: 'ready';
bounds: TLBoundsWithCenter[];
};
export class TransformSession extends BaseSession {
type = SessionType.Transform;
performanceMode: undefined;
status = TDStatus.Transforming;
scaleX = 1;
scaleY = 1;
initialShapes: TDShape[];
initialShapeIds: string[];
initialSelectedIds: string[];
shapeBounds: {
initialShape: TDShape;
initialShapeBounds: TLBounds;
transformOrigin: number[];
}[];
hasUnlockedShapes: boolean;
isAllAspectRatioLocked: boolean;
initialCommonBounds: TLBounds;
snapInfo: SnapInfo = { state: 'empty' };
prevPoint = [0, 0];
speed = 1;
constructor(
app: TldrawApp,
public transformType:
| TLBoundsEdge
| TLBoundsCorner = TLBoundsCorner.BottomRight,
public isCreate = false
) {
super(app);
this.initialSelectedIds = [...this.app.selectedIds];
this.app.rotationInfo.selectedIds = [...this.initialSelectedIds];
this.initialShapes = TLDR.get_selected_branch_snapshot(
this.app.state,
this.app.currentPageId
).filter(shape => !shape.isLocked);
this.initialShapeIds = this.initialShapes.map(shape => shape.id);
this.hasUnlockedShapes = this.initialShapes.length > 0;
this.isAllAspectRatioLocked = this.initialShapes.every(
shape =>
shape.isAspectRatioLocked ||
TLDR.get_shape_util(shape).isAspectRatioLocked
);
const shapesBounds = Object.fromEntries(
this.initialShapes.map(shape => [shape.id, TLDR.get_bounds(shape)])
);
const boundsArr = Object.values(shapesBounds);
this.initialCommonBounds = Utils.getCommonBounds(boundsArr);
const initialInnerBounds = Utils.getBoundsFromPoints(
boundsArr.map(Utils.getBoundsCenter)
);
// Return a mapping of shapes to bounds together with the relative
// positions of the shape's bounds within the common bounds shape.
this.shapeBounds = this.initialShapes.map(shape => {
const initialShapeBounds = shapesBounds[shape.id];
const ic = Utils.getBoundsCenter(initialShapeBounds);
const ix =
(ic[0] - initialInnerBounds.minX) / initialInnerBounds.width;
const iy =
(ic[1] - initialInnerBounds.minY) / initialInnerBounds.height;
return {
initialShape: shape,
initialShapeBounds,
transformOrigin: [ix, iy],
};
});
}
start = (): TldrawPatch | undefined => {
this.snapInfo = {
state: 'ready',
bounds: this.app.shapes
.filter(shape => !this.initialShapeIds.includes(shape.id))
.map(shape =>
Utils.getBoundsWithCenter(TLDR.get_rotated_bounds(shape))
),
};
return void null;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
update = (): TldrawPatch | undefined => {
const {
transformType,
shapeBounds,
initialCommonBounds,
isAllAspectRatioLocked,
app: {
currentPageId,
pageState: { camera },
viewport,
currentPoint,
previousPoint,
originPoint,
shiftKey,
altKey,
metaKey,
currentGrid,
settings: { isSnapping, showGrid },
},
} = this;
const shapes = {} as Record<string, TDShape>;
const delta = altKey
? Vec.mul(Vec.sub(currentPoint, originPoint), 2)
: Vec.sub(currentPoint, originPoint);
let newBounds = Utils.getTransformedBoundingBox(
initialCommonBounds,
transformType,
delta,
0,
shiftKey || isAllAspectRatioLocked
);
if (altKey) {
newBounds = {
...newBounds,
...Utils.centerBounds(
newBounds,
Utils.getBoundsCenter(initialCommonBounds)
),
};
}
if (showGrid) {
newBounds = {
...newBounds,
...Utils.snapBoundsToGrid(newBounds, currentGrid),
};
}
// Should we snap?
const speed = Vec.dist(currentPoint, previousPoint);
const speedChange = speed - this.speed;
this.speed = this.speed + speedChange * (speedChange > 1 ? 0.5 : 0.15);
let snapLines: TLSnapLine[] = [];
if (
((isSnapping && !metaKey) || (!isSnapping && metaKey)) &&
this.speed * camera.zoom < SLOW_SPEED &&
this.snapInfo.state === 'ready'
) {
const snapResult = Utils.getSnapPoints(
Utils.getBoundsWithCenter(newBounds),
this.snapInfo.bounds.filter(
bounds =>
Utils.boundsContain(viewport, bounds) ||
Utils.boundsCollide(viewport, bounds)
),
SNAP_DISTANCE / camera.zoom
);
if (snapResult) {
snapLines = snapResult.snapLines;
newBounds = Utils.getTransformedBoundingBox(
initialCommonBounds,
transformType,
Vec.sub(delta, snapResult.offset),
0,
shiftKey || isAllAspectRatioLocked
);
}
}
// Now work backward to calculate a new bounding box for each of the shapes.
this.scaleX = newBounds.scaleX;
this.scaleY = newBounds.scaleY;
shapeBounds.forEach(
({ initialShape, initialShapeBounds, transformOrigin }) => {
let newShapeBounds = Utils.getRelativeTransformedBoundingBox(
newBounds,
initialCommonBounds,
initialShapeBounds,
this.scaleX < 0,
this.scaleY < 0
);
if (showGrid) {
newShapeBounds = Utils.snapBoundsToGrid(
newShapeBounds,
currentGrid
);
}
const afterShape = TLDR.transform(
this.app.getShape(initialShape.id),
newShapeBounds,
{
type: this.transformType,
initialShape,
scaleX: this.scaleX,
scaleY: this.scaleY,
transformOrigin,
}
);
shapes[initialShape.id] = afterShape;
}
);
return {
appState: {
snapLines,
},
document: {
pages: {
[currentPageId]: {
shapes,
},
},
},
};
};
cancel = (): TldrawPatch | undefined => {
const {
shapeBounds,
app: { currentPageId },
} = this;
const shapes = {} as Record<string, TDShape | undefined>;
if (this.isCreate) {
shapeBounds.forEach(
shape => (shapes[shape.initialShape.id] = undefined)
);
} else {
shapeBounds.forEach(
shape => (shapes[shape.initialShape.id] = shape.initialShape)
);
}
return {
appState: {
snapLines: [],
},
document: {
pages: {
[currentPageId]: {
shapes,
},
},
pageStates: {
[currentPageId]: {
selectedIds: this.isCreate
? []
: shapeBounds.map(shape => shape.initialShape.id),
},
},
},
};
};
complete = (): TldrawPatch | TldrawCommand | undefined => {
const {
isCreate,
shapeBounds,
hasUnlockedShapes,
app: { currentPageId },
} = this;
if (!hasUnlockedShapes) return;
if (
this.isCreate &&
Vec.dist(this.app.originPoint, this.app.currentPoint) < 2
) {
return this.cancel();
}
const beforeShapes: Record<string, TDShape | undefined> = {};
const afterShapes: Record<string, TDShape> = {};
let beforeSelectedIds: string[];
let afterSelectedIds: string[];
if (isCreate) {
beforeSelectedIds = [];
afterSelectedIds = [];
shapeBounds.forEach(({ initialShape }) => {
beforeShapes[initialShape.id] = undefined;
afterShapes[initialShape.id] = this.app.getShape(
initialShape.id
);
});
} else {
beforeSelectedIds = this.initialSelectedIds;
afterSelectedIds = this.initialSelectedIds;
shapeBounds.forEach(({ initialShape }) => {
beforeShapes[initialShape.id] = initialShape;
afterShapes[initialShape.id] = this.app.getShape(
initialShape.id
);
});
}
return {
id: 'transform',
before: {
appState: {
snapLines: [],
},
document: {
pages: {
[currentPageId]: {
shapes: beforeShapes,
},
},
pageStates: {
[currentPageId]: {
selectedIds: beforeSelectedIds,
hoveredId: undefined,
editingId: undefined,
},
},
},
},
after: {
appState: {
snapLines: [],
},
document: {
pages: {
[currentPageId]: {
shapes: afterShapes,
},
},
pageStates: {
[currentPageId]: {
selectedIds: afterSelectedIds,
hoveredId: undefined,
editingId: undefined,
},
},
},
},
};
};
}
@@ -0,0 +1,310 @@
import {
TLBoundsCorner,
TLSnapLine,
TLBoundsEdge,
Utils,
TLBoundsWithCenter,
TLBounds,
} from '@tldraw/core';
import { Vec } from '@tldraw/vec';
import {
SessionType,
TldrawCommand,
TldrawPatch,
TDShape,
TDStatus,
SLOW_SPEED,
SNAP_DISTANCE,
} from '@toeverything/components/board-types';
import { TLDR } from '@toeverything/components/board-state';
import { BaseSession } from './base-session';
import type { TldrawApp } from '@toeverything/components/board-state';
type SnapInfo =
| {
state: 'empty';
}
| {
state: 'ready';
bounds: TLBoundsWithCenter[];
};
export class TransformSingleSession extends BaseSession {
type = SessionType.TransformSingle;
status = TDStatus.Transforming;
performanceMode: undefined;
transformType: TLBoundsEdge | TLBoundsCorner;
scaleX = 1;
scaleY = 1;
isCreate: boolean;
initialShape: TDShape;
initialShapeBounds: TLBounds;
initialCommonBounds: TLBounds;
snapInfo: SnapInfo = { state: 'empty' };
prevPoint = [0, 0];
speed = 1;
constructor(
app: TldrawApp,
id: string,
transformType: TLBoundsEdge | TLBoundsCorner,
isCreate = false
) {
super(app);
this.isCreate = isCreate;
this.transformType = transformType;
const shape = this.app.getShape(id);
this.initialShape = shape;
this.initialShapeBounds = TLDR.get_bounds(shape);
this.initialCommonBounds = TLDR.get_rotated_bounds(shape);
this.app.rotationInfo.selectedIds = [shape.id];
}
start = (): TldrawPatch | undefined => {
this.snapInfo = {
state: 'ready',
bounds: this.app.shapes
.filter(shape => shape.id !== this.initialShape.id)
.map(shape =>
Utils.getBoundsWithCenter(TLDR.get_rotated_bounds(shape))
),
};
return void null;
};
update = (): TldrawPatch | undefined => {
const {
transformType,
initialShape,
initialShapeBounds,
app: {
settings: { isSnapping, showGrid },
currentPageId,
pageState: { camera },
viewport,
currentPoint,
previousPoint,
originPoint,
currentGrid,
shiftKey,
altKey,
metaKey,
},
} = this;
if (initialShape.isLocked) return void null;
const shapes = {} as Record<string, Partial<TDShape>>;
const delta = altKey
? Vec.mul(Vec.sub(currentPoint, originPoint), 2)
: Vec.sub(currentPoint, originPoint);
const shape = this.app.getShape(initialShape.id);
const utils = TLDR.get_shape_util(shape);
let newBounds = Utils.getTransformedBoundingBox(
initialShapeBounds,
transformType,
delta,
shape.rotation,
shiftKey || shape.isAspectRatioLocked || utils.isAspectRatioLocked
);
if (altKey) {
newBounds = {
...newBounds,
...Utils.centerBounds(
newBounds,
Utils.getBoundsCenter(initialShapeBounds)
),
};
}
if (showGrid) {
newBounds = {
...newBounds,
...Utils.snapBoundsToGrid(newBounds, currentGrid),
};
}
// Should we snap?
const speed = Vec.dist(currentPoint, previousPoint);
const speedChange = speed - this.speed;
this.speed = this.speed + speedChange * (speedChange > 1 ? 0.5 : 0.15);
let snapLines: TLSnapLine[] = [];
if (
((isSnapping && !metaKey) || (!isSnapping && metaKey)) &&
!initialShape.rotation && // not now anyway
this.speed * camera.zoom < SLOW_SPEED &&
this.snapInfo.state === 'ready'
) {
const snapResult = Utils.getSnapPoints(
Utils.getBoundsWithCenter(newBounds),
this.snapInfo.bounds.filter(
bounds =>
Utils.boundsContain(viewport, bounds) ||
Utils.boundsCollide(viewport, bounds)
),
SNAP_DISTANCE / camera.zoom
);
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.get_shape_util(shape).transformSingle(
shape,
newBounds,
{
initialShape,
type: this.transformType,
scaleX: newBounds.scaleX,
scaleY: newBounds.scaleY,
transformOrigin: [0.5, 0.5],
}
);
if (afterShape) {
shapes[shape.id] = afterShape;
}
if (showGrid && afterShape && afterShape.point) {
afterShape.point = Vec.snap(afterShape.point, currentGrid);
}
return {
appState: {
snapLines,
},
document: {
pages: {
[currentPageId]: {
shapes,
},
},
},
};
};
cancel = (): TldrawPatch | undefined => {
const {
initialShape,
app: { currentPageId },
} = this;
const shapes = {} as Record<string, TDShape | undefined>;
if (this.isCreate) {
shapes[initialShape.id] = undefined;
} else {
shapes[initialShape.id] = initialShape;
}
return {
appState: {
snapLines: [],
},
document: {
pages: {
[currentPageId]: {
shapes,
},
},
pageStates: {
[currentPageId]: {
selectedIds: this.isCreate ? [] : [initialShape.id],
},
},
},
};
};
complete = (): TldrawPatch | TldrawCommand | undefined => {
const {
initialShape,
app: { currentPageId },
} = this;
if (initialShape.isLocked) return;
if (
this.isCreate &&
Vec.dist(this.app.originPoint, this.app.currentPoint) < 2
) {
return this.cancel();
}
const beforeShapes = {} as Record<string, Partial<TDShape> | undefined>;
const afterShapes = {} as Record<string, Partial<TDShape>>;
beforeShapes[initialShape.id] = this.isCreate
? undefined
: initialShape;
afterShapes[initialShape.id] = TLDR.on_session_complete(
this.app.getShape(initialShape.id)
);
return {
id: 'transform_single',
before: {
appState: {
snapLines: [],
},
document: {
pages: {
[currentPageId]: {
shapes: beforeShapes,
},
},
pageStates: {
[currentPageId]: {
selectedIds: this.isCreate ? [] : [initialShape.id],
editingId: undefined,
hoveredId: undefined,
},
},
},
},
after: {
appState: {
snapLines: [],
},
document: {
pages: {
[currentPageId]: {
shapes: afterShapes,
},
},
pageStates: {
[currentPageId]: {
selectedIds: [initialShape.id],
editingId: undefined,
hoveredId: undefined,
},
},
},
},
};
};
}
@@ -0,0 +1,122 @@
import {
SessionType,
TldrawCommand,
TldrawPatch,
TDStatus,
RectangleShape,
TriangleShape,
EllipseShape,
ArrowShape,
} from '@toeverything/components/board-types';
import { TLDR } from '@toeverything/components/board-state';
import { BaseSession } from './base-session';
import type { TldrawApp } from '@toeverything/components/board-state';
import type { TLBounds } from '@tldraw/core';
export class TranslateLabelSession extends BaseSession {
type = SessionType.Handle;
performanceMode: undefined;
status = TDStatus.TranslatingHandle;
initialShape: RectangleShape | TriangleShape | EllipseShape | ArrowShape;
initialShapeBounds: TLBounds;
constructor(app: TldrawApp, shapeId: string) {
super(app);
this.initialShape = this.app.getShape<
RectangleShape | TriangleShape | EllipseShape | ArrowShape
>(shapeId);
this.initialShapeBounds = this.app.getShapeBounds(shapeId);
}
start = (): TldrawPatch | undefined => void null;
update = (): TldrawPatch | undefined => {
const {
initialShapeBounds,
app: { currentPageId, currentPoint },
} = this;
const newHandlePoint = [
Math.max(
0,
Math.min(1, currentPoint[0] / initialShapeBounds.width)
),
Math.max(
0,
Math.min(1, currentPoint[1] / initialShapeBounds.height)
),
];
// First update the handle's next point
const change = {
handlePoint: newHandlePoint,
} as Partial<
RectangleShape | TriangleShape | EllipseShape | ArrowShape
>;
return {
document: {
pages: {
[currentPageId]: {
shapes: {
[this.initialShape.id]: change,
},
},
},
},
};
};
cancel = (): TldrawPatch | undefined => {
const {
initialShape,
app: { currentPageId },
} = this;
return {
document: {
pages: {
[currentPageId]: {
shapes: {
[initialShape.id]: initialShape,
},
},
},
},
};
};
complete = (): TldrawPatch | TldrawCommand | undefined => {
const {
initialShape,
app: { currentPageId },
} = this;
return {
before: {
document: {
pages: {
[currentPageId]: {
shapes: {
[initialShape.id]: initialShape,
},
},
},
},
},
after: {
document: {
pages: {
[currentPageId]: {
shapes: {
[initialShape.id]: TLDR.on_session_complete(
this.app.getShape(this.initialShape.id)
),
},
},
},
},
},
};
};
}
@@ -0,0 +1,822 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {
TLPageState,
Utils,
TLBoundsWithCenter,
TLSnapLine,
TLBounds,
} from '@tldraw/core';
import { Vec } from '@tldraw/vec';
import {
TDShape,
TDBinding,
TldrawCommand,
TDStatus,
ArrowShape,
Patch,
GroupShape,
SessionType,
ArrowBinding,
TldrawPatch,
TDShapeType,
SLOW_SPEED,
SNAP_DISTANCE,
} from '@toeverything/components/board-types';
import { TLDR } from '@toeverything/components/board-state';
import { BaseSession } from './base-session';
import type { TldrawApp } from '@toeverything/components/board-state';
type CloneInfo =
| {
state: 'empty';
}
| {
state: 'ready';
cloneMap: Record<string, string>;
clones: TDShape[];
clonedBindings: ArrowBinding[];
};
type SnapInfo =
| {
state: 'empty';
}
| {
state: 'ready';
others: TLBoundsWithCenter[];
bounds: TLBoundsWithCenter[];
};
export class TranslateSession extends BaseSession {
performanceMode: undefined;
type = SessionType.Translate;
status = TDStatus.Translating;
delta = [0, 0];
prev = [0, 0];
prevPoint = [0, 0];
speed = 1;
cloneInfo: CloneInfo = {
state: 'empty',
};
snapInfo: SnapInfo = {
state: 'empty',
};
snapLines: TLSnapLine[] = [];
isCloning = false;
isCreate: boolean;
link: 'left' | 'right' | 'center' | false;
initialIds: Set<string>;
hasUnlockedShapes: boolean;
initialSelectedIds: string[];
initialCommonBounds: TLBounds;
initialShapes: TDShape[];
initialParentChildren: Record<string, string[]>;
bindingsToDelete: ArrowBinding[];
constructor(
app: TldrawApp,
isCreate = false,
link: 'left' | 'right' | 'center' | false = false
) {
super(app);
this.isCreate = isCreate;
this.link = link;
const { currentPageId, selectedIds, page } = this.app;
this.initialSelectedIds = [...selectedIds];
const selectedShapes = (
link
? TLDR.get_linked_shape_ids(
this.app.state,
currentPageId,
link,
false
)
: selectedIds
)
.map(id => this.app.getShape(id))
.filter(shape => !shape.isLocked);
const selectedShapeIds = new Set(selectedShapes.map(shape => shape.id));
selectedIds.forEach(item => {
// let shap = this.app.page.shapes[selectedIds[]];
const shap = this.app.getShape(item);
if (shap.type === TDShapeType.Frame) {
Object.entries(this.app.page.shapes).map(([id, shapItem]) => {
if (id !== shap.id) {
if (
Utils.boundsContain(
TLDR.get_bounds(shap),
TLDR.get_bounds(shapItem)
)
) {
selectedShapes.push(shapItem);
}
}
});
}
});
// }
this.hasUnlockedShapes = selectedShapes.length > 0;
this.initialShapes = Array.from(
new Set(
selectedShapes
.filter(shape => !selectedShapeIds.has(shape.parentId))
.flatMap(shape => {
return shape.children
? [
shape,
...shape.children.map(childId =>
this.app.getShape(childId)
),
]
: [shape];
})
).values()
);
this.initialIds = new Set(this.initialShapes.map(shape => shape.id));
this.bindingsToDelete = [];
Object.values(page.bindings)
.filter(
binding =>
this.initialIds.has(binding.fromId) ||
this.initialIds.has(binding.toId)
)
.forEach(binding => {
if (this.initialIds.has(binding.fromId)) {
if (!this.initialIds.has(binding.toId)) {
this.bindingsToDelete.push(binding);
}
}
});
this.initialParentChildren = {};
this.initialShapes
.map(s => s.parentId)
.filter(id => id !== page.id)
.forEach(id => {
this.initialParentChildren[id] =
this.app.getShape(id).children!;
});
this.initialCommonBounds = Utils.getCommonBounds(
this.initialShapes.map(TLDR.get_rotated_bounds)
);
this.app.rotationInfo.selectedIds = [...this.app.selectedIds];
}
start = (): TldrawPatch | undefined => {
const {
bindingsToDelete,
initialIds,
app: { currentPageId, page },
} = this;
const allBounds: TLBoundsWithCenter[] = [];
const otherBounds: TLBoundsWithCenter[] = [];
Object.values(page.shapes).forEach(shape => {
const bounds = Utils.getBoundsWithCenter(
TLDR.get_rotated_bounds(shape)
);
allBounds.push(bounds);
if (!initialIds.has(shape.id)) {
otherBounds.push(bounds);
}
});
this.snapInfo = {
state: 'ready',
bounds: allBounds,
others: otherBounds,
};
if (bindingsToDelete.length === 0) return;
const nextBindings: Patch<Record<string, TDBinding>> = {};
bindingsToDelete.forEach(
binding => (nextBindings[binding.id] = undefined)
);
return {
document: {
pages: {
[currentPageId]: {
bindings: nextBindings,
},
},
},
};
};
update = (): TldrawPatch | undefined => {
const {
initialParentChildren,
initialShapes,
initialCommonBounds,
bindingsToDelete,
app: {
pageState: { camera },
settings: { isSnapping, showGrid },
currentPageId,
viewport,
selectedIds,
currentPoint,
previousPoint,
originPoint,
altKey,
shiftKey,
metaKey,
currentGrid,
},
} = this;
const nextBindings: Patch<Record<string, TDBinding>> = {};
const nextShapes: Patch<Record<string, TDShape>> = {};
const nextPageState: Patch<TLPageState> = {};
let delta = Vec.sub(currentPoint, originPoint);
let didChangeCloning = false;
if (!this.isCreate) {
if (altKey && !this.isCloning) {
this.isCloning = true;
didChangeCloning = true;
} else if (!altKey && this.isCloning) {
this.isCloning = false;
didChangeCloning = true;
}
}
if (shiftKey) {
if (Math.abs(delta[0]) < Math.abs(delta[1])) {
delta[0] = 0;
} else {
delta[1] = 0;
}
}
// Should we snap?
// Speed is used to decide which snap points to use. At a high
// speed, we don't use any snap points. At a low speed, we only
// allow center-to-center snap points. At very low speed, we
// enable all snap points (still preferring middle snaps). We're
// using an acceleration function here to smooth the changes in
// speed, but we also want the speed to accelerate faster than
// it decelerates.
const speed = Vec.dist(currentPoint, previousPoint);
const change = speed - this.speed;
this.speed = this.speed + change * (change > 1 ? 0.5 : 0.15);
this.snapLines = [];
if (
((isSnapping && !metaKey) || (!isSnapping && metaKey)) &&
this.speed * camera.zoom < SLOW_SPEED &&
this.snapInfo.state === 'ready'
) {
const snapResult = Utils.getSnapPoints(
Utils.getBoundsWithCenter(
showGrid
? Utils.snapBoundsToGrid(
Utils.translateBounds(initialCommonBounds, delta),
currentGrid
)
: Utils.translateBounds(initialCommonBounds, delta)
),
(this.isCloning
? this.snapInfo.bounds
: this.snapInfo.others
).filter(bounds => {
return (
Utils.boundsContain(viewport, bounds) ||
Utils.boundsCollide(viewport, bounds)
);
}),
SNAP_DISTANCE / camera.zoom
);
if (snapResult) {
this.snapLines = snapResult.snapLines;
delta = Vec.sub(delta, snapResult.offset);
}
}
// We've now calculated the "delta", or difference between the
// cursor's position (real or adjusted by snaps or axis locking)
// and the cursor's original position ("origin").
// The "movement" is the actual change of position between this
// computed position and the previous computed position.
this.prev = delta;
// If cloning...
if (this.isCloning) {
// Not Cloning -> Cloning
if (didChangeCloning) {
if (this.cloneInfo.state === 'empty') {
this.create_clone_info();
}
if (this.cloneInfo.state === 'empty') {
throw Error;
}
const { clones, clonedBindings } = this.cloneInfo;
this.isCloning = true;
// Put back any bindings we deleted
bindingsToDelete.forEach(
binding => (nextBindings[binding.id] = binding)
);
// Move original shapes back to start
initialShapes.forEach(
shape => (nextShapes[shape.id] = { point: shape.point })
);
// Add the clones to the page
clones.forEach(clone => {
nextShapes[clone.id] = { ...clone };
// Add clones to non-selected parents
if (
clone.parentId !== currentPageId &&
!selectedIds.includes(clone.parentId)
) {
const children =
nextShapes[clone.parentId]?.children ||
initialParentChildren[clone.parentId];
if (!children.includes(clone.id)) {
nextShapes[clone.parentId] = {
...nextShapes[clone.parentId],
children: [...children, clone.id],
};
}
}
});
// Add the cloned bindings
for (const binding of clonedBindings) {
nextBindings[binding.id] = binding;
}
// Set the selected ids to the clones
nextPageState.selectedIds = clones.map(clone => clone.id);
// Either way, move the clones
clones.forEach(clone => {
nextShapes[clone.id] = {
...clone,
point: showGrid
? Vec.snap(
Vec.toFixed(Vec.add(clone.point, delta)),
currentGrid
)
: Vec.toFixed(Vec.add(clone.point, delta)),
};
});
} else {
if (this.cloneInfo.state === 'empty') throw Error;
const { clones } = this.cloneInfo;
clones.forEach(clone => {
nextShapes[clone.id] = {
point: showGrid
? Vec.snap(
Vec.toFixed(Vec.add(clone.point, delta)),
currentGrid
)
: Vec.toFixed(Vec.add(clone.point, delta)),
};
});
}
} else {
// If not cloning...
// Cloning -> Not Cloning
if (didChangeCloning) {
if (this.cloneInfo.state === 'empty') throw Error;
const { clones, clonedBindings } = this.cloneInfo;
this.isCloning = false;
// Delete the bindings
bindingsToDelete.forEach(
binding => (nextBindings[binding.id] = undefined)
);
// Remove the clones from parents
clones.forEach(clone => {
if (clone.parentId !== currentPageId) {
nextShapes[clone.parentId] = {
...nextShapes[clone.parentId],
children: initialParentChildren[clone.parentId],
};
}
});
// Delete the clones (including any parent clones)
clones.forEach(clone => (nextShapes[clone.id] = undefined));
// Move the original shapes back to the cursor position
initialShapes.forEach(shape => {
nextShapes[shape.id] = {
point: showGrid
? Vec.snap(
Vec.toFixed(Vec.add(shape.point, delta)),
currentGrid
)
: Vec.toFixed(Vec.add(shape.point, delta)),
};
});
// Delete the cloned bindings
for (const binding of clonedBindings) {
nextBindings[binding.id] = undefined;
}
// Set selected ids
nextPageState.selectedIds = initialShapes.map(
shape => shape.id
);
} else {
// Move the shapes by the delta
initialShapes.forEach(shape => {
// const current = (nextShapes[shape.id] || this.app.getShape(shape.id)) as TDShape
nextShapes[shape.id] = {
point: showGrid
? Vec.snap(
Vec.toFixed(Vec.add(shape.point, delta)),
currentGrid
)
: Vec.toFixed(Vec.add(shape.point, delta)),
};
});
}
}
return {
appState: {
snapLines: this.snapLines,
},
document: {
pages: {
[currentPageId]: {
shapes: nextShapes,
bindings: nextBindings,
},
},
pageStates: {
[currentPageId]: nextPageState,
},
},
};
};
cancel = (): TldrawPatch | undefined => {
const {
initialShapes,
initialSelectedIds,
bindingsToDelete,
app: { currentPageId },
} = this;
const nextBindings: Record<string, Partial<TDBinding> | undefined> = {};
const nextShapes: Record<string, Partial<TDShape> | undefined> = {};
const nextPageState: Partial<TLPageState> = {
editingId: undefined,
hoveredId: undefined,
};
// Put back any deleted bindings
bindingsToDelete.forEach(
binding => (nextBindings[binding.id] = binding)
);
if (this.isCreate) {
initialShapes.forEach(({ id }) => (nextShapes[id] = undefined));
nextPageState.selectedIds = [];
} else {
// Put initial shapes back to where they started
initialShapes.forEach(
({ id, point }) =>
(nextShapes[id] = { ...nextShapes[id], point })
);
nextPageState.selectedIds = initialSelectedIds;
}
if (this.cloneInfo.state === 'ready') {
const { clones, clonedBindings } = this.cloneInfo;
// Delete clones
clones.forEach(clone => (nextShapes[clone.id] = undefined));
// Delete cloned bindings
clonedBindings.forEach(
binding => (nextBindings[binding.id] = undefined)
);
}
return {
appState: {
snapLines: [],
},
document: {
pages: {
[currentPageId]: {
shapes: nextShapes,
bindings: nextBindings,
},
},
pageStates: {
[currentPageId]: nextPageState,
},
},
};
};
complete = (): TldrawPatch | TldrawCommand | undefined => {
const {
initialShapes,
initialParentChildren,
bindingsToDelete,
app: { currentPageId },
} = this;
const beforeBindings: Patch<Record<string, TDBinding>> = {};
const beforeShapes: Patch<Record<string, TDShape>> = {};
const afterBindings: Patch<Record<string, TDBinding>> = {};
const afterShapes: Patch<Record<string, TDShape>> = {};
if (this.isCloning) {
if (this.cloneInfo.state === 'empty') {
this.create_clone_info();
}
if (this.cloneInfo.state !== 'ready') throw Error;
const { clones, clonedBindings } = this.cloneInfo;
// Update the clones
clones.forEach(clone => {
beforeShapes[clone.id] = undefined;
afterShapes[clone.id] = this.app.getShape(clone.id);
if (clone.parentId !== currentPageId) {
beforeShapes[clone.parentId] = {
...beforeShapes[clone.parentId],
children: initialParentChildren[clone.parentId],
};
afterShapes[clone.parentId] = {
...afterShapes[clone.parentId],
children: this.app.getShape<GroupShape>(clone.parentId)
.children,
};
}
});
// Update the cloned bindings
clonedBindings.forEach(binding => {
beforeBindings[binding.id] = undefined;
afterBindings[binding.id] = this.app.getBinding(binding.id);
});
} else {
// If we aren't cloning, then update the initial shapes
initialShapes.forEach(shape => {
beforeShapes[shape.id] = this.isCreate
? undefined
: {
...beforeShapes[shape.id],
point: shape.point,
};
afterShapes[shape.id] = {
...afterShapes[shape.id],
...(this.isCreate
? this.app.getShape(shape.id)
: { point: this.app.getShape(shape.id).point }),
};
});
}
// Update the deleted bindings and any associated shapes
bindingsToDelete.forEach(binding => {
beforeBindings[binding.id] = binding;
for (const id of [binding.toId, binding.fromId]) {
// Let's also look at the bound shape...
const shape = this.app.getShape(id);
// If the bound shape has a handle that references the deleted binding, delete that reference
if (!shape.handles) continue;
Object.values(shape.handles)
.filter(handle => handle.bindingId === binding.id)
.forEach(handle => {
beforeShapes[id] = { ...beforeShapes[id], handles: {} };
afterShapes[id] = { ...afterShapes[id], handles: {} };
// There should be before and after shapes
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
beforeShapes[id]!.handles![
handle.id as keyof ArrowShape['handles']
] = {
bindingId: binding.id,
};
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
afterShapes[id]!.handles![
handle.id as keyof ArrowShape['handles']
] = {
bindingId: undefined,
};
});
}
});
return {
id: 'translate',
before: {
appState: {
snapLines: [],
},
document: {
pages: {
[currentPageId]: {
shapes: beforeShapes,
bindings: beforeBindings,
},
},
pageStates: {
[currentPageId]: {
selectedIds: this.isCreate
? []
: [...this.initialSelectedIds],
},
},
},
},
after: {
appState: {
snapLines: [],
},
document: {
pages: {
[currentPageId]: {
shapes: afterShapes,
bindings: afterBindings,
},
},
pageStates: {
[currentPageId]: {
selectedIds: [...this.app.selectedIds],
},
},
},
},
};
};
private create_clone_info = () => {
// Create clones when as they're needed.
// Consider doing this work in a worker.
const {
initialShapes,
initialParentChildren,
app: { selectedIds, currentPageId, page },
} = this;
const cloneMap: Record<string, string> = {};
const clonedBindingsMap: Record<string, string> = {};
const clonedBindings: TDBinding[] = [];
// Create clones of selected shapes
const clones: TDShape[] = [];
initialShapes.forEach(shape => {
const newId = Utils.uniqueId();
initialParentChildren[newId] = initialParentChildren[shape.id];
cloneMap[shape.id] = newId;
const clone = {
...Utils.deepClone(shape),
id: newId,
parentId: shape.parentId,
childIndex: TLDR.get_child_index_above(
this.app.state,
shape.id,
currentPageId
),
};
if (clone.type === TDShapeType.Video) {
const element = document.getElementById(
shape.id + '_video'
) as HTMLVideoElement;
if (element)
clone.currentTime =
(element.currentTime + 16) % element.duration;
}
clones.push(clone);
});
clones.forEach(clone => {
if (clone.children !== undefined) {
clone.children = clone.children.map(
childId => cloneMap[childId]
);
}
});
clones.forEach(clone => {
if (selectedIds.includes(clone.parentId)) {
clone.parentId = cloneMap[clone.parentId];
}
});
// Potentially confusing name here: these are the ids of the
// original shapes that were cloned, not their clones' ids.
const clonedShapeIds = new Set(Object.keys(cloneMap));
// Create cloned bindings for shapes where both to and from shapes are selected
// (if the user clones, then we will create a new binding for the clones)
Object.values(page.bindings)
.filter(
binding =>
clonedShapeIds.has(binding.fromId) ||
clonedShapeIds.has(binding.toId)
)
.forEach(binding => {
if (clonedShapeIds.has(binding.fromId)) {
if (clonedShapeIds.has(binding.toId)) {
const cloneId = Utils.uniqueId();
const cloneBinding = {
...Utils.deepClone(binding),
id: cloneId,
fromId: cloneMap[binding.fromId] || binding.fromId,
toId: cloneMap[binding.toId] || binding.toId,
};
clonedBindingsMap[binding.id] = cloneId;
clonedBindings.push(cloneBinding);
}
}
});
// Assign new binding ids to clones (or delete them!)
clones.forEach(clone => {
if (clone.handles) {
if (clone.handles) {
for (const id in clone.handles) {
const handle =
clone.handles[id as keyof ArrowShape['handles']];
handle.bindingId = handle.bindingId
? clonedBindingsMap[handle.bindingId]
: undefined;
}
}
}
});
clones.forEach(clone => {
if (page.shapes[clone.id]) {
throw Error("uh oh, we didn't clone correctly");
}
});
this.cloneInfo = {
state: 'ready',
clones,
cloneMap,
clonedBindings,
};
};
}