Files
AFFiNE-Mirror/libs/components/board-tools/src/select-tool/select-tool.ts
2022-07-22 15:49:21 +08:00

769 lines
25 KiB
TypeScript

import {
TLBoundsCorner,
TLBoundsEdge,
TLBoundsEventHandler,
TLBoundsHandleEventHandler,
TLCanvasEventHandler,
TLPointerEventHandler,
TLKeyboardEventHandler,
TLShapeCloneHandler,
Utils,
} from '@tldraw/core';
import {
SessionType,
TDShapeType,
CLONING_DISTANCE,
DEAD_ZONE,
} from '@toeverything/components/board-types';
import { BaseTool, TLDR } from '@toeverything/components/board-state';
import Vec from '@tldraw/vec';
enum Status {
Idle = 'idle',
Creating = 'creating',
Pinching = 'pinching',
PointingCanvas = 'pointingCanvas',
PointingHandle = 'pointingHandle',
PointingBounds = 'pointingBounds',
PointingClone = 'pointingClone',
TranslatingClone = 'translatingClone',
PointingBoundsHandle = 'pointingBoundsHandle',
TranslatingHandle = 'translatingHandle',
Translating = 'translating',
Transforming = 'transforming',
Rotating = 'rotating',
Brushing = 'brushing',
GridCloning = 'gridCloning',
ClonePainting = 'clonePainting',
}
export class SelectTool extends BaseTool<Status> {
override type = 'select' as const;
pointedId?: string;
selectedGroupId?: string;
pointedHandleId?: 'start' | 'end' | 'bend';
pointedBoundsHandle?:
| TLBoundsCorner
| TLBoundsEdge
| 'rotate'
| 'center'
| 'left'
| 'right';
pointedLinkHandleId?: 'left' | 'center' | 'right';
/* --------------------- Methods -------------------- */
private deselect(id: string) {
this.app.select(...this.app.selectedIds.filter(oid => oid !== id));
}
private select(id: string) {
this.app.select(id);
}
private push_select(id: string) {
const shape = this.app.getShape(id);
this.app.select(
...this.app.selectedIds.filter(oid => oid !== shape.parentId),
id
);
}
private select_none() {
this.app.selectNone();
}
override onEnter = () => {
this.set_status(Status.Idle);
};
override onExit = () => {
this.set_status(Status.Idle);
};
clonePaint = (point: number[]) => {
if (this.app.selectedIds.length === 0) return;
const shapes = this.app.selectedIds.map(id => this.app.getShape(id));
const bounds = Utils.expandBounds(
Utils.getCommonBounds(shapes.map(TLDR.get_bounds)),
16
);
const center = Utils.getBoundsCenter(bounds);
const size = [bounds.width, bounds.height];
const gridPoint = [
center[0] +
size[0] *
Math.floor((point[0] + size[0] / 2 - center[0]) / size[0]),
center[1] +
size[1] *
Math.floor((point[1] + size[1] / 2 - center[1]) / size[1]),
];
const centeredBounds = Utils.centerBounds(bounds, gridPoint);
const hit = this.app.shapes.some(shape =>
TLDR.get_shape_util(shape).hitTestBounds(shape, centeredBounds)
);
if (!hit) {
this.app.duplicate(this.app.selectedIds, gridPoint);
}
};
getShapeClone = (
id: string,
side:
| 'top'
| 'right'
| 'bottom'
| 'left'
| 'topLeft'
| 'topRight'
| 'bottomRight'
| 'bottomLeft'
) => {
const shape = this.app.getShape(id);
const utils = TLDR.get_shape_util(shape);
if (utils.canClone) {
const bounds = utils.getBounds(shape);
const center = utils.getCenter(shape);
let point = {
top: [
bounds.minX,
bounds.minY - (bounds.height + CLONING_DISTANCE),
],
right: [bounds.maxX + CLONING_DISTANCE, bounds.minY],
bottom: [bounds.minX, bounds.maxY + CLONING_DISTANCE],
left: [
bounds.minX - (bounds.width + CLONING_DISTANCE),
bounds.minY,
],
topLeft: [
bounds.minX - (bounds.width + CLONING_DISTANCE),
bounds.minY - (bounds.height + CLONING_DISTANCE),
],
topRight: [
bounds.maxX + CLONING_DISTANCE,
bounds.minY - (bounds.height + CLONING_DISTANCE),
],
bottomLeft: [
bounds.minX - (bounds.width + CLONING_DISTANCE),
bounds.maxY + CLONING_DISTANCE,
],
bottomRight: [
bounds.maxX + CLONING_DISTANCE,
bounds.maxY + CLONING_DISTANCE,
],
}[side];
if (shape.rotation !== 0) {
const newCenter = Vec.add(point, [
bounds.width / 2,
bounds.height / 2,
]);
const rotatedCenter = Vec.rotWith(
newCenter,
center,
shape.rotation || 0
);
point = Vec.sub(rotatedCenter, [
bounds.width / 2,
bounds.height / 2,
]);
}
const id = Utils.uniqueId();
const clone = {
...shape,
id,
point,
};
// if (clone.type === TDShapeType.Sticky) {
// clone.text = '';
// }
return clone;
}
return;
};
/* ----------------- Event Handlers ----------------- */
override onCancel = () => {
if (this.app.pageState.editingId) {
this.app.setEditingId();
} else {
this.select_none();
}
this.app.cancelSession();
this.set_status(Status.Idle);
};
override onKeyDown: TLKeyboardEventHandler = (key, info, e) => {
switch (key) {
case 'Escape': {
this.onCancel();
break;
}
case 'Tab': {
if (
!this.app.pageState.editingId &&
this.status === Status.Idle &&
this.app.selectedIds.length === 1
) {
const [selectedId] = this.app.selectedIds;
const clonedShape = this.getShapeClone(selectedId, 'right');
if (clonedShape) {
this.app.createShapes(clonedShape);
this.set_status(Status.Idle);
// if (clonedShape.type === TDShapeType.Sticky) {
// this.app.select(clonedShape.id);
// this.app.setEditingId(clonedShape.id);
// }
}
}
break;
}
case 'Meta':
case 'Control':
case 'Alt': {
this.app.updateSession();
break;
}
case 'Enter': {
const { pageState } = this.app;
if (
pageState.selectedIds.length === 1 &&
!pageState.editingId
) {
this.app.setEditingId(pageState.selectedIds[0]);
e.preventDefault();
}
}
}
};
override onKeyUp: TLKeyboardEventHandler = (key, info) => {
if (
this.status === Status.ClonePainting &&
!(info.altKey && info.shiftKey)
) {
this.set_status(Status.Idle);
return;
}
/* noop */
if (key === 'Meta' || key === 'Control' || key === 'Alt') {
this.app.updateSession();
return;
}
};
// Keyup is handled on BaseTool
// Pointer Events (generic)
override onPointerMove: TLPointerEventHandler = (info, e) => {
const { originPoint, currentPoint } = this.app;
switch (this.status) {
case Status.PointingBoundsHandle: {
if (!this.pointedBoundsHandle)
throw Error('No pointed bounds handle');
if (Vec.dist(originPoint, currentPoint) > DEAD_ZONE) {
if (this.pointedBoundsHandle === 'rotate') {
// Stat a rotate session
this.set_status(Status.Rotating);
this.app.startSession(SessionType.Rotate);
} else if (
this.pointedBoundsHandle === 'center' ||
this.pointedBoundsHandle === 'left' ||
this.pointedBoundsHandle === 'right'
) {
this.set_status(Status.Translating);
this.app.startSession(
SessionType.Translate,
false,
this.pointedBoundsHandle
);
} else {
// Stat a transform session
this.set_status(Status.Transforming);
const idsToTransform = this.app.selectedIds.flatMap(
id =>
TLDR.get_document_branch(
this.app.state,
id,
this.app.currentPageId
)
);
if (idsToTransform.length === 1) {
// if only one shape is selected, transform single
this.app.startSession(
SessionType.TransformSingle,
idsToTransform[0],
this.pointedBoundsHandle
);
} else {
// otherwise, transform
this.app.startSession(
SessionType.Transform,
this.pointedBoundsHandle
);
}
}
// Also update the session with the current point
this.app.updateSession();
}
break;
}
case Status.PointingCanvas: {
if (Vec.dist(originPoint, currentPoint) > DEAD_ZONE) {
this.app.startSession(SessionType.Brush);
this.set_status(Status.Brushing);
}
break;
}
case Status.PointingClone: {
if (Vec.dist(originPoint, currentPoint) > DEAD_ZONE) {
this.set_status(Status.TranslatingClone);
this.app.startSession(SessionType.Translate);
this.app.updateSession();
}
break;
}
case Status.PointingBounds: {
if (Vec.dist(originPoint, currentPoint) > DEAD_ZONE) {
this.set_status(Status.Translating);
this.app.startSession(SessionType.Translate);
this.app.updateSession();
}
break;
}
case Status.PointingHandle: {
if (Vec.dist(originPoint, currentPoint) > DEAD_ZONE) {
this.set_status(Status.TranslatingHandle);
const selectedShape = this.app.getShape(
this.app.selectedIds[0]
);
if (selectedShape) {
if (this.pointedHandleId === 'bend') {
this.app.startSession(
SessionType.Handle,
selectedShape.id,
this.pointedHandleId
);
this.app.updateSession();
} else {
this.app.startSession(
SessionType.Arrow,
selectedShape.id,
this.pointedHandleId,
false
);
this.app.updateSession();
}
}
}
break;
}
case Status.ClonePainting: {
this.clonePaint(currentPoint);
break;
}
default: {
if (this.app.session) {
this.app.updateSession();
break;
}
}
}
};
override onPointerDown: TLPointerEventHandler = (info, e) => {
if (info.target === 'canvas' && this.status === Status.Idle) {
const { currentPoint } = this.app;
if (info.spaceKey && e.buttons === 1) return;
if (this.status === Status.Idle && info.altKey && info.shiftKey) {
this.set_status(Status.ClonePainting);
this.clonePaint(currentPoint);
return;
}
// Unless the user is holding shift or meta, clear the current selection
if (!info.shiftKey) {
this.app.onShapeBlur();
if (info.altKey && this.app.selectedIds.length > 0) {
this.app.duplicate(this.app.selectedIds, currentPoint);
return;
}
this.select_none();
}
this.set_status(Status.PointingCanvas);
}
};
override onPointerUp: TLPointerEventHandler = info => {
if (
this.status === Status.TranslatingClone ||
this.status === Status.PointingClone
) {
if (this.pointedId) {
this.app.completeSession();
this.app.setEditingId(this.pointedId);
}
this.set_status(Status.Idle);
this.pointedId = undefined;
return;
}
if (this.status === Status.PointingBounds) {
if (info.target === 'bounds') {
// If we just clicked the selecting bounds's background,
// clear the selection
this.select_none();
} else if (this.app.isSelected(info.target)) {
// If we're holding shift...
if (info.shiftKey) {
// unless we just shift-selected the shape, remove it from
// the selected shapes
if (this.pointedId !== info.target) {
this.deselect(info.target);
}
} else {
// If we have other selected shapes, select this one instead
if (
this.pointedId !== info.target &&
this.app.selectedIds.length > 1
) {
this.select(info.target);
}
}
} else if (this.pointedId === info.target) {
if (this.app.getShape(info.target).isLocked) return;
// If the target is not selected and was just pointed
// on pointer down...
if (info.shiftKey) {
this.push_select(info.target);
} else {
this.select(info.target);
}
}
}
// Complete the current session, if any; and reset the status
this.app.completeSession();
this.set_status(Status.Idle);
this.pointedBoundsHandle = undefined;
this.pointedHandleId = undefined;
this.pointedId = undefined;
};
// Canvas
override onDoubleClickCanvas: TLCanvasEventHandler = () => {
// Needs debugging
// const { currentPoint } = this.app
// this.app.selectTool(TDShapeType.Text)
// this.setStatus(Status.Idle)
// this.app.createTextShapeAtPoint(currentPoint)
};
// Shape
override onPointShape: TLPointerEventHandler = (info, e) => {
if (info.spaceKey && e.buttons === 1) return;
if (this.app.getShape(info.target).isLocked) return;
const { editingId, hoveredId } = this.app.pageState;
if (editingId && info.target !== editingId) {
this.app.onShapeBlur();
}
// While holding command and shift, select or deselect
// the shape, ignoring any group that may contain it. Yikes!
if (
(this.status === Status.Idle ||
this.status === Status.PointingBounds) &&
info.metaKey &&
info.shiftKey &&
hoveredId
) {
this.pointedId = hoveredId;
if (this.app.isSelected(hoveredId)) {
this.deselect(hoveredId);
} else {
this.push_select(hoveredId);
this.set_status(Status.PointingBounds);
}
return;
}
if (this.status === Status.PointingBounds) {
// The pointed id should be the shape's group, if it belongs
// to a group, or else the shape itself, if it is on the page.
const { parentId } = this.app.getShape(info.target);
this.pointedId =
parentId === this.app.currentPageId ? info.target : parentId;
return;
}
if (this.status === Status.Idle) {
this.set_status(Status.PointingBounds);
if (info.metaKey) {
if (!info.shiftKey) {
this.select_none();
}
this.app.startSession(SessionType.Brush);
this.set_status(Status.Brushing);
return;
}
// If we've clicked on a shape that is inside of a group,
// then select the group rather than the shape.
let shapeIdToSelect: string;
const { parentId } = this.app.getShape(info.target);
// If the pointed shape is a child of the page, select the
// target shape and clear the selected group id.
if (parentId === this.app.currentPageId) {
shapeIdToSelect = info.target;
this.selectedGroupId = undefined;
} else {
// If the parent is some other group...
if (parentId === this.selectedGroupId) {
// If that group is the selected group, then select
// the target shape.
shapeIdToSelect = info.target;
} else {
// Otherwise, select the group and clear the selected
// group id.
shapeIdToSelect = parentId;
this.selectedGroupId = undefined;
}
}
if (!this.app.isSelected(shapeIdToSelect)) {
// Set the pointed ID to the shape that was clicked.
this.pointedId = shapeIdToSelect;
// If the shape is not selected: then if the user is pressing shift,
// add the shape to the current selection; otherwise, set the shape as
// the only selected shape.
if (info.shiftKey) {
this.push_select(shapeIdToSelect);
} else {
this.select(shapeIdToSelect);
}
}
}
};
override onDoubleClickShape: TLPointerEventHandler = info => {
const shape = this.app.getShape(info.target);
if (shape.isLocked) {
this.app.select(info.target);
return;
}
// If we can edit the shape (and if we can select the shape) then
// start editing
if (
TLDR.get_shape_util(shape.type).canEdit &&
(shape.parentId === this.app.currentPageId ||
shape.parentId === this.selectedGroupId)
) {
this.app.setEditingId(info.target);
}
// If the shape is the child of a group, then drill into the group?
if (shape.parentId !== this.app.currentPageId) {
this.selectedGroupId = shape.parentId;
}
this.app.select(info.target);
};
override onRightPointShape: TLPointerEventHandler = info => {
if (!this.app.isSelected(info.target)) {
this.app.select(info.target);
}
};
override onHoverShape: TLPointerEventHandler = info => {
this.app.setHoveredId(info.target);
};
override onUnhoverShape: TLPointerEventHandler = info => {
const { currentPageId: oldCurrentPageId } = this.app;
// Wait a frame; and if we haven't changed the hovered id,
// clear the current hovered id
requestAnimationFrame(() => {
if (
oldCurrentPageId === this.app.currentPageId &&
this.app.pageState.hoveredId === info.target
) {
this.app.setHoveredId(undefined);
}
});
};
/* --------------------- Bounds --------------------- */
override onPointBounds: TLBoundsEventHandler = info => {
if (info.metaKey) {
if (!info.shiftKey) {
this.select_none();
}
this.app.startSession(SessionType.Brush);
this.set_status(Status.Brushing);
return;
}
this.set_status(Status.PointingBounds);
};
override onRightPointBounds: TLPointerEventHandler = (info, e) => {
e.stopPropagation();
};
override onReleaseBounds: TLBoundsEventHandler = () => {
if (
this.status === Status.Translating ||
this.status === Status.Brushing
) {
this.app.completeSession();
}
this.set_status(Status.Idle);
};
/* ----------------- Bounds Handles ----------------- */
override onPointBoundsHandle: TLBoundsHandleEventHandler = info => {
this.pointedBoundsHandle = info.target;
this.set_status(Status.PointingBoundsHandle);
};
override onDoubleClickBoundsHandle: TLBoundsHandleEventHandler = info => {
switch (info.target) {
case 'center':
case 'left':
case 'right': {
this.app.select(
...TLDR.get_linked_shape_ids(
this.app.state,
this.app.currentPageId,
info.target,
info.shiftKey
)
);
break;
}
default: {
if (this.app.selectedIds.length === 1) {
this.app.resetBounds(this.app.selectedIds);
const shape = this.app.getShape(this.app.selectedIds[0]);
if ('label' in shape) {
this.app.setEditingId(shape.id);
}
}
}
}
};
override onReleaseBoundsHandle: TLBoundsHandleEventHandler = () => {
this.set_status(Status.Idle);
};
/* --------------------- Handles -------------------- */
override onPointHandle: TLPointerEventHandler = info => {
this.pointedHandleId = info.target as 'start' | 'end';
this.set_status(Status.PointingHandle);
};
override onDoubleClickHandle: TLPointerEventHandler = info => {
if (info.target === 'bend') {
const { selectedIds } = this.app;
if (selectedIds.length !== 1) return;
const shape = this.app.getShape(selectedIds[0]);
if (
TLDR.get_shape_util(shape.type).canEdit &&
(shape.parentId === this.app.currentPageId ||
shape.parentId === this.selectedGroupId)
) {
this.app.setEditingId(shape.id);
}
return;
}
this.app.toggleDecoration(info.target);
};
override onReleaseHandle: TLPointerEventHandler = () => {
this.set_status(Status.Idle);
};
/* ---------------------- Misc ---------------------- */
override onShapeClone: TLShapeCloneHandler = info => {
const selectedShapeId = this.app.selectedIds[0];
const clonedShape = this.getShapeClone(selectedShapeId, info.target);
if (
info.target === 'left' ||
info.target === 'right' ||
info.target === 'top' ||
info.target === 'bottom'
) {
if (clonedShape) {
this.app.createShapes(clonedShape);
// Now start pointing the bounds, so that a user can start
// dragging to reposition if they wish.
this.pointedId = clonedShape.id;
this.set_status(Status.PointingClone);
}
} else {
this.set_status(Status.GridCloning);
this.app.startSession(SessionType.Grid, selectedShapeId);
}
};
}