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

View File

@@ -0,0 +1,126 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Vec } from '@tldraw/vec';
import { Utils } from '@tldraw/core';
import {
AlignType,
TldrawCommand,
TDShapeType,
} from '@toeverything/components/board-types';
import { TLDR } from '@toeverything/components/board-state';
import type { TldrawApp } from '@toeverything/components/board-state';
export function alignShapes(
app: TldrawApp,
ids: string[],
type: AlignType
): TldrawCommand {
const { currentPageId } = app;
const initialShapes = ids.map(id => app.getShape(id));
const boundsForShapes = initialShapes.map(shape => {
return {
id: shape.id,
point: [...shape.point],
bounds: TLDR.get_bounds(shape),
};
});
const commonBounds = Utils.getCommonBounds(
boundsForShapes.map(({ bounds }) => bounds)
);
const midX = commonBounds.minX + commonBounds.width / 2;
const midY = commonBounds.minY + commonBounds.height / 2;
const deltaMap = Object.fromEntries(
boundsForShapes.map(({ id, point, bounds }) => {
return [
id,
{
prev: point,
next: {
[AlignType.CenterVertical]: [
point[0],
midY - bounds.height / 2,
],
[AlignType.CenterHorizontal]: [
midX - bounds.width / 2,
point[1],
],
[AlignType.Top]: [point[0], commonBounds.minY],
[AlignType.Bottom]: [
point[0],
commonBounds.maxY - bounds.height,
],
[AlignType.Left]: [commonBounds.minX, point[1]],
[AlignType.Right]: [
commonBounds.maxX - bounds.width,
point[1],
],
}[type],
},
];
})
);
const { before, after } = TLDR.mutate_shapes(
app.state,
ids,
shape => {
if (!deltaMap[shape.id]) return shape;
return { point: deltaMap[shape.id].next };
},
currentPageId
);
initialShapes.forEach(shape => {
if (shape.type === TDShapeType.Group) {
const delta = Vec.sub(
after[shape.id].point!,
before[shape.id].point!
);
shape.children.forEach(id => {
const child = app.getShape(id);
before[child.id] = { point: child.point };
after[child.id] = { point: Vec.add(child.point, delta) };
});
delete before[shape.id];
delete after[shape.id];
}
});
return {
id: 'align',
before: {
document: {
pages: {
[currentPageId]: {
shapes: before,
},
},
pageStates: {
[currentPageId]: {
selectedIds: ids,
},
},
},
},
after: {
document: {
pages: {
[currentPageId]: {
shapes: after,
},
},
pageStates: {
[currentPageId]: {
selectedIds: ids,
},
},
},
},
};
}

View File

@@ -0,0 +1,18 @@
import type { TldrawCommand } from '@toeverything/components/board-types';
import type { TldrawApp } from '@toeverything/components/board-state';
export function changePage(app: TldrawApp, pageId: string): TldrawCommand {
return {
id: 'change_page',
before: {
appState: {
currentPageId: app.currentPageId,
},
},
after: {
appState: {
currentPageId: pageId,
},
},
};
}

View File

@@ -0,0 +1,71 @@
import type {
TldrawCommand,
TDPage,
} from '@toeverything/components/board-types';
import { Utils, TLPageState } from '@tldraw/core';
import type { TldrawApp } from '@toeverything/components/board-state';
export function createPage(
app: TldrawApp,
center: number[],
pageId = Utils.uniqueId()
): TldrawCommand {
const { currentPageId } = app;
const topPage = Object.values(app.state.document.pages).sort(
(a, b) => (b.childIndex || 0) - (a.childIndex || 0)
)[0];
const nextChildIndex = topPage?.childIndex ? topPage?.childIndex + 1 : 1;
// TODO: Iterate the name better
const nextName = `New Page`;
const page: TDPage = {
id: pageId,
name: nextName,
childIndex: nextChildIndex,
shapes: {},
bindings: {},
};
const pageState: TLPageState = {
id: pageId,
selectedIds: [],
camera: { point: center, zoom: 1 },
editingId: undefined,
bindingId: undefined,
hoveredId: undefined,
pointedId: undefined,
};
return {
id: 'create_page',
before: {
appState: {
currentPageId,
},
document: {
pages: {
[pageId]: undefined,
},
pageStates: {
[pageId]: undefined,
},
},
},
after: {
appState: {
currentPageId: page.id,
},
document: {
pages: {
[pageId]: page,
},
pageStates: {
[pageId]: pageState,
},
},
},
};
}

View File

@@ -0,0 +1,65 @@
import type {
Patch,
TDShape,
TldrawCommand,
TDBinding,
} from '@toeverything/components/board-types';
import type { TldrawApp } from '@toeverything/components/board-state';
export function createShapes(
app: TldrawApp,
shapes: TDShape[],
bindings: TDBinding[] = []
): TldrawCommand {
const { currentPageId } = app;
const beforeShapes: Record<string, Patch<TDShape> | undefined> = {};
const afterShapes: Record<string, Patch<TDShape> | undefined> = {};
shapes.forEach(shape => {
beforeShapes[shape.id] = undefined;
afterShapes[shape.id] = shape;
});
const beforeBindings: Record<string, Patch<TDBinding> | undefined> = {};
const afterBindings: Record<string, Patch<TDBinding> | undefined> = {};
bindings.forEach(binding => {
beforeBindings[binding.id] = undefined;
afterBindings[binding.id] = binding;
});
return {
id: 'create',
before: {
document: {
pages: {
[currentPageId]: {
shapes: beforeShapes,
bindings: beforeBindings,
},
},
pageStates: {
[currentPageId]: {
selectedIds: [...app.selectedIds],
},
},
},
},
after: {
document: {
pages: {
[currentPageId]: {
shapes: afterShapes,
bindings: afterBindings,
},
},
pageStates: {
[currentPageId]: {
selectedIds: shapes.map(shape => shape.id),
},
},
},
},
};
}

View File

@@ -0,0 +1,57 @@
import type { TldrawCommand } from '@toeverything/components/board-types';
import type { TldrawApp } from '@toeverything/components/board-state';
export function deletePage(app: TldrawApp, pageId: string): TldrawCommand {
const {
currentPageId,
document: { pages, pageStates },
} = app;
const pagesArr = Object.values(pages).sort(
(a, b) => (a.childIndex || 0) - (b.childIndex || 0)
);
const currentIndex = pagesArr.findIndex(page => page.id === pageId);
let nextCurrentPageId: string;
if (pageId === currentPageId) {
if (currentIndex === pagesArr.length - 1) {
nextCurrentPageId = pagesArr[pagesArr.length - 2].id;
} else {
nextCurrentPageId = pagesArr[currentIndex + 1].id;
}
} else {
nextCurrentPageId = currentPageId;
}
return {
id: 'delete_page',
before: {
appState: {
currentPageId: pageId,
},
document: {
pages: {
[pageId]: { ...pages[pageId] },
},
pageStates: {
[pageId]: { ...pageStates[pageId] },
},
},
},
after: {
appState: {
currentPageId: nextCurrentPageId,
},
document: {
pages: {
[pageId]: undefined,
},
pageStates: {
[pageId]: undefined,
},
},
},
};
}

View File

@@ -0,0 +1,66 @@
import type {
TDAsset,
TDAssets,
TldrawCommand,
} from '@toeverything/components/board-types';
import type { TldrawApp } from '@toeverything/components/board-state';
import { removeShapesFromPage } from './shared/remove-shapes-from-page';
const removeAssetsFromDocument = (assets: TDAssets, idsToRemove: string[]) => {
const afterAssets: Record<string, TDAsset | undefined> = { ...assets };
idsToRemove.forEach(id => (afterAssets[id] = undefined));
return afterAssets;
};
export function deleteShapes(
app: TldrawApp,
ids: string[],
pageId = app.currentPageId
): TldrawCommand {
const {
pageState,
selectedIds,
document: { assets: beforeAssets },
} = app;
const { before, after, assetsToRemove } = removeShapesFromPage(
app.state,
ids,
pageId
);
const afterAssets = removeAssetsFromDocument(beforeAssets, assetsToRemove);
return {
id: 'delete',
before: {
document: {
assets: beforeAssets,
pages: {
[pageId]: before,
},
pageStates: {
[pageId]: { selectedIds: [...app.selectedIds] },
},
},
},
after: {
document: {
assets: afterAssets,
pages: {
[pageId]: after,
},
pageStates: {
[pageId]: {
selectedIds: selectedIds.filter(
id => !ids.includes(id)
),
hoveredId:
pageState.hoveredId &&
ids.includes(pageState.hoveredId)
? undefined
: pageState.hoveredId,
},
},
},
},
};
}

View File

@@ -0,0 +1,187 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Utils } from '@tldraw/core';
import {
DistributeType,
TDShape,
TldrawCommand,
TDShapeType,
} from '@toeverything/components/board-types';
import { TLDR } from '@toeverything/components/board-state';
import Vec from '@tldraw/vec';
import type { TldrawApp } from '@toeverything/components/board-state';
export function distributeShapes(
app: TldrawApp,
ids: string[],
type: DistributeType
): TldrawCommand {
const { currentPageId } = app;
const initialShapes = ids.map(id => app.getShape(id));
const deltaMap = Object.fromEntries(
getDistributions(initialShapes, type).map(d => [d.id, d])
);
const { before, after } = TLDR.mutate_shapes(
app.state,
ids.filter(id => deltaMap[id] !== undefined),
shape => ({ point: deltaMap[shape.id].next }),
currentPageId
);
initialShapes.forEach(shape => {
if (shape.type === TDShapeType.Group) {
const delta = Vec.sub(
after[shape.id].point!,
before[shape.id].point!
);
shape.children.forEach(id => {
const child = app.getShape(id);
before[child.id] = { point: child.point };
after[child.id] = { point: Vec.add(child.point, delta) };
});
delete before[shape.id];
delete after[shape.id];
}
});
return {
id: 'distribute',
before: {
document: {
pages: {
[currentPageId]: { shapes: before },
},
pageStates: {
[currentPageId]: {
selectedIds: ids,
},
},
},
},
after: {
document: {
pages: {
[currentPageId]: { shapes: after },
},
pageStates: {
[currentPageId]: {
selectedIds: ids,
},
},
},
},
};
}
function getDistributions(initialShapes: TDShape[], type: DistributeType) {
const entries = initialShapes.map(shape => {
const utils = TLDR.get_shape_util(shape);
return {
id: shape.id,
point: [...shape.point],
bounds: utils.getBounds(shape),
center: utils.getCenter(shape),
};
});
const len = entries.length;
const commonBounds = Utils.getCommonBounds(
entries.map(({ bounds }) => bounds)
);
const results: { id: string; prev: number[]; next: number[] }[] = [];
switch (type) {
case DistributeType.Horizontal: {
const span = entries.reduce((a, c) => a + c.bounds.width, 0);
if (span > commonBounds.width) {
const left = entries.sort(
(a, b) => a.bounds.minX - b.bounds.minX
)[0];
const right = entries.sort(
(a, b) => b.bounds.maxX - a.bounds.maxX
)[0];
const entriesToMove = entries
.filter(a => a !== left && a !== right)
.sort((a, b) => a.center[0] - b.center[0]);
const step = (right.center[0] - left.center[0]) / (len - 1);
const x = left.center[0] + step;
entriesToMove.forEach(({ id, point, bounds }, i) => {
results.push({
id,
prev: point,
next: [x + step * i - bounds.width / 2, bounds.minY],
});
});
} else {
const entriesToMove = entries.sort(
(a, b) => a.center[0] - b.center[0]
);
let x = commonBounds.minX;
const step = (commonBounds.width - span) / (len - 1);
entriesToMove.forEach(({ id, point, bounds }) => {
results.push({ id, prev: point, next: [x, bounds.minY] });
x += bounds.width + step;
});
}
break;
}
case DistributeType.Vertical: {
const span = entries.reduce((a, c) => a + c.bounds.height, 0);
if (span > commonBounds.height) {
const top = entries.sort(
(a, b) => a.bounds.minY - b.bounds.minY
)[0];
const bottom = entries.sort(
(a, b) => b.bounds.maxY - a.bounds.maxY
)[0];
const entriesToMove = entries
.filter(a => a !== top && a !== bottom)
.sort((a, b) => a.center[1] - b.center[1]);
const step = (bottom.center[1] - top.center[1]) / (len - 1);
const y = top.center[1] + step;
entriesToMove.forEach(({ id, point, bounds }, i) => {
results.push({
id,
prev: point,
next: [bounds.minX, y + step * i - bounds.height / 2],
});
});
} else {
const entriesToMove = entries.sort(
(a, b) => a.center[1] - b.center[1]
);
let y = commonBounds.minY;
const step = (commonBounds.height - span) / (len - 1);
entriesToMove.forEach(({ id, point, bounds }) => {
results.push({ id, prev: point, next: [bounds.minX, y] });
y += bounds.height + step;
});
}
break;
}
}
return results;
}

View File

@@ -0,0 +1,69 @@
import type { TldrawCommand } from '@toeverything/components/board-types';
import { Utils } from '@tldraw/core';
import type { TldrawApp } from '@toeverything/components/board-state';
export function duplicatePage(app: TldrawApp, pageId: string): TldrawCommand {
const newId = Utils.uniqueId();
const {
currentPageId,
page,
pageState: { camera },
} = app;
const nextPage = {
...page,
id: newId,
name: page.name + ' Copy',
shapes: Object.fromEntries(
Object.entries(page.shapes).map(([id, shape]) => {
return [
id,
{
...shape,
parentId:
shape.parentId === pageId ? newId : shape.parentId,
},
];
})
),
};
return {
id: 'duplicate_page',
before: {
appState: {
currentPageId,
},
document: {
pages: {
[newId]: undefined,
},
pageStates: {
[newId]: undefined,
},
},
},
after: {
appState: {
currentPageId: newId,
},
document: {
pages: {
[newId]: nextPage,
},
pageStates: {
[newId]: {
...page,
id: newId,
selectedIds: [],
camera: { ...camera },
editingId: undefined,
bindingId: undefined,
hoveredId: undefined,
pointedId: undefined,
},
},
},
},
};
}

View File

@@ -0,0 +1,206 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Utils } from '@tldraw/core';
import { Vec } from '@tldraw/vec';
import { TLDR } from '@toeverything/components/board-state';
import type {
PagePartial,
TldrawCommand,
TDShape,
} from '@toeverything/components/board-types';
import type { TldrawApp } from '@toeverything/components/board-state';
export function duplicateShapes(
app: TldrawApp,
ids: string[],
point?: number[]
): TldrawCommand {
const { selectedIds, currentPageId, page, shapes } = app;
const before: PagePartial = {
shapes: {},
bindings: {},
};
const after: PagePartial = {
shapes: {},
bindings: {},
};
const duplicateMap: Record<string, string> = {};
const shapesToDuplicate = ids
.map(id => app.getShape(id))
.filter(shape => !ids.includes(shape.parentId));
// Create duplicates
shapesToDuplicate.forEach(shape => {
const duplicatedId = Utils.uniqueId();
before.shapes[duplicatedId] = undefined;
after.shapes[duplicatedId] = {
...Utils.deepClone(shape),
id: duplicatedId,
childIndex: TLDR.get_child_index_above(
app.state,
shape.id,
currentPageId
),
};
if (shape.children) {
after.shapes[duplicatedId]!.children = [];
}
if (shape.parentId !== currentPageId) {
const parent = app.getShape(shape.parentId);
before.shapes[parent.id] = {
...before.shapes[parent.id],
children: parent.children,
};
after.shapes[parent.id] = {
...after.shapes[parent.id],
children: [
...(after.shapes[parent.id] || parent).children!,
duplicatedId,
],
};
}
duplicateMap[shape.id] = duplicatedId;
});
// If the shapes have children, then duplicate those too
shapesToDuplicate.forEach(shape => {
if (shape.children) {
shape.children.forEach(childId => {
const child = app.getShape(childId);
const duplicatedId = Utils.uniqueId();
const duplicatedParentId = duplicateMap[shape.id];
before.shapes[duplicatedId] = undefined;
after.shapes[duplicatedId] = {
...Utils.deepClone(child),
id: duplicatedId,
parentId: duplicatedParentId,
childIndex: TLDR.get_child_index_above(
app.state,
child.id,
currentPageId
),
};
duplicateMap[childId] = duplicatedId;
after.shapes[duplicateMap[shape.id]]?.children?.push(
duplicatedId
);
});
}
});
// Which ids did we end up duplicating?
const dupedShapeIds = new Set(Object.keys(duplicateMap));
// Handle bindings that effect duplicated shapes
Object.values(page.bindings)
.filter(
binding =>
dupedShapeIds.has(binding.fromId) ||
dupedShapeIds.has(binding.toId)
)
.forEach(binding => {
if (dupedShapeIds.has(binding.fromId)) {
if (dupedShapeIds.has(binding.toId)) {
// If the binding is between two duplicating shapes then
// duplicate the binding, too
const duplicatedBindingId = Utils.uniqueId();
const duplicatedBinding = {
...Utils.deepClone(binding),
id: duplicatedBindingId,
fromId: duplicateMap[binding.fromId],
toId: duplicateMap[binding.toId],
};
before.bindings[duplicatedBindingId] = undefined;
after.bindings[duplicatedBindingId] = duplicatedBinding;
// Change the duplicated shape's handle so that it reference
// the duplicated binding
const boundShape = after.shapes[duplicatedBinding.fromId];
Object.values(boundShape!.handles!).forEach(handle => {
if (handle!.bindingId === binding.id) {
handle!.bindingId = duplicatedBindingId;
}
});
} else {
// If only the fromId is selected, delete the binding on
// the duplicated shape's handles
const boundShape =
after.shapes[duplicateMap[binding.fromId]];
Object.values(boundShape!.handles!).forEach(handle => {
if (handle!.bindingId === binding.id) {
handle!.bindingId = undefined;
}
});
}
}
});
// Now move the shapes
const shapesToMove = Object.values(after.shapes) as TDShape[];
if (point) {
const commonBounds = Utils.getCommonBounds(
shapesToMove.map(shape => TLDR.get_bounds(shape))
);
const center = Utils.getBoundsCenter(commonBounds);
shapesToMove.forEach(shape => {
// Could be a group
if (!shape.point) return;
shape.point = Vec.sub(point, Vec.sub(center, shape.point));
});
} else {
const offset = [16, 16];
shapesToMove.forEach(shape => {
// Could be a group
if (!shape.point) return;
shape.point = Vec.add(shape.point, offset);
});
}
// Unlock any locked shapes
shapesToMove.forEach(shape => {
if (shape.isLocked) {
shape.isLocked = false;
}
});
return {
id: 'duplicate',
before: {
document: {
pages: {
[currentPageId]: before,
},
pageStates: {
[currentPageId]: { selectedIds },
},
},
},
after: {
document: {
pages: {
[currentPageId]: after,
},
pageStates: {
[currentPageId]: {
selectedIds: Array.from(dupedShapeIds.values()).map(
id => duplicateMap[id]
),
},
},
},
},
};
}

View File

@@ -0,0 +1,101 @@
import { FlipType } from '@toeverything/components/board-types';
import { TLBoundsCorner, Utils } from '@tldraw/core';
import type { TldrawCommand } from '@toeverything/components/board-types';
import type { TldrawApp } from '@toeverything/components/board-state';
import { TLDR } from '@toeverything/components/board-state';
export function flipShapes(
app: TldrawApp,
ids: string[],
type: FlipType
): TldrawCommand {
const { selectedIds, currentPageId, shapes } = app;
const boundsForShapes = shapes.map(shape => TLDR.get_bounds(shape));
const commonBounds = Utils.getCommonBounds(boundsForShapes);
const { before, after } = TLDR.mutate_shapes(
app.state,
ids,
shape => {
const shapeBounds = TLDR.get_bounds(shape);
switch (type) {
case FlipType.Horizontal: {
const newShapeBounds =
Utils.getRelativeTransformedBoundingBox(
commonBounds,
commonBounds,
shapeBounds,
true,
false
);
return TLDR.get_shape_util(shape).transform(
shape,
newShapeBounds,
{
type: TLBoundsCorner.TopLeft,
scaleX: -1,
scaleY: 1,
initialShape: shape,
transformOrigin: [0.5, 0.5],
}
);
}
case FlipType.Vertical: {
const newShapeBounds =
Utils.getRelativeTransformedBoundingBox(
commonBounds,
commonBounds,
shapeBounds,
false,
true
);
return TLDR.get_shape_util(shape).transform(
shape,
newShapeBounds,
{
type: TLBoundsCorner.TopLeft,
scaleX: 1,
scaleY: -1,
initialShape: shape,
transformOrigin: [0.5, 0.5],
}
);
}
}
},
currentPageId
);
return {
id: 'flip',
before: {
document: {
pages: {
[currentPageId]: { shapes: before },
},
pageStates: {
[currentPageId]: {
selectedIds,
},
},
},
},
after: {
document: {
pages: {
[currentPageId]: { shapes: after },
},
pageStates: {
[currentPageId]: {
selectedIds: ids,
},
},
},
},
};
}

View File

@@ -0,0 +1,254 @@
import { TDShape, TDShapeType } from '@toeverything/components/board-types';
import { Utils } from '@tldraw/core';
import type {
Patch,
TldrawCommand,
TDBinding,
} from '@toeverything/components/board-types';
import type { TldrawApp } from '@toeverything/components/board-state';
import { TLDR } from '@toeverything/components/board-state';
export function groupShapes(
app: TldrawApp,
ids: string[],
groupId: string,
pageId: string
): TldrawCommand | undefined {
if (ids.length < 2) return;
const beforeShapes: Record<string, Patch<TDShape | undefined>> = {};
const afterShapes: Record<string, Patch<TDShape | undefined>> = {};
const beforeBindings: Record<string, Patch<TDBinding | undefined>> = {};
const afterBindings: Record<string, Patch<TDBinding | undefined>> = {};
const idsToGroup = [...ids];
const shapesToGroup: TDShape[] = [];
const deletedGroupIds: string[] = [];
const otherEffectedGroups: TDShape[] = [];
// Collect all of the shapes to group (and their ids)
for (const id of ids) {
const shape = app.getShape(id);
if (shape.isLocked) continue;
if (shape.children === undefined) {
shapesToGroup.push(shape);
} else {
const childIds = shape.children.filter(
id => !app.getShape(id).isLocked
);
otherEffectedGroups.push(shape);
idsToGroup.push(...childIds);
shapesToGroup.push(
...childIds.map(id => app.getShape(id)).filter(Boolean)
);
}
}
// 1. Can we create this group?
// Do the shapes have the same parent?
if (
shapesToGroup.every(
shape => shape.parentId === shapesToGroup[0].parentId
)
) {
// Is the common parent a shape (not the page)?
if (shapesToGroup[0].parentId !== pageId) {
const commonParent = app.getShape(shapesToGroup[0].parentId);
// Are all of the common parent's shapes selected?
if (commonParent.children?.length === idsToGroup.length) {
// Don't create a group if that group would be the same as the
// existing group.
return;
}
}
}
// A flattened array of shapes from the page
const flattenedShapes = TLDR.flatten_page(app.state, pageId);
// A map of shapes to their index in flattendShapes
const shapeIndexMap = Object.fromEntries(
shapesToGroup.map(shape => [shape.id, flattenedShapes.indexOf(shape)])
);
// An array of shapes in order by their index in flattendShapes
const sortedShapes = shapesToGroup.sort(
(a, b) => shapeIndexMap[a.id] - shapeIndexMap[b.id]
);
// The parentId is always the current page
const groupParentId = pageId; // sortedShapes[0].parentId
// The childIndex should be the lowest index of the selected shapes
// with a parent that is the current page; or else the child index
// of the lowest selected shape.
const groupChildIndex = (
sortedShapes.filter(shape => shape.parentId === pageId)[0] ||
sortedShapes[0]
).childIndex;
// The shape's point is the min point of its childrens' common bounds
const groupBounds = Utils.getCommonBounds(
shapesToGroup.map(shape => TLDR.get_bounds(shape))
);
// Create the group
beforeShapes[groupId] = undefined;
afterShapes[groupId] = TLDR.get_shape_util(TDShapeType.Group).create({
id: groupId,
childIndex: groupChildIndex,
parentId: groupParentId,
point: [groupBounds.minX, groupBounds.minY],
size: [groupBounds.width, groupBounds.height],
children: sortedShapes.map(shape => shape.id),
workspace: app.document.id,
});
// Reparent shapes to the new group
sortedShapes.forEach((shape, index) => {
// If the shape is part of a different group, mark the parent shape for cleanup
if (shape.parentId !== pageId) {
const parentShape = app.getShape(shape.parentId);
otherEffectedGroups.push(parentShape);
}
beforeShapes[shape.id] = {
...beforeShapes[shape.id],
parentId: shape.parentId,
childIndex: shape.childIndex,
};
afterShapes[shape.id] = {
...afterShapes[shape.id],
parentId: groupId,
childIndex: index + 1,
};
});
// Clean up effected parents
while (otherEffectedGroups.length > 0) {
const shape = otherEffectedGroups.pop();
if (!shape) break;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const nextChildren = (beforeShapes[shape.id]?.children ||
shape.children)!.filter(
childId =>
childId &&
!(
idsToGroup.includes(childId) ||
deletedGroupIds.includes(childId)
)
);
// If the parent has no children, remove it
if (nextChildren.length === 0) {
beforeShapes[shape.id] = shape;
afterShapes[shape.id] = undefined;
// And if that parent is part of a different group, mark it for cleanup
// (This is necessary only when we implement nested groups.)
if (shape.parentId !== pageId) {
deletedGroupIds.push(shape.id);
otherEffectedGroups.push(app.getShape(shape.parentId));
}
} else {
beforeShapes[shape.id] = {
...beforeShapes[shape.id],
children: shape.children,
};
afterShapes[shape.id] = {
...afterShapes[shape.id],
children: nextChildren,
};
}
}
// TODO: This code is copied from delete.command. Create a shared helper!
const { bindings } = app;
const deletedGroupIdsSet = new Set(deletedGroupIds);
// We also need to delete bindings that reference the deleted shapes
bindings.forEach(binding => {
for (const id of [binding.toId, binding.fromId]) {
// If the binding references a deleted shape...
if (deletedGroupIdsSet.has(id)) {
// Delete this binding
beforeBindings[binding.id] = binding;
afterBindings[binding.id] = undefined;
// Let's also look each the bound shape...
const shape = app.getShape(id);
// If the bound shape has a handle that references the deleted binding...
if (shape.handles) {
Object.values(shape.handles)
.filter(handle => handle.bindingId === binding.id)
.forEach(handle => {
// Save the binding reference in the before patch
beforeShapes[id] = {
...beforeShapes[id],
handles: {
...beforeShapes[id]?.handles,
[handle.id]: { bindingId: binding.id },
},
};
// Unless we're currently deleting the shape, remove the
// binding reference from the after patch
if (!deletedGroupIds.includes(id)) {
afterShapes[id] = {
...afterShapes[id],
handles: {
...afterShapes[id]?.handles,
[handle.id]: { bindingId: undefined },
},
};
}
});
}
}
}
});
return {
id: 'group',
before: {
document: {
pages: {
[pageId]: {
shapes: beforeShapes,
bindings: beforeBindings,
},
},
pageStates: {
[pageId]: {
selectedIds: ids,
},
},
},
},
after: {
document: {
pages: {
[pageId]: {
shapes: afterShapes,
bindings: beforeBindings,
},
},
pageStates: {
[pageId]: {
selectedIds: [groupId],
},
},
},
},
};
}

View File

@@ -0,0 +1,24 @@
export * from './align-shapes';
export * from './change-page';
export * from './create-page';
export * from './create-shapes';
export * from './delete-page';
export * from './delete-shapes';
export * from './distribute-shapes';
export * from './duplicate-page';
export * from './duplicate-shapes';
export * from './flip-shapes';
export * from './group-shapes';
export * from './move-shapes-to-page';
export * from './reorder-shapes';
export * from './rename-page';
export * from './reset-bounds';
export * from './rotate-shapes';
export * from './stretch-shapes';
export * from './style-shapes';
export * from './toggle-shapes-decoration';
export * from './toggle-shapes-prop';
export * from './translate-shapes';
export * from './ungroup-shapes';
export * from './update-shapes';
export * from './set-shapes-props';

View File

@@ -0,0 +1,231 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type {
ArrowShape,
PagePartial,
TldrawCommand,
TDShape,
} from '@toeverything/components/board-types';
import type { TldrawApp } from '@toeverything/components/board-state';
import { TLDR } from '@toeverything/components/board-state';
import { Utils, TLBounds } from '@tldraw/core';
import { Vec } from '@tldraw/vec';
export function moveShapesToPage(
app: TldrawApp,
ids: string[],
viewportBounds: TLBounds,
fromPageId: string,
toPageId: string
): TldrawCommand {
const { page } = app;
const fromPage: Record<string, PagePartial> = {
before: {
shapes: {},
bindings: {},
},
after: {
shapes: {},
bindings: {},
},
};
const toPage: Record<string, PagePartial> = {
before: {
shapes: {},
bindings: {},
},
after: {
shapes: {},
bindings: {},
},
};
// Collect all the shapes to move and their keys.
const movingShapeIds = new Set<string>();
const shapesToMove = new Set<TDShape>();
ids.map(id => app.getShape(id, fromPageId))
.filter(shape => !shape.isLocked)
.forEach(shape => {
movingShapeIds.add(shape.id);
shapesToMove.add(shape);
if (shape.children !== undefined) {
shape.children.forEach(childId => {
movingShapeIds.add(childId);
shapesToMove.add(app.getShape(childId, fromPageId));
});
}
});
// Where should we put start putting shapes on the "to" page?
const startingChildIndex = TLDR.get_top_child_index(app.state, toPageId);
// Which shapes are we moving?
const movingShapes = Array.from(shapesToMove.values());
movingShapes.forEach((shape, i) => {
// Remove the shape from the fromPage
fromPage['before'].shapes[shape.id] = shape;
fromPage['after'].shapes[shape.id] = undefined;
// But the moved shape on the "to" page
toPage['before'].shapes[shape.id] = undefined;
toPage['after'].shapes[shape.id] = shape;
// If the shape's parent isn't moving too, reparent the shape to
// the "to" page, at the top of the z stack
if (!movingShapeIds.has(shape.parentId)) {
toPage['after'].shapes[shape.id] = {
...shape,
parentId: toPageId,
childIndex: startingChildIndex + i,
};
// If the shape was in a group, then pull the shape from the
// parent's children array.
if (shape.parentId !== fromPageId) {
const parent = app.getShape(shape.parentId, fromPageId);
fromPage['before'].shapes[parent.id] = {
children: parent.children,
};
fromPage['after'].shapes[parent.id] = {
children: parent.children!.filter(
childId => childId !== shape.id
),
};
}
}
});
// Handle bindings that effect duplicated shapes
Object.values(page.bindings)
.filter(
binding =>
movingShapeIds.has(binding.fromId) ||
movingShapeIds.has(binding.toId)
)
.forEach(binding => {
// Always delete the binding from the from page
fromPage['before'].bindings[binding.id] = binding;
fromPage['after'].bindings[binding.id] = undefined;
// Delete the reference from the binding's fromShape
const fromBoundShape = app.getShape(binding.fromId, fromPageId);
// Will we be copying this binding to the new page?
const shouldCopy =
movingShapeIds.has(binding.fromId) &&
movingShapeIds.has(binding.toId);
if (shouldCopy) {
// Just move the binding to the new page
toPage['before'].bindings[binding.id] = undefined;
toPage['after'].bindings[binding.id] = binding;
} else {
if (movingShapeIds.has(binding.fromId)) {
// If we are only moving the "from" shape, we need to delete
// the binding reference from the "from" shapes handles
const fromShape = app.getShape(binding.fromId, fromPageId);
const handle = Object.values(fromBoundShape.handles!).find(
handle => handle.bindingId === binding.id
)!;
// Remove the handle from the shape on the toPage
const handleId = handle.id as keyof ArrowShape['handles'];
const toPageShape = toPage['after'].shapes[fromShape.id]!;
toPageShape.handles = {
...toPageShape.handles,
[handleId]: {
...toPageShape.handles![handleId],
bindingId: undefined,
},
};
} else {
// If we are only moving the "to" shape, we need to delete
// the binding reference from the "from" shape's handles
const fromShape = app.getShape(binding.fromId, fromPageId);
const handle = Object.values(fromBoundShape.handles!).find(
handle => handle.bindingId === binding.id
)!;
fromPage['before'].shapes[fromShape.id] = {
handles: { [handle.id]: { bindingId: binding.id } },
};
fromPage['after'].shapes[fromShape.id] = {
handles: { [handle.id]: { bindingId: undefined } },
};
}
}
});
// Finally, center camera on selection
const toPageState = app.state.document.pageStates[toPageId];
const bounds = Utils.getCommonBounds(
movingShapes.map(shape => TLDR.get_bounds(shape))
);
const zoom = TLDR.get_camera_zoom(
viewportBounds.width < viewportBounds.height
? (viewportBounds.width - 128) / bounds.width
: (viewportBounds.height - 128) / bounds.height
);
const mx = (viewportBounds.width - bounds.width * zoom) / 2 / zoom;
const my = (viewportBounds.height - bounds.height * zoom) / 2 / zoom;
const point = Vec.toFixed(Vec.add([-bounds.minX, -bounds.minY], [mx, my]));
return {
id: 'move_to_page',
before: {
appState: {
currentPageId: fromPageId,
},
document: {
pages: {
[fromPageId]: fromPage['before'],
[toPageId]: toPage['before'],
},
pageStates: {
[fromPageId]: { selectedIds: ids },
[toPageId]: {
selectedIds: toPageState.selectedIds,
camera: toPageState.camera,
},
},
},
},
after: {
appState: {
currentPageId: toPageId,
},
document: {
pages: {
[fromPageId]: fromPage['after'],
[toPageId]: toPage['after'],
},
pageStates: {
[fromPageId]: { selectedIds: [] },
[toPageId]: {
selectedIds: ids,
camera: {
zoom,
point,
},
},
},
},
},
};
}

View File

@@ -0,0 +1,28 @@
import type { TldrawCommand } from '@toeverything/components/board-types';
import type { TldrawApp } from '@toeverything/components/board-state';
export function renamePage(
app: TldrawApp,
pageId: string,
name: string
): TldrawCommand {
const { page } = app;
return {
id: 'rename_page',
before: {
document: {
pages: {
[pageId]: { name: page.name },
},
},
},
after: {
document: {
pages: {
[pageId]: { name: name },
},
},
},
};
}

View File

@@ -0,0 +1,265 @@
import {
MoveType,
TDShape,
TldrawCommand,
} from '@toeverything/components/board-types';
import { TLDR } from '@toeverything/components/board-state';
import type { TldrawApp } from '@toeverything/components/board-state';
export function reorderShapes(
app: TldrawApp,
ids: string[],
type: MoveType
): TldrawCommand {
const { currentPageId, page } = app;
// Get the unique parent ids for the selected elements
const parentIds = new Set(ids.map(id => app.getShape(id).parentId));
let result: {
before: Record<string, Partial<TDShape>>;
after: Record<string, Partial<TDShape>>;
} = { before: {}, after: {} };
let startIndex: number;
let startChildIndex: number;
let step: number;
// Collect shapes with common parents into a table under their parent id
Array.from(parentIds.values()).forEach(parentId => {
let sortedChildren: TDShape[] = [];
if (parentId === page.id) {
sortedChildren = Object.values(page.shapes).sort(
(a, b) => a.childIndex - b.childIndex
);
} else {
const parent = app.getShape(parentId);
if (!parent.children) throw Error('No children in parent!');
sortedChildren = parent.children
.map(childId => app.getShape(childId))
.sort((a, b) => a.childIndex - b.childIndex);
}
const sortedChildIds = sortedChildren.map(shape => shape.id);
const sortedIndicesToMove = ids
.filter(id => sortedChildIds.includes(id))
.map(id => sortedChildIds.indexOf(id))
.sort((a, b) => a - b);
if (sortedIndicesToMove.length === sortedChildIds.length) return;
switch (type) {
case MoveType.ToBack: {
// a b c
// Initial 1 2 3 4 5 6 7
// Final .25 .5 .75 1 3 6 7
// a b c
// Find the lowest "open" index
for (let i = 0; i < sortedChildIds.length; i++) {
if (sortedIndicesToMove.includes(i)) continue;
startIndex = i;
break;
}
// Find the lowest child index that isn't in sortedIndicesToMove
startChildIndex = sortedChildren[startIndex].childIndex;
// Find the step for each additional child
step = startChildIndex / (sortedIndicesToMove.length + 1);
// Get the results of moving the selected shapes below the first open index's shape
result = TLDR.mutate_shapes(
app.state,
sortedIndicesToMove
.map(i => sortedChildren[i].id)
.reverse(),
(shape, i) => ({
childIndex: startChildIndex - (i + 1) * step,
}),
currentPageId
);
break;
}
case MoveType.ToFront: {
// a b c
// Initial 1 2 3 4 5 6 7
// Final 1 3 6 7 8 9 10
// a b c
// Find the highest "open" index
for (let i = sortedChildIds.length - 1; i >= 0; i--) {
if (sortedIndicesToMove.includes(i)) continue;
startIndex = i;
break;
}
// Find the lowest child index that isn't in sortedIndicesToMove
startChildIndex = sortedChildren[startIndex].childIndex;
// Find the step for each additional child
step = 1;
// Get the results of moving the selected shapes below the first open index's shape
result = TLDR.mutate_shapes(
app.state,
sortedIndicesToMove.map(i => sortedChildren[i].id),
(shape, i) => ({
childIndex: startChildIndex + (i + 1),
}),
currentPageId
);
break;
}
case MoveType.Backward: {
// a b c
// Initial 1 2 3 4 5 6 7
// Final .5 1 1.66 2.33 3 6 7
// a b c
const indexMap: Record<string, number> = {};
// Starting from the top...
for (let i = sortedChildIds.length - 1; i >= 0; i--) {
// If we found a moving index...
if (sortedIndicesToMove.includes(i)) {
for (let j = i; j >= 0; j--) {
// iterate downward until we find an open spot
if (!sortedIndicesToMove.includes(j)) {
// i = the index of the first closed spot
// j = the index of the first open spot
const endChildIndex =
sortedChildren[j].childIndex;
let startChildIndex: number;
let step: number;
if (j === 0) {
// We're moving below the first child, start from
// half of its child index.
startChildIndex = endChildIndex / 2;
step = endChildIndex / 2 / (i - j + 1);
} else {
// Start from the child index of the child below the
// child above.
startChildIndex =
sortedChildren[j - 1].childIndex;
step =
(endChildIndex - startChildIndex) /
(i - j + 1);
startChildIndex += step;
}
for (let k = 0; k < i - j; k++) {
indexMap[sortedChildren[j + k + 1].id] =
startChildIndex + step * k;
}
break;
}
}
}
}
if (Object.values(indexMap).length > 0) {
// Get the results of moving the selected shapes below the first open index's shape
result = TLDR.mutate_shapes(
app.state,
sortedIndicesToMove.map(i => sortedChildren[i].id),
shape => ({
childIndex: indexMap[shape.id],
}),
currentPageId
);
}
break;
}
case MoveType.Forward: {
// a b c
// Initial 1 2 3 4 5 6 7
// Final 1 3 3.5 6 7 8 9
// a b c
const indexMap: Record<string, number> = {};
// Starting from the top...
for (let i = 0; i < sortedChildIds.length; i++) {
// If we found a moving index...
if (sortedIndicesToMove.includes(i)) {
// Search for the first open spot above this one
for (let j = i; j < sortedChildIds.length; j++) {
if (!sortedIndicesToMove.includes(j)) {
// i = the low index of the first closed spot
// j = the high index of the first open spot
startChildIndex = sortedChildren[j].childIndex;
const step =
j === sortedChildIds.length - 1
? 1
: (sortedChildren[j + 1].childIndex -
startChildIndex) /
(j - i + 1);
for (let k = 0; k < j - i; k++) {
indexMap[sortedChildren[i + k].id] =
startChildIndex + step * (k + 1);
}
break;
}
}
}
}
if (Object.values(indexMap).length > 0) {
// Get the results of moving the selected shapes below the first open index's shape
result = TLDR.mutate_shapes(
app.state,
sortedIndicesToMove.map(i => sortedChildren[i].id),
shape => ({
childIndex: indexMap[shape.id],
}),
currentPageId
);
}
break;
}
}
});
return {
id: 'move',
before: {
document: {
pages: {
[currentPageId]: { shapes: result.before },
},
pageStates: {
[currentPageId]: {
selectedIds: ids,
},
},
},
},
after: {
document: {
pages: {
[currentPageId]: { shapes: result.after },
},
pageStates: {
[currentPageId]: {
selectedIds: ids,
},
},
},
},
};
}

View File

@@ -0,0 +1,46 @@
import type { TldrawCommand } from '@toeverything/components/board-types';
import { TLDR } from '@toeverything/components/board-state';
import type { TldrawApp } from '@toeverything/components/board-state';
export function resetBounds(
app: TldrawApp,
ids: string[],
pageId: string
): TldrawCommand {
const { currentPageId } = app;
const { before, after } = TLDR.mutate_shapes(
app.state,
ids,
shape => app.getShapeUtil(shape).onDoubleClickBoundsHandle?.(shape),
pageId
);
return {
id: 'reset_bounds',
before: {
document: {
pages: {
[currentPageId]: { shapes: before },
},
pageStates: {
[currentPageId]: {
selectedIds: ids,
},
},
},
},
after: {
document: {
pages: {
[currentPageId]: { shapes: after },
},
pageStates: {
[currentPageId]: {
selectedIds: ids,
},
},
},
},
};
}

View File

@@ -0,0 +1,83 @@
import { Utils } from '@tldraw/core';
import type {
TldrawCommand,
TDShape,
} from '@toeverything/components/board-types';
import { TLDR } from '@toeverything/components/board-state';
import type { TldrawApp } from '@toeverything/components/board-state';
const PI2 = Math.PI * 2;
export function rotateShapes(
app: TldrawApp,
ids: string[],
delta = -PI2 / 4
): TldrawCommand | void {
const { currentPageId } = app;
// The shapes for the before patch
const before: Record<string, Partial<TDShape>> = {};
// The shapes for the after patch
const after: Record<string, Partial<TDShape>> = {};
// Find the shapes that we want to rotate.
// We don't rotate groups: we rotate their children instead.
const shapesToRotate = ids
.flatMap(id => {
const shape = app.getShape(id);
return shape.children
? shape.children.map(childId => app.getShape(childId))
: shape;
})
.filter(shape => !shape.isLocked);
// Find the common center to all shapes
// This is the point that we'll rotate around
const origin = Utils.getBoundsCenter(
Utils.getCommonBounds(
shapesToRotate.map(shape => TLDR.get_bounds(shape))
)
);
// Find the rotate mutations for each shape
shapesToRotate.forEach(shape => {
const change = TLDR.get_rotated_shape_mutation(
shape,
TLDR.get_center(shape),
origin,
delta
);
if (!change) return;
before[shape.id] = TLDR.get_before_shape(shape, change);
after[shape.id] = change;
});
return {
id: 'rotate',
before: {
document: {
pages: {
[currentPageId]: { shapes: before },
},
pageStates: {
[currentPageId]: {
selectedIds: ids,
},
},
},
},
after: {
document: {
pages: {
[currentPageId]: { shapes: after },
},
pageStates: {
[currentPageId]: {
selectedIds: ids,
},
},
},
},
};
}

View File

@@ -0,0 +1,61 @@
import type {
TDShape,
TldrawCommand,
} from '@toeverything/components/board-types';
import type { TldrawApp } from '@toeverything/components/board-state';
export function setShapesProps<T extends TDShape>(
app: TldrawApp,
ids: string[],
partial: Partial<T>
): TldrawCommand {
const { currentPageId, selectedIds } = app;
const initialShapes = ids
.map(id => app.getShape<T>(id))
.filter(shape => (partial['isLocked'] ? true : !shape.isLocked));
const before: Record<string, Partial<TDShape>> = {};
const after: Record<string, Partial<TDShape>> = {};
const keys = Object.keys(partial) as (keyof T)[];
initialShapes.forEach(shape => {
before[shape.id] = Object.fromEntries(
keys.map(key => [key, shape[key]])
);
after[shape.id] = partial;
});
return {
id: 'set_props',
before: {
document: {
pages: {
[currentPageId]: {
shapes: before,
},
},
pageStates: {
[currentPageId]: {
selectedIds,
},
},
},
},
after: {
document: {
pages: {
[currentPageId]: {
shapes: after,
},
},
pageStates: {
[currentPageId]: {
selectedIds,
},
},
},
},
};
}

View File

@@ -0,0 +1,146 @@
import { TLDR } from '@toeverything/components/board-state';
import type {
ArrowShape,
GroupShape,
PagePartial,
TDSnapshot,
} from '@toeverything/components/board-types';
export function removeShapesFromPage(
data: TDSnapshot,
ids: string[],
pageId: string
) {
const before: PagePartial = {
shapes: {},
bindings: {},
};
const after: PagePartial = {
shapes: {},
bindings: {},
};
const parentsToUpdate: GroupShape[] = [];
const deletedIds = new Set();
const assetsToRemove = new Set<string>();
// These are the shapes we're definitely going to delete
ids.filter(id => !TLDR.get_shape(data, id, pageId).isLocked).forEach(id => {
deletedIds.add(id);
const shape = TLDR.get_shape(data, id, pageId);
before.shapes[id] = shape;
after.shapes[id] = undefined;
// Also delete the shape's children
if (shape.children !== undefined) {
shape.children.forEach(childId => {
deletedIds.add(childId);
const child = TLDR.get_shape(data, childId, pageId);
before.shapes[childId] = child;
after.shapes[childId] = undefined;
});
}
if (shape.parentId !== pageId) {
parentsToUpdate.push(TLDR.get_shape(data, shape.parentId, pageId));
}
if (shape.assetId) {
assetsToRemove.add(shape.assetId);
}
});
parentsToUpdate.forEach(parent => {
if (ids.includes(parent.id)) return;
deletedIds.add(parent.id);
before.shapes[parent.id] = { children: parent.children };
after.shapes[parent.id] = {
children: parent.children.filter(id => !ids.includes(id)),
};
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (after.shapes[parent.id]?.children!.length === 0) {
after.shapes[parent.id] = undefined;
before.shapes[parent.id] = TLDR.get_shape(data, parent.id, pageId);
}
});
// Recursively check for empty parents?
const page = TLDR.get_page(data, pageId);
// We also need to delete bindings that reference the deleted shapes
Object.values(page.bindings)
.filter(
binding =>
deletedIds.has(binding.fromId) || deletedIds.has(binding.toId)
)
.forEach(binding => {
for (const id of [binding.toId, binding.fromId]) {
// If the binding references a deleted shape...
if (after.shapes[id] === undefined) {
// Delete this binding
before.bindings[binding.id] = binding;
after.bindings[binding.id] = undefined;
// Let's also look each the bound shape...
const shape = page.shapes[id];
// If the bound shape has a handle that references the deleted binding...
if (shape && shape.handles) {
Object.values(shape.handles)
.filter(handle => handle.bindingId === binding.id)
.forEach(handle => {
// Save the binding reference in the before patch
before.shapes[id] = {
...before.shapes[id],
handles: {
...before.shapes[id]?.handles,
[handle.id]: {
...before.shapes[id]?.handles?.[
handle.id as keyof ArrowShape['handles']
],
bindingId: binding.id,
},
},
};
// Unless we're currently deleting the shape, remove the
// binding reference from the after patch
if (!deletedIds.has(id)) {
after.shapes[id] = {
...after.shapes[id],
handles: {
...after.shapes[id]?.handles,
[handle.id]: {
...after.shapes[id]?.handles?.[
handle.id as keyof ArrowShape['handles']
],
bindingId: undefined,
},
},
};
}
});
}
}
}
});
// If any other shapes are using the deleted assets, don't remove them
Object.values(data.document.pages)
.flatMap(page => Object.values(page.shapes))
.forEach(shape => {
if (
'assetId' in shape &&
shape.assetId &&
!deletedIds.has(shape.id)
) {
assetsToRemove.delete(shape.assetId);
}
});
return { before, after, assetsToRemove: Array.from(assetsToRemove) };
}

View File

@@ -0,0 +1,114 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { TLBoundsCorner, Utils } from '@tldraw/core';
import { StretchType, TDShapeType } from '@toeverything/components/board-types';
import type { TldrawCommand } from '@toeverything/components/board-types';
import { TLDR } from '@toeverything/components/board-state';
import type { TldrawApp } from '@toeverything/components/board-state';
export function stretchShapes(
app: TldrawApp,
ids: string[],
type: StretchType
): TldrawCommand {
const { currentPageId, selectedIds } = app;
const initialShapes = ids.map(id => app.getShape(id));
const boundsForShapes = initialShapes.map(shape => TLDR.get_bounds(shape));
const commonBounds = Utils.getCommonBounds(boundsForShapes);
const idsToMutate = ids
.flatMap(id => {
const shape = app.getShape(id);
return shape.children ? shape.children : shape.id;
})
.filter(id => !app.getShape(id).isLocked);
const { before, after } = TLDR.mutate_shapes(
app.state,
idsToMutate,
shape => {
const bounds = TLDR.get_bounds(shape);
switch (type) {
case StretchType.Horizontal: {
const newBounds = {
...bounds,
minX: commonBounds.minX,
maxX: commonBounds.maxX,
width: commonBounds.width,
};
return TLDR.get_shape_util(shape).transformSingle(
shape,
newBounds,
{
type: TLBoundsCorner.TopLeft,
scaleX: newBounds.width / bounds.width,
scaleY: 1,
initialShape: shape,
transformOrigin: [0.5, 0.5],
}
);
}
case StretchType.Vertical: {
const newBounds = {
...bounds,
minY: commonBounds.minY,
maxY: commonBounds.maxY,
height: commonBounds.height,
};
return TLDR.get_shape_util(shape).transformSingle(
shape,
newBounds,
{
type: TLBoundsCorner.TopLeft,
scaleX: 1,
scaleY: newBounds.height / bounds.height,
initialShape: shape,
transformOrigin: [0.5, 0.5],
}
);
}
}
},
currentPageId
);
initialShapes.forEach(shape => {
if (shape.type === TDShapeType.Group) {
delete before[shape.id];
delete after[shape.id];
}
});
return {
id: 'stretch',
before: {
document: {
pages: {
[currentPageId]: { shapes: before },
},
pageStates: {
[currentPageId]: {
selectedIds,
},
},
},
},
after: {
document: {
pages: {
[currentPageId]: { shapes: after },
},
pageStates: {
[currentPageId]: {
selectedIds: ids,
},
},
},
},
};
}

View File

@@ -0,0 +1,100 @@
import {
Patch,
ShapeStyles,
TldrawCommand,
TDShape,
// TDShapeType,
// TextShape
} from '@toeverything/components/board-types';
import { TLDR } from '@toeverything/components/board-state';
import { Vec } from '@tldraw/vec';
import type { TldrawApp } from '@toeverything/components/board-state';
export function styleShapes(
app: TldrawApp,
ids: string[],
changes: Partial<ShapeStyles>
): TldrawCommand {
const { currentPageId, selectedIds } = app;
const shapeIdsToMutate = ids
.flatMap(id => TLDR.get_document_branch(app.state, id, currentPageId))
.filter(id => !app.getShape(id).isLocked);
const beforeShapes: Record<string, Patch<TDShape>> = {};
const afterShapes: Record<string, Patch<TDShape>> = {};
shapeIdsToMutate
.map(id => app.getShape(id))
.filter(shape => !shape.isLocked)
.forEach(shape => {
beforeShapes[shape.id] = {
style: {
...Object.fromEntries(
Object.keys(changes).map(key => [
key,
shape.style[key as keyof typeof shape.style],
])
),
},
};
afterShapes[shape.id] = {
style: changes,
};
// if (shape.type === TDShapeType.Text) {
// beforeShapes[shape.id].point = shape.point;
// afterShapes[shape.id].point = Vec.toFixed(
// Vec.add(
// shape.point,
// Vec.sub(
// app.getShapeUtil(shape).getCenter(shape),
// app.getShapeUtil(shape).getCenter({
// ...shape,
// style: { ...shape.style, ...changes }
// } as TextShape)
// )
// )
// );
// }
});
return {
id: 'style',
before: {
document: {
pages: {
[currentPageId]: {
shapes: beforeShapes,
},
},
pageStates: {
[currentPageId]: {
selectedIds: selectedIds,
},
},
},
appState: {
currentStyle: { ...app.appState.currentStyle },
},
},
after: {
document: {
pages: {
[currentPageId]: {
shapes: afterShapes,
},
},
pageStates: {
[currentPageId]: {
selectedIds: ids,
},
},
},
appState: {
currentStyle: changes,
},
},
};
}

View File

@@ -0,0 +1,73 @@
import { Decoration } from '@toeverything/components/board-types';
import type {
Patch,
ArrowShape,
TldrawCommand,
} from '@toeverything/components/board-types';
import type { TldrawApp } from '@toeverything/components/board-state';
export function toggleShapesDecoration(
app: TldrawApp,
ids: string[],
decorationId: 'start' | 'end'
): TldrawCommand {
const { currentPageId, selectedIds } = app;
const beforeShapes: Record<string, Patch<ArrowShape>> = Object.fromEntries(
ids.map(id => [
id,
{
decorations: {
[decorationId]:
app.getShape<ArrowShape>(id).decorations?.[
decorationId
],
},
},
])
);
const afterShapes: Record<string, Patch<ArrowShape>> = Object.fromEntries(
ids
.filter(id => !app.getShape(id).isLocked)
.map(id => [
id,
{
decorations: {
[decorationId]: app.getShape<ArrowShape>(id)
.decorations?.[decorationId]
? undefined
: Decoration.Arrow,
},
},
])
);
return {
id: 'toggle_decorations',
before: {
document: {
pages: {
[currentPageId]: { shapes: beforeShapes },
},
pageStates: {
[currentPageId]: {
selectedIds,
},
},
},
},
after: {
document: {
pages: {
[currentPageId]: { shapes: afterShapes },
},
pageStates: {
[currentPageId]: {
selectedIds: ids,
},
},
},
},
};
}

View File

@@ -0,0 +1,59 @@
import type {
TDShape,
TldrawCommand,
} from '@toeverything/components/board-types';
import type { TldrawApp } from '@toeverything/components/board-state';
export function toggleShapesProp(
app: TldrawApp,
ids: string[],
prop: keyof TDShape
): TldrawCommand {
const { currentPageId } = app;
const initialShapes = ids
.map(id => app.getShape(id))
.filter(shape => (prop === 'isLocked' ? true : !shape.isLocked));
const isAllToggled = initialShapes.every(shape => shape[prop]);
const before: Record<string, Partial<TDShape>> = {};
const after: Record<string, Partial<TDShape>> = {};
initialShapes.forEach(shape => {
before[shape.id] = { [prop]: shape[prop] };
after[shape.id] = { [prop]: !isAllToggled };
});
return {
id: 'toggle',
before: {
document: {
pages: {
[currentPageId]: {
shapes: before,
},
},
pageStates: {
[currentPageId]: {
selectedIds: ids,
},
},
},
},
after: {
document: {
pages: {
[currentPageId]: {
shapes: after,
},
},
pageStates: {
[currentPageId]: {
selectedIds: ids,
},
},
},
},
};
}

View File

@@ -0,0 +1,113 @@
import { Vec } from '@tldraw/vec';
import { TLDR } from '@toeverything/components/board-state';
import type {
TldrawCommand,
PagePartial,
} from '@toeverything/components/board-types';
import type { TldrawApp } from '@toeverything/components/board-state';
export function translateShapes(
app: TldrawApp,
ids: string[],
delta: number[]
): TldrawCommand {
const { currentPageId, selectedIds } = app;
// Clear session cache
app.rotationInfo.selectedIds = [...selectedIds];
const before: PagePartial = {
shapes: {},
bindings: {},
};
const after: PagePartial = {
shapes: {},
bindings: {},
};
const idsToMutate = ids
.flatMap(id => {
const shape = app.getShape(id);
return shape.children ? shape.children : shape.id;
})
.filter(id => !app.getShape(id).isLocked);
const change = TLDR.mutate_shapes(
app.state,
idsToMutate,
shape => ({
point: Vec.toFixed(Vec.add(shape.point, delta)),
}),
currentPageId
);
before.shapes = change.before;
after.shapes = change.after;
// Delete bindings from nudged shapes, unless both bound and bound-to shapes are selected
const bindingsToDelete = TLDR.get_bindings(app.state, currentPageId).filter(
binding => ids.includes(binding.fromId) && !ids.includes(binding.toId)
);
bindingsToDelete.forEach(binding => {
before.bindings[binding.id] = binding;
after.bindings[binding.id] = undefined;
for (const id of [binding.toId, binding.fromId]) {
// Let's also look at the bound shape...
const shape = app.getShape(id);
if (!shape.handles) continue;
// If the bound shape has a handle that references the deleted binding, delete that reference
Object.values(shape.handles)
.filter(handle => handle.bindingId === binding.id)
.forEach(handle => {
before.shapes[id] = {
...before.shapes[id],
handles: {
...before.shapes[id]?.handles,
[handle.id]: { bindingId: binding.id },
},
};
after.shapes[id] = {
...after.shapes[id],
handles: {
...after.shapes[id]?.handles,
[handle.id]: { bindingId: undefined },
},
};
});
}
});
return {
id: 'translate',
before: {
document: {
pages: {
[currentPageId]: before,
},
pageStates: {
[currentPageId]: {
selectedIds: ids,
},
},
},
},
after: {
document: {
pages: {
[currentPageId]: after,
},
pageStates: {
[currentPageId]: {
selectedIds: ids,
},
},
},
},
};
}

View File

@@ -0,0 +1,170 @@
import { TLDR } from '@toeverything/components/board-state';
import type {
Patch,
GroupShape,
TDBinding,
TDShape,
TldrawCommand,
} from '@toeverything/components/board-types';
import type { TldrawApp } from '@toeverything/components/board-state';
export function ungroupShapes(
app: TldrawApp,
selectedIds: string[],
groupShapes: GroupShape[],
pageId: string
): TldrawCommand | undefined {
const { bindings } = app;
const beforeShapes: Record<string, Patch<TDShape | undefined>> = {};
const afterShapes: Record<string, Patch<TDShape | undefined>> = {};
const beforeBindings: Record<string, Patch<TDBinding | undefined>> = {};
const afterBindings: Record<string, Patch<TDBinding | undefined>> = {};
const beforeSelectedIds = selectedIds;
const afterSelectedIds = selectedIds.filter(
id => !groupShapes.find(shape => shape.id === id)
);
// The group shape
groupShapes
.filter(shape => !shape.isLocked)
.forEach(groupShape => {
const shapesToReparent: TDShape[] = [];
const deletedGroupIds: string[] = [];
// Remove the group shape in the next state
beforeShapes[groupShape.id] = groupShape;
afterShapes[groupShape.id] = undefined;
// Select its children in the next state
groupShape.children.forEach(id => {
afterSelectedIds.push(id);
const shape = app.getShape(id, pageId);
shapesToReparent.push(shape);
});
// We'll start placing the shapes at this childIndex
const startingChildIndex = groupShape.childIndex;
// And we'll need to fit them under this child index
const endingChildIndex = TLDR.get_child_index_above(
app.state,
groupShape.id,
pageId
);
const step =
(endingChildIndex - startingChildIndex) /
shapesToReparent.length;
// An array of shapes in order by their child index
const sortedShapes = shapesToReparent.sort(
(a, b) => a.childIndex - b.childIndex
);
// Reparent shapes to the page
sortedShapes.forEach((shape, index) => {
beforeShapes[shape.id] = {
parentId: shape.parentId,
childIndex: shape.childIndex,
};
afterShapes[shape.id] = {
parentId: pageId,
childIndex: startingChildIndex + step * index,
};
});
// We also need to delete bindings that reference the deleted shapes
bindings
.filter(
binding =>
binding.toId === groupShape.id ||
binding.fromId === groupShape.id
)
.forEach(binding => {
for (const id of [binding.toId, binding.fromId]) {
// If the binding references the deleted group...
if (afterShapes[id] === undefined) {
// Delete the binding
beforeBindings[binding.id] = binding;
afterBindings[binding.id] = undefined;
// Let's also look each the bound shape...
const shape = app.getShape(id, pageId);
// If the bound shape has a handle that references the deleted binding...
if (shape.handles) {
Object.values(shape.handles)
.filter(
handle =>
handle.bindingId === binding.id
)
.forEach(handle => {
// Save the binding reference in the before patch
beforeShapes[id] = {
...beforeShapes[id],
handles: {
...beforeShapes[id]?.handles,
[handle.id]: {
bindingId: binding.id,
},
},
};
// Unless we're currently deleting the shape, remove the
// binding reference from the after patch
if (!deletedGroupIds.includes(id)) {
afterShapes[id] = {
...afterShapes[id],
handles: {
...afterShapes[id]?.handles,
[handle.id]: {
bindingId: undefined,
},
},
};
}
});
}
}
}
});
});
return {
id: 'ungroup',
before: {
document: {
pages: {
[pageId]: {
shapes: beforeShapes,
bindings: beforeBindings,
},
},
pageStates: {
[pageId]: {
selectedIds: beforeSelectedIds,
},
},
},
},
after: {
document: {
pages: {
[pageId]: {
shapes: afterShapes,
bindings: beforeBindings,
},
},
pageStates: {
[pageId]: {
selectedIds: afterSelectedIds,
},
},
},
},
};
}

View File

@@ -0,0 +1,43 @@
import type {
TldrawCommand,
TDShape,
} from '@toeverything/components/board-types';
import { TLDR } from '@toeverything/components/board-state';
import type { TldrawApp } from '@toeverything/components/board-state';
export function updateShapes(
app: TldrawApp,
updates: ({ id: string } & Partial<TDShape>)[],
pageId: string
): TldrawCommand {
const ids = updates.map(update => update.id);
const change = TLDR.mutate_shapes(
app.state,
ids.filter(id => !app.getShape(id, pageId).isLocked),
(shape, i) => updates[i],
pageId
);
return {
id: 'update',
before: {
document: {
pages: {
[pageId]: {
shapes: change.before,
},
},
},
},
after: {
document: {
pages: {
[pageId]: {
shapes: change.after,
},
},
},
},
};
}