mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
init: the first public commit for AFFiNE
This commit is contained in:
251
libs/components/board-shapes/src/TDShapeUtil.tsx
Normal file
251
libs/components/board-shapes/src/TDShapeUtil.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { Utils, TLShapeUtil } from '@tldraw/core';
|
||||
import type { TLPointerInfo, TLBounds } from '@tldraw/core';
|
||||
import {
|
||||
intersectLineSegmentBounds,
|
||||
intersectLineSegmentPolyline,
|
||||
intersectRayBounds,
|
||||
} from '@tldraw/intersect';
|
||||
import { Vec } from '@tldraw/vec';
|
||||
import type {
|
||||
TDMeta,
|
||||
TDShape,
|
||||
TransformInfo,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { BINDING_DISTANCE } from '@toeverything/components/board-types';
|
||||
import { createRef } from 'react';
|
||||
import { getTextSvgElement } from './shared/get-text-svg-element';
|
||||
import { getTextLabelSize } from './shared/get-text-size';
|
||||
import { getFontStyle, getShapeStyle } from './shared';
|
||||
|
||||
export abstract class TDShapeUtil<
|
||||
T extends TDShape,
|
||||
E extends Element = any
|
||||
> extends TLShapeUtil<T, E, TDMeta> {
|
||||
abstract type: T['type'];
|
||||
|
||||
canBind = false;
|
||||
|
||||
canEdit = false;
|
||||
|
||||
canClone = false;
|
||||
|
||||
isAspectRatioLocked = false;
|
||||
|
||||
hideResizeHandles = false;
|
||||
|
||||
bindingDistance = BINDING_DISTANCE;
|
||||
|
||||
abstract getShape: (props: Partial<T>) => T;
|
||||
|
||||
hitTestPoint = (shape: T, point: number[]): boolean => {
|
||||
return Utils.pointInBounds(point, this.getRotatedBounds(shape));
|
||||
};
|
||||
|
||||
hitTestLineSegment = (shape: T, A: number[], B: number[]): boolean => {
|
||||
const box = Utils.getBoundsFromPoints([A, B]);
|
||||
const bounds = this.getBounds(shape);
|
||||
|
||||
return Utils.boundsContain(bounds, box) || shape.rotation
|
||||
? intersectLineSegmentPolyline(
|
||||
A,
|
||||
B,
|
||||
Utils.getRotatedCorners(this.getBounds(shape))
|
||||
).didIntersect
|
||||
: intersectLineSegmentBounds(A, B, this.getBounds(shape)).length >
|
||||
0;
|
||||
};
|
||||
|
||||
create = (props: { id: string; workspace: string } & Partial<T>) => {
|
||||
this.refMap.set(props.id, createRef());
|
||||
return this.getShape(props);
|
||||
};
|
||||
|
||||
getCenter = (shape: T) => {
|
||||
return Utils.getBoundsCenter(this.getBounds(shape));
|
||||
};
|
||||
|
||||
getExpandedBounds = (shape: T) => {
|
||||
return Utils.expandBounds(this.getBounds(shape), this.bindingDistance);
|
||||
};
|
||||
|
||||
getBindingPoint = <K extends TDShape>(
|
||||
shape: T,
|
||||
fromShape: K,
|
||||
point: number[],
|
||||
origin: number[],
|
||||
direction: number[],
|
||||
bindAnywhere: boolean
|
||||
) => {
|
||||
// Algorithm time! We need to find the binding point (a normalized point inside of the shape, or around the shape, where the arrow will point to) and the distance from the binding shape to the anchor.
|
||||
|
||||
const bounds = this.getBounds(shape);
|
||||
const expandedBounds = this.getExpandedBounds(shape);
|
||||
|
||||
// The point must be inside of the expanded bounding box
|
||||
if (!Utils.pointInBounds(point, expandedBounds)) return;
|
||||
|
||||
const intersections = intersectRayBounds(
|
||||
origin,
|
||||
direction,
|
||||
expandedBounds
|
||||
)
|
||||
.filter(int => int.didIntersect)
|
||||
.map(int => int.points[0]);
|
||||
|
||||
if (!intersections.length) return;
|
||||
|
||||
// The center of the shape
|
||||
const center = this.getCenter(shape);
|
||||
|
||||
// Find furthest intersection between ray from origin through point and expanded bounds. TODO: What if the shape has a curve? In that case, should we intersect the circle-from-three-points instead?
|
||||
const intersection = intersections.sort(
|
||||
(a, b) => Vec.dist(b, origin) - Vec.dist(a, origin)
|
||||
)[0];
|
||||
|
||||
// The point between the handle and the intersection
|
||||
const middlePoint = Vec.med(point, intersection);
|
||||
|
||||
// The anchor is the point in the shape where the arrow will be pointing
|
||||
let anchor: number[];
|
||||
|
||||
// The distance is the distance from the anchor to the handle
|
||||
let distance: number;
|
||||
|
||||
if (bindAnywhere) {
|
||||
// If the user is indicating that they want to bind inside of the shape, we just use the handle's point
|
||||
anchor =
|
||||
Vec.dist(point, center) < BINDING_DISTANCE / 2 ? center : point;
|
||||
distance = 0;
|
||||
} else {
|
||||
if (
|
||||
Vec.distanceToLineSegment(point, middlePoint, center) <
|
||||
BINDING_DISTANCE / 2
|
||||
) {
|
||||
// If the line segment would pass near to the center, snap the anchor the center point
|
||||
anchor = center;
|
||||
} else {
|
||||
// Otherwise, the anchor is the middle point between the handle and the intersection
|
||||
anchor = middlePoint;
|
||||
}
|
||||
|
||||
if (Utils.pointInBounds(point, bounds)) {
|
||||
// If the point is inside of the shape, use the shape's binding distance
|
||||
|
||||
distance = this.bindingDistance;
|
||||
} else {
|
||||
// Otherwise, use the actual distance from the handle point to nearest edge
|
||||
distance = Math.max(
|
||||
this.bindingDistance,
|
||||
Utils.getBoundsSides(bounds)
|
||||
.map(side =>
|
||||
Vec.distanceToLineSegment(
|
||||
side[1][0],
|
||||
side[1][1],
|
||||
point
|
||||
)
|
||||
)
|
||||
.sort((a, b) => a - b)[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The binding point is a normalized point indicating the position of the anchor.
|
||||
// An anchor at the middle of the shape would be (0.5, 0.5). When the shape's bounds
|
||||
// changes, we will re-recalculate the actual anchor point by multiplying the
|
||||
// normalized point by the shape's new bounds.
|
||||
const bindingPoint = Vec.divV(
|
||||
Vec.sub(anchor, [expandedBounds.minX, expandedBounds.minY]),
|
||||
[expandedBounds.width, expandedBounds.height]
|
||||
);
|
||||
|
||||
return {
|
||||
point: Vec.clampV(bindingPoint, 0, 1),
|
||||
distance,
|
||||
};
|
||||
};
|
||||
|
||||
mutate = (shape: T, props: Partial<T>): Partial<T> => {
|
||||
return props;
|
||||
};
|
||||
|
||||
transform = (
|
||||
shape: T,
|
||||
bounds: TLBounds,
|
||||
info: TransformInfo<T>
|
||||
): Partial<T> => {
|
||||
return { ...shape, point: [bounds.minX, bounds.minY] };
|
||||
};
|
||||
|
||||
transformSingle = (
|
||||
shape: T,
|
||||
bounds: TLBounds,
|
||||
info: TransformInfo<T>
|
||||
): Partial<T> | void => {
|
||||
return this.transform(shape, bounds, info);
|
||||
};
|
||||
|
||||
updateChildren?: <K extends TDShape>(
|
||||
shape: T,
|
||||
children: K[]
|
||||
) => Partial<K>[] | void;
|
||||
|
||||
onChildrenChange?: (shape: T, children: TDShape[]) => Partial<T> | void;
|
||||
|
||||
onHandleChange?: (
|
||||
shape: T,
|
||||
handles: Partial<T['handles']>
|
||||
) => Partial<T> | void;
|
||||
|
||||
onRightPointHandle?: (
|
||||
shape: T,
|
||||
handles: Partial<T['handles']>,
|
||||
info: Partial<TLPointerInfo>
|
||||
) => Partial<T> | void;
|
||||
|
||||
onDoubleClickHandle?: (
|
||||
shape: T,
|
||||
handles: Partial<T['handles']>,
|
||||
info: Partial<TLPointerInfo>
|
||||
) => Partial<T> | void;
|
||||
|
||||
onDoubleClickBoundsHandle?: (shape: T) => Partial<T> | void;
|
||||
|
||||
onSessionComplete?: (shape: T) => Partial<T> | void;
|
||||
|
||||
getSvgElement = (shape: T, isDarkMode: boolean): SVGElement | void => {
|
||||
const elm = document
|
||||
.getElementById(shape.id + '_svg')
|
||||
?.cloneNode(true) as SVGElement;
|
||||
if (!elm) return; // possibly in test mode
|
||||
if ('label' in shape && (shape as any).label) {
|
||||
const s = shape as TDShape & { label: string };
|
||||
const g = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'g'
|
||||
);
|
||||
const bounds = this.getBounds(shape);
|
||||
const labelElm = getTextSvgElement(s['label'], shape.style, bounds);
|
||||
labelElm.setAttribute(
|
||||
'fill',
|
||||
getShapeStyle(shape.style, isDarkMode).stroke
|
||||
);
|
||||
const font = getFontStyle(shape.style);
|
||||
const size = getTextLabelSize(s['label'], font);
|
||||
labelElm.setAttribute('transform-origin', 'top left');
|
||||
labelElm.setAttribute(
|
||||
'transform',
|
||||
`translate(${bounds.width / 2}, ${
|
||||
(bounds.height - size[1]) / 2
|
||||
})`
|
||||
);
|
||||
g.setAttribute('text-align', 'center');
|
||||
g.setAttribute('text-anchor', 'middle');
|
||||
g.appendChild(elm);
|
||||
g.appendChild(labelElm);
|
||||
return g;
|
||||
}
|
||||
return elm;
|
||||
};
|
||||
}
|
||||
268
libs/components/board-shapes/src/arrow-util/arrow-helpers.ts
Normal file
268
libs/components/board-shapes/src/arrow-util/arrow-helpers.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { Utils } from '@tldraw/core';
|
||||
import {
|
||||
intersectCircleCircle,
|
||||
intersectCircleLineSegment,
|
||||
} from '@tldraw/intersect';
|
||||
import Vec from '@tldraw/vec';
|
||||
import getStroke from 'perfect-freehand';
|
||||
import { EASINGS } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle } from '../shared/shape-styles';
|
||||
import type {
|
||||
ArrowShape,
|
||||
Decoration,
|
||||
ShapeStyles,
|
||||
} from '@toeverything/components/board-types';
|
||||
|
||||
export function getArrowArcPath(
|
||||
start: number[],
|
||||
end: number[],
|
||||
circle: number[],
|
||||
bend: number
|
||||
) {
|
||||
return [
|
||||
'M',
|
||||
start[0],
|
||||
start[1],
|
||||
'A',
|
||||
circle[2],
|
||||
circle[2],
|
||||
0,
|
||||
0,
|
||||
bend < 0 ? 0 : 1,
|
||||
end[0],
|
||||
end[1],
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
export function getBendPoint(handles: ArrowShape['handles'], bend: number) {
|
||||
const { start, end } = handles;
|
||||
|
||||
const dist = Vec.dist(start.point, end.point);
|
||||
|
||||
const midPoint = Vec.med(start.point, end.point);
|
||||
|
||||
const bendDist = (dist / 2) * bend;
|
||||
|
||||
const u = Vec.uni(Vec.vec(start.point, end.point));
|
||||
|
||||
const point = Vec.toFixed(
|
||||
Math.abs(bendDist) < 10
|
||||
? midPoint
|
||||
: Vec.add(midPoint, Vec.mul(Vec.per(u), bendDist))
|
||||
);
|
||||
|
||||
return point;
|
||||
}
|
||||
|
||||
export function renderFreehandArrowShaft(
|
||||
id: string,
|
||||
style: ShapeStyles,
|
||||
start: number[],
|
||||
end: number[],
|
||||
decorationStart: Decoration | undefined,
|
||||
decorationEnd: Decoration | undefined
|
||||
) {
|
||||
const getRandom = Utils.rng(id);
|
||||
const strokeWidth = getShapeStyle(style).strokeWidth;
|
||||
const startPoint = decorationStart
|
||||
? Vec.nudge(start, end, strokeWidth)
|
||||
: start;
|
||||
const endPoint = decorationEnd ? Vec.nudge(end, start, strokeWidth) : end;
|
||||
const stroke = getStroke([startPoint, endPoint], {
|
||||
size: strokeWidth,
|
||||
thinning: 0.618 + getRandom() * 0.2,
|
||||
easing: EASINGS.easeOutQuad,
|
||||
simulatePressure: true,
|
||||
streamline: 0,
|
||||
last: true,
|
||||
});
|
||||
return Utils.getSvgPathFromStroke(stroke);
|
||||
}
|
||||
|
||||
export function renderCurvedFreehandArrowShaft(
|
||||
id: string,
|
||||
style: ShapeStyles,
|
||||
start: number[],
|
||||
end: number[],
|
||||
decorationStart: Decoration | undefined,
|
||||
decorationEnd: Decoration | undefined,
|
||||
center: number[],
|
||||
radius: number,
|
||||
length: number,
|
||||
easing: (t: number) => number
|
||||
) {
|
||||
const getRandom = Utils.rng(id);
|
||||
const strokeWidth = getShapeStyle(style).strokeWidth;
|
||||
const startPoint = decorationStart
|
||||
? Vec.rotWith(start, center, strokeWidth / length)
|
||||
: start;
|
||||
const endPoint = decorationEnd
|
||||
? Vec.rotWith(end, center, -(strokeWidth / length))
|
||||
: end;
|
||||
const startAngle = Vec.angle(center, startPoint);
|
||||
const endAngle = Vec.angle(center, endPoint);
|
||||
const points: number[][] = [];
|
||||
const count = 8 + Math.floor((Math.abs(length) / 20) * 1 + getRandom() / 2);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = easing(i / count);
|
||||
const angle = Utils.lerpAngles(startAngle, endAngle, t);
|
||||
points.push(Vec.toFixed(Vec.nudgeAtAngle(center, angle, radius)));
|
||||
}
|
||||
const stroke = getStroke([startPoint, ...points, endPoint], {
|
||||
size: 1 + strokeWidth,
|
||||
thinning: 0.618 + getRandom() * 0.2,
|
||||
easing: EASINGS.easeOutQuad,
|
||||
simulatePressure: false,
|
||||
streamline: 0,
|
||||
last: true,
|
||||
});
|
||||
return Utils.getSvgPathFromStroke(stroke);
|
||||
}
|
||||
|
||||
export function getCtp(start: number[], bend: number[], end: number[]) {
|
||||
return Utils.circleFromThreePoints(start, end, bend);
|
||||
}
|
||||
|
||||
export function getCurvedArrowHeadPoints(
|
||||
A: number[],
|
||||
r1: number,
|
||||
C: number[],
|
||||
r2: number,
|
||||
sweep: boolean
|
||||
) {
|
||||
const ints = intersectCircleCircle(A, r1 * 0.618, C, r2).points;
|
||||
if (!ints) {
|
||||
console.warn('Could not find an intersection for the arrow head.');
|
||||
return { left: A, right: A };
|
||||
}
|
||||
const int = sweep ? ints[0] : ints[1];
|
||||
const left = int
|
||||
? Vec.nudge(Vec.rotWith(int, A, Math.PI / 6), A, r1 * -0.382)
|
||||
: A;
|
||||
const right = int
|
||||
? Vec.nudge(Vec.rotWith(int, A, -Math.PI / 6), A, r1 * -0.382)
|
||||
: A;
|
||||
return { left, right };
|
||||
}
|
||||
|
||||
export function getStraightArrowHeadPoints(
|
||||
A: number[],
|
||||
B: number[],
|
||||
r: number
|
||||
) {
|
||||
const ints = intersectCircleLineSegment(A, r, A, B).points;
|
||||
if (!ints) {
|
||||
console.warn('Could not find an intersection for the arrow head.');
|
||||
return { left: A, right: A };
|
||||
}
|
||||
const int = ints[0];
|
||||
const left = int ? Vec.rotWith(int, A, Math.PI / 6) : A;
|
||||
const right = int ? Vec.rotWith(int, A, -Math.PI / 6) : A;
|
||||
return { left, right };
|
||||
}
|
||||
|
||||
export function getCurvedArrowHeadPath(
|
||||
A: number[],
|
||||
r1: number,
|
||||
C: number[],
|
||||
r2: number,
|
||||
sweep: boolean
|
||||
) {
|
||||
const { left, right } = getCurvedArrowHeadPoints(A, r1, C, r2, sweep);
|
||||
return `M ${left} L ${A} ${right}`;
|
||||
}
|
||||
|
||||
export function getStraightArrowHeadPath(A: number[], B: number[], r: number) {
|
||||
const { left, right } = getStraightArrowHeadPoints(A, B, r);
|
||||
return `M ${left} L ${A} ${right}`;
|
||||
}
|
||||
|
||||
export function getArrowPath(
|
||||
style: ShapeStyles,
|
||||
start: number[],
|
||||
bend: number[],
|
||||
end: number[],
|
||||
decorationStart: Decoration | undefined,
|
||||
decorationEnd: Decoration | undefined
|
||||
) {
|
||||
const { strokeWidth } = getShapeStyle(style, false);
|
||||
const arrowDist = Vec.dist(start, end);
|
||||
const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8);
|
||||
const path: (string | number)[] = [];
|
||||
const isStraightLine = Vec.dist(bend, Vec.toFixed(Vec.med(start, end))) < 1;
|
||||
if (isStraightLine) {
|
||||
path.push(`M ${start} L ${end}`);
|
||||
if (decorationStart) {
|
||||
path.push(getStraightArrowHeadPath(start, end, arrowHeadLength));
|
||||
}
|
||||
if (decorationEnd) {
|
||||
path.push(getStraightArrowHeadPath(end, start, arrowHeadLength));
|
||||
}
|
||||
} else {
|
||||
const circle = getCtp(start, bend, end);
|
||||
const center = [circle[0], circle[1]];
|
||||
const radius = circle[2];
|
||||
const length = getArcLength(center, radius, start, end);
|
||||
path.push(
|
||||
`M ${start} A ${radius} ${radius} 0 0 ${
|
||||
length > 0 ? '1' : '0'
|
||||
} ${end}`
|
||||
);
|
||||
if (decorationStart)
|
||||
path.push(
|
||||
getCurvedArrowHeadPath(
|
||||
start,
|
||||
arrowHeadLength,
|
||||
center,
|
||||
radius,
|
||||
length < 0
|
||||
)
|
||||
);
|
||||
if (decorationEnd) {
|
||||
path.push(
|
||||
getCurvedArrowHeadPath(
|
||||
end,
|
||||
arrowHeadLength,
|
||||
center,
|
||||
radius,
|
||||
length >= 0
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
return path.join(' ');
|
||||
}
|
||||
|
||||
export function getArcPoints(start: number[], bend: number[], end: number[]) {
|
||||
if (Vec.dist2(bend, Vec.med(start, end)) <= 4) return [start, end];
|
||||
// The arc is curved; calculate twenty points along the arc
|
||||
const points: number[][] = [];
|
||||
const circle = getCtp(start, bend, end);
|
||||
const center = [circle[0], circle[1]];
|
||||
const radius = circle[2];
|
||||
const startAngle = Vec.angle(center, start);
|
||||
const endAngle = Vec.angle(center, end);
|
||||
for (let i = 1 / 20; i < 1; i += 1 / 20) {
|
||||
const angle = Utils.lerpAngles(startAngle, endAngle, i);
|
||||
points.push(Vec.nudgeAtAngle(center, angle, radius));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
export function isAngleBetween(a: number, b: number, c: number): boolean {
|
||||
if (c === a || c === b) return true;
|
||||
const PI2 = Math.PI * 2;
|
||||
const AB = (b - a + PI2) % PI2;
|
||||
const AC = (c - a + PI2) % PI2;
|
||||
return AB <= Math.PI !== AC > AB;
|
||||
}
|
||||
|
||||
export function getArcLength(
|
||||
C: number[],
|
||||
r: number,
|
||||
A: number[],
|
||||
B: number[]
|
||||
): number {
|
||||
const sweep = Utils.getSweep(C, A, B);
|
||||
return r * (2 * Math.PI) * (sweep / (2 * Math.PI));
|
||||
}
|
||||
604
libs/components/board-shapes/src/arrow-util/arrow-util.tsx
Normal file
604
libs/components/board-shapes/src/arrow-util/arrow-util.tsx
Normal file
@@ -0,0 +1,604 @@
|
||||
import * as React from 'react';
|
||||
import { Utils, TLBounds, SVGContainer } from '@tldraw/core';
|
||||
import { Vec } from '@tldraw/vec';
|
||||
import { defaultStyle } from '../shared/shape-styles';
|
||||
import {
|
||||
ArrowShape,
|
||||
TransformInfo,
|
||||
Decoration,
|
||||
TDShapeType,
|
||||
DashStyle,
|
||||
TDMeta,
|
||||
GHOSTED_OPACITY,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TDShapeUtil } from '../TDShapeUtil';
|
||||
import {
|
||||
intersectArcBounds,
|
||||
intersectLineSegmentBounds,
|
||||
intersectLineSegmentLineSegment,
|
||||
} from '@tldraw/intersect';
|
||||
import {
|
||||
getArcLength,
|
||||
getArcPoints,
|
||||
getArrowPath,
|
||||
getBendPoint,
|
||||
getCtp,
|
||||
isAngleBetween,
|
||||
} from './arrow-helpers';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import {
|
||||
TextLabel,
|
||||
getFontStyle,
|
||||
getShapeStyle,
|
||||
getTextLabelSize,
|
||||
LabelMask,
|
||||
} from '../shared';
|
||||
import { StraightArrow } from './components/straight-arrow';
|
||||
import { CurvedArrow } from './components/curved-arrow';
|
||||
|
||||
type T = ArrowShape;
|
||||
type E = HTMLDivElement;
|
||||
|
||||
export class ArrowUtil extends TDShapeUtil<T, E> {
|
||||
type = TDShapeType.Arrow as const;
|
||||
|
||||
override hideBounds = true;
|
||||
|
||||
override canEdit = true;
|
||||
|
||||
pathCache = new WeakMap<T, string>();
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
return {
|
||||
id: 'id',
|
||||
type: TDShapeType.Arrow,
|
||||
name: 'Arrow',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
point: [0, 0],
|
||||
rotation: 0,
|
||||
bend: 0,
|
||||
handles: {
|
||||
start: {
|
||||
id: 'start',
|
||||
index: 0,
|
||||
point: [0, 0],
|
||||
canBind: true,
|
||||
...props.handles?.start,
|
||||
},
|
||||
end: {
|
||||
id: 'end',
|
||||
index: 1,
|
||||
point: [1, 1],
|
||||
canBind: true,
|
||||
...props.handles?.end,
|
||||
},
|
||||
bend: {
|
||||
id: 'bend',
|
||||
index: 2,
|
||||
point: [0.5, 0.5],
|
||||
...props.handles?.bend,
|
||||
},
|
||||
},
|
||||
decorations: props.decorations ?? {
|
||||
end: Decoration.Arrow,
|
||||
},
|
||||
style: {
|
||||
...defaultStyle,
|
||||
isFilled: false,
|
||||
...props.style,
|
||||
},
|
||||
label: '',
|
||||
labelPoint: [0.5, 0.5],
|
||||
workspace: props.workspace,
|
||||
...props,
|
||||
};
|
||||
};
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
(
|
||||
{
|
||||
shape,
|
||||
isEditing,
|
||||
isGhost,
|
||||
meta,
|
||||
events,
|
||||
onShapeChange,
|
||||
onShapeBlur,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const {
|
||||
id,
|
||||
label = '',
|
||||
handles: { start, bend, end },
|
||||
decorations = {},
|
||||
style,
|
||||
} = shape;
|
||||
const isStraightLine =
|
||||
Vec.dist(
|
||||
bend.point,
|
||||
Vec.toFixed(Vec.med(start.point, end.point))
|
||||
) < 1;
|
||||
const font = getFontStyle(style);
|
||||
const styles = getShapeStyle(style, meta.isDarkMode);
|
||||
const labelSize =
|
||||
label || isEditing ? getTextLabelSize(label, font) : [0, 0];
|
||||
const bounds = this.getBounds(shape);
|
||||
const dist = React.useMemo(() => {
|
||||
const { start, bend, end } = shape.handles;
|
||||
if (isStraightLine) return Vec.dist(start.point, end.point);
|
||||
const circle = getCtp(start.point, bend.point, end.point);
|
||||
const center = circle.slice(0, 2);
|
||||
const radius = circle[2];
|
||||
const length = getArcLength(
|
||||
center,
|
||||
radius,
|
||||
start.point,
|
||||
end.point
|
||||
);
|
||||
return Math.abs(length);
|
||||
}, [shape.handles]);
|
||||
const scale = Math.max(
|
||||
0.5,
|
||||
Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
dist / (labelSize[1] + 128),
|
||||
dist / (labelSize[0] + 128)
|
||||
)
|
||||
)
|
||||
);
|
||||
const offset = React.useMemo(() => {
|
||||
const bounds = this.getBounds(shape);
|
||||
const offset = Vec.sub(
|
||||
shape.handles.bend.point,
|
||||
Vec.toFixed([bounds.width / 2, bounds.height / 2])
|
||||
);
|
||||
return offset;
|
||||
}, [shape, scale]);
|
||||
const handleLabelChange = React.useCallback(
|
||||
(label: string) => {
|
||||
onShapeChange?.({ id, label });
|
||||
},
|
||||
[onShapeChange]
|
||||
);
|
||||
const Component = isStraightLine ? StraightArrow : CurvedArrow;
|
||||
return (
|
||||
<FullWrapper ref={ref} {...events}>
|
||||
<TextLabel
|
||||
font={font}
|
||||
text={label}
|
||||
color={styles.stroke}
|
||||
offsetX={offset[0]}
|
||||
offsetY={offset[1]}
|
||||
scale={scale}
|
||||
isEditing={isEditing}
|
||||
onChange={handleLabelChange}
|
||||
onBlur={onShapeBlur}
|
||||
/>
|
||||
<SVGContainer id={shape.id + '_svg'}>
|
||||
<defs>
|
||||
<mask id={shape.id + '_clip'}>
|
||||
<rect
|
||||
x={-100}
|
||||
y={-100}
|
||||
width={bounds.width + 200}
|
||||
height={bounds.height + 200}
|
||||
fill="white"
|
||||
/>
|
||||
<rect
|
||||
x={
|
||||
bounds.width / 2 -
|
||||
(labelSize[0] / 2) * scale +
|
||||
offset[0]
|
||||
}
|
||||
y={
|
||||
bounds.height / 2 -
|
||||
(labelSize[1] / 2) * scale +
|
||||
offset[1]
|
||||
}
|
||||
width={labelSize[0] * scale}
|
||||
height={labelSize[1] * scale}
|
||||
rx={4 * scale}
|
||||
ry={4 * scale}
|
||||
fill="black"
|
||||
opacity={1}
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
<g
|
||||
pointerEvents="none"
|
||||
opacity={isGhost ? GHOSTED_OPACITY : 1}
|
||||
mask={
|
||||
label || isEditing
|
||||
? `url(#${shape.id}_clip)`
|
||||
: ``
|
||||
}
|
||||
>
|
||||
<Component
|
||||
id={id}
|
||||
style={style}
|
||||
start={start.point}
|
||||
end={end.point}
|
||||
bend={bend.point}
|
||||
arrowBend={shape.bend}
|
||||
decorationStart={decorations?.start}
|
||||
decorationEnd={decorations?.end}
|
||||
isDraw={style.dash === DashStyle.Draw}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
/>
|
||||
</g>
|
||||
</SVGContainer>
|
||||
</FullWrapper>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<ArrowShape>(({ shape, bounds }) => {
|
||||
const {
|
||||
style,
|
||||
decorations,
|
||||
label,
|
||||
handles: { start, bend, end },
|
||||
} = shape;
|
||||
const font = getFontStyle(style);
|
||||
const labelSize = label ? getTextLabelSize(label, font) : [0, 0];
|
||||
const isStraightLine =
|
||||
Vec.dist(bend.point, Vec.toFixed(Vec.med(start.point, end.point))) <
|
||||
1;
|
||||
const dist = React.useMemo(() => {
|
||||
const { start, bend, end } = shape.handles;
|
||||
if (isStraightLine) return Vec.dist(start.point, end.point);
|
||||
const circle = getCtp(start.point, bend.point, end.point);
|
||||
const center = circle.slice(0, 2);
|
||||
const radius = circle[2];
|
||||
const length = getArcLength(center, radius, start.point, end.point);
|
||||
return Math.abs(length);
|
||||
}, [shape.handles]);
|
||||
const scale = Math.max(
|
||||
0.5,
|
||||
Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
dist / (labelSize[1] + 128),
|
||||
dist / (labelSize[0] + 128)
|
||||
)
|
||||
)
|
||||
);
|
||||
const offset = React.useMemo(() => {
|
||||
const bounds = this.getBounds(shape);
|
||||
const offset = Vec.sub(shape.handles.bend.point, [
|
||||
bounds.width / 2,
|
||||
bounds.height / 2,
|
||||
]);
|
||||
return offset;
|
||||
}, [shape, scale]);
|
||||
return (
|
||||
<>
|
||||
<LabelMask
|
||||
id={shape.id}
|
||||
scale={scale}
|
||||
offset={offset}
|
||||
bounds={bounds}
|
||||
labelSize={labelSize}
|
||||
/>
|
||||
<path
|
||||
d={getArrowPath(
|
||||
style,
|
||||
start.point,
|
||||
bend.point,
|
||||
end.point,
|
||||
decorations?.start,
|
||||
decorations?.end
|
||||
)}
|
||||
mask={label ? `url(#${shape.id}_clip)` : ``}
|
||||
/>
|
||||
{label && (
|
||||
<rect
|
||||
x={
|
||||
bounds.width / 2 -
|
||||
(labelSize[0] / 2) * scale +
|
||||
offset[0]
|
||||
}
|
||||
y={
|
||||
bounds.height / 2 -
|
||||
(labelSize[1] / 2) * scale +
|
||||
offset[1]
|
||||
}
|
||||
width={labelSize[0] * scale}
|
||||
height={labelSize[1] * scale}
|
||||
rx={4 * scale}
|
||||
ry={4 * scale}
|
||||
fill="transparent"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
getBounds = (shape: T) => {
|
||||
const bounds = Utils.getFromCache(this.boundsCache, shape, () => {
|
||||
const {
|
||||
handles: { start, bend, end },
|
||||
} = shape;
|
||||
return Utils.getBoundsFromPoints(
|
||||
getArcPoints(start.point, bend.point, end.point)
|
||||
);
|
||||
});
|
||||
return Utils.translateBounds(bounds, shape.point);
|
||||
};
|
||||
|
||||
override getRotatedBounds = (shape: T) => {
|
||||
const {
|
||||
handles: { start, bend, end },
|
||||
} = shape;
|
||||
let points = getArcPoints(start.point, bend.point, end.point);
|
||||
const { minX, minY, maxX, maxY } = Utils.getBoundsFromPoints(points);
|
||||
if (shape.rotation !== 0) {
|
||||
points = points.map(pt =>
|
||||
Vec.rotWith(
|
||||
pt,
|
||||
[(minX + maxX) / 2, (minY + maxY) / 2],
|
||||
shape.rotation || 0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return Utils.translateBounds(
|
||||
Utils.getBoundsFromPoints(points),
|
||||
shape.point
|
||||
);
|
||||
};
|
||||
|
||||
override getCenter = (shape: T) => {
|
||||
const { start, end } = shape.handles;
|
||||
return Vec.add(shape.point, Vec.med(start.point, end.point));
|
||||
};
|
||||
|
||||
override shouldRender = (prev: T, next: T) => {
|
||||
return (
|
||||
next.decorations !== prev.decorations ||
|
||||
next.handles !== prev.handles ||
|
||||
next.style !== prev.style ||
|
||||
next.label !== prev.label
|
||||
);
|
||||
};
|
||||
|
||||
override hitTestPoint = (shape: T, point: number[]): boolean => {
|
||||
const {
|
||||
handles: { start, bend, end },
|
||||
} = shape;
|
||||
const pt = Vec.sub(point, shape.point);
|
||||
const points = getArcPoints(start.point, bend.point, end.point);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
if (Vec.distanceToLineSegment(points[i - 1], points[i], pt) < 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
override hitTestLineSegment = (
|
||||
shape: T,
|
||||
A: number[],
|
||||
B: number[]
|
||||
): boolean => {
|
||||
const {
|
||||
handles: { start, bend, end },
|
||||
} = shape;
|
||||
const ptA = Vec.sub(A, shape.point);
|
||||
const ptB = Vec.sub(B, shape.point);
|
||||
const points = getArcPoints(start.point, bend.point, end.point);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
if (
|
||||
intersectLineSegmentLineSegment(
|
||||
points[i - 1],
|
||||
points[i],
|
||||
ptA,
|
||||
ptB
|
||||
).didIntersect
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
override hitTestBounds = (shape: T, bounds: TLBounds) => {
|
||||
const { start, end, bend } = shape.handles;
|
||||
const sp = Vec.add(shape.point, start.point);
|
||||
const ep = Vec.add(shape.point, end.point);
|
||||
if (
|
||||
Utils.pointInBounds(sp, bounds) ||
|
||||
Utils.pointInBounds(ep, bounds)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (Vec.isEqual(Vec.med(start.point, end.point), bend.point)) {
|
||||
return intersectLineSegmentBounds(sp, ep, bounds).length > 0;
|
||||
} else {
|
||||
const [cx, cy, r] = getCtp(start.point, bend.point, end.point);
|
||||
const cp = Vec.add(shape.point, [cx, cy]);
|
||||
return intersectArcBounds(cp, r, sp, ep, bounds).length > 0;
|
||||
}
|
||||
};
|
||||
|
||||
override transform = (
|
||||
shape: T,
|
||||
bounds: TLBounds,
|
||||
{ initialShape, scaleX, scaleY }: TransformInfo<T>
|
||||
): Partial<T> => {
|
||||
const initialShapeBounds = this.getBounds(initialShape);
|
||||
const handles: (keyof T['handles'])[] = ['start', 'end'];
|
||||
const nextHandles = { ...initialShape.handles };
|
||||
handles.forEach(handle => {
|
||||
const [x, y] = nextHandles[handle].point;
|
||||
const nw = x / initialShapeBounds.width;
|
||||
const nh = y / initialShapeBounds.height;
|
||||
nextHandles[handle] = {
|
||||
...nextHandles[handle],
|
||||
point: [
|
||||
bounds.width * (scaleX < 0 ? 1 - nw : nw),
|
||||
bounds.height * (scaleY < 0 ? 1 - nh : nh),
|
||||
],
|
||||
};
|
||||
});
|
||||
const { start, bend, end } = nextHandles;
|
||||
const dist = Vec.dist(start.point, end.point);
|
||||
const midPoint = Vec.med(start.point, end.point);
|
||||
const bendDist = (dist / 2) * initialShape.bend;
|
||||
const u = Vec.uni(Vec.vec(start.point, end.point));
|
||||
const point = Vec.add(midPoint, Vec.mul(Vec.per(u), bendDist));
|
||||
nextHandles['bend'] = {
|
||||
...bend,
|
||||
point: Vec.toFixed(Math.abs(bendDist) < 10 ? midPoint : point),
|
||||
};
|
||||
return {
|
||||
point: Vec.toFixed([bounds.minX, bounds.minY]),
|
||||
handles: nextHandles,
|
||||
};
|
||||
};
|
||||
|
||||
override onDoubleClickHandle = (
|
||||
shape: T,
|
||||
handle: Partial<T['handles']>
|
||||
): Partial<T> | void => {
|
||||
switch (handle) {
|
||||
case 'bend': {
|
||||
return {
|
||||
bend: 0,
|
||||
handles: {
|
||||
...shape.handles,
|
||||
bend: {
|
||||
...shape.handles.bend,
|
||||
point: getBendPoint(shape.handles, shape.bend),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'start': {
|
||||
return {
|
||||
decorations: {
|
||||
...shape.decorations,
|
||||
start: shape.decorations?.start
|
||||
? undefined
|
||||
: Decoration.Arrow,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'end': {
|
||||
return {
|
||||
decorations: {
|
||||
...shape.decorations,
|
||||
end: shape.decorations?.end
|
||||
? undefined
|
||||
: Decoration.Arrow,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
override onHandleChange = (
|
||||
shape: T,
|
||||
handles: Partial<T['handles']>
|
||||
): Partial<T> | void => {
|
||||
let nextHandles = Utils.deepMerge<ArrowShape['handles']>(
|
||||
shape.handles,
|
||||
handles
|
||||
);
|
||||
let nextBend = shape.bend;
|
||||
|
||||
nextHandles = Utils.deepMerge(nextHandles, {
|
||||
start: {
|
||||
point: Vec.toFixed(nextHandles.start.point),
|
||||
},
|
||||
end: {
|
||||
point: Vec.toFixed(nextHandles.end.point),
|
||||
},
|
||||
});
|
||||
|
||||
// This will produce NaN values
|
||||
if (Vec.isEqual(nextHandles.start.point, nextHandles.end.point)) return;
|
||||
|
||||
// If the user is moving the bend handle, we want to move the bend point
|
||||
if ('bend' in handles) {
|
||||
const { start, end, bend } = nextHandles;
|
||||
|
||||
const distance = Vec.dist(start.point, end.point);
|
||||
const midPoint = Vec.med(start.point, end.point);
|
||||
const angle = Vec.angle(start.point, end.point);
|
||||
const u = Vec.uni(Vec.vec(start.point, end.point));
|
||||
|
||||
// Create a line segment perendicular to the line between the start and end points
|
||||
const ap = Vec.add(midPoint, Vec.mul(Vec.per(u), distance));
|
||||
const bp = Vec.sub(midPoint, Vec.mul(Vec.per(u), distance));
|
||||
|
||||
const bendPoint = Vec.nearestPointOnLineSegment(
|
||||
ap,
|
||||
bp,
|
||||
bend.point,
|
||||
true
|
||||
);
|
||||
|
||||
// Find the distance between the midpoint and the nearest point on the
|
||||
// line segment to the bend handle's dragged point
|
||||
const bendDist = Vec.dist(midPoint, bendPoint);
|
||||
|
||||
// The shape's "bend" is the ratio of the bend to the distance between
|
||||
// the start and end points. If the bend is below a certain amount, the
|
||||
// bend should be zero.
|
||||
const realBend = bendDist / (distance / 2);
|
||||
|
||||
nextBend = Utils.clamp(realBend, -0.99, 0.99);
|
||||
|
||||
// If the point is to the left of the line segment, we make the bend
|
||||
// negative, otherwise it's positive.
|
||||
const angleToBend = Vec.angle(start.point, bendPoint);
|
||||
|
||||
// If resulting bend is low enough that the handle will snap to center,
|
||||
// then also snap the bend to center
|
||||
|
||||
if (Vec.isEqual(midPoint, getBendPoint(nextHandles, nextBend))) {
|
||||
nextBend = 0;
|
||||
} else if (isAngleBetween(angle, angle + Math.PI, angleToBend)) {
|
||||
// Otherwise, fix the bend direction
|
||||
nextBend *= -1;
|
||||
}
|
||||
}
|
||||
|
||||
const nextShape = {
|
||||
point: shape.point,
|
||||
bend: nextBend,
|
||||
handles: {
|
||||
...nextHandles,
|
||||
bend: {
|
||||
...nextHandles.bend,
|
||||
point: getBendPoint(nextHandles, nextBend),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Zero out the handles to prevent handles with negative points. If a handle's x or y
|
||||
// is below zero, we need to move the shape left or up to make it zero.
|
||||
const topLeft = shape.point;
|
||||
|
||||
const nextBounds = this.getBounds({ ...nextShape } as ArrowShape);
|
||||
|
||||
const offset = Vec.sub([nextBounds.minX, nextBounds.minY], topLeft);
|
||||
|
||||
if (!Vec.isEqual(offset, [0, 0])) {
|
||||
Object.values(nextShape.handles).forEach(handle => {
|
||||
handle.point = Vec.toFixed(Vec.sub(handle.point, offset));
|
||||
});
|
||||
nextShape.point = Vec.toFixed(Vec.add(nextShape.point, offset));
|
||||
}
|
||||
|
||||
return nextShape;
|
||||
};
|
||||
}
|
||||
|
||||
const FullWrapper = styled('div')({ width: '100%', height: '100%' });
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export interface ArrowheadProps {
|
||||
left: number[];
|
||||
middle: number[];
|
||||
right: number[];
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
export function Arrowhead({
|
||||
left,
|
||||
middle,
|
||||
right,
|
||||
stroke,
|
||||
strokeWidth,
|
||||
}: ArrowheadProps) {
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
className="tl-stroke-hitarea"
|
||||
d={`M ${left} L ${middle} ${right}`}
|
||||
/>
|
||||
<path
|
||||
d={`M ${left} L ${middle} ${right}`}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Utils } from '@tldraw/core';
|
||||
import Vec from '@tldraw/vec';
|
||||
import * as React from 'react';
|
||||
import { EASINGS } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle } from '../../shared';
|
||||
import type {
|
||||
Decoration,
|
||||
ShapeStyles,
|
||||
} from '@toeverything/components/board-types';
|
||||
import {
|
||||
getArcLength,
|
||||
getArrowArcPath,
|
||||
getCtp,
|
||||
getCurvedArrowHeadPoints,
|
||||
renderCurvedFreehandArrowShaft,
|
||||
} from '../arrow-helpers';
|
||||
import { Arrowhead } from './arrow-head';
|
||||
|
||||
interface ArrowSvgProps {
|
||||
id: string;
|
||||
style: ShapeStyles;
|
||||
start: number[];
|
||||
bend: number[];
|
||||
end: number[];
|
||||
arrowBend: number;
|
||||
decorationStart: Decoration | undefined;
|
||||
decorationEnd: Decoration | undefined;
|
||||
isDarkMode: boolean;
|
||||
isDraw: boolean;
|
||||
}
|
||||
|
||||
export const CurvedArrow = React.memo(function CurvedArrow({
|
||||
id,
|
||||
style,
|
||||
start,
|
||||
bend,
|
||||
end,
|
||||
arrowBend,
|
||||
decorationStart,
|
||||
decorationEnd,
|
||||
isDraw,
|
||||
isDarkMode,
|
||||
}: ArrowSvgProps) {
|
||||
const arrowDist = Vec.dist(start, end);
|
||||
if (arrowDist < 2) return null;
|
||||
const styles = getShapeStyle(style, isDarkMode);
|
||||
const { strokeWidth } = styles;
|
||||
const sw = 1 + strokeWidth * 1.618;
|
||||
// Calculate a path as a segment of a circle passing through the three points start, bend, and end
|
||||
const circle = getCtp(start, bend, end);
|
||||
const center = [circle[0], circle[1]];
|
||||
const radius = circle[2];
|
||||
const length = getArcLength(center, radius, start, end);
|
||||
const getRandom = Utils.rng(id);
|
||||
const easing =
|
||||
EASINGS[getRandom() > 0 ? 'easeInOutSine' : 'easeInOutCubic'];
|
||||
const path = isDraw
|
||||
? renderCurvedFreehandArrowShaft(
|
||||
id,
|
||||
style,
|
||||
start,
|
||||
end,
|
||||
decorationStart,
|
||||
decorationEnd,
|
||||
center,
|
||||
radius,
|
||||
length,
|
||||
easing
|
||||
)
|
||||
: getArrowArcPath(start, end, circle, arrowBend);
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
Math.abs(length),
|
||||
sw,
|
||||
style.dash,
|
||||
2,
|
||||
false
|
||||
);
|
||||
// Arrowheads
|
||||
const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8);
|
||||
const startArrowHead = decorationStart
|
||||
? getCurvedArrowHeadPoints(
|
||||
start,
|
||||
arrowHeadLength,
|
||||
center,
|
||||
radius,
|
||||
length < 0
|
||||
)
|
||||
: null;
|
||||
const endArrowHead = decorationEnd
|
||||
? getCurvedArrowHeadPoints(
|
||||
end,
|
||||
arrowHeadLength,
|
||||
center,
|
||||
radius,
|
||||
length >= 0
|
||||
)
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
<path className="tl-stroke-hitarea" d={path} />
|
||||
<path
|
||||
d={path}
|
||||
fill={isDraw ? styles.stroke : 'none'}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={isDraw ? 0 : sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
{startArrowHead && (
|
||||
<Arrowhead
|
||||
left={startArrowHead.left}
|
||||
middle={start}
|
||||
right={startArrowHead.right}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
/>
|
||||
)}
|
||||
{endArrowHead && (
|
||||
<Arrowhead
|
||||
left={endArrowHead.left}
|
||||
middle={end}
|
||||
right={endArrowHead.right}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Utils } from '@tldraw/core';
|
||||
import Vec from '@tldraw/vec';
|
||||
import * as React from 'react';
|
||||
import { getShapeStyle } from '../../shared';
|
||||
import type {
|
||||
Decoration,
|
||||
ShapeStyles,
|
||||
} from '@toeverything/components/board-types';
|
||||
import {
|
||||
getStraightArrowHeadPoints,
|
||||
renderFreehandArrowShaft,
|
||||
} from '../arrow-helpers';
|
||||
import { Arrowhead } from './arrow-head';
|
||||
|
||||
interface ArrowSvgProps {
|
||||
id: string;
|
||||
style: ShapeStyles;
|
||||
start: number[];
|
||||
bend: number[];
|
||||
end: number[];
|
||||
arrowBend: number;
|
||||
decorationStart: Decoration | undefined;
|
||||
decorationEnd: Decoration | undefined;
|
||||
isDarkMode: boolean;
|
||||
isDraw: boolean;
|
||||
}
|
||||
|
||||
export const StraightArrow = React.memo(function StraightArrow({
|
||||
id,
|
||||
style,
|
||||
start,
|
||||
end,
|
||||
decorationStart,
|
||||
decorationEnd,
|
||||
isDraw,
|
||||
isDarkMode,
|
||||
}: ArrowSvgProps) {
|
||||
const arrowDist = Vec.dist(start, end);
|
||||
if (arrowDist < 2) return null;
|
||||
const styles = getShapeStyle(style, isDarkMode);
|
||||
const { strokeWidth } = styles;
|
||||
const sw = 1 + strokeWidth * 1.618;
|
||||
// Path between start and end points
|
||||
const path = isDraw
|
||||
? renderFreehandArrowShaft(
|
||||
id,
|
||||
style,
|
||||
start,
|
||||
end,
|
||||
decorationStart,
|
||||
decorationEnd
|
||||
)
|
||||
: 'M' + Vec.toFixed(start) + 'L' + Vec.toFixed(end);
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
arrowDist,
|
||||
strokeWidth * 1.618,
|
||||
style.dash,
|
||||
2,
|
||||
false
|
||||
);
|
||||
// Arrowheads
|
||||
const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8);
|
||||
const startArrowHead = decorationStart
|
||||
? getStraightArrowHeadPoints(start, end, arrowHeadLength)
|
||||
: null;
|
||||
const endArrowHead = decorationEnd
|
||||
? getStraightArrowHeadPoints(end, start, arrowHeadLength)
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
<path className="tl-stroke-hitarea" d={path} />
|
||||
<path
|
||||
d={path}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={isDraw ? sw / 2 : sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="stroke"
|
||||
/>
|
||||
{startArrowHead && (
|
||||
<Arrowhead
|
||||
left={startArrowHead.left}
|
||||
middle={start}
|
||||
right={startArrowHead.right}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
/>
|
||||
)}
|
||||
{endArrowHead && (
|
||||
<Arrowhead
|
||||
left={endArrowHead.left}
|
||||
middle={end}
|
||||
right={endArrowHead.right}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
1
libs/components/board-shapes/src/arrow-util/index.ts
Normal file
1
libs/components/board-shapes/src/arrow-util/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './arrow-util';
|
||||
376
libs/components/board-shapes/src/draw-util/DrawUtil.tsx
Normal file
376
libs/components/board-shapes/src/draw-util/DrawUtil.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import * as React from 'react';
|
||||
import { Utils, SVGContainer, TLBounds } from '@tldraw/core';
|
||||
import { Vec } from '@tldraw/vec';
|
||||
import { defaultStyle, getShapeStyle } from '../shared/shape-styles';
|
||||
import {
|
||||
DrawShape,
|
||||
DashStyle,
|
||||
TDShapeType,
|
||||
TransformInfo,
|
||||
TDMeta,
|
||||
GHOSTED_OPACITY,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TDShapeUtil } from '../TDShapeUtil';
|
||||
import {
|
||||
intersectBoundsBounds,
|
||||
intersectBoundsPolyline,
|
||||
intersectLineSegmentBounds,
|
||||
intersectLineSegmentLineSegment,
|
||||
} from '@tldraw/intersect';
|
||||
import {
|
||||
getDrawStrokePathTDSnapshot,
|
||||
getFillPath,
|
||||
getSolidStrokePathTDSnapshot,
|
||||
} from './draw-helpers';
|
||||
|
||||
type T = DrawShape;
|
||||
type E = SVGSVGElement;
|
||||
|
||||
export class DrawUtil extends TDShapeUtil<T, E> {
|
||||
type = TDShapeType.Draw as const;
|
||||
|
||||
pointsBoundsCache = new WeakMap<T['points'], TLBounds>([]);
|
||||
|
||||
shapeBoundsCache = new Map<string, TLBounds>();
|
||||
|
||||
rotatedCache = new WeakMap<T, number[][]>([]);
|
||||
|
||||
pointCache: Record<string, number[]> = {};
|
||||
|
||||
override canClone = true;
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
return Utils.deepMerge<T>(
|
||||
{
|
||||
id: 'id',
|
||||
type: TDShapeType.Draw,
|
||||
name: 'Draw',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
point: [0, 0],
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
points: [],
|
||||
isComplete: false,
|
||||
workspace: props.workspace,
|
||||
},
|
||||
props
|
||||
);
|
||||
};
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
({ shape, meta, isSelected, isGhost, events }, ref) => {
|
||||
const { points, style, isComplete } = shape;
|
||||
|
||||
const polygon_path_td_snapshot = React.useMemo(() => {
|
||||
return getFillPath(shape);
|
||||
}, [points, style.strokeWidth]);
|
||||
|
||||
const path_td_snapshot = React.useMemo(() => {
|
||||
return style.dash === DashStyle.Draw
|
||||
? getDrawStrokePathTDSnapshot(shape)
|
||||
: getSolidStrokePathTDSnapshot(shape);
|
||||
}, [points, style.strokeWidth, style.dash, isComplete]);
|
||||
|
||||
const styles = getShapeStyle(style, meta.isDarkMode);
|
||||
const { stroke, fill, strokeWidth } = styles;
|
||||
|
||||
// For very short lines, draw a point instead of a line
|
||||
const bounds = this.getBounds(shape);
|
||||
|
||||
const verySmall =
|
||||
bounds.width <= strokeWidth / 2 &&
|
||||
bounds.height <= strokeWidth / 2;
|
||||
|
||||
if (verySmall) {
|
||||
const sw = 1 + strokeWidth;
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
|
||||
<circle
|
||||
r={sw}
|
||||
fill={stroke}
|
||||
stroke={stroke}
|
||||
pointerEvents="all"
|
||||
opacity={isGhost ? GHOSTED_OPACITY : 1}
|
||||
/>
|
||||
</SVGContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const shouldFill =
|
||||
style.isFilled &&
|
||||
points.length > 3 &&
|
||||
Vec.dist(points[0], points[points.length - 1]) <
|
||||
strokeWidth * 2;
|
||||
|
||||
if (shape.style.dash === DashStyle.Draw) {
|
||||
return (
|
||||
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
|
||||
<g opacity={isGhost ? GHOSTED_OPACITY : 1}>
|
||||
<path
|
||||
className={
|
||||
shouldFill || isSelected
|
||||
? 'tl-fill-hitarea'
|
||||
: 'tl-stroke-hitarea'
|
||||
}
|
||||
d={path_td_snapshot}
|
||||
/>
|
||||
{shouldFill && (
|
||||
<path
|
||||
d={polygon_path_td_snapshot}
|
||||
stroke="none"
|
||||
fill={fill}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
d={path_td_snapshot}
|
||||
fill={stroke}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth / 2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</g>
|
||||
</SVGContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// For solid, dash and dotted lines, draw a regular stroke path
|
||||
|
||||
const strokeDasharray = {
|
||||
[DashStyle.None]: 'none',
|
||||
[DashStyle.Draw]: 'none',
|
||||
[DashStyle.Solid]: `none`,
|
||||
[DashStyle.Dotted]: `0.1 ${strokeWidth * 4}`,
|
||||
[DashStyle.Dashed]: `${strokeWidth * 4} ${strokeWidth * 4}`,
|
||||
}[style.dash];
|
||||
|
||||
const strokeDashoffset = {
|
||||
[DashStyle.None]: 'none',
|
||||
[DashStyle.Draw]: 'none',
|
||||
[DashStyle.Solid]: `none`,
|
||||
[DashStyle.Dotted]: `0`,
|
||||
[DashStyle.Dashed]: `0`,
|
||||
}[style.dash];
|
||||
|
||||
const sw = 1 + strokeWidth * 1.5;
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
|
||||
<g opacity={isGhost ? GHOSTED_OPACITY : 1}>
|
||||
<path
|
||||
className={
|
||||
shouldFill && isSelected
|
||||
? 'tl-fill-hitarea'
|
||||
: 'tl-stroke-hitarea'
|
||||
}
|
||||
d={path_td_snapshot}
|
||||
/>
|
||||
<path
|
||||
d={path_td_snapshot}
|
||||
fill={shouldFill ? fill : 'none'}
|
||||
stroke="none"
|
||||
strokeWidth={Math.min(4, strokeWidth * 2)}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<path
|
||||
d={path_td_snapshot}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</g>
|
||||
</SVGContainer>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
|
||||
const { points } = shape;
|
||||
|
||||
const path_td_snapshot = React.useMemo(() => {
|
||||
return getSolidStrokePathTDSnapshot(shape);
|
||||
}, [points]);
|
||||
|
||||
const bounds = this.getBounds(shape);
|
||||
|
||||
const verySmall = bounds.width < 4 && bounds.height < 4;
|
||||
|
||||
if (verySmall) {
|
||||
return <circle x={bounds.width / 2} y={bounds.height / 2} r={1} />;
|
||||
}
|
||||
|
||||
return <path d={path_td_snapshot} />;
|
||||
});
|
||||
|
||||
override transform = (
|
||||
shape: T,
|
||||
bounds: TLBounds,
|
||||
{ initialShape, scaleX, scaleY }: TransformInfo<T>
|
||||
): Partial<T> => {
|
||||
const initialShapeBounds = Utils.getFromCache(
|
||||
this.boundsCache,
|
||||
initialShape,
|
||||
() => Utils.getBoundsFromPoints(initialShape.points)
|
||||
);
|
||||
|
||||
const points = initialShape.points.map(([x, y, r]) => {
|
||||
return [
|
||||
bounds.width *
|
||||
(scaleX < 0 // * sin?
|
||||
? 1 - x / initialShapeBounds.width
|
||||
: x / initialShapeBounds.width),
|
||||
bounds.height *
|
||||
(scaleY < 0 // * cos?
|
||||
? 1 - y / initialShapeBounds.height
|
||||
: y / initialShapeBounds.height),
|
||||
r,
|
||||
];
|
||||
});
|
||||
|
||||
const newBounds = Utils.getBoundsFromPoints(shape.points);
|
||||
|
||||
const point = Vec.sub(
|
||||
[bounds.minX, bounds.minY],
|
||||
[newBounds.minX, newBounds.minY]
|
||||
);
|
||||
|
||||
return {
|
||||
points,
|
||||
point,
|
||||
};
|
||||
};
|
||||
|
||||
getBounds = (shape: T) => {
|
||||
// The goal here is to avoid recalculating the bounds from the
|
||||
// points array, which is expensive. However, we still need a
|
||||
// new bounds if the point has changed, but we will reuse the
|
||||
// previous bounds-from-points result if we can.
|
||||
|
||||
const pointsHaveChanged = !this.pointsBoundsCache.has(shape.points);
|
||||
const pointHasChanged = !(this.pointCache[shape.id] === shape.point);
|
||||
|
||||
if (pointsHaveChanged) {
|
||||
// If the points have changed, then bust the points cache
|
||||
const bounds = Utils.getBoundsFromPoints(shape.points);
|
||||
this.pointsBoundsCache.set(shape.points, bounds);
|
||||
this.shapeBoundsCache.set(
|
||||
shape.id,
|
||||
Utils.translateBounds(bounds, shape.point)
|
||||
);
|
||||
this.pointCache[shape.id] = shape.point;
|
||||
} else if (pointHasChanged && !pointsHaveChanged) {
|
||||
// If the point have has changed, then bust the point cache
|
||||
this.pointCache[shape.id] = shape.point;
|
||||
this.shapeBoundsCache.set(
|
||||
shape.id,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
Utils.translateBounds(
|
||||
this.pointsBoundsCache.get(shape.points)!,
|
||||
shape.point
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return this.shapeBoundsCache.get(shape.id)!;
|
||||
};
|
||||
|
||||
override shouldRender = (prev: T, next: T) => {
|
||||
return (
|
||||
next.points !== prev.points ||
|
||||
next.style !== prev.style ||
|
||||
next.isComplete !== prev.isComplete
|
||||
);
|
||||
};
|
||||
|
||||
override hitTestPoint = (shape: T, point: number[]) => {
|
||||
const ptA = Vec.sub(point, shape.point);
|
||||
return Utils.pointInPolyline(ptA, shape.points);
|
||||
};
|
||||
|
||||
override hitTestLineSegment = (
|
||||
shape: T,
|
||||
A: number[],
|
||||
B: number[]
|
||||
): boolean => {
|
||||
const { points, point } = shape;
|
||||
const ptA = Vec.sub(A, point);
|
||||
const ptB = Vec.sub(B, point);
|
||||
const bounds = this.getBounds(shape);
|
||||
|
||||
if (points.length <= 2) {
|
||||
return Vec.distanceToLineSegment(A, B, shape.point) < 4;
|
||||
}
|
||||
|
||||
if (intersectLineSegmentBounds(ptA, ptB, bounds)) {
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
if (
|
||||
intersectLineSegmentLineSegment(
|
||||
points[i - 1],
|
||||
points[i],
|
||||
ptA,
|
||||
ptB
|
||||
).didIntersect
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
override hitTestBounds = (shape: T, bounds: TLBounds) => {
|
||||
// Test axis-aligned shape
|
||||
if (!shape.rotation) {
|
||||
const shapeBounds = this.getBounds(shape);
|
||||
|
||||
return (
|
||||
Utils.boundsContain(bounds, shapeBounds) ||
|
||||
((Utils.boundsContain(shapeBounds, bounds) ||
|
||||
intersectBoundsBounds(shapeBounds, bounds).length > 0) &&
|
||||
intersectBoundsPolyline(
|
||||
Utils.translateBounds(bounds, Vec.neg(shape.point)),
|
||||
shape.points
|
||||
).length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
// Test rotated shape
|
||||
const rBounds = this.getRotatedBounds(shape);
|
||||
|
||||
const rotatedBounds = Utils.getFromCache(
|
||||
this.rotatedCache,
|
||||
shape,
|
||||
() => {
|
||||
const c = Utils.getBoundsCenter(
|
||||
Utils.getBoundsFromPoints(shape.points)
|
||||
);
|
||||
return shape.points.map(pt =>
|
||||
Vec.rotWith(pt, c, shape.rotation || 0)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
Utils.boundsContain(bounds, rBounds) ||
|
||||
intersectBoundsPolyline(
|
||||
Utils.translateBounds(bounds, Vec.neg(shape.point)),
|
||||
rotatedBounds
|
||||
).length > 0
|
||||
);
|
||||
};
|
||||
}
|
||||
81
libs/components/board-shapes/src/draw-util/draw-helpers.ts
Normal file
81
libs/components/board-shapes/src/draw-util/draw-helpers.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Utils } from '@tldraw/core';
|
||||
import Vec from '@tldraw/vec';
|
||||
import {
|
||||
getStrokeOutlinePoints,
|
||||
getStrokePoints,
|
||||
StrokeOptions,
|
||||
} from 'perfect-freehand';
|
||||
import type { DrawShape } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle } from '../shared/shape-styles';
|
||||
|
||||
const simulatePressureSettings: StrokeOptions = {
|
||||
easing: t => Math.sin((t * Math.PI) / 2),
|
||||
simulatePressure: true,
|
||||
};
|
||||
|
||||
const realPressureSettings: StrokeOptions = {
|
||||
easing: t => t * t,
|
||||
simulatePressure: false,
|
||||
};
|
||||
|
||||
export function getFreehandOptions(shape: DrawShape) {
|
||||
const styles = getShapeStyle(shape.style);
|
||||
|
||||
const options: StrokeOptions = {
|
||||
size: 1 + styles.strokeWidth * 1.5,
|
||||
thinning: 0.65,
|
||||
streamline: 0.65,
|
||||
smoothing: 0.65,
|
||||
...(shape.points[1][2] === 0.5
|
||||
? simulatePressureSettings
|
||||
: realPressureSettings),
|
||||
last: shape.isComplete,
|
||||
};
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function getFillPath(shape: DrawShape) {
|
||||
if (shape.points.length < 2) return '';
|
||||
|
||||
return Utils.getSvgPathFromStroke(
|
||||
getStrokePoints(shape.points, getFreehandOptions(shape)).map(
|
||||
pt => pt.point
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function getDrawStrokePoints(shape: DrawShape, options: StrokeOptions) {
|
||||
return getStrokePoints(shape.points, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path data for a stroke with the DashStyle.Draw dash style.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export function getDrawStrokePathTDSnapshot(shape: DrawShape) {
|
||||
if (shape.points.length < 2) return '';
|
||||
const options = getFreehandOptions(shape);
|
||||
const strokePoints = getDrawStrokePoints(shape, options);
|
||||
const path = Utils.getSvgPathFromStroke(
|
||||
getStrokeOutlinePoints(strokePoints, options)
|
||||
);
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SVG path data for a shape that has a DashStyle other than DashStyles.Draw.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export function getSolidStrokePathTDSnapshot(shape: DrawShape) {
|
||||
const { points } = shape;
|
||||
if (points.length < 2) return 'M 0 0 L 0 0';
|
||||
const options = getFreehandOptions(shape);
|
||||
const strokePoints = getDrawStrokePoints(shape, options).map(pt =>
|
||||
pt.point.slice(0, 2)
|
||||
);
|
||||
const last = points[points.length - 1].slice(0, 2);
|
||||
if (!Vec.isEqual(strokePoints[0], last)) strokePoints.push(last);
|
||||
const path = Utils.getSvgPathFromStroke(strokePoints, false);
|
||||
return path;
|
||||
}
|
||||
1
libs/components/board-shapes/src/draw-util/index.ts
Normal file
1
libs/components/board-shapes/src/draw-util/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './DrawUtil';
|
||||
267
libs/components/board-shapes/src/editor-util/EditorUtil.tsx
Normal file
267
libs/components/board-shapes/src/editor-util/EditorUtil.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import { useRef, useCallback, useEffect, memo } from 'react';
|
||||
import type { SyntheticEvent } from 'react';
|
||||
import { Utils, HTMLContainer, TLBounds } from '@tldraw/core';
|
||||
import {
|
||||
EditorShape,
|
||||
TDMeta,
|
||||
TDShapeType,
|
||||
TransformInfo,
|
||||
} from '@toeverything/components/board-types';
|
||||
import {
|
||||
defaultTextStyle,
|
||||
getBoundsRectangle,
|
||||
getTextSvgElement,
|
||||
} from '../shared';
|
||||
import { TDShapeUtil } from '../TDShapeUtil';
|
||||
import { getShapeStyle } from '../shared/shape-styles';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { Vec } from '@tldraw/vec';
|
||||
import { AffineEditor } from '@toeverything/components/affine-editor';
|
||||
import { MIN_PAGE_WIDTH } from '@toeverything/components/editor-core';
|
||||
const MemoAffineEditor = memo(AffineEditor, (prev, next) => {
|
||||
return (
|
||||
prev.workspace === next.workspace &&
|
||||
prev.rootBlockId === next.rootBlockId
|
||||
);
|
||||
});
|
||||
|
||||
type T = EditorShape;
|
||||
type E = HTMLDivElement;
|
||||
|
||||
export class EditorUtil extends TDShapeUtil<T, E> {
|
||||
type = TDShapeType.Editor as const;
|
||||
|
||||
override canBind = true;
|
||||
|
||||
override canEdit = true;
|
||||
|
||||
override canClone = true;
|
||||
|
||||
override showCloneHandles = true;
|
||||
/**
|
||||
* Prevent editor from being destroyed when moving out of viewport
|
||||
*/
|
||||
override isStateful = true;
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
return Utils.deepMerge<T>(
|
||||
{
|
||||
id: props.id,
|
||||
type: TDShapeType.Editor,
|
||||
name: 'Editor',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
point: [0, 0],
|
||||
size: [MIN_PAGE_WIDTH, 200],
|
||||
rotation: 0,
|
||||
style: defaultTextStyle,
|
||||
rootBlockId: props.rootBlockId,
|
||||
workspace: props.workspace,
|
||||
},
|
||||
props
|
||||
);
|
||||
};
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
(
|
||||
{
|
||||
shape,
|
||||
meta: {
|
||||
app: { useStore },
|
||||
},
|
||||
events,
|
||||
isEditing,
|
||||
onShapeChange,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const containerRef = useRef<HTMLDivElement>();
|
||||
const {
|
||||
workspace,
|
||||
rootBlockId,
|
||||
size: [width, height],
|
||||
} = shape;
|
||||
|
||||
const state = useStore();
|
||||
const { currentPageId } = state.appState;
|
||||
const { editingId } = state.document.pageStates[currentPageId];
|
||||
const { shapes } = state.document.pages[currentPageId];
|
||||
const editingText =
|
||||
editingId != null &&
|
||||
shapes[editingId].type === TDShapeType.Editor;
|
||||
|
||||
const zoomLevel =
|
||||
state.document.pageStates[state.appState.currentPageId].camera
|
||||
.zoom;
|
||||
|
||||
// TODO: useEvent
|
||||
const onResize = useRef((_: ResizeObserverEntry[]) => {});
|
||||
|
||||
useEffect(() => {
|
||||
onResize.current = e => {
|
||||
const first = e[0];
|
||||
const bounds = first.contentRect;
|
||||
const realHeight = bounds.height / zoomLevel;
|
||||
if (
|
||||
bounds.height !== 0 &&
|
||||
Math.abs(realHeight - height) > 1
|
||||
) {
|
||||
onShapeChange({
|
||||
id: shape.id,
|
||||
size: [width, realHeight],
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [height, onShapeChange, shape.id, width, zoomLevel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const obv = new ResizeObserver(e => onResize.current(e));
|
||||
|
||||
obv.observe(containerRef.current);
|
||||
|
||||
return () => obv.disconnect();
|
||||
}
|
||||
}, [onShapeChange]);
|
||||
|
||||
const stopPropagation = useCallback(
|
||||
(e: SyntheticEvent) => {
|
||||
if (isEditing) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
[isEditing]
|
||||
);
|
||||
|
||||
return (
|
||||
<HTMLContainer ref={ref} {...events}>
|
||||
<Container
|
||||
ref={containerRef}
|
||||
onPointerDown={stopPropagation}
|
||||
>
|
||||
<MemoAffineEditor
|
||||
workspace={workspace}
|
||||
rootBlockId={rootBlockId}
|
||||
scrollBlank={false}
|
||||
isWhiteboard
|
||||
/>
|
||||
{editingText ? null : <Mask />}
|
||||
</Container>
|
||||
</HTMLContainer>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
|
||||
const {
|
||||
size: [width, height],
|
||||
} = shape;
|
||||
|
||||
return (
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
rx={3}
|
||||
ry={3}
|
||||
width={Math.max(1, width)}
|
||||
height={Math.max(1, height)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
getBounds = (shape: T) => {
|
||||
return getBoundsRectangle(shape, this.boundsCache);
|
||||
};
|
||||
|
||||
override shouldRender = (prev: T, next: T) => {
|
||||
return (
|
||||
next.size !== prev.size ||
|
||||
next.style !== prev.style ||
|
||||
next.rootBlockId !== prev.rootBlockId ||
|
||||
next.workspace !== prev.workspace
|
||||
);
|
||||
};
|
||||
|
||||
override transform = (
|
||||
shape: T,
|
||||
bounds: TLBounds,
|
||||
{ scaleX, scaleY, transformOrigin }: TransformInfo<T>
|
||||
): Partial<T> => {
|
||||
const point = Vec.toFixed([
|
||||
bounds.minX +
|
||||
(bounds.width - shape.size[0]) *
|
||||
(scaleX < 0 ? 1 - transformOrigin[0] : transformOrigin[0]),
|
||||
bounds.minY +
|
||||
(bounds.height - shape.size[1]) *
|
||||
(scaleY < 0 ? 1 - transformOrigin[1] : transformOrigin[1]),
|
||||
]);
|
||||
|
||||
return {
|
||||
point,
|
||||
};
|
||||
};
|
||||
|
||||
override transformSingle = (shape: T, bounds: TLBounds): Partial<T> => {
|
||||
return {
|
||||
size: Vec.toFixed([
|
||||
bounds.width > MIN_PAGE_WIDTH ? MIN_PAGE_WIDTH : bounds.width,
|
||||
shape.size[1],
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
override getSvgElement = (
|
||||
shape: T,
|
||||
isDarkMode: boolean
|
||||
): SVGElement | void => {
|
||||
const bounds = this.getBounds(shape);
|
||||
const textBounds = Utils.expandBounds(bounds, -PADDING);
|
||||
const textElm = getTextSvgElement(
|
||||
'This feature is currently not supported',
|
||||
shape.style,
|
||||
textBounds
|
||||
);
|
||||
const style = getShapeStyle(shape.style, isDarkMode);
|
||||
textElm.setAttribute('fill', style.fill);
|
||||
textElm.setAttribute('transform', `translate(${PADDING}, ${PADDING})`);
|
||||
|
||||
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
const rect = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'rect'
|
||||
);
|
||||
rect.setAttribute('width', bounds.width + '');
|
||||
rect.setAttribute('height', bounds.height + '');
|
||||
rect.setAttribute('fill', style.fill);
|
||||
rect.setAttribute('rx', '3');
|
||||
rect.setAttribute('ry', '3');
|
||||
|
||||
g.appendChild(rect);
|
||||
g.appendChild(textElm);
|
||||
|
||||
return g;
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------- */
|
||||
|
||||
const PADDING = 16;
|
||||
// const MIN_CONTAINER_HEIGHT = 200;
|
||||
|
||||
const Container = styled('div')({
|
||||
pointerEvents: 'all',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
const Mask = styled('div')({
|
||||
position: 'absolute',
|
||||
userSelect: 'none',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
});
|
||||
1
libs/components/board-shapes/src/editor-util/index.ts
Normal file
1
libs/components/board-shapes/src/editor-util/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { EditorUtil } from './EditorUtil';
|
||||
363
libs/components/board-shapes/src/ellipse-util/EllipseUtil.tsx
Normal file
363
libs/components/board-shapes/src/ellipse-util/EllipseUtil.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import * as React from 'react';
|
||||
import { Utils, SVGContainer, TLBounds } from '@tldraw/core';
|
||||
import { Vec } from '@tldraw/vec';
|
||||
import {
|
||||
defaultStyle,
|
||||
getShapeStyle,
|
||||
getFontStyle,
|
||||
TextLabel,
|
||||
} from '../shared';
|
||||
import {
|
||||
EllipseShape,
|
||||
DashStyle,
|
||||
TDShapeType,
|
||||
TDShape,
|
||||
TransformInfo,
|
||||
TDMeta,
|
||||
GHOSTED_OPACITY,
|
||||
LABEL_POINT,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TDShapeUtil } from '../TDShapeUtil';
|
||||
import {
|
||||
intersectEllipseBounds,
|
||||
intersectLineSegmentEllipse,
|
||||
intersectRayEllipse,
|
||||
} from '@tldraw/intersect';
|
||||
import { getEllipseIndicatorPath } from './ellipse-helpers';
|
||||
import { DrawEllipse } from './components/DrawEllipse';
|
||||
import { DashedEllipse } from './components/DashedEllipse';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
type T = EllipseShape;
|
||||
type E = HTMLDivElement;
|
||||
type M = TDMeta;
|
||||
|
||||
export class EllipseUtil extends TDShapeUtil<T, E> {
|
||||
type = TDShapeType.Ellipse as const;
|
||||
|
||||
override canBind = true;
|
||||
|
||||
override canClone = true;
|
||||
|
||||
override canEdit = true;
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
return Utils.deepMerge<T>(
|
||||
{
|
||||
id: 'id',
|
||||
type: TDShapeType.Ellipse,
|
||||
name: 'Ellipse',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
point: [0, 0],
|
||||
radius: [1, 1],
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
label: '',
|
||||
labelPoint: [0.5, 0.5],
|
||||
workspace: props.workspace,
|
||||
},
|
||||
props
|
||||
);
|
||||
};
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
(
|
||||
{
|
||||
shape,
|
||||
isGhost,
|
||||
isSelected,
|
||||
isBinding,
|
||||
isEditing,
|
||||
meta,
|
||||
bounds,
|
||||
events,
|
||||
onShapeChange,
|
||||
onShapeBlur,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const {
|
||||
id,
|
||||
radius,
|
||||
style,
|
||||
label = '',
|
||||
labelPoint = LABEL_POINT,
|
||||
} = shape;
|
||||
const font = getFontStyle(shape.style);
|
||||
const styles = getShapeStyle(style, meta.isDarkMode);
|
||||
const strokeWidth = styles.strokeWidth;
|
||||
const sw = 1 + strokeWidth * 1.618;
|
||||
const rx = Math.max(0, radius[0] - sw / 2);
|
||||
const ry = Math.max(0, radius[1] - sw / 2);
|
||||
const Component =
|
||||
style.dash === DashStyle.Draw ? DrawEllipse : DashedEllipse;
|
||||
const handleLabelChange = React.useCallback(
|
||||
(label: string) => onShapeChange?.({ id, label }),
|
||||
[onShapeChange]
|
||||
);
|
||||
return (
|
||||
<FullWrapper ref={ref} {...events}>
|
||||
<TextLabel
|
||||
isEditing={isEditing}
|
||||
onChange={handleLabelChange}
|
||||
onBlur={onShapeBlur}
|
||||
font={font}
|
||||
text={label}
|
||||
color={styles.stroke}
|
||||
offsetX={(labelPoint[0] - 0.5) * bounds.width}
|
||||
offsetY={(labelPoint[1] - 0.5) * bounds.height}
|
||||
/>
|
||||
<SVGContainer
|
||||
id={shape.id + '_svg'}
|
||||
opacity={isGhost ? GHOSTED_OPACITY : 1}
|
||||
>
|
||||
{isBinding && (
|
||||
<ellipse
|
||||
className="tl-binding-indicator"
|
||||
cx={radius[0]}
|
||||
cy={radius[1]}
|
||||
rx={rx}
|
||||
ry={ry}
|
||||
strokeWidth={this.bindingDistance}
|
||||
/>
|
||||
)}
|
||||
<Component
|
||||
id={id}
|
||||
radius={radius}
|
||||
style={style}
|
||||
isSelected={isSelected}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
/>
|
||||
</SVGContainer>
|
||||
</FullWrapper>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<T, M>(({ shape }) => {
|
||||
const { id, radius, style } = shape;
|
||||
const styles = getShapeStyle(style);
|
||||
const strokeWidth = styles.strokeWidth;
|
||||
const sw = 1 + strokeWidth * 1.618;
|
||||
const rx = Math.max(0, radius[0] - sw / 2);
|
||||
const ry = Math.max(0, radius[1] - sw / 2);
|
||||
return style.dash === DashStyle.Draw ? (
|
||||
<path d={getEllipseIndicatorPath(id, radius, style)} />
|
||||
) : (
|
||||
<ellipse cx={radius[0]} cy={radius[1]} rx={rx} ry={ry} />
|
||||
);
|
||||
});
|
||||
|
||||
override hitTestPoint = (shape: T, point: number[]): boolean => {
|
||||
return (
|
||||
Utils.pointInBounds(point, this.getRotatedBounds(shape)) &&
|
||||
Utils.pointInEllipse(
|
||||
point,
|
||||
this.getCenter(shape),
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
shape.rotation || 0
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
override hitTestLineSegment = (
|
||||
shape: T,
|
||||
A: number[],
|
||||
B: number[]
|
||||
): boolean => {
|
||||
return intersectLineSegmentEllipse(
|
||||
A,
|
||||
B,
|
||||
this.getCenter(shape),
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
shape.rotation || 0
|
||||
).didIntersect;
|
||||
};
|
||||
|
||||
getBounds = (shape: T) => {
|
||||
return Utils.getFromCache(this.boundsCache, shape, () => {
|
||||
return Utils.getRotatedEllipseBounds(
|
||||
shape.point[0],
|
||||
shape.point[1],
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
0
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
override getRotatedBounds = (shape: T): TLBounds => {
|
||||
return Utils.getRotatedEllipseBounds(
|
||||
shape.point[0],
|
||||
shape.point[1],
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
shape.rotation
|
||||
);
|
||||
};
|
||||
|
||||
override hitTestBounds = (shape: T, bounds: TLBounds): boolean => {
|
||||
const shapeBounds = this.getBounds(shape);
|
||||
|
||||
return (
|
||||
Utils.boundsContained(shapeBounds, bounds) ||
|
||||
intersectEllipseBounds(
|
||||
this.getCenter(shape),
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
shape.rotation || 0,
|
||||
bounds
|
||||
).length > 0
|
||||
);
|
||||
};
|
||||
|
||||
override shouldRender = (prev: T, next: T): boolean => {
|
||||
return (
|
||||
next.radius !== prev.radius ||
|
||||
next.style !== prev.style ||
|
||||
next.label !== prev.label
|
||||
);
|
||||
};
|
||||
|
||||
override getCenter = (shape: T): number[] => {
|
||||
return Vec.add(shape.point, shape.radius);
|
||||
};
|
||||
|
||||
override getBindingPoint = <K extends TDShape>(
|
||||
shape: T,
|
||||
fromShape: K,
|
||||
point: number[],
|
||||
origin: number[],
|
||||
direction: number[],
|
||||
bindAnywhere: boolean
|
||||
) => {
|
||||
const expandedBounds = this.getExpandedBounds(shape);
|
||||
const center = this.getCenter(shape);
|
||||
let bindingPoint: number[];
|
||||
let distance: number;
|
||||
if (
|
||||
!Utils.pointInEllipse(
|
||||
point,
|
||||
center,
|
||||
shape.radius[0] + this.bindingDistance,
|
||||
shape.radius[1] + this.bindingDistance
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (bindAnywhere) {
|
||||
if (Vec.dist(point, this.getCenter(shape)) < 12) {
|
||||
bindingPoint = [0.5, 0.5];
|
||||
} else {
|
||||
bindingPoint = Vec.divV(
|
||||
Vec.sub(point, [expandedBounds.minX, expandedBounds.minY]),
|
||||
[expandedBounds.width, expandedBounds.height]
|
||||
);
|
||||
}
|
||||
distance = 0;
|
||||
} else {
|
||||
let intersection = intersectRayEllipse(
|
||||
origin,
|
||||
direction,
|
||||
center,
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
shape.rotation || 0
|
||||
).points.sort(
|
||||
(a, b) => Vec.dist(a, origin) - Vec.dist(b, origin)
|
||||
)[0];
|
||||
if (!intersection) {
|
||||
intersection = intersectLineSegmentEllipse(
|
||||
point,
|
||||
center,
|
||||
center,
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
shape.rotation || 0
|
||||
).points.sort(
|
||||
(a, b) => Vec.dist(a, point) - Vec.dist(b, point)
|
||||
)[0];
|
||||
}
|
||||
if (!intersection) {
|
||||
return undefined;
|
||||
}
|
||||
// The anchor is a point between the handle and the intersection
|
||||
const anchor = Vec.med(point, intersection);
|
||||
if (
|
||||
Vec.distanceToLineSegment(
|
||||
point,
|
||||
anchor,
|
||||
this.getCenter(shape)
|
||||
) < 12
|
||||
) {
|
||||
// If we're close to the center, snap to the center
|
||||
bindingPoint = [0.5, 0.5];
|
||||
} else {
|
||||
// Or else calculate a normalized point
|
||||
bindingPoint = Vec.divV(
|
||||
Vec.sub(anchor, [expandedBounds.minX, expandedBounds.minY]),
|
||||
[expandedBounds.width, expandedBounds.height]
|
||||
);
|
||||
}
|
||||
if (
|
||||
Utils.pointInEllipse(
|
||||
point,
|
||||
center,
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
shape.rotation || 0
|
||||
)
|
||||
) {
|
||||
// Pad the arrow out by 16 points
|
||||
distance = this.bindingDistance / 2;
|
||||
} else {
|
||||
// Find the distance between the point and the ellipse
|
||||
const innerIntersection = intersectLineSegmentEllipse(
|
||||
point,
|
||||
center,
|
||||
center,
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
shape.rotation || 0
|
||||
).points[0];
|
||||
if (!innerIntersection) return undefined;
|
||||
distance = Math.max(
|
||||
this.bindingDistance / 2,
|
||||
Vec.dist(point, innerIntersection)
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
point: bindingPoint,
|
||||
distance,
|
||||
};
|
||||
};
|
||||
|
||||
override transform = (
|
||||
shape: T,
|
||||
bounds: TLBounds,
|
||||
{ scaleX, scaleY, initialShape }: TransformInfo<T>
|
||||
): Partial<T> => {
|
||||
const { rotation = 0 } = initialShape;
|
||||
return {
|
||||
point: [bounds.minX, bounds.minY],
|
||||
radius: [bounds.width / 2, bounds.height / 2],
|
||||
rotation:
|
||||
(scaleX < 0 && scaleY >= 0) || (scaleY < 0 && scaleX >= 0)
|
||||
? -(rotation || 0)
|
||||
: rotation || 0,
|
||||
};
|
||||
};
|
||||
|
||||
override transformSingle = (shape: T, bounds: TLBounds): Partial<T> => {
|
||||
return {
|
||||
point: Vec.toFixed([bounds.minX, bounds.minY]),
|
||||
radius: Vec.div([bounds.width, bounds.height], 2),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const FullWrapper = styled('div')({ width: '100%', height: '100%' });
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as React from 'react';
|
||||
import { Utils } from '@tldraw/core';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle } from '../../shared';
|
||||
|
||||
interface EllipseSvgProps {
|
||||
radius: number[];
|
||||
style: ShapeStyles;
|
||||
isSelected: boolean;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const DashedEllipse = React.memo(function DashedEllipse({
|
||||
radius,
|
||||
style,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: EllipseSvgProps) {
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode);
|
||||
const sw = 1 + strokeWidth * 1.618;
|
||||
const rx = Math.max(0, radius[0] - sw / 2);
|
||||
const ry = Math.max(0, radius[1] - sw / 2);
|
||||
const perimeter = Utils.perimeterOfEllipse(rx, ry);
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
perimeter < 64 ? perimeter * 2 : perimeter,
|
||||
strokeWidth * 1.618,
|
||||
style.dash,
|
||||
4
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ellipse
|
||||
className={
|
||||
style.isFilled || isSelected
|
||||
? 'tl-fill-hitarea'
|
||||
: 'tl-stroke-hitarea'
|
||||
}
|
||||
cx={radius[0]}
|
||||
cy={radius[1]}
|
||||
rx={radius[0]}
|
||||
ry={radius[1]}
|
||||
/>
|
||||
<ellipse
|
||||
cx={radius[0]}
|
||||
cy={radius[1]}
|
||||
rx={rx}
|
||||
ry={ry}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth={sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
pointerEvents="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from 'react';
|
||||
import { getShapeStyle } from '../../shared';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import { getEllipseIndicatorPath, getEllipsePath } from '../ellipse-helpers';
|
||||
|
||||
interface EllipseSvgProps {
|
||||
id: string;
|
||||
radius: number[];
|
||||
style: ShapeStyles;
|
||||
isSelected: boolean;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const DrawEllipse = React.memo(function DrawEllipse({
|
||||
id,
|
||||
radius,
|
||||
style,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: EllipseSvgProps) {
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode);
|
||||
const innerPath = getEllipsePath(id, radius, style);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ellipse
|
||||
className={
|
||||
style.isFilled || isSelected
|
||||
? 'tl-fill-hitarea'
|
||||
: 'tl-stroke-hitarea'
|
||||
}
|
||||
cx={radius[0]}
|
||||
cy={radius[1]}
|
||||
rx={radius[0]}
|
||||
ry={radius[1]}
|
||||
/>
|
||||
{style.isFilled && (
|
||||
<path
|
||||
d={getEllipseIndicatorPath(id, radius, style)}
|
||||
stroke="none"
|
||||
fill={fill}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
d={innerPath}
|
||||
fill={stroke}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
pointerEvents="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Utils } from '@tldraw/core';
|
||||
import { getStrokeOutlinePoints, getStrokePoints } from 'perfect-freehand';
|
||||
import { EASINGS } from '@toeverything/components/board-types';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle } from '../shared/shape-styles';
|
||||
|
||||
export function getEllipseStrokePoints(
|
||||
id: string,
|
||||
radius: number[],
|
||||
style: ShapeStyles
|
||||
) {
|
||||
const { strokeWidth } = getShapeStyle(style);
|
||||
const getRandom = Utils.rng(id);
|
||||
const rx = radius[0] + getRandom() * strokeWidth * 2;
|
||||
const ry = radius[1] + getRandom() * strokeWidth * 2;
|
||||
const perimeter = Utils.perimeterOfEllipse(rx, ry);
|
||||
const points: number[][] = [];
|
||||
const start = Math.PI + Math.PI * getRandom();
|
||||
const extra = Math.abs(getRandom());
|
||||
const count = Math.max(16, perimeter / 10);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = EASINGS.easeInOutSine(i / (count + 1));
|
||||
const rads = start * 2 + Math.PI * (2 + extra) * t;
|
||||
const c = Math.cos(rads);
|
||||
const s = Math.sin(rads);
|
||||
points.push([
|
||||
rx * c + radius[0],
|
||||
ry * s + radius[1],
|
||||
t + 0.5 + getRandom() / 2,
|
||||
]);
|
||||
}
|
||||
return getStrokePoints(points, {
|
||||
size: 1 + strokeWidth * 2,
|
||||
thinning: 0.618,
|
||||
end: { taper: perimeter / 8 },
|
||||
start: { taper: perimeter / 12 },
|
||||
streamline: 0,
|
||||
simulatePressure: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function getEllipsePath(
|
||||
id: string,
|
||||
radius: number[],
|
||||
style: ShapeStyles
|
||||
) {
|
||||
const { strokeWidth } = getShapeStyle(style);
|
||||
const getRandom = Utils.rng(id);
|
||||
const rx = radius[0] + getRandom() * strokeWidth * 2;
|
||||
const ry = radius[1] + getRandom() * strokeWidth * 2;
|
||||
const perimeter = Utils.perimeterOfEllipse(rx, ry);
|
||||
return Utils.getSvgPathFromStroke(
|
||||
getStrokeOutlinePoints(getEllipseStrokePoints(id, radius, style), {
|
||||
size: 2 + strokeWidth * 2,
|
||||
thinning: 0.618,
|
||||
end: { taper: perimeter / 8 },
|
||||
start: { taper: perimeter / 12 },
|
||||
streamline: 0,
|
||||
simulatePressure: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function getEllipseIndicatorPath(
|
||||
id: string,
|
||||
radius: number[],
|
||||
style: ShapeStyles
|
||||
) {
|
||||
return Utils.getSvgPathFromStroke(
|
||||
getEllipseStrokePoints(id, radius, style).map(pt =>
|
||||
pt.point.slice(0, 2)
|
||||
),
|
||||
false
|
||||
);
|
||||
}
|
||||
1
libs/components/board-shapes/src/ellipse-util/index.ts
Normal file
1
libs/components/board-shapes/src/ellipse-util/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './EllipseUtil';
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from 'react';
|
||||
import { BINDING_DISTANCE } from '@toeverything/components/board-types';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle } from '../../shared';
|
||||
|
||||
interface RectangleSvgProps {
|
||||
id: string;
|
||||
style: ShapeStyles;
|
||||
isSelected: boolean;
|
||||
size: number[];
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const DrawFrame = React.memo(function DashedRectangle({
|
||||
id,
|
||||
style,
|
||||
size,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: RectangleSvgProps) {
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode);
|
||||
|
||||
const sw = 1 + strokeWidth * 1.618;
|
||||
|
||||
const w = Math.max(0, size[0] - sw / 2);
|
||||
const h = Math.max(0, size[1] - sw / 2);
|
||||
|
||||
return (
|
||||
<rect
|
||||
className={
|
||||
isSelected || style.isFilled
|
||||
? 'tl-fill-hitarea'
|
||||
: 'tl-stroke-hitarea'
|
||||
}
|
||||
x={sw / 2}
|
||||
y={sw / 2}
|
||||
width={w}
|
||||
height={h}
|
||||
strokeWidth={BINDING_DISTANCE}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import { BINDING_DISTANCE } from '@toeverything/components/board-types';
|
||||
|
||||
interface BindingIndicatorProps {
|
||||
strokeWidth: number;
|
||||
size: number[];
|
||||
}
|
||||
export function BindingIndicator({ strokeWidth, size }: BindingIndicatorProps) {
|
||||
return (
|
||||
<rect
|
||||
className="tl-binding-indicator"
|
||||
x={strokeWidth}
|
||||
y={strokeWidth}
|
||||
width={Math.max(0, size[0] - strokeWidth / 2)}
|
||||
height={Math.max(0, size[1] - strokeWidth / 2)}
|
||||
strokeWidth={BINDING_DISTANCE * 2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
147
libs/components/board-shapes/src/frame-util/frame-util.tsx
Normal file
147
libs/components/board-shapes/src/frame-util/frame-util.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import * as React from 'react';
|
||||
import { Utils, SVGContainer } from '@tldraw/core';
|
||||
import {
|
||||
FrameShape,
|
||||
DashStyle,
|
||||
TDShapeType,
|
||||
TDMeta,
|
||||
GHOSTED_OPACITY,
|
||||
LABEL_POINT,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TDShapeUtil } from '../TDShapeUtil';
|
||||
import {
|
||||
defaultStyle,
|
||||
getShapeStyle,
|
||||
getBoundsRectangle,
|
||||
transformRectangle,
|
||||
getFontStyle,
|
||||
transformSingleRectangle,
|
||||
} from '../shared';
|
||||
import { DrawFrame } from './components/draw-frame';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
type T = FrameShape;
|
||||
type E = HTMLDivElement;
|
||||
|
||||
export class FrameUtil extends TDShapeUtil<T, E> {
|
||||
type = TDShapeType.Frame as const;
|
||||
|
||||
override canBind = true;
|
||||
|
||||
override canClone = true;
|
||||
|
||||
override canEdit = true;
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
return Utils.deepMerge<T>(
|
||||
{
|
||||
id: 'id',
|
||||
type: TDShapeType.Frame,
|
||||
name: 'Frame',
|
||||
parentId: 'page',
|
||||
childIndex: 0,
|
||||
point: [0, 0],
|
||||
size: [1, 1],
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
label: '',
|
||||
labelPoint: [0.5, 0.5],
|
||||
workspace: props.workspace,
|
||||
},
|
||||
props
|
||||
);
|
||||
};
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
(
|
||||
{
|
||||
shape,
|
||||
isEditing,
|
||||
isBinding,
|
||||
isSelected,
|
||||
isGhost,
|
||||
meta,
|
||||
bounds,
|
||||
events,
|
||||
onShapeBlur,
|
||||
onShapeChange,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { id, size, style } = shape;
|
||||
return (
|
||||
<FullWrapper ref={ref} {...events}>
|
||||
<SVGContainer
|
||||
id={shape.id + '_svg'}
|
||||
opacity={1}
|
||||
fill={'#fff'}
|
||||
>
|
||||
<DrawFrame
|
||||
id={id}
|
||||
style={style}
|
||||
size={size}
|
||||
isSelected={isSelected}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
/>
|
||||
</SVGContainer>
|
||||
</FullWrapper>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
|
||||
const { id, style, size } = shape;
|
||||
|
||||
const styles = getShapeStyle(style, false);
|
||||
const sw = styles.strokeWidth;
|
||||
return (
|
||||
<rect
|
||||
x={sw}
|
||||
y={sw}
|
||||
rx={1}
|
||||
ry={1}
|
||||
width={Math.max(1, size[0] - sw * 2)}
|
||||
height={Math.max(1, size[1] - sw * 2)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
getBounds = (shape: T) => {
|
||||
return getBoundsRectangle(shape, this.boundsCache);
|
||||
};
|
||||
|
||||
override shouldRender = (prev: T, next: T) => {
|
||||
return (
|
||||
next.size !== prev.size ||
|
||||
next.style !== prev.style ||
|
||||
next.label !== prev.label
|
||||
);
|
||||
};
|
||||
|
||||
override transform = transformRectangle;
|
||||
|
||||
override transformSingle = transformSingleRectangle;
|
||||
|
||||
override hitTestPoint = (shape: T, point: number[]): boolean => {
|
||||
return false;
|
||||
};
|
||||
|
||||
override hitTestLineSegment = (
|
||||
shape: T,
|
||||
A: number[],
|
||||
B: number[]
|
||||
): boolean => {
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
const FullWrapper = styled('div')({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
'.tl-fill-hitarea': {
|
||||
fill: '#F7F9FF',
|
||||
},
|
||||
'.tl-stroke-hitarea': {
|
||||
fill: '#F7F9FF',
|
||||
},
|
||||
});
|
||||
1
libs/components/board-shapes/src/frame-util/index.ts
Normal file
1
libs/components/board-shapes/src/frame-util/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './frame-util';
|
||||
144
libs/components/board-shapes/src/group-util/group-util.tsx
Normal file
144
libs/components/board-shapes/src/group-util/group-util.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import * as React from 'react';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { Utils, SVGContainer } from '@tldraw/core';
|
||||
import { defaultStyle } from '../shared/shape-styles';
|
||||
import {
|
||||
TDShapeType,
|
||||
GroupShape,
|
||||
TDMeta,
|
||||
GHOSTED_OPACITY,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TDShapeUtil } from '../TDShapeUtil';
|
||||
import { getBoundsRectangle, commonColors } from '../shared';
|
||||
|
||||
type T = GroupShape;
|
||||
type E = SVGSVGElement;
|
||||
|
||||
export class GroupUtil extends TDShapeUtil<T, E> {
|
||||
type = TDShapeType.Group as const;
|
||||
|
||||
override canBind = true;
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
return Utils.deepMerge<T>(
|
||||
{
|
||||
id: 'id',
|
||||
type: TDShapeType.Group,
|
||||
name: 'Group',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
point: [0, 0],
|
||||
size: [100, 100],
|
||||
rotation: 0,
|
||||
children: [],
|
||||
style: defaultStyle,
|
||||
workspace: props.workspace,
|
||||
},
|
||||
props
|
||||
);
|
||||
};
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
({ shape, isBinding, isGhost, isHovered, isSelected, events }, ref) => {
|
||||
const { id, size } = shape;
|
||||
|
||||
const sw = 2;
|
||||
const w = Math.max(0, size[0] - sw / 2);
|
||||
const h = Math.max(0, size[1] - sw / 2);
|
||||
|
||||
const strokes: [number[], number[], number][] = [
|
||||
[[sw / 2, sw / 2], [w, sw / 2], w - sw / 2],
|
||||
[[w, sw / 2], [w, h], h - sw / 2],
|
||||
[[w, h], [sw / 2, h], w - sw / 2],
|
||||
[[sw / 2, h], [sw / 2, sw / 2], h - sw / 2],
|
||||
];
|
||||
|
||||
const paths = strokes.map(([start, end], i) => {
|
||||
return (
|
||||
<line
|
||||
key={id + '_' + i}
|
||||
x1={start[0]}
|
||||
y1={start[1]}
|
||||
x2={end[0]}
|
||||
y2={end[1]}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} {...events}>
|
||||
{isBinding && (
|
||||
<rect
|
||||
className="tl-binding-indicator"
|
||||
strokeWidth={this.bindingDistance}
|
||||
/>
|
||||
)}
|
||||
<g opacity={isGhost ? GHOSTED_OPACITY : 1}>
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={size[0]}
|
||||
height={size[1]}
|
||||
fill="transparent"
|
||||
pointerEvents="all"
|
||||
/>
|
||||
<ScaledLines
|
||||
stroke={commonColors.black}
|
||||
opacity={isHovered || isSelected ? 1 : 0}
|
||||
strokeLinecap="round"
|
||||
pointerEvents="stroke"
|
||||
>
|
||||
{paths}
|
||||
</ScaledLines>
|
||||
</g>
|
||||
</SVGContainer>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
|
||||
const { id, size } = shape;
|
||||
|
||||
const sw = 2;
|
||||
const w = Math.max(0, size[0] - sw / 2);
|
||||
const h = Math.max(0, size[1] - sw / 2);
|
||||
|
||||
const strokes: [number[], number[], number][] = [
|
||||
[[sw / 2, sw / 2], [w, sw / 2], w - sw / 2],
|
||||
[[w, sw / 2], [w, h], h - sw / 2],
|
||||
[[w, h], [sw / 2, h], w - sw / 2],
|
||||
[[sw / 2, h], [sw / 2, sw / 2], h - sw / 2],
|
||||
];
|
||||
|
||||
const paths = strokes.map(([start, end], i) => {
|
||||
return (
|
||||
<line
|
||||
key={id + '_' + i}
|
||||
x1={start[0]}
|
||||
y1={start[1]}
|
||||
x2={end[0]}
|
||||
y2={end[1]}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<ScaledLines strokeLinecap="round" pointerEvents="stroke">
|
||||
{paths}
|
||||
</ScaledLines>
|
||||
);
|
||||
});
|
||||
|
||||
getBounds = (shape: T) => {
|
||||
return getBoundsRectangle(shape, this.boundsCache);
|
||||
};
|
||||
|
||||
override shouldRender = (prev: T, next: T) => {
|
||||
return next.size !== prev.size || next.style !== prev.style;
|
||||
};
|
||||
}
|
||||
|
||||
const ScaledLines = styled('g')({
|
||||
strokeWidth: 'calc(1.5px * var(--tl-scale))',
|
||||
strokeDasharray: `calc(1px * var(--tl-scale)), calc(3px * var(--tl-scale))`,
|
||||
});
|
||||
1
libs/components/board-shapes/src/group-util/index.ts
Normal file
1
libs/components/board-shapes/src/group-util/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './group-util';
|
||||
294
libs/components/board-shapes/src/hexagon-util/HexagonUtil.tsx
Normal file
294
libs/components/board-shapes/src/hexagon-util/HexagonUtil.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import * as React from 'react';
|
||||
import { Utils, SVGContainer, TLBounds } from '@tldraw/core';
|
||||
import {
|
||||
HexagonShape,
|
||||
TDShapeType,
|
||||
TDMeta,
|
||||
TDShape,
|
||||
DashStyle,
|
||||
BINDING_DISTANCE,
|
||||
GHOSTED_OPACITY,
|
||||
LABEL_POINT,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TDShapeUtil } from '../TDShapeUtil';
|
||||
import {
|
||||
defaultStyle,
|
||||
getBoundsRectangle,
|
||||
transformRectangle,
|
||||
transformSingleRectangle,
|
||||
getFontStyle,
|
||||
TextLabel,
|
||||
getShapeStyle,
|
||||
} from '../shared';
|
||||
import {
|
||||
intersectBoundsPolygon,
|
||||
intersectLineSegmentPolyline,
|
||||
intersectRayLineSegment,
|
||||
} from '@tldraw/intersect';
|
||||
import Vec from '@tldraw/vec';
|
||||
import { getHexagonCentroid, getHexagonPoints } from './hexagon-helpers';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { DrawHexagon } from './components/DrawHexagon';
|
||||
import { DashedHexagon } from './components/DashedHexagon';
|
||||
import { HexagonBindingIndicator } from './components/HexagonBindingIndicator';
|
||||
|
||||
type T = HexagonShape;
|
||||
type E = HTMLDivElement;
|
||||
|
||||
export class HexagonUtil extends TDShapeUtil<T, E> {
|
||||
type = TDShapeType.Hexagon as const;
|
||||
|
||||
override canBind = true;
|
||||
|
||||
override canClone = true;
|
||||
|
||||
override canEdit = true;
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
return Utils.deepMerge<T>(
|
||||
{
|
||||
id: 'id',
|
||||
type: TDShapeType.Hexagon,
|
||||
name: 'Triangle',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
point: [0, 0],
|
||||
size: [1, 1],
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
label: '',
|
||||
labelPoint: [0.5, 0.5],
|
||||
workspace: props.workspace,
|
||||
},
|
||||
props
|
||||
);
|
||||
};
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
(
|
||||
{
|
||||
shape,
|
||||
bounds,
|
||||
isBinding,
|
||||
isEditing,
|
||||
isSelected,
|
||||
isGhost,
|
||||
meta,
|
||||
events,
|
||||
onShapeChange,
|
||||
onShapeBlur,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const {
|
||||
id,
|
||||
label = '',
|
||||
size,
|
||||
style,
|
||||
labelPoint = LABEL_POINT,
|
||||
} = shape;
|
||||
const font = getFontStyle(style);
|
||||
const styles = getShapeStyle(style, meta.isDarkMode);
|
||||
const Component =
|
||||
style.dash === DashStyle.Draw ? DrawHexagon : DashedHexagon;
|
||||
const handleLabelChange = React.useCallback(
|
||||
(label: string) => onShapeChange?.({ id, label }),
|
||||
[onShapeChange]
|
||||
);
|
||||
const offsetY = React.useMemo(() => {
|
||||
const center = Vec.div(size, 2);
|
||||
const centroid = getHexagonCentroid(size);
|
||||
return (centroid[1] - center[1]) * 0.72;
|
||||
}, [size]);
|
||||
return (
|
||||
<FullWrapper ref={ref} {...events}>
|
||||
<TextLabel
|
||||
font={font}
|
||||
text={label}
|
||||
color={styles.stroke}
|
||||
offsetX={(labelPoint[0] - 0.5) * bounds.width}
|
||||
offsetY={
|
||||
offsetY + (labelPoint[1] - 0.5) * bounds.height
|
||||
}
|
||||
isEditing={isEditing}
|
||||
onChange={handleLabelChange}
|
||||
onBlur={onShapeBlur}
|
||||
/>
|
||||
<SVGContainer
|
||||
id={shape.id + '_svg'}
|
||||
opacity={isGhost ? GHOSTED_OPACITY : 1}
|
||||
>
|
||||
{isBinding && <HexagonBindingIndicator size={size} />}
|
||||
<Component
|
||||
id={id}
|
||||
style={style}
|
||||
size={size}
|
||||
isSelected={isSelected}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
/>
|
||||
</SVGContainer>
|
||||
</FullWrapper>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
|
||||
const { size } = shape;
|
||||
return <polygon points={getHexagonPoints(size).join()} />;
|
||||
});
|
||||
|
||||
private get_points(shape: T) {
|
||||
const {
|
||||
rotation = 0,
|
||||
point: [x, y],
|
||||
size: [w, h],
|
||||
} = shape;
|
||||
return [
|
||||
[x + w / 2, y],
|
||||
[x, y + h],
|
||||
[x + w, y + h],
|
||||
].map(pt => Vec.rotWith(pt, this.getCenter(shape), rotation));
|
||||
}
|
||||
|
||||
override shouldRender = (prev: T, next: T) => {
|
||||
return (
|
||||
next.size !== prev.size ||
|
||||
next.style !== prev.style ||
|
||||
next.label !== prev.label
|
||||
);
|
||||
};
|
||||
|
||||
getBounds = (shape: T) => {
|
||||
return getBoundsRectangle(shape, this.boundsCache);
|
||||
};
|
||||
|
||||
override getExpandedBounds = (shape: T) => {
|
||||
return Utils.getBoundsFromPoints(
|
||||
getHexagonPoints(shape.size, this.bindingDistance).map(pt =>
|
||||
Vec.add(pt, shape.point)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
override hitTestLineSegment = (
|
||||
shape: T,
|
||||
A: number[],
|
||||
B: number[]
|
||||
): boolean => {
|
||||
return intersectLineSegmentPolyline(A, B, this.get_points(shape))
|
||||
.didIntersect;
|
||||
};
|
||||
|
||||
override hitTestBounds = (shape: T, bounds: TLBounds): boolean => {
|
||||
return (
|
||||
Utils.boundsContained(this.getBounds(shape), bounds) ||
|
||||
intersectBoundsPolygon(bounds, this.get_points(shape)).length > 0
|
||||
);
|
||||
};
|
||||
|
||||
override getBindingPoint = <K extends TDShape>(
|
||||
shape: T,
|
||||
fromShape: K,
|
||||
point: number[],
|
||||
origin: number[],
|
||||
direction: number[],
|
||||
bindAnywhere: boolean
|
||||
) => {
|
||||
// Algorithm time! We need to find the binding point (a normalized point inside of the shape, or around the shape, where the arrow will point to) and the distance from the binding shape to the anchor.
|
||||
|
||||
const expandedBounds = this.getExpandedBounds(shape);
|
||||
|
||||
if (!Utils.pointInBounds(point, expandedBounds)) return;
|
||||
|
||||
const points = getHexagonPoints(shape.size).map(pt =>
|
||||
Vec.add(pt, shape.point)
|
||||
);
|
||||
|
||||
const expandedPoints = getHexagonPoints(
|
||||
shape.size,
|
||||
this.bindingDistance
|
||||
).map(pt => Vec.add(pt, shape.point));
|
||||
|
||||
const closestDistanceToEdge = Utils.pointsToLineSegments(points, true)
|
||||
.map(([a, b]) => Vec.distanceToLineSegment(a, b, point))
|
||||
.sort((a, b) => a - b)[0];
|
||||
|
||||
if (
|
||||
!(
|
||||
Utils.pointInPolygon(point, expandedPoints) ||
|
||||
closestDistanceToEdge < this.bindingDistance
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
const intersections = Utils.pointsToLineSegments(
|
||||
expandedPoints.concat([expandedPoints[0]])
|
||||
)
|
||||
.map(segment =>
|
||||
intersectRayLineSegment(
|
||||
origin,
|
||||
direction,
|
||||
segment[0],
|
||||
segment[1]
|
||||
)
|
||||
)
|
||||
.filter(intersection => intersection.didIntersect)
|
||||
.flatMap(intersection => intersection.points);
|
||||
|
||||
if (!intersections.length) return;
|
||||
|
||||
// The center of the triangle
|
||||
const center = Vec.add(getHexagonCentroid(shape.size), shape.point);
|
||||
|
||||
// Find furthest intersection between ray from origin through point and expanded bounds. TODO: What if the shape has a curve? In that case, should we intersect the circle-from-three-points instead?
|
||||
const intersection = intersections.sort(
|
||||
(a, b) => Vec.dist(b, origin) - Vec.dist(a, origin)
|
||||
)[0];
|
||||
|
||||
// The point between the handle and the intersection
|
||||
const middlePoint = Vec.med(point, intersection);
|
||||
|
||||
let anchor: number[];
|
||||
let distance: number;
|
||||
|
||||
if (bindAnywhere) {
|
||||
anchor =
|
||||
Vec.dist(point, center) < BINDING_DISTANCE / 2 ? center : point;
|
||||
distance = 0;
|
||||
} else {
|
||||
if (
|
||||
Vec.distanceToLineSegment(point, middlePoint, center) <
|
||||
BINDING_DISTANCE / 2
|
||||
) {
|
||||
anchor = center;
|
||||
} else {
|
||||
anchor = middlePoint;
|
||||
}
|
||||
|
||||
if (Utils.pointInPolygon(point, points)) {
|
||||
distance = this.bindingDistance;
|
||||
} else {
|
||||
distance = Math.max(
|
||||
this.bindingDistance,
|
||||
closestDistanceToEdge
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const bindingPoint = Vec.divV(
|
||||
Vec.sub(anchor, [expandedBounds.minX, expandedBounds.minY]),
|
||||
[expandedBounds.width, expandedBounds.height]
|
||||
);
|
||||
|
||||
return {
|
||||
point: Vec.clampV(bindingPoint, 0, 1),
|
||||
distance,
|
||||
};
|
||||
};
|
||||
|
||||
override transform = transformRectangle;
|
||||
|
||||
override transformSingle = transformSingleRectangle;
|
||||
}
|
||||
|
||||
const FullWrapper = styled('div')({ width: '100%', height: '100%' });
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import { Utils } from '@tldraw/core';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle } from '../../shared';
|
||||
import { getHexagonPoints } from '../hexagon-helpers';
|
||||
import Vec from '@tldraw/vec';
|
||||
|
||||
interface HexagonSvgProps {
|
||||
id: string;
|
||||
size: number[];
|
||||
style: ShapeStyles;
|
||||
isSelected: boolean;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const DashedHexagon = React.memo(function DashedHexagon({
|
||||
id,
|
||||
size,
|
||||
style,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: HexagonSvgProps) {
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode);
|
||||
const sw = 1 + strokeWidth * 1.618;
|
||||
const points = getHexagonPoints(size);
|
||||
const sides = Utils.pointsToLineSegments(points, true);
|
||||
const paths = sides.map(([start, end], i) => {
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
Vec.dist(start, end),
|
||||
strokeWidth * 1.618,
|
||||
style.dash
|
||||
);
|
||||
|
||||
return (
|
||||
<line
|
||||
key={id + '_' + i}
|
||||
x1={start[0]}
|
||||
y1={start[1]}
|
||||
x2={end[0]}
|
||||
y2={end[1]}
|
||||
stroke={stroke}
|
||||
strokeWidth={sw}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const bgPath = points.join();
|
||||
|
||||
return (
|
||||
<>
|
||||
<polygon
|
||||
className={
|
||||
style.isFilled || isSelected
|
||||
? 'tl-fill-hitarea'
|
||||
: 'tl-stroke-hitarea'
|
||||
}
|
||||
points={bgPath}
|
||||
/>
|
||||
{style.isFilled && (
|
||||
<polygon fill={fill} points={bgPath} pointerEvents="none" />
|
||||
)}
|
||||
<g pointerEvents="stroke">{paths}</g>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react';
|
||||
import { getShapeStyle } from '../../shared';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import {
|
||||
getHexagonIndicatorPathTDSnapshot,
|
||||
getHexagonPath,
|
||||
} from '../hexagon-helpers';
|
||||
|
||||
interface HexagonSvgProps {
|
||||
id: string;
|
||||
size: number[];
|
||||
style: ShapeStyles;
|
||||
isSelected: boolean;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const DrawHexagon = React.memo(function DrawTriangle({
|
||||
id,
|
||||
size,
|
||||
style,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: HexagonSvgProps) {
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode);
|
||||
const path_td_snapshot = getHexagonPath(id, size, style);
|
||||
const indicatorPath = getHexagonIndicatorPathTDSnapshot(id, size, style);
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
className={
|
||||
style.isFilled || isSelected
|
||||
? 'tl-fill-hitarea'
|
||||
: 'tl-stroke-hitarea'
|
||||
}
|
||||
d={indicatorPath}
|
||||
/>
|
||||
{style.isFilled && (
|
||||
<path d={indicatorPath} fill={fill} pointerEvents="none" />
|
||||
)}
|
||||
<path
|
||||
d={path_td_snapshot}
|
||||
fill={stroke}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import { BINDING_DISTANCE } from '@toeverything/components/board-types';
|
||||
import { getHexagonPoints } from '../hexagon-helpers';
|
||||
|
||||
interface TriangleBindingIndicatorProps {
|
||||
size: number[];
|
||||
}
|
||||
|
||||
export function HexagonBindingIndicator({
|
||||
size,
|
||||
}: TriangleBindingIndicatorProps) {
|
||||
const trianglePoints = getHexagonPoints(size).join();
|
||||
return (
|
||||
<polygon
|
||||
className="tl-binding-indicator"
|
||||
points={trianglePoints}
|
||||
strokeWidth={BINDING_DISTANCE * 2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
115
libs/components/board-shapes/src/hexagon-util/hexagon-helpers.ts
Normal file
115
libs/components/board-shapes/src/hexagon-util/hexagon-helpers.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Utils } from '@tldraw/core';
|
||||
import Vec from '@tldraw/vec';
|
||||
import getStroke, { getStrokePoints } from 'perfect-freehand';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle, getOffsetPolygon } from '../shared';
|
||||
function getPonits(w: number, h: number) {
|
||||
return [
|
||||
[w / 5, 0],
|
||||
[(w / 5) * 4, 0],
|
||||
[w, h / 2],
|
||||
[(w / 5) * 4, h],
|
||||
[w / 5, h],
|
||||
[0, h / 2],
|
||||
];
|
||||
}
|
||||
|
||||
export function getHexagonPoints(size: number[], offset = 0, rotation = 0) {
|
||||
const [w, h] = size;
|
||||
let points = getPonits(w, h);
|
||||
if (offset) points = getOffsetPolygon(points, offset);
|
||||
if (rotation)
|
||||
points = points.map(pt => Vec.rotWith(pt, [w / 2, h / 2], rotation));
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
export function getHexagonCentroid(size: number[]) {
|
||||
const [w, h] = size;
|
||||
const points = getPonits(w, h);
|
||||
return [
|
||||
(points[0][0] + points[1][0] + points[2][0]) / 3,
|
||||
(points[0][1] + points[1][1] + points[2][1]) / 3,
|
||||
];
|
||||
}
|
||||
|
||||
function getHexagonDrawPoints(id: string, size: number[], strokeWidth: number) {
|
||||
const [w, h] = size;
|
||||
const getRandom = Utils.rng(id);
|
||||
// Random corner offsets
|
||||
const offsets = Array.from(Array(6)).map(() => {
|
||||
return [
|
||||
getRandom() * strokeWidth * 0.75,
|
||||
getRandom() * strokeWidth * 0.75,
|
||||
];
|
||||
});
|
||||
// Corners
|
||||
const point = getPonits(w, h);
|
||||
const corners = [
|
||||
Vec.add(point[0], offsets[0]),
|
||||
Vec.add(point[1], offsets[1]),
|
||||
Vec.add(point[2], offsets[2]),
|
||||
Vec.add(point[3], offsets[3]),
|
||||
Vec.add(point[4], offsets[4]),
|
||||
Vec.add(point[5], offsets[5]),
|
||||
];
|
||||
|
||||
// Which side to start drawing first
|
||||
const rm = Math.round(Math.abs(getRandom() * 2 * 3));
|
||||
// Number of points per side
|
||||
// Inset each line by the corner radii and let the freehand algo
|
||||
// interpolate points for the corners.
|
||||
const lines = Utils.rotateArray(
|
||||
[
|
||||
Vec.pointsBetween(corners[0], corners[1], 32),
|
||||
Vec.pointsBetween(corners[1], corners[2], 32),
|
||||
Vec.pointsBetween(corners[2], corners[3], 32),
|
||||
Vec.pointsBetween(corners[3], corners[4], 32),
|
||||
Vec.pointsBetween(corners[4], corners[5], 32),
|
||||
Vec.pointsBetween(corners[5], corners[0], 32),
|
||||
],
|
||||
rm
|
||||
);
|
||||
// For the final points, include the first half of the first line again,
|
||||
// so that the line wraps around and avoids ending on a sharp corner.
|
||||
// This has a bit of finesse and magic—if you change the points between
|
||||
// function, then you'll likely need to change this one too.
|
||||
const points = [...lines.flat(), ...lines[0]];
|
||||
return {
|
||||
points,
|
||||
};
|
||||
}
|
||||
|
||||
function getDrawStrokeInfo(id: string, size: number[], style: ShapeStyles) {
|
||||
const { strokeWidth } = getShapeStyle(style);
|
||||
const { points } = getHexagonDrawPoints(id, size, strokeWidth);
|
||||
const options = {
|
||||
size: strokeWidth,
|
||||
thinning: 0.65,
|
||||
streamline: 0.3,
|
||||
smoothing: 1,
|
||||
simulatePressure: false,
|
||||
last: true,
|
||||
};
|
||||
return { points, options };
|
||||
}
|
||||
|
||||
export function getHexagonPath(id: string, size: number[], style: ShapeStyles) {
|
||||
const { points, options } = getDrawStrokeInfo(id, size, style);
|
||||
const stroke = getStroke(points, options);
|
||||
return Utils.getSvgPathFromStroke(stroke);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export function getHexagonIndicatorPathTDSnapshot(
|
||||
id: string,
|
||||
size: number[],
|
||||
style: ShapeStyles
|
||||
) {
|
||||
const { points, options } = getDrawStrokeInfo(id, size, style);
|
||||
const strokePoints = getStrokePoints(points, options);
|
||||
return Utils.getSvgPathFromStroke(
|
||||
strokePoints.map(pt => pt.point.slice(0, 2)),
|
||||
false
|
||||
);
|
||||
}
|
||||
1
libs/components/board-shapes/src/hexagon-util/index.ts
Normal file
1
libs/components/board-shapes/src/hexagon-util/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './HexagonUtil';
|
||||
227
libs/components/board-shapes/src/image-util/image-util.tsx
Normal file
227
libs/components/board-shapes/src/image-util/image-util.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import * as React from 'react';
|
||||
import { Utils, HTMLContainer } from '@tldraw/core';
|
||||
import {
|
||||
TDShapeType,
|
||||
TDMeta,
|
||||
ImageShape,
|
||||
TDImageAsset,
|
||||
GHOSTED_OPACITY,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TDShapeUtil } from '../TDShapeUtil';
|
||||
import {
|
||||
defaultStyle,
|
||||
getBoundsRectangle,
|
||||
transformRectangle,
|
||||
transformSingleRectangle,
|
||||
} from '../shared';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
type T = ImageShape;
|
||||
type E = HTMLDivElement;
|
||||
|
||||
export class ImageUtil extends TDShapeUtil<T, E> {
|
||||
type = TDShapeType.Image as const;
|
||||
|
||||
override canBind = true;
|
||||
|
||||
override canClone = true;
|
||||
|
||||
override isAspectRatioLocked = true;
|
||||
|
||||
override showCloneHandles = true;
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
return Utils.deepMerge<T>(
|
||||
{
|
||||
id: 'image',
|
||||
type: TDShapeType.Image,
|
||||
name: 'Image',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
point: [0, 0],
|
||||
size: [1, 1],
|
||||
rotation: 0,
|
||||
style: { ...defaultStyle, isFilled: true },
|
||||
assetId: 'assetId',
|
||||
workspace: props.workspace,
|
||||
},
|
||||
props
|
||||
);
|
||||
};
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
(
|
||||
{
|
||||
shape,
|
||||
asset = { src: '' },
|
||||
isBinding,
|
||||
isGhost,
|
||||
meta,
|
||||
events,
|
||||
onShapeChange,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { size, style } = shape;
|
||||
|
||||
const rImage = React.useRef<HTMLImageElement>(null);
|
||||
const rWrapper = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const wrapper = rWrapper.current;
|
||||
if (!wrapper) return;
|
||||
const [width, height] = size;
|
||||
wrapper.style.width = `${width}px`;
|
||||
wrapper.style.height = `${height}px`;
|
||||
}, [size]);
|
||||
|
||||
return (
|
||||
<HTMLContainer ref={ref} {...events}>
|
||||
{isBinding && (
|
||||
<div
|
||||
className="tl-binding-indicator"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `calc(${-this
|
||||
.bindingDistance}px * var(--tl-zoom))`,
|
||||
left: `calc(${-this
|
||||
.bindingDistance}px * var(--tl-zoom))`,
|
||||
width: `calc(100% + ${
|
||||
this.bindingDistance * 2
|
||||
}px * var(--tl-zoom))`,
|
||||
height: `calc(100% + ${
|
||||
this.bindingDistance * 2
|
||||
}px * var(--tl-zoom))`,
|
||||
backgroundColor: 'var(--tl-selectFill)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Wrapper
|
||||
ref={rWrapper}
|
||||
isDarkMode={meta.isDarkMode} //
|
||||
isFilled={style.isFilled}
|
||||
isGhost={isGhost}
|
||||
>
|
||||
<ImageElement
|
||||
id={shape.id + '_image'}
|
||||
ref={rImage}
|
||||
src={(asset as TDImageAsset).src}
|
||||
alt="tl_image_asset"
|
||||
draggable={false}
|
||||
// onLoad={onImageLoad}
|
||||
/>
|
||||
</Wrapper>
|
||||
</HTMLContainer>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
|
||||
const {
|
||||
size: [width, height],
|
||||
} = shape;
|
||||
|
||||
return (
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
rx={2}
|
||||
ry={2}
|
||||
width={Math.max(1, width)}
|
||||
height={Math.max(1, height)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
getBounds = (shape: T) => {
|
||||
return getBoundsRectangle(shape, this.boundsCache);
|
||||
};
|
||||
|
||||
override shouldRender = (prev: T, next: T) => {
|
||||
return next.size !== prev.size || next.style !== prev.style;
|
||||
};
|
||||
|
||||
override transform = transformRectangle;
|
||||
|
||||
override transformSingle = transformSingleRectangle;
|
||||
|
||||
override getSvgElement = (shape: ImageShape) => {
|
||||
const bounds = this.getBounds(shape);
|
||||
const elm = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'image'
|
||||
);
|
||||
elm.setAttribute('width', `${bounds.width}`);
|
||||
elm.setAttribute('height', `${bounds.height}`);
|
||||
elm.setAttribute('xmlns:xlink', `http://www.w3.org/1999/xlink`);
|
||||
return elm;
|
||||
};
|
||||
}
|
||||
|
||||
const Wrapper = styled('div')<{
|
||||
isDarkMode: boolean;
|
||||
isFilled: boolean;
|
||||
isGhost: boolean;
|
||||
}>({
|
||||
pointerEvents: 'all',
|
||||
position: 'relative',
|
||||
fontFamily: 'sans-serif',
|
||||
fontSize: '2em',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
borderRadius: '3px',
|
||||
perspective: '800px',
|
||||
overflow: 'hidden',
|
||||
p: {
|
||||
userSelect: 'none',
|
||||
},
|
||||
img: {
|
||||
userSelect: 'none',
|
||||
},
|
||||
variants: {
|
||||
isGhost: {
|
||||
false: { opacity: 1 },
|
||||
true: { transition: 'opacity .2s', opacity: GHOSTED_OPACITY },
|
||||
},
|
||||
isFilled: {
|
||||
true: {},
|
||||
false: {},
|
||||
},
|
||||
isDarkMode: {
|
||||
true: {},
|
||||
false: {},
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
isFilled: true,
|
||||
isDarkMode: true,
|
||||
css: {
|
||||
boxShadow:
|
||||
'2px 3px 12px -2px rgba(0,0,0,.3), 1px 1px 4px rgba(0,0,0,.3), 1px 1px 2px rgba(0,0,0,.3)',
|
||||
},
|
||||
},
|
||||
{
|
||||
isFilled: true,
|
||||
isDarkMode: false,
|
||||
css: {
|
||||
boxShadow:
|
||||
'2px 3px 12px -2px rgba(0,0,0,.2), 1px 1px 4px rgba(0,0,0,.16), 1px 1px 2px rgba(0,0,0,.16)',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const ImageElement = styled('img')({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
minWidth: '100%',
|
||||
pointerEvents: 'none',
|
||||
objectFit: 'cover',
|
||||
userSelect: 'none',
|
||||
borderRadius: 2,
|
||||
});
|
||||
1
libs/components/board-shapes/src/image-util/index.ts
Normal file
1
libs/components/board-shapes/src/image-util/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './image-util';
|
||||
60
libs/components/board-shapes/src/index.ts
Normal file
60
libs/components/board-shapes/src/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { TDShape, TDShapeType } from '@toeverything/components/board-types';
|
||||
import type { TDShapeUtil } from './TDShapeUtil';
|
||||
import { RectangleUtil } from './rectangle-util';
|
||||
import { TriangleUtil } from './triangle-util';
|
||||
import { HexagonUtil } from './hexagon-util';
|
||||
import { ArrowUtil } from './arrow-util';
|
||||
import { DrawUtil } from './draw-util';
|
||||
import { EllipseUtil } from './ellipse-util';
|
||||
import { GroupUtil } from './group-util';
|
||||
import { ImageUtil } from './image-util';
|
||||
import { VideoUtil } from './video-util';
|
||||
import { EditorUtil } from './editor-util';
|
||||
import { PentagramUtil } from './pentagram-util';
|
||||
import { WhiteArrowUtil } from './white-arrow-util';
|
||||
import { FrameUtil } from './frame-util';
|
||||
|
||||
export { TDShapeUtil } from './TDShapeUtil';
|
||||
|
||||
export const Rectangle = new RectangleUtil();
|
||||
export const Triangle = new TriangleUtil();
|
||||
export const Hexagon = new HexagonUtil();
|
||||
export const Pentagram = new PentagramUtil();
|
||||
export const WhiteArrow = new WhiteArrowUtil();
|
||||
export const Arrow = new ArrowUtil();
|
||||
export const Draw = new DrawUtil();
|
||||
export const Ellipse = new EllipseUtil();
|
||||
const Group = new GroupUtil();
|
||||
const Image = new ImageUtil();
|
||||
const Video = new VideoUtil();
|
||||
export const Frame = new FrameUtil();
|
||||
|
||||
export const Editor = new EditorUtil();
|
||||
|
||||
export const shapeUtils = {
|
||||
[TDShapeType.Rectangle]: Rectangle,
|
||||
[TDShapeType.Frame]: Frame,
|
||||
[TDShapeType.Triangle]: Triangle,
|
||||
[TDShapeType.Pentagram]: Pentagram,
|
||||
[TDShapeType.Hexagon]: Hexagon,
|
||||
[TDShapeType.WhiteArrow]: WhiteArrow,
|
||||
[TDShapeType.Arrow]: Arrow,
|
||||
[TDShapeType.Draw]: Draw,
|
||||
[TDShapeType.Ellipse]: Ellipse,
|
||||
[TDShapeType.Group]: Group,
|
||||
[TDShapeType.Image]: Image,
|
||||
[TDShapeType.Video]: Video,
|
||||
[TDShapeType.Editor]: Editor,
|
||||
};
|
||||
|
||||
export const getShapeUtil = <T extends TDShape>(
|
||||
shape: T | T['type']
|
||||
): TDShapeUtil<T> | undefined => {
|
||||
if (typeof shape === 'string')
|
||||
return shapeUtils[shape] as unknown as TDShapeUtil<T>;
|
||||
return shapeUtils[shape.type] as unknown as TDShapeUtil<T>;
|
||||
};
|
||||
|
||||
export { getTrianglePoints } from './triangle-util/triangle-helpers';
|
||||
export { defaultStyle } from './shared/shape-styles';
|
||||
export { clearPrevSize } from './shared/get-text-size';
|
||||
@@ -0,0 +1,294 @@
|
||||
import * as React from 'react';
|
||||
import { Utils, SVGContainer, TLBounds } from '@tldraw/core';
|
||||
import {
|
||||
PentagramShape,
|
||||
TDShapeType,
|
||||
TDMeta,
|
||||
TDShape,
|
||||
DashStyle,
|
||||
BINDING_DISTANCE,
|
||||
GHOSTED_OPACITY,
|
||||
LABEL_POINT,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TDShapeUtil } from '../TDShapeUtil';
|
||||
import {
|
||||
defaultStyle,
|
||||
getBoundsRectangle,
|
||||
transformRectangle,
|
||||
transformSingleRectangle,
|
||||
getFontStyle,
|
||||
TextLabel,
|
||||
getShapeStyle,
|
||||
} from '../shared';
|
||||
import {
|
||||
intersectBoundsPolygon,
|
||||
intersectLineSegmentPolyline,
|
||||
intersectRayLineSegment,
|
||||
} from '@tldraw/intersect';
|
||||
import Vec from '@tldraw/vec';
|
||||
import { getPentagramCentroid, getPentagramPoints } from './pentagram-helpers';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { DrawPentagram } from './components/DrawPentagram';
|
||||
import { DashedPentagram } from './components/DashedPentagram';
|
||||
import { PentagramBindingIndicator } from './components/PentagramBindingIndicator';
|
||||
|
||||
type T = PentagramShape;
|
||||
type E = HTMLDivElement;
|
||||
|
||||
export class PentagramUtil extends TDShapeUtil<T, E> {
|
||||
type = TDShapeType.Pentagram as const;
|
||||
|
||||
override canBind = true;
|
||||
|
||||
override canClone = true;
|
||||
|
||||
override canEdit = true;
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
return Utils.deepMerge<T>(
|
||||
{
|
||||
id: 'id',
|
||||
type: TDShapeType.Pentagram,
|
||||
name: 'Triangle',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
point: [0, 0],
|
||||
size: [1, 1],
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
label: '',
|
||||
labelPoint: [0.5, 0.5],
|
||||
workspace: props.workspace,
|
||||
},
|
||||
props
|
||||
);
|
||||
};
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
(
|
||||
{
|
||||
shape,
|
||||
bounds,
|
||||
isBinding,
|
||||
isEditing,
|
||||
isSelected,
|
||||
isGhost,
|
||||
meta,
|
||||
events,
|
||||
onShapeChange,
|
||||
onShapeBlur,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const {
|
||||
id,
|
||||
label = '',
|
||||
size,
|
||||
style,
|
||||
labelPoint = LABEL_POINT,
|
||||
} = shape;
|
||||
const font = getFontStyle(style);
|
||||
const styles = getShapeStyle(style, meta.isDarkMode);
|
||||
const Component =
|
||||
style.dash === DashStyle.Draw ? DrawPentagram : DashedPentagram;
|
||||
const handleLabelChange = React.useCallback(
|
||||
(label: string) => onShapeChange?.({ id, label }),
|
||||
[onShapeChange]
|
||||
);
|
||||
const offsetY = React.useMemo(() => {
|
||||
const center = Vec.div(size, 2);
|
||||
const centroid = getPentagramCentroid(size);
|
||||
return (centroid[1] - center[1]) * 0.72;
|
||||
}, [size]);
|
||||
return (
|
||||
<FullWrapper ref={ref} {...events}>
|
||||
<TextLabel
|
||||
font={font}
|
||||
text={label}
|
||||
color={styles.stroke}
|
||||
offsetX={(labelPoint[0] - 0.5) * bounds.width}
|
||||
offsetY={
|
||||
offsetY + (labelPoint[1] - 0.5) * bounds.height
|
||||
}
|
||||
isEditing={isEditing}
|
||||
onChange={handleLabelChange}
|
||||
onBlur={onShapeBlur}
|
||||
/>
|
||||
<SVGContainer
|
||||
id={shape.id + '_svg'}
|
||||
opacity={isGhost ? GHOSTED_OPACITY : 1}
|
||||
>
|
||||
{isBinding && <PentagramBindingIndicator size={size} />}
|
||||
<Component
|
||||
id={id}
|
||||
style={style}
|
||||
size={size}
|
||||
isSelected={isSelected}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
/>
|
||||
</SVGContainer>
|
||||
</FullWrapper>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
|
||||
const { size } = shape;
|
||||
return <polygon points={getPentagramPoints(size).join()} />;
|
||||
});
|
||||
|
||||
private get_points(shape: T) {
|
||||
const {
|
||||
rotation = 0,
|
||||
point: [x, y],
|
||||
size: [w, h],
|
||||
} = shape;
|
||||
return [
|
||||
[x + w / 2, y],
|
||||
[x, y + h],
|
||||
[x + w, y + h],
|
||||
].map(pt => Vec.rotWith(pt, this.getCenter(shape), rotation));
|
||||
}
|
||||
|
||||
override shouldRender = (prev: T, next: T) => {
|
||||
return (
|
||||
next.size !== prev.size ||
|
||||
next.style !== prev.style ||
|
||||
next.label !== prev.label
|
||||
);
|
||||
};
|
||||
|
||||
getBounds = (shape: T) => {
|
||||
return getBoundsRectangle(shape, this.boundsCache);
|
||||
};
|
||||
|
||||
override getExpandedBounds = (shape: T) => {
|
||||
return Utils.getBoundsFromPoints(
|
||||
getPentagramPoints(shape.size, this.bindingDistance).map(pt =>
|
||||
Vec.add(pt, shape.point)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
override hitTestLineSegment = (
|
||||
shape: T,
|
||||
A: number[],
|
||||
B: number[]
|
||||
): boolean => {
|
||||
return intersectLineSegmentPolyline(A, B, this.get_points(shape))
|
||||
.didIntersect;
|
||||
};
|
||||
|
||||
override hitTestBounds = (shape: T, bounds: TLBounds): boolean => {
|
||||
return (
|
||||
Utils.boundsContained(this.getBounds(shape), bounds) ||
|
||||
intersectBoundsPolygon(bounds, this.get_points(shape)).length > 0
|
||||
);
|
||||
};
|
||||
|
||||
override getBindingPoint = <K extends TDShape>(
|
||||
shape: T,
|
||||
fromShape: K,
|
||||
point: number[],
|
||||
origin: number[],
|
||||
direction: number[],
|
||||
bindAnywhere: boolean
|
||||
) => {
|
||||
// Algorithm time! We need to find the binding point (a normalized point inside of the shape, or around the shape, where the arrow will point to) and the distance from the binding shape to the anchor.
|
||||
|
||||
const expandedBounds = this.getExpandedBounds(shape);
|
||||
|
||||
if (!Utils.pointInBounds(point, expandedBounds)) return;
|
||||
|
||||
const points = getPentagramPoints(shape.size).map(pt =>
|
||||
Vec.add(pt, shape.point)
|
||||
);
|
||||
|
||||
const expandedPoints = getPentagramPoints(
|
||||
shape.size,
|
||||
this.bindingDistance
|
||||
).map(pt => Vec.add(pt, shape.point));
|
||||
|
||||
const closestDistanceToEdge = Utils.pointsToLineSegments(points, true)
|
||||
.map(([a, b]) => Vec.distanceToLineSegment(a, b, point))
|
||||
.sort((a, b) => a - b)[0];
|
||||
|
||||
if (
|
||||
!(
|
||||
Utils.pointInPolygon(point, expandedPoints) ||
|
||||
closestDistanceToEdge < this.bindingDistance
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
const intersections = Utils.pointsToLineSegments(
|
||||
expandedPoints.concat([expandedPoints[0]])
|
||||
)
|
||||
.map(segment =>
|
||||
intersectRayLineSegment(
|
||||
origin,
|
||||
direction,
|
||||
segment[0],
|
||||
segment[1]
|
||||
)
|
||||
)
|
||||
.filter(intersection => intersection.didIntersect)
|
||||
.flatMap(intersection => intersection.points);
|
||||
|
||||
if (!intersections.length) return;
|
||||
|
||||
// The center of the triangle
|
||||
const center = Vec.add(getPentagramCentroid(shape.size), shape.point);
|
||||
|
||||
// Find furthest intersection between ray from origin through point and expanded bounds. TODO: What if the shape has a curve? In that case, should we intersect the circle-from-three-points instead?
|
||||
const intersection = intersections.sort(
|
||||
(a, b) => Vec.dist(b, origin) - Vec.dist(a, origin)
|
||||
)[0];
|
||||
|
||||
// The point between the handle and the intersection
|
||||
const middlePoint = Vec.med(point, intersection);
|
||||
|
||||
let anchor: number[];
|
||||
let distance: number;
|
||||
|
||||
if (bindAnywhere) {
|
||||
anchor =
|
||||
Vec.dist(point, center) < BINDING_DISTANCE / 2 ? center : point;
|
||||
distance = 0;
|
||||
} else {
|
||||
if (
|
||||
Vec.distanceToLineSegment(point, middlePoint, center) <
|
||||
BINDING_DISTANCE / 2
|
||||
) {
|
||||
anchor = center;
|
||||
} else {
|
||||
anchor = middlePoint;
|
||||
}
|
||||
|
||||
if (Utils.pointInPolygon(point, points)) {
|
||||
distance = this.bindingDistance;
|
||||
} else {
|
||||
distance = Math.max(
|
||||
this.bindingDistance,
|
||||
closestDistanceToEdge
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const bindingPoint = Vec.divV(
|
||||
Vec.sub(anchor, [expandedBounds.minX, expandedBounds.minY]),
|
||||
[expandedBounds.width, expandedBounds.height]
|
||||
);
|
||||
|
||||
return {
|
||||
point: Vec.clampV(bindingPoint, 0, 1),
|
||||
distance,
|
||||
};
|
||||
};
|
||||
|
||||
override transform = transformRectangle;
|
||||
|
||||
override transformSingle = transformSingleRectangle;
|
||||
}
|
||||
|
||||
const FullWrapper = styled('div')({ width: '100%', height: '100%' });
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import { Utils } from '@tldraw/core';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle } from '../../shared';
|
||||
import { getPentagramPoints } from '../pentagram-helpers';
|
||||
import Vec from '@tldraw/vec';
|
||||
|
||||
interface PentagramSvgProps {
|
||||
id: string;
|
||||
size: number[];
|
||||
style: ShapeStyles;
|
||||
isSelected: boolean;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const DashedPentagram = React.memo(function DashedPentagram({
|
||||
id,
|
||||
size,
|
||||
style,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: PentagramSvgProps) {
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode);
|
||||
const sw = 1 + strokeWidth * 1.618;
|
||||
const points = getPentagramPoints(size);
|
||||
const sides = Utils.pointsToLineSegments(points, true);
|
||||
const paths = sides.map(([start, end], i) => {
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
Vec.dist(start, end),
|
||||
strokeWidth * 1.618,
|
||||
style.dash
|
||||
);
|
||||
|
||||
return (
|
||||
<line
|
||||
key={id + '_' + i}
|
||||
x1={start[0]}
|
||||
y1={start[1]}
|
||||
x2={end[0]}
|
||||
y2={end[1]}
|
||||
stroke={stroke}
|
||||
strokeWidth={sw}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const bgPath = points.join();
|
||||
|
||||
return (
|
||||
<>
|
||||
<polygon
|
||||
className={
|
||||
style.isFilled || isSelected
|
||||
? 'tl-fill-hitarea'
|
||||
: 'tl-stroke-hitarea'
|
||||
}
|
||||
points={bgPath}
|
||||
/>
|
||||
{style.isFilled && (
|
||||
<polygon fill={fill} points={bgPath} pointerEvents="none" />
|
||||
)}
|
||||
<g pointerEvents="stroke">{paths}</g>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react';
|
||||
import { getShapeStyle } from '../../shared';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import {
|
||||
getPentagramIndicatorPathTDSnapshot,
|
||||
getPentagramPath,
|
||||
} from '../pentagram-helpers';
|
||||
|
||||
interface PentagramSvgProps {
|
||||
id: string;
|
||||
size: number[];
|
||||
style: ShapeStyles;
|
||||
isSelected: boolean;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const DrawPentagram = React.memo(function DrawTriangle({
|
||||
id,
|
||||
size,
|
||||
style,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: PentagramSvgProps) {
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode);
|
||||
const path_td_snapshot = getPentagramPath(id, size, style);
|
||||
const indicatorPath = getPentagramIndicatorPathTDSnapshot(id, size, style);
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
className={
|
||||
style.isFilled || isSelected
|
||||
? 'tl-fill-hitarea'
|
||||
: 'tl-stroke-hitarea'
|
||||
}
|
||||
d={indicatorPath}
|
||||
/>
|
||||
{style.isFilled && (
|
||||
<path d={indicatorPath} fill={fill} pointerEvents="none" />
|
||||
)}
|
||||
<path
|
||||
d={path_td_snapshot}
|
||||
fill={stroke}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import { BINDING_DISTANCE } from '@toeverything/components/board-types';
|
||||
import { getPentagramPoints } from '../pentagram-helpers';
|
||||
|
||||
interface TriangleBindingIndicatorProps {
|
||||
size: number[];
|
||||
}
|
||||
|
||||
export function PentagramBindingIndicator({
|
||||
size,
|
||||
}: TriangleBindingIndicatorProps) {
|
||||
const trianglePoints = getPentagramPoints(size).join();
|
||||
return (
|
||||
<polygon
|
||||
className="tl-binding-indicator"
|
||||
points={trianglePoints}
|
||||
strokeWidth={BINDING_DISTANCE * 2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
1
libs/components/board-shapes/src/pentagram-util/index.ts
Normal file
1
libs/components/board-shapes/src/pentagram-util/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './PentagramUtil';
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Utils } from '@tldraw/core';
|
||||
import Vec from '@tldraw/vec';
|
||||
import getStroke, { getStrokePoints } from 'perfect-freehand';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle, getOffsetPolygon } from '../shared';
|
||||
function getPonits(w: number, h: number) {
|
||||
return [
|
||||
[0, (76 / 200) * h],
|
||||
[(76 / 200) * w, (76 / 200) * h],
|
||||
[(100 / 200) * w, 0],
|
||||
[(124 / 200) * w, (76 / 200) * h],
|
||||
[(200 / 200) * w, (76 / 200) * h],
|
||||
[(138 / 200) * w, (124 / 200) * h],
|
||||
[(162 / 200) * w, (200 / 200) * h],
|
||||
[(100 / 200) * w, (153 / 200) * h],
|
||||
[(38 / 200) * w, (200 / 200) * h],
|
||||
[(62 / 200) * w, (124 / 200) * h],
|
||||
];
|
||||
}
|
||||
|
||||
export function getPentagramPoints(size: number[], offset = 0, rotation = 0) {
|
||||
const [w, h] = size;
|
||||
let points = getPonits(w, h);
|
||||
if (offset) points = getOffsetPolygon(points, offset);
|
||||
if (rotation)
|
||||
points = points.map(pt => Vec.rotWith(pt, [w / 2, h / 2], rotation));
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
export function getPentagramCentroid(size: number[]) {
|
||||
const [w, h] = size;
|
||||
const points = getPonits(w, h);
|
||||
return [
|
||||
(points[0][0] + points[1][0] + points[2][0]) / 3,
|
||||
(points[0][1] + points[1][1] + points[2][1]) / 3,
|
||||
];
|
||||
}
|
||||
|
||||
function getPentagramDrawPoints(
|
||||
id: string,
|
||||
size: number[],
|
||||
strokeWidth: number
|
||||
) {
|
||||
const [w, h] = size;
|
||||
const getRandom = Utils.rng(id);
|
||||
// Random corner offsets
|
||||
const offsets = Array.from(Array(10)).map(() => {
|
||||
return [
|
||||
getRandom() * strokeWidth * 0.75,
|
||||
getRandom() * strokeWidth * 0.75,
|
||||
];
|
||||
});
|
||||
// Corners
|
||||
const point = getPonits(w, h);
|
||||
const corners = [
|
||||
Vec.add(point[0], offsets[0]),
|
||||
Vec.add(point[1], offsets[1]),
|
||||
Vec.add(point[2], offsets[2]),
|
||||
Vec.add(point[3], offsets[3]),
|
||||
Vec.add(point[4], offsets[4]),
|
||||
Vec.add(point[5], offsets[5]),
|
||||
Vec.add(point[6], offsets[6]),
|
||||
Vec.add(point[7], offsets[7]),
|
||||
Vec.add(point[8], offsets[8]),
|
||||
Vec.add(point[9], offsets[9]),
|
||||
];
|
||||
|
||||
// Which side to start drawing first
|
||||
const rm = Math.round(Math.abs(getRandom() * 2 * 3));
|
||||
// Number of points per side
|
||||
// Inset each line by the corner radii and let the freehand algo
|
||||
// interpolate points for the corners.
|
||||
const lines = Utils.rotateArray(
|
||||
[
|
||||
Vec.pointsBetween(corners[0], corners[1], 32),
|
||||
Vec.pointsBetween(corners[1], corners[2], 32),
|
||||
Vec.pointsBetween(corners[2], corners[3], 32),
|
||||
Vec.pointsBetween(corners[3], corners[4], 32),
|
||||
Vec.pointsBetween(corners[4], corners[5], 32),
|
||||
Vec.pointsBetween(corners[5], corners[6], 32),
|
||||
Vec.pointsBetween(corners[6], corners[7], 32),
|
||||
Vec.pointsBetween(corners[7], corners[8], 32),
|
||||
Vec.pointsBetween(corners[8], corners[9], 32),
|
||||
Vec.pointsBetween(corners[9], corners[0], 32),
|
||||
],
|
||||
rm
|
||||
);
|
||||
// For the final points, include the first half of the first line again,
|
||||
// so that the line wraps around and avoids ending on a sharp corner.
|
||||
// This has a bit of finesse and magic—if you change the points between
|
||||
// function, then you'll likely need to change this one too.
|
||||
const points = [...lines.flat(), ...lines[0]];
|
||||
return {
|
||||
points,
|
||||
};
|
||||
}
|
||||
|
||||
function getDrawStrokeInfo(id: string, size: number[], style: ShapeStyles) {
|
||||
const { strokeWidth } = getShapeStyle(style);
|
||||
const { points } = getPentagramDrawPoints(id, size, strokeWidth);
|
||||
const options = {
|
||||
size: strokeWidth,
|
||||
thinning: 0.65,
|
||||
streamline: 0.3,
|
||||
smoothing: 1,
|
||||
simulatePressure: false,
|
||||
last: true,
|
||||
};
|
||||
return { points, options };
|
||||
}
|
||||
|
||||
export function getPentagramPath(
|
||||
id: string,
|
||||
size: number[],
|
||||
style: ShapeStyles
|
||||
) {
|
||||
const { points, options } = getDrawStrokeInfo(id, size, style);
|
||||
const stroke = getStroke(points, options);
|
||||
return Utils.getSvgPathFromStroke(stroke);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export function getPentagramIndicatorPathTDSnapshot(
|
||||
id: string,
|
||||
size: number[],
|
||||
style: ShapeStyles
|
||||
) {
|
||||
const { points, options } = getDrawStrokeInfo(id, size, style);
|
||||
const strokePoints = getStrokePoints(points, options);
|
||||
return Utils.getSvgPathFromStroke(
|
||||
strokePoints.map(pt => pt.point.slice(0, 2)),
|
||||
false
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import * as React from 'react';
|
||||
import { Utils, SVGContainer } from '@tldraw/core';
|
||||
import {
|
||||
RectangleShape,
|
||||
DashStyle,
|
||||
TDShapeType,
|
||||
TDMeta,
|
||||
GHOSTED_OPACITY,
|
||||
LABEL_POINT,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TDShapeUtil } from '../TDShapeUtil';
|
||||
import {
|
||||
defaultStyle,
|
||||
getShapeStyle,
|
||||
getBoundsRectangle,
|
||||
transformRectangle,
|
||||
getFontStyle,
|
||||
transformSingleRectangle,
|
||||
} from '../shared';
|
||||
import { TextLabel } from '../shared/text-label';
|
||||
import { getRectangleIndicatorPathTDSnapshot } from './rectangle-helpers';
|
||||
import { DrawRectangle } from './components/DrawRectangle';
|
||||
import { DashedRectangle } from './components/DashedRectangle';
|
||||
import { BindingIndicator } from './components/BindingIndicator';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
type T = RectangleShape;
|
||||
type E = HTMLDivElement;
|
||||
|
||||
export class RectangleUtil extends TDShapeUtil<T, E> {
|
||||
type = TDShapeType.Rectangle as const;
|
||||
|
||||
override canBind = true;
|
||||
|
||||
override canClone = true;
|
||||
|
||||
override canEdit = true;
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
return Utils.deepMerge<T>(
|
||||
{
|
||||
id: 'id',
|
||||
type: TDShapeType.Rectangle,
|
||||
name: 'Rectangle',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
point: [0, 0],
|
||||
size: [1, 1],
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
label: '',
|
||||
labelPoint: [0.5, 0.5],
|
||||
workspace: props.workspace,
|
||||
},
|
||||
props
|
||||
);
|
||||
};
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
(
|
||||
{
|
||||
shape,
|
||||
isEditing,
|
||||
isBinding,
|
||||
isSelected,
|
||||
isGhost,
|
||||
meta,
|
||||
bounds,
|
||||
events,
|
||||
onShapeBlur,
|
||||
onShapeChange,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const {
|
||||
id,
|
||||
size,
|
||||
style,
|
||||
label = '',
|
||||
labelPoint = LABEL_POINT,
|
||||
} = shape;
|
||||
const font = getFontStyle(style);
|
||||
const styles = getShapeStyle(style, meta.isDarkMode);
|
||||
const Component =
|
||||
style.dash === DashStyle.Draw ? DrawRectangle : DashedRectangle;
|
||||
const handleLabelChange = React.useCallback(
|
||||
(label: string) => onShapeChange?.({ id, label }),
|
||||
[onShapeChange]
|
||||
);
|
||||
return (
|
||||
<FullWrapper ref={ref} {...events}>
|
||||
<TextLabel
|
||||
isEditing={isEditing}
|
||||
onChange={handleLabelChange}
|
||||
onBlur={onShapeBlur}
|
||||
font={font}
|
||||
text={label}
|
||||
color={styles.stroke}
|
||||
offsetX={(labelPoint[0] - 0.5) * bounds.width}
|
||||
offsetY={(labelPoint[1] - 0.5) * bounds.height}
|
||||
/>
|
||||
<SVGContainer
|
||||
id={shape.id + '_svg'}
|
||||
opacity={isGhost ? GHOSTED_OPACITY : 1}
|
||||
>
|
||||
{isBinding && (
|
||||
<BindingIndicator
|
||||
strokeWidth={styles.strokeWidth}
|
||||
size={size}
|
||||
/>
|
||||
)}
|
||||
<Component
|
||||
id={id}
|
||||
style={style}
|
||||
size={size}
|
||||
isSelected={isSelected}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
/>
|
||||
</SVGContainer>
|
||||
</FullWrapper>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
|
||||
const { id, style, size } = shape;
|
||||
|
||||
const styles = getShapeStyle(style, false);
|
||||
const sw = styles.strokeWidth;
|
||||
|
||||
if (style.dash === DashStyle.Draw) {
|
||||
return (
|
||||
<path
|
||||
d={getRectangleIndicatorPathTDSnapshot(id, style, size)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<rect
|
||||
x={sw}
|
||||
y={sw}
|
||||
rx={1}
|
||||
ry={1}
|
||||
width={Math.max(1, size[0] - sw * 2)}
|
||||
height={Math.max(1, size[1] - sw * 2)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
getBounds = (shape: T) => {
|
||||
return getBoundsRectangle(shape, this.boundsCache);
|
||||
};
|
||||
|
||||
override shouldRender = (prev: T, next: T) => {
|
||||
return (
|
||||
next.size !== prev.size ||
|
||||
next.style !== prev.style ||
|
||||
next.label !== prev.label
|
||||
);
|
||||
};
|
||||
|
||||
override transform = transformRectangle;
|
||||
|
||||
override transformSingle = transformSingleRectangle;
|
||||
}
|
||||
|
||||
const FullWrapper = styled('div')({ width: '100%', height: '100%' });
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import { BINDING_DISTANCE } from '@toeverything/components/board-types';
|
||||
|
||||
interface BindingIndicatorProps {
|
||||
strokeWidth: number;
|
||||
size: number[];
|
||||
}
|
||||
export function BindingIndicator({ strokeWidth, size }: BindingIndicatorProps) {
|
||||
return (
|
||||
<rect
|
||||
className="tl-binding-indicator"
|
||||
x={strokeWidth}
|
||||
y={strokeWidth}
|
||||
width={Math.max(0, size[0] - strokeWidth / 2)}
|
||||
height={Math.max(0, size[1] - strokeWidth / 2)}
|
||||
strokeWidth={BINDING_DISTANCE * 2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as React from 'react';
|
||||
import { Utils } from '@tldraw/core';
|
||||
import { BINDING_DISTANCE } from '@toeverything/components/board-types';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle } from '../../shared';
|
||||
|
||||
interface RectangleSvgProps {
|
||||
id: string;
|
||||
style: ShapeStyles;
|
||||
isSelected: boolean;
|
||||
size: number[];
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const DashedRectangle = React.memo(function DashedRectangle({
|
||||
id,
|
||||
style,
|
||||
size,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: RectangleSvgProps) {
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode);
|
||||
|
||||
const sw = 1 + strokeWidth * 1.618;
|
||||
|
||||
const w = Math.max(0, size[0] - sw / 2);
|
||||
const h = Math.max(0, size[1] - sw / 2);
|
||||
|
||||
const strokes: [number[], number[], number][] = [
|
||||
[[sw / 2, sw / 2], [w, sw / 2], w - sw / 2],
|
||||
[[w, sw / 2], [w, h], h - sw / 2],
|
||||
[[w, h], [sw / 2, h], w - sw / 2],
|
||||
[[sw / 2, h], [sw / 2, sw / 2], h - sw / 2],
|
||||
];
|
||||
|
||||
const paths = strokes.map(([start, end, length], i) => {
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
length,
|
||||
strokeWidth * 1.618,
|
||||
style.dash
|
||||
);
|
||||
|
||||
return (
|
||||
<line
|
||||
key={id + '_' + i}
|
||||
x1={start[0]}
|
||||
y1={start[1]}
|
||||
x2={end[0]}
|
||||
y2={end[1]}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<rect
|
||||
className={
|
||||
isSelected || style.isFilled
|
||||
? 'tl-fill-hitarea'
|
||||
: 'tl-stroke-hitarea'
|
||||
}
|
||||
x={sw / 2}
|
||||
y={sw / 2}
|
||||
width={w}
|
||||
height={h}
|
||||
strokeWidth={BINDING_DISTANCE}
|
||||
/>
|
||||
{style.isFilled && (
|
||||
<rect
|
||||
x={sw / 2}
|
||||
y={sw / 2}
|
||||
width={w}
|
||||
height={h}
|
||||
fill={fill}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)}
|
||||
<g
|
||||
pointerEvents="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={sw}
|
||||
strokeLinecap="round"
|
||||
>
|
||||
{paths}
|
||||
</g>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as React from 'react';
|
||||
import { getShapeStyle } from '../../shared';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import {
|
||||
getRectangleIndicatorPathTDSnapshot,
|
||||
getRectanglePath,
|
||||
} from '../rectangle-helpers';
|
||||
|
||||
interface RectangleSvgProps {
|
||||
id: string;
|
||||
style: ShapeStyles;
|
||||
isSelected: boolean;
|
||||
isDarkMode: boolean;
|
||||
size: number[];
|
||||
}
|
||||
|
||||
export const DrawRectangle = React.memo(function DrawRectangle({
|
||||
id,
|
||||
style,
|
||||
size,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: RectangleSvgProps) {
|
||||
const { isFilled } = style;
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode);
|
||||
const path_td_snapshot = getRectanglePath(id, style, size);
|
||||
const innerPath = getRectangleIndicatorPathTDSnapshot(id, style, size);
|
||||
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
className={
|
||||
style.isFilled || isSelected
|
||||
? 'tl-fill-hitarea'
|
||||
: 'tl-stroke-hitarea'
|
||||
}
|
||||
d={innerPath}
|
||||
/>
|
||||
{isFilled && (
|
||||
<path d={innerPath} fill={fill} pointerEvents="none" />
|
||||
)}
|
||||
<path
|
||||
d={path_td_snapshot}
|
||||
fill={stroke}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
1
libs/components/board-shapes/src/rectangle-util/index.ts
Normal file
1
libs/components/board-shapes/src/rectangle-util/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './RectangleUtil';
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Utils } from '@tldraw/core';
|
||||
import Vec from '@tldraw/vec';
|
||||
import getStroke, { getStrokePoints } from 'perfect-freehand';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle } from '../shared';
|
||||
|
||||
function getRectangleDrawPoints(
|
||||
id: string,
|
||||
style: ShapeStyles,
|
||||
size: number[]
|
||||
) {
|
||||
const styles = getShapeStyle(style);
|
||||
|
||||
const getRandom = Utils.rng(id);
|
||||
|
||||
const sw = styles.strokeWidth;
|
||||
|
||||
// Dimensions
|
||||
const w = Math.max(0, size[0]);
|
||||
const h = Math.max(0, size[1]);
|
||||
|
||||
// Random corner offsets
|
||||
const offsets = Array.from(Array(4)).map(() => {
|
||||
return [getRandom() * sw * 0.75, getRandom() * sw * 0.75];
|
||||
});
|
||||
|
||||
// Corners
|
||||
const tl = Vec.add([sw / 2, sw / 2], offsets[0]);
|
||||
const tr = Vec.add([w - sw / 2, sw / 2], offsets[1]);
|
||||
const br = Vec.add([w - sw / 2, h - sw / 2], offsets[2]);
|
||||
const bl = Vec.add([sw / 2, h - sw / 2], offsets[3]);
|
||||
|
||||
// Which side to start drawing first
|
||||
const rm = Math.round(Math.abs(getRandom() * 2 * 4));
|
||||
|
||||
// Corner radii
|
||||
const rx = Math.min(w / 4, sw * 2);
|
||||
const ry = Math.min(h / 4, sw * 2);
|
||||
|
||||
// Number of points per side
|
||||
const px = Math.max(8, Math.floor(w / 16));
|
||||
const py = Math.max(8, Math.floor(h / 16));
|
||||
|
||||
// Inset each line by the corner radii and let the freehand algo
|
||||
// interpolate points for the corners.
|
||||
const lines = Utils.rotateArray(
|
||||
[
|
||||
Vec.pointsBetween(Vec.add(tl, [rx, 0]), Vec.sub(tr, [rx, 0]), px),
|
||||
Vec.pointsBetween(Vec.add(tr, [0, ry]), Vec.sub(br, [0, ry]), py),
|
||||
Vec.pointsBetween(Vec.sub(br, [rx, 0]), Vec.add(bl, [rx, 0]), px),
|
||||
Vec.pointsBetween(Vec.sub(bl, [0, ry]), Vec.add(tl, [0, ry]), py),
|
||||
],
|
||||
rm
|
||||
);
|
||||
|
||||
// For the final points, include the first half of the first line again,
|
||||
// so that the line wraps around and avoids ending on a sharp corner.
|
||||
// This has a bit of finesse and magic—if you change the points between
|
||||
// function, then you'll likely need to change this one too.
|
||||
|
||||
const points = [...lines.flat(), ...lines[0]].slice(
|
||||
5,
|
||||
Math.floor((rm % 2 === 0 ? px : py) / -2) + 3
|
||||
);
|
||||
|
||||
return {
|
||||
points,
|
||||
};
|
||||
}
|
||||
|
||||
function getDrawStrokeInfo(id: string, style: ShapeStyles, size: number[]) {
|
||||
const { points } = getRectangleDrawPoints(id, style, size);
|
||||
const { strokeWidth } = getShapeStyle(style);
|
||||
const options = {
|
||||
size: strokeWidth,
|
||||
thinning: 0.65,
|
||||
streamline: 0.3,
|
||||
smoothing: 1,
|
||||
simulatePressure: false,
|
||||
last: true,
|
||||
};
|
||||
return { points, options };
|
||||
}
|
||||
|
||||
export function getRectanglePath(
|
||||
id: string,
|
||||
style: ShapeStyles,
|
||||
size: number[]
|
||||
) {
|
||||
const { points, options } = getDrawStrokeInfo(id, style, size);
|
||||
const stroke = getStroke(points, options);
|
||||
return Utils.getSvgPathFromStroke(stroke);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export function getRectangleIndicatorPathTDSnapshot(
|
||||
id: string,
|
||||
style: ShapeStyles,
|
||||
size: number[]
|
||||
) {
|
||||
const { points, options } = getDrawStrokeInfo(id, style, size);
|
||||
const strokePoints = getStrokePoints(points, options);
|
||||
return Utils.getSvgPathFromStroke(
|
||||
strokePoints.map(pt => pt.point.slice(0, 2)),
|
||||
false
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { TLBounds, TLShape, Utils } from '@tldraw/core';
|
||||
|
||||
/**
|
||||
* Find the bounds of a rectangular shape.
|
||||
* @param shape
|
||||
* @param boundsCache
|
||||
*/
|
||||
export function getBoundsRectangle<T extends TLShape & { size: number[] }>(
|
||||
shape: T,
|
||||
boundsCache: WeakMap<T, TLBounds>
|
||||
) {
|
||||
const bounds = Utils.getFromCache(boundsCache, shape, () => {
|
||||
const [width, height] = shape.size;
|
||||
return {
|
||||
minX: 0,
|
||||
maxX: width,
|
||||
minY: 0,
|
||||
maxY: height,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
});
|
||||
|
||||
return Utils.translateBounds(bounds, shape.point);
|
||||
}
|
||||
12
libs/components/board-shapes/src/shared/get-text-align.ts
Normal file
12
libs/components/board-shapes/src/shared/get-text-align.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { AlignStyle } from '@toeverything/components/board-types';
|
||||
|
||||
const ALIGN_VALUES = {
|
||||
[AlignStyle.Start]: 'left',
|
||||
[AlignStyle.Middle]: 'center',
|
||||
[AlignStyle.End]: 'right',
|
||||
[AlignStyle.Justify]: 'justify',
|
||||
} as const;
|
||||
|
||||
export function getTextAlign(alignStyle: AlignStyle = AlignStyle.Start) {
|
||||
return ALIGN_VALUES[alignStyle];
|
||||
}
|
||||
77
libs/components/board-shapes/src/shared/get-text-size.ts
Normal file
77
libs/components/board-shapes/src/shared/get-text-size.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { LETTER_SPACING } from '@toeverything/components/board-types';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let melm: any;
|
||||
|
||||
function getMeasurementDiv() {
|
||||
// A div used for measurement
|
||||
document.getElementById('__textLabelMeasure')?.remove();
|
||||
|
||||
const pre = document.createElement('pre');
|
||||
pre.id = '__textLabelMeasure';
|
||||
|
||||
Object.assign(pre.style, {
|
||||
whiteSpace: 'pre',
|
||||
width: 'auto',
|
||||
border: '1px solid transparent',
|
||||
padding: '4px',
|
||||
margin: '0px',
|
||||
letterSpacing: LETTER_SPACING,
|
||||
opacity: '0',
|
||||
position: 'absolute',
|
||||
top: '-500px',
|
||||
left: '0px',
|
||||
zIndex: '9999',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
alignmentBaseline: 'mathematical',
|
||||
dominantBaseline: 'mathematical',
|
||||
});
|
||||
|
||||
pre.tabIndex = -1;
|
||||
|
||||
document.body.appendChild(pre);
|
||||
return pre;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
melm = getMeasurementDiv();
|
||||
}
|
||||
|
||||
let prevText = '';
|
||||
let prevFont = '';
|
||||
let prevSize = [0, 0];
|
||||
|
||||
export function clearPrevSize() {
|
||||
prevText = '';
|
||||
}
|
||||
|
||||
export function getTextLabelSize(text: string, font: string) {
|
||||
if (!text) {
|
||||
return [16, 32];
|
||||
}
|
||||
|
||||
if (!melm) {
|
||||
// We're in SSR
|
||||
return [10, 10];
|
||||
}
|
||||
|
||||
if (!melm.parent) document.body.appendChild(melm);
|
||||
|
||||
if (text === prevText && font === prevFont) {
|
||||
return prevSize;
|
||||
}
|
||||
|
||||
prevText = text;
|
||||
prevFont = font;
|
||||
|
||||
melm.textContent = text;
|
||||
melm.style.font = font;
|
||||
|
||||
// In tests, offsetWidth and offsetHeight will be 0
|
||||
const width = melm.offsetWidth || 1;
|
||||
const height = melm.offsetHeight || 1;
|
||||
|
||||
prevSize = [width, height];
|
||||
return prevSize;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { TLBounds } from '@tldraw/core';
|
||||
import { getFontFace, getFontSize } from './shape-styles';
|
||||
import { getTextAlign } from './get-text-align';
|
||||
import {
|
||||
LINE_HEIGHT,
|
||||
AlignStyle,
|
||||
ShapeStyles,
|
||||
} from '@toeverything/components/board-types';
|
||||
|
||||
export function getTextSvgElement(
|
||||
text: string,
|
||||
style: ShapeStyles,
|
||||
bounds: TLBounds
|
||||
) {
|
||||
const fontSize = getFontSize(style.fontSize, style.font);
|
||||
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
const scale = style.scale ?? 1;
|
||||
|
||||
const textLines = text.split('\n').map((line, i) => {
|
||||
const textElm = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'text'
|
||||
);
|
||||
textElm.textContent = line;
|
||||
textElm.setAttribute('y', fontSize * (0.5 + i * LINE_HEIGHT) + '');
|
||||
textElm.setAttribute('letter-spacing', fontSize * -0.03 + '');
|
||||
textElm.setAttribute('font-size', fontSize + 'px');
|
||||
textElm.setAttribute(
|
||||
'font-family',
|
||||
getFontFace(style.font).slice(1, -1)
|
||||
);
|
||||
textElm.setAttribute('text-align', getTextAlign(style.textAlign));
|
||||
textElm.setAttribute('text-align', getTextAlign(style.textAlign));
|
||||
textElm.setAttribute('alignment-baseline', 'central');
|
||||
if (style.scale !== 1) {
|
||||
textElm.setAttribute('transform', `scale(${style.scale})`);
|
||||
}
|
||||
g.appendChild(textElm);
|
||||
|
||||
return textElm;
|
||||
});
|
||||
|
||||
switch (style.textAlign) {
|
||||
case AlignStyle.Middle: {
|
||||
g.setAttribute('text-align', 'center');
|
||||
g.setAttribute('text-anchor', 'middle');
|
||||
textLines.forEach(textElm => {
|
||||
textElm.setAttribute('x', bounds.width / 2 / scale + '');
|
||||
});
|
||||
break;
|
||||
}
|
||||
case AlignStyle.End: {
|
||||
g.setAttribute('text-align', 'right');
|
||||
g.setAttribute('text-anchor', 'end');
|
||||
textLines.forEach(textElm =>
|
||||
textElm.setAttribute('x', bounds.width / scale + '')
|
||||
);
|
||||
break;
|
||||
}
|
||||
case AlignStyle.Start: {
|
||||
g.setAttribute('text-align', 'left');
|
||||
g.setAttribute('text-anchor', 'start');
|
||||
}
|
||||
}
|
||||
|
||||
return g;
|
||||
}
|
||||
12
libs/components/board-shapes/src/shared/index.ts
Normal file
12
libs/components/board-shapes/src/shared/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export * from './get-text-align';
|
||||
export * from './get-text-size';
|
||||
export * from './get-text-svg-element';
|
||||
export * from './shape-styles';
|
||||
export * from './get-bounds-rectangle';
|
||||
export * from './transform-rectangle';
|
||||
export * from './transform-single-rectangle';
|
||||
export * from './text-label';
|
||||
export * from './polygon-utils';
|
||||
export * from './label-mask';
|
||||
export * from './normalize-text';
|
||||
export * from './stop-propagation';
|
||||
50
libs/components/board-shapes/src/shared/label-mask.tsx
Normal file
50
libs/components/board-shapes/src/shared/label-mask.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { TLBounds } from '@tldraw/core';
|
||||
import * as React from 'react';
|
||||
|
||||
interface WithLabelMaskProps {
|
||||
id: string;
|
||||
bounds: TLBounds;
|
||||
labelSize: number[];
|
||||
offset?: number[];
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
export function LabelMask({
|
||||
id,
|
||||
bounds,
|
||||
labelSize,
|
||||
offset,
|
||||
scale = 1,
|
||||
}: WithLabelMaskProps) {
|
||||
return (
|
||||
<defs>
|
||||
<mask id={id + '_clip'}>
|
||||
<rect
|
||||
x={-100}
|
||||
y={-100}
|
||||
width={bounds.width + 200}
|
||||
height={bounds.height + 200}
|
||||
fill="white"
|
||||
/>
|
||||
<rect
|
||||
x={
|
||||
bounds.width / 2 -
|
||||
(labelSize[0] / 2) * scale +
|
||||
(offset?.[0] || 0)
|
||||
}
|
||||
y={
|
||||
bounds.height / 2 -
|
||||
(labelSize[1] / 2) * scale +
|
||||
(offset?.[1] || 0)
|
||||
}
|
||||
width={labelSize[0] * scale}
|
||||
height={labelSize[1] * scale}
|
||||
rx={4 * scale}
|
||||
ry={4 * scale}
|
||||
fill="black"
|
||||
opacity={Math.max(scale, 0.8)}
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
const fixNewLines = /\r?\n|\r/g;
|
||||
|
||||
export function normalizeText(text: string) {
|
||||
return text
|
||||
.replace(fixNewLines, '\n')
|
||||
.split('\n')
|
||||
.map(x => x || ' ')
|
||||
.join('\n');
|
||||
}
|
||||
198
libs/components/board-shapes/src/shared/polygon-utils.ts
Normal file
198
libs/components/board-shapes/src/shared/polygon-utils.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { intersectLineLine } from '@tldraw/intersect';
|
||||
import Vec from '@tldraw/vec';
|
||||
|
||||
const PI2 = Math.PI * 2;
|
||||
|
||||
type Vert = number[];
|
||||
type Edge = Vert[];
|
||||
type Polygon = Vert[];
|
||||
|
||||
export class PolygonUtils {
|
||||
static inward_edge_normal(edge: Edge) {
|
||||
// Assuming that polygon vertices are in clockwise order
|
||||
const delta = Vec.sub(edge[1], edge[0]);
|
||||
const len = Vec.len2(delta);
|
||||
return [-delta[0] / len, delta[1] / len];
|
||||
}
|
||||
|
||||
static outward_edge_normal(edge: Edge) {
|
||||
return Vec.neg(PolygonUtils.inward_edge_normal(edge));
|
||||
}
|
||||
|
||||
// If the slope of line v1,v2 greater than the slope of v1,p then p is on the left side of v1,v2 and the return value is > 0.
|
||||
// If p is colinear with v1,v2 then return 0, otherwise return a value < 0.
|
||||
|
||||
static left_side = Vec.isLeft;
|
||||
|
||||
static is_reflex_vertex(polygon: Polygon, index: number) {
|
||||
const len = polygon.length;
|
||||
// Assuming that polygon vertices are in clockwise order
|
||||
const v0 = polygon[(index + len - 1) % len];
|
||||
const v1 = polygon[index];
|
||||
const v2 = polygon[(index + 1) % len];
|
||||
if (PolygonUtils.left_side(v0, v2, v1) < 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
static get_edges(vertices: Vert[]) {
|
||||
return vertices.map((vert, i) => [
|
||||
vert,
|
||||
vertices[(i + 1) % vertices.length],
|
||||
]);
|
||||
}
|
||||
|
||||
// based on http://local.wasp.uwa.edu.au/~pbourke/geometry/lineline2d/, A => "line a", B => "line b"
|
||||
static edges_intersection([A1, A2]: number[][], [B1, B2]: number[][]) {
|
||||
const den =
|
||||
(B2[1] - B1[1]) * (A2[0] - A1[0]) -
|
||||
(B2[0] - B1[0]) * (A2[1] - A1[1]);
|
||||
|
||||
if (den == 0) return null; // lines are parallel or conincident
|
||||
|
||||
const ua =
|
||||
((B2[0] - B1[0]) * (A1[1] - B1[1]) -
|
||||
(B2[1] - B1[1]) * (A1[0] - B1[0])) /
|
||||
den;
|
||||
|
||||
const ub =
|
||||
((A2[0] - A1[0]) * (A1[1] - B1[1]) -
|
||||
(A2[1] - A1[1]) * (A1[0] - B1[0])) /
|
||||
den;
|
||||
|
||||
if (ua < 0 || ub < 0 || ua > 1 || ub > 1) return null;
|
||||
|
||||
return [A1[0] + ua * (A2[0] - A1[0]), A1[1] + ua * (A2[1] - A1[1])];
|
||||
}
|
||||
|
||||
static append_arc(
|
||||
polygon: number[][],
|
||||
center: number[],
|
||||
radius: number,
|
||||
startVertex: number[],
|
||||
endVertex: number[],
|
||||
isPaddingBoundary = false
|
||||
) {
|
||||
const vertices = [...polygon];
|
||||
let startAngle = Math.atan2(
|
||||
startVertex[1] - center[1],
|
||||
startVertex[0] - center[0]
|
||||
);
|
||||
let endAngle = Math.atan2(
|
||||
endVertex[1] - center[1],
|
||||
endVertex[0] - center[0]
|
||||
);
|
||||
if (startAngle < 0) startAngle += PI2;
|
||||
if (endAngle < 0) endAngle += PI2;
|
||||
const arcSegmentCount = 5; // An odd number so that one arc vertex will be eactly arcRadius from center.
|
||||
const angle =
|
||||
startAngle > endAngle
|
||||
? startAngle - endAngle
|
||||
: startAngle + PI2 - endAngle;
|
||||
const angle5 =
|
||||
(isPaddingBoundary ? -angle : PI2 - angle) / arcSegmentCount;
|
||||
|
||||
vertices.push(startVertex);
|
||||
for (let i = 1; i < arcSegmentCount; ++i) {
|
||||
const angle = startAngle + angle5 * i;
|
||||
vertices.push([
|
||||
center[0] + Math.cos(angle) * radius,
|
||||
center[1] + Math.sin(angle) * radius,
|
||||
]);
|
||||
}
|
||||
vertices.push(endVertex);
|
||||
|
||||
return vertices;
|
||||
}
|
||||
|
||||
static create_offset_edge(edge: Edge, offset: number[]) {
|
||||
return edge.map(vert => Vec.add(vert, offset));
|
||||
}
|
||||
|
||||
static get_offset_polygon(polygon: Polygon, offset = 0) {
|
||||
const edges = PolygonUtils.get_edges(polygon);
|
||||
|
||||
const offsetEdges = edges.map(edge =>
|
||||
PolygonUtils.create_offset_edge(
|
||||
edge,
|
||||
Vec.mul(PolygonUtils.outward_edge_normal(edge), offset)
|
||||
)
|
||||
);
|
||||
|
||||
const vertices = [];
|
||||
|
||||
for (let i = 0; i < offsetEdges.length; i++) {
|
||||
const thisEdge = offsetEdges[i];
|
||||
const prevEdge =
|
||||
offsetEdges[(i + offsetEdges.length - 1) % offsetEdges.length];
|
||||
const vertex = PolygonUtils.edges_intersection(prevEdge, thisEdge);
|
||||
if (vertex) vertices.push(vertex);
|
||||
else {
|
||||
PolygonUtils.append_arc(
|
||||
vertices,
|
||||
edges[i][0],
|
||||
offset,
|
||||
prevEdge[1],
|
||||
thisEdge[0],
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// var marginPolygon = PolygonUtils.createPolygon(vertices)
|
||||
// marginPolygon.offsetEdges = offsetEdges
|
||||
return vertices;
|
||||
}
|
||||
|
||||
static create_padding_polygon(polygon: number[][][], shapePadding = 0) {
|
||||
const offsetEdges = polygon.map(edge =>
|
||||
PolygonUtils.create_offset_edge(
|
||||
edge,
|
||||
PolygonUtils.inward_edge_normal(edge)
|
||||
)
|
||||
);
|
||||
|
||||
const vertices = [];
|
||||
for (let i = 0; i < offsetEdges.length; i++) {
|
||||
const thisEdge = offsetEdges[i];
|
||||
const prevEdge =
|
||||
offsetEdges[(i + offsetEdges.length - 1) % offsetEdges.length];
|
||||
const vertex = PolygonUtils.edges_intersection(prevEdge, thisEdge);
|
||||
if (vertex) vertices.push(vertex);
|
||||
else {
|
||||
PolygonUtils.append_arc(
|
||||
vertices,
|
||||
polygon[i][0],
|
||||
shapePadding,
|
||||
prevEdge[1],
|
||||
thisEdge[0],
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return vertices;
|
||||
}
|
||||
}
|
||||
|
||||
export function getOffsetPolygon(points: number[][], offset: number) {
|
||||
if (points.length < 3) throw Error('Polygon must have at least 3 points');
|
||||
const len = points.length;
|
||||
return points
|
||||
.map((point, i) => [point, points[(i + 1) % len]])
|
||||
.map(([A, B]) => {
|
||||
const offsetVector = Vec.mul(
|
||||
Vec.per(Vec.uni(Vec.sub(B, A))),
|
||||
offset
|
||||
);
|
||||
return [Vec.add(A, offsetVector), Vec.add(B, offsetVector)];
|
||||
})
|
||||
.map((edge, i, edges) => {
|
||||
const intersection = intersectLineLine(
|
||||
edge,
|
||||
edges[(i + 1) % edges.length]
|
||||
);
|
||||
if (intersection === undefined)
|
||||
throw Error('Expected an intersection');
|
||||
return intersection;
|
||||
});
|
||||
}
|
||||
149
libs/components/board-shapes/src/shared/shape-styles.ts
Normal file
149
libs/components/board-shapes/src/shared/shape-styles.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { Utils } from '@tldraw/core';
|
||||
import {
|
||||
Theme,
|
||||
ColorStyle,
|
||||
DashStyle,
|
||||
ShapeStyles,
|
||||
FontSizeStyle,
|
||||
FontStyle,
|
||||
AlignStyle,
|
||||
StrokeWidth,
|
||||
} from '@toeverything/components/board-types';
|
||||
|
||||
const canvasLight = '#fafafa';
|
||||
|
||||
const canvasDark = '#343d45';
|
||||
|
||||
export const commonColors = {
|
||||
black: '#3A4C5C',
|
||||
white: '#FFFFFF',
|
||||
};
|
||||
|
||||
export const strokes: Record<Theme, Record<ColorStyle, string>> = {
|
||||
light: {
|
||||
...commonColors,
|
||||
white: '#1d1d1d',
|
||||
},
|
||||
dark: {
|
||||
...(Object.fromEntries(
|
||||
Object.entries(commonColors).map(([k, v]) => [
|
||||
k,
|
||||
Utils.lerpColor(v, canvasDark, 0.1),
|
||||
])
|
||||
) as Record<ColorStyle, string>),
|
||||
white: '#cecece',
|
||||
black: '#cecece',
|
||||
},
|
||||
};
|
||||
|
||||
export const fills: Record<Theme, Record<ColorStyle, string>> = {
|
||||
light: {
|
||||
...(Object.fromEntries(
|
||||
Object.entries(commonColors).map(([k, v]) => [
|
||||
k,
|
||||
Utils.lerpColor(v, canvasLight, 0.82),
|
||||
])
|
||||
) as Record<ColorStyle, string>),
|
||||
white: '#fefefe',
|
||||
},
|
||||
dark: {
|
||||
...(Object.fromEntries(
|
||||
Object.entries(commonColors).map(([k, v]) => [
|
||||
k,
|
||||
Utils.lerpColor(v, canvasDark, 0.82),
|
||||
])
|
||||
) as Record<ColorStyle, string>),
|
||||
white: 'rgb(30,33,37)',
|
||||
black: '#1e1e1f',
|
||||
},
|
||||
};
|
||||
|
||||
const fontFaces = {
|
||||
[FontStyle.Script]: '"Caveat Brush"',
|
||||
[FontStyle.Sans]: '"Source Sans Pro"',
|
||||
[FontStyle.Serif]: '"Crimson Pro"',
|
||||
[FontStyle.Mono]: '"Source Code Pro"',
|
||||
};
|
||||
|
||||
const fontSizeModifiers = {
|
||||
[FontStyle.Script]: 1,
|
||||
[FontStyle.Sans]: 1,
|
||||
[FontStyle.Serif]: 1,
|
||||
[FontStyle.Mono]: 1,
|
||||
};
|
||||
|
||||
const _lineHeights = {
|
||||
[FontSizeStyle.h1]: 40,
|
||||
[FontSizeStyle.h2]: 34,
|
||||
[FontSizeStyle.h3]: 28,
|
||||
[FontSizeStyle.body]: 22,
|
||||
};
|
||||
|
||||
export function getFontSize(
|
||||
size: FontSizeStyle,
|
||||
fontStyle: FontStyle = FontStyle.Script
|
||||
): number {
|
||||
return size * fontSizeModifiers[fontStyle];
|
||||
}
|
||||
|
||||
function getLineHeight(size: FontSizeStyle): number {
|
||||
return _lineHeights[size];
|
||||
}
|
||||
|
||||
export function getFontFace(font: FontStyle = FontStyle.Script): string {
|
||||
return fontFaces[font];
|
||||
}
|
||||
|
||||
export function getStickyFontSize(size: FontSizeStyle): number {
|
||||
return size;
|
||||
}
|
||||
|
||||
export function getFontStyle(style: ShapeStyles): string {
|
||||
const fontSize = getFontSize(style.fontSize, style.font);
|
||||
const fontFace = getFontFace(style.font);
|
||||
const lineHeight = getLineHeight(style.fontSize);
|
||||
const { scale = 1 } = style;
|
||||
|
||||
return `${fontSize * scale}px/${lineHeight}px ${fontFace}`;
|
||||
}
|
||||
|
||||
export function getStickyFontStyle(style: ShapeStyles): string {
|
||||
const fontSize = getStickyFontSize(style.fontSize);
|
||||
const fontFace = getFontFace(style.font);
|
||||
const { scale = 1 } = style;
|
||||
|
||||
return `${fontSize * scale}px/1 ${fontFace}`;
|
||||
}
|
||||
|
||||
export function getShapeStyle(
|
||||
style: ShapeStyles,
|
||||
isDarkMode?: boolean
|
||||
): {
|
||||
stroke: string;
|
||||
fill: string;
|
||||
strokeWidth: number;
|
||||
} {
|
||||
const { stroke, strokeWidth, fill, isFilled, dash } = style;
|
||||
|
||||
return {
|
||||
stroke: dash === DashStyle.None ? 'none' : stroke,
|
||||
fill: isFilled ? fill : 'none',
|
||||
strokeWidth,
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultStyle: ShapeStyles = {
|
||||
stroke: commonColors.black,
|
||||
strokeWidth: StrokeWidth.s1,
|
||||
fill: 'none',
|
||||
fontSize: FontSizeStyle.body,
|
||||
isFilled: false,
|
||||
dash: DashStyle.Draw,
|
||||
scale: 1,
|
||||
};
|
||||
|
||||
export const defaultTextStyle: ShapeStyles = {
|
||||
...defaultStyle,
|
||||
font: FontStyle.Script,
|
||||
textAlign: AlignStyle.Middle,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import type React from 'react';
|
||||
|
||||
export const stopPropagation = (
|
||||
e: KeyboardEvent | React.SyntheticEvent<any, Event>
|
||||
) => e.stopPropagation();
|
||||
203
libs/components/board-shapes/src/shared/text-area-utils.ts
Normal file
203
libs/components/board-shapes/src/shared/text-area-utils.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
// Adapted (mostly copied) the work of https://github.com/fregante
|
||||
// Copyright (c) Federico Brigante <opensource@bfred.it> (bfred.it)
|
||||
|
||||
type ReplacerCallback = (substring: string, ...args: unknown[]) => string;
|
||||
|
||||
const INDENT = ' ';
|
||||
|
||||
export class TextAreaUtils {
|
||||
static insert_text_firefox(
|
||||
field: HTMLTextAreaElement | HTMLInputElement,
|
||||
text: string
|
||||
): void {
|
||||
// Found on https://www.everythingfrontend.com/posts/insert-text-into-textarea-at-cursor-position.html 🎈
|
||||
field.setRangeText(
|
||||
text,
|
||||
field.selectionStart || 0,
|
||||
field.selectionEnd || 0,
|
||||
'end' // Without this, the cursor is either at the beginning or `text` remains selected
|
||||
);
|
||||
|
||||
field.dispatchEvent(
|
||||
new InputEvent('input', {
|
||||
data: text,
|
||||
inputType: 'insertText',
|
||||
isComposing: false, // TODO: fix @types/jsdom, this shouldn't be required
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Inserts `text` at the cursor’s position, replacing any selection, with **undo** support and by firing the `input` event. */
|
||||
static insert(
|
||||
field: HTMLTextAreaElement | HTMLInputElement,
|
||||
text: string
|
||||
): void {
|
||||
const document = field.ownerDocument;
|
||||
const initialFocus = document.activeElement;
|
||||
if (initialFocus !== field) {
|
||||
field.focus();
|
||||
}
|
||||
|
||||
if (!document.execCommand('insertText', false, text)) {
|
||||
TextAreaUtils.insert_text_firefox(field, text);
|
||||
}
|
||||
|
||||
if (initialFocus === document.body) {
|
||||
field.blur();
|
||||
} else if (
|
||||
initialFocus instanceof HTMLElement &&
|
||||
initialFocus !== field
|
||||
) {
|
||||
initialFocus.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces the entire content, equivalent to `field.value = text` but with **undo** support and by firing the `input` event. */
|
||||
static set(
|
||||
field: HTMLTextAreaElement | HTMLInputElement,
|
||||
text: string
|
||||
): void {
|
||||
field.select();
|
||||
TextAreaUtils.insert(field, text);
|
||||
}
|
||||
|
||||
/** Get the selected text in a field or an empty string if nothing is selected. */
|
||||
static get_selection(
|
||||
field: HTMLTextAreaElement | HTMLInputElement
|
||||
): string {
|
||||
const { selectionStart, selectionEnd } = field;
|
||||
return field.value.slice(
|
||||
selectionStart ? selectionStart : undefined,
|
||||
selectionEnd ? selectionEnd : undefined
|
||||
);
|
||||
}
|
||||
|
||||
/** Adds the `wrappingText` before and after field’s selection (or cursor). If `endWrappingText` is provided, it will be used instead of `wrappingText` at on the right. */
|
||||
static wrap_selection(
|
||||
field: HTMLTextAreaElement | HTMLInputElement,
|
||||
wrap: string,
|
||||
wrapEnd?: string
|
||||
): void {
|
||||
const { selectionStart, selectionEnd } = field;
|
||||
const selection = TextAreaUtils.get_selection(field);
|
||||
TextAreaUtils.insert(field, wrap + selection + (wrapEnd ?? wrap));
|
||||
|
||||
// Restore the selection around the previously-selected text
|
||||
field.selectionStart = (selectionStart || 0) + wrap.length;
|
||||
field.selectionEnd = (selectionEnd || 0) + wrap.length;
|
||||
}
|
||||
|
||||
/** Finds and replaces strings and regex in the field’s value, like `field.value = field.value.replace()` but better */
|
||||
static replace(
|
||||
field: HTMLTextAreaElement | HTMLInputElement,
|
||||
searchValue: string | RegExp,
|
||||
replacer: string | ReplacerCallback
|
||||
): void {
|
||||
/** Remembers how much each match offset should be adjusted */
|
||||
let drift = 0;
|
||||
|
||||
field.value.replace(searchValue, (...args): string => {
|
||||
// Select current match to replace it later
|
||||
const matchStart = drift + (args[args.length - 2] as number);
|
||||
const matchLength = args[0].length;
|
||||
field.selectionStart = matchStart;
|
||||
field.selectionEnd = matchStart + matchLength;
|
||||
|
||||
const replacement =
|
||||
typeof replacer === 'string' ? replacer : replacer(...args);
|
||||
TextAreaUtils.insert(field, replacement);
|
||||
|
||||
// Select replacement. Without this, the cursor would be after the replacement
|
||||
field.selectionStart = matchStart;
|
||||
drift += replacement.length - matchLength;
|
||||
return replacement;
|
||||
});
|
||||
}
|
||||
|
||||
static find_line_end(value: string, currentEnd: number): number {
|
||||
// Go to the beginning of the last line
|
||||
const lastLineStart = value.lastIndexOf('\n', currentEnd - 1) + 1;
|
||||
|
||||
// There's nothing to unindent after the last cursor, so leave it as is
|
||||
if (value.charAt(lastLineStart) !== '\t') {
|
||||
return currentEnd;
|
||||
}
|
||||
|
||||
return lastLineStart + 1; // Include the first character, which will be a tab
|
||||
}
|
||||
|
||||
static indent(element: HTMLTextAreaElement): void {
|
||||
const { selectionStart, selectionEnd, value } = element;
|
||||
const selectedContrast = value.slice(selectionStart, selectionEnd);
|
||||
// The first line should be indented, even if it starts with `\n`
|
||||
// The last line should only be indented if includes any character after `\n`
|
||||
const lineBreakCount = /\n/g.exec(selectedContrast)?.length;
|
||||
|
||||
if (lineBreakCount && lineBreakCount > 0) {
|
||||
// Select full first line to replace everything at once
|
||||
const firstLineStart =
|
||||
value.lastIndexOf('\n', selectionStart - 1) + 1;
|
||||
|
||||
const newSelection = element.value.slice(
|
||||
firstLineStart,
|
||||
selectionEnd - 1
|
||||
);
|
||||
const indentedText = newSelection.replace(
|
||||
/^|\n/g, // Match all line starts
|
||||
`$&${INDENT}`
|
||||
);
|
||||
const replacementsCount = indentedText.length - newSelection.length;
|
||||
|
||||
// Replace newSelection with indentedText
|
||||
element.setSelectionRange(firstLineStart, selectionEnd - 1);
|
||||
TextAreaUtils.insert(element, indentedText);
|
||||
|
||||
// Restore selection position, including the indentation
|
||||
element.setSelectionRange(
|
||||
selectionStart + 1,
|
||||
selectionEnd + replacementsCount
|
||||
);
|
||||
} else {
|
||||
TextAreaUtils.insert(element, INDENT);
|
||||
}
|
||||
}
|
||||
|
||||
// The first line should always be unindented
|
||||
// The last line should only be unindented if the selection includes any characters after `\n`
|
||||
static unindent(element: HTMLTextAreaElement): void {
|
||||
const { selectionStart, selectionEnd, value } = element;
|
||||
|
||||
// Select the whole first line because it might contain \t
|
||||
const firstLineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
|
||||
const minimumSelectionEnd = TextAreaUtils.find_line_end(
|
||||
value,
|
||||
selectionEnd
|
||||
);
|
||||
|
||||
const newSelection = element.value.slice(
|
||||
firstLineStart,
|
||||
minimumSelectionEnd
|
||||
);
|
||||
const indentedText = newSelection.replace(/(^|\n)(\t| {1,2})/g, '$1');
|
||||
const replacementsCount = newSelection.length - indentedText.length;
|
||||
|
||||
// Replace newSelection with indentedText
|
||||
element.setSelectionRange(firstLineStart, minimumSelectionEnd);
|
||||
TextAreaUtils.insert(element, indentedText);
|
||||
|
||||
// Restore selection position, including the indentation
|
||||
const firstLineIndentation = /\t| {1,2}/.exec(
|
||||
value.slice(firstLineStart, selectionStart)
|
||||
);
|
||||
|
||||
const difference = firstLineIndentation
|
||||
? firstLineIndentation[0].length
|
||||
: 0;
|
||||
|
||||
const newSelectionStart = selectionStart - difference;
|
||||
element.setSelectionRange(
|
||||
selectionStart - difference,
|
||||
Math.max(newSelectionStart, selectionEnd - replacementsCount)
|
||||
);
|
||||
}
|
||||
}
|
||||
281
libs/components/board-shapes/src/shared/text-label.tsx
Normal file
281
libs/components/board-shapes/src/shared/text-label.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import * as React from 'react';
|
||||
import { stopPropagation } from './stop-propagation';
|
||||
import {
|
||||
GHOSTED_OPACITY,
|
||||
LETTER_SPACING,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { normalizeText } from './normalize-text';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { getTextLabelSize } from './get-text-size';
|
||||
import { TextAreaUtils } from './text-area-utils';
|
||||
|
||||
export interface TextLabelProps {
|
||||
font: string;
|
||||
text: string;
|
||||
color: string;
|
||||
onBlur?: () => void;
|
||||
onChange: (text: string) => void;
|
||||
offsetY?: number;
|
||||
offsetX?: number;
|
||||
scale?: number;
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
export const TextLabel = React.memo(function TextLabel({
|
||||
font,
|
||||
text,
|
||||
color,
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
scale = 1,
|
||||
isEditing = false,
|
||||
onBlur,
|
||||
onChange,
|
||||
}: TextLabelProps) {
|
||||
const rInput = React.useRef<HTMLTextAreaElement>(null);
|
||||
const rIsMounted = React.useRef(false);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(normalizeText(e.currentTarget.value));
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') return;
|
||||
|
||||
if (e.key === 'Tab' && text.length === 0) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(e.key === 'Meta' || e.metaKey)) {
|
||||
e.stopPropagation();
|
||||
} else if (e.key === 'z' && e.metaKey) {
|
||||
if (e.shiftKey) {
|
||||
document.execCommand('redo', false);
|
||||
} else {
|
||||
document.execCommand('undo', false);
|
||||
}
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
TextAreaUtils.unindent(e.currentTarget);
|
||||
} else {
|
||||
TextAreaUtils.indent(e.currentTarget);
|
||||
}
|
||||
|
||||
onChange?.(normalizeText(e.currentTarget.value));
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleBlur = React.useCallback(
|
||||
(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
e.currentTarget.setSelectionRange(0, 0);
|
||||
onBlur?.();
|
||||
},
|
||||
[onBlur]
|
||||
);
|
||||
|
||||
const handleFocus = React.useCallback(
|
||||
(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
if (!isEditing) return;
|
||||
if (!rIsMounted.current) return;
|
||||
|
||||
if (document.activeElement === e.currentTarget) {
|
||||
e.currentTarget.select();
|
||||
}
|
||||
},
|
||||
[isEditing]
|
||||
);
|
||||
|
||||
const handlePointerDown = React.useCallback<
|
||||
React.PointerEventHandler<HTMLTextAreaElement>
|
||||
>(
|
||||
e => {
|
||||
if (isEditing) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
[isEditing]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isEditing) {
|
||||
requestAnimationFrame(() => {
|
||||
rIsMounted.current = true;
|
||||
const elm = rInput.current;
|
||||
if (elm) {
|
||||
elm.focus();
|
||||
elm.select();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
onBlur?.();
|
||||
}
|
||||
}, [isEditing, onBlur]);
|
||||
|
||||
const rInnerWrapper = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const elm = rInnerWrapper.current;
|
||||
if (!elm) return;
|
||||
const size = getTextLabelSize(text, font);
|
||||
elm.style.transform = `scale(${scale}, ${scale}) translate(${offsetX}px, ${offsetY}px)`;
|
||||
elm.style.width = size[0] + 1 + 'px';
|
||||
elm.style.height = size[1] + 1 + 'px';
|
||||
}, [text, font, offsetY, offsetX, scale]);
|
||||
|
||||
return (
|
||||
<TextWrapper>
|
||||
<InnerWrapper
|
||||
ref={rInnerWrapper}
|
||||
hasText={!!text}
|
||||
isEditing={isEditing}
|
||||
style={{
|
||||
font,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<TextArea
|
||||
ref={rInput}
|
||||
style={{
|
||||
font,
|
||||
color,
|
||||
}}
|
||||
name="text"
|
||||
tabIndex={-1}
|
||||
autoComplete="false"
|
||||
autoCapitalize="false"
|
||||
autoCorrect="false"
|
||||
autoSave="false"
|
||||
autoFocus
|
||||
placeholder=""
|
||||
spellCheck="true"
|
||||
wrap="off"
|
||||
dir="auto"
|
||||
datatype="wysiwyg"
|
||||
defaultValue={text}
|
||||
color={color}
|
||||
onFocus={handleFocus}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
onPointerDown={handlePointerDown}
|
||||
onContextMenu={stopPropagation}
|
||||
onCopy={stopPropagation}
|
||||
onPaste={stopPropagation}
|
||||
onCut={stopPropagation}
|
||||
/>
|
||||
) : (
|
||||
text
|
||||
)}
|
||||
​
|
||||
</InnerWrapper>
|
||||
</TextWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
const TextWrapper = styled('div')({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
variants: {
|
||||
isGhost: {
|
||||
false: { opacity: 1 },
|
||||
true: { transition: 'opacity .2s', opacity: GHOSTED_OPACITY },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const InnerWrapper = styled('div')<{ hasText: boolean; isEditing: boolean }>({
|
||||
position: 'absolute',
|
||||
padding: '4px',
|
||||
zIndex: 1,
|
||||
minHeight: 1,
|
||||
minWidth: 1,
|
||||
lineHeight: 1,
|
||||
letterSpacing: LETTER_SPACING,
|
||||
outline: 0,
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
backfaceVisibility: 'hidden',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowWrap: 'break-word',
|
||||
|
||||
variants: {
|
||||
hasText: {
|
||||
false: {
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
true: {
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
},
|
||||
isEditing: {
|
||||
false: {
|
||||
userSelect: 'none',
|
||||
},
|
||||
true: {
|
||||
background: '$boundsBg',
|
||||
userSelect: 'text',
|
||||
WebkitUserSelect: 'text',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const TextArea = styled('textarea')({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
padding: '4px',
|
||||
resize: 'none',
|
||||
textAlign: 'inherit',
|
||||
minHeight: 'inherit',
|
||||
minWidth: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
letterSpacing: 'inherit',
|
||||
outline: 0,
|
||||
fontWeight: 'inherit',
|
||||
overflow: 'hidden',
|
||||
backfaceVisibility: 'hidden',
|
||||
display: 'inline-block',
|
||||
pointerEvents: 'all',
|
||||
background: '$boundsBg',
|
||||
userSelect: 'text',
|
||||
WebkitUserSelect: 'text',
|
||||
fontSmooth: 'always',
|
||||
WebkitFontSmoothing: 'subpixel-antialiased',
|
||||
MozOsxFontSmoothing: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowWrap: 'break-word',
|
||||
|
||||
'&:focus': {
|
||||
outline: 'none',
|
||||
border: 'none',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { TLBounds, TLShape, TLTransformInfo } from '@tldraw/core';
|
||||
import Vec from '@tldraw/vec';
|
||||
|
||||
/**
|
||||
* Transform a rectangular shape.
|
||||
* @param shape
|
||||
* @param bounds
|
||||
* @param param2
|
||||
*/
|
||||
export function transformRectangle<T extends TLShape & { size: number[] }>(
|
||||
shape: T,
|
||||
bounds: TLBounds,
|
||||
{ initialShape, transformOrigin, scaleX, scaleY }: TLTransformInfo<T>
|
||||
) {
|
||||
if (shape.rotation || initialShape.isAspectRatioLocked) {
|
||||
const size = Vec.toFixed(
|
||||
Vec.mul(
|
||||
initialShape.size,
|
||||
Math.min(Math.abs(scaleX), Math.abs(scaleY))
|
||||
)
|
||||
);
|
||||
const point = Vec.toFixed([
|
||||
bounds.minX +
|
||||
(bounds.width - shape.size[0]) *
|
||||
(scaleX < 0 ? 1 - transformOrigin[0] : transformOrigin[0]),
|
||||
bounds.minY +
|
||||
(bounds.height - shape.size[1]) *
|
||||
(scaleY < 0 ? 1 - transformOrigin[1] : transformOrigin[1]),
|
||||
]);
|
||||
const rotation =
|
||||
(scaleX < 0 && scaleY >= 0) || (scaleY < 0 && scaleX >= 0)
|
||||
? initialShape.rotation
|
||||
? -initialShape.rotation
|
||||
: 0
|
||||
: initialShape.rotation;
|
||||
return {
|
||||
size,
|
||||
point,
|
||||
rotation,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
point: Vec.toFixed([bounds.minX, bounds.minY]),
|
||||
size: Vec.toFixed([bounds.width, bounds.height]),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { TLBounds, TLShape } from '@tldraw/core';
|
||||
import Vec from '@tldraw/vec';
|
||||
|
||||
/**
|
||||
* Transform a single rectangular shape.
|
||||
* @param shape
|
||||
* @param bounds
|
||||
*/
|
||||
export function transformSingleRectangle<
|
||||
T extends TLShape & { size: number[] }
|
||||
>(shape: T, bounds: TLBounds) {
|
||||
return {
|
||||
size: Vec.toFixed([bounds.width, bounds.height]),
|
||||
point: Vec.toFixed([bounds.minX, bounds.minY]),
|
||||
};
|
||||
}
|
||||
294
libs/components/board-shapes/src/triangle-util/TriangleUtil.tsx
Normal file
294
libs/components/board-shapes/src/triangle-util/TriangleUtil.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import * as React from 'react';
|
||||
import { Utils, SVGContainer, TLBounds } from '@tldraw/core';
|
||||
import {
|
||||
TriangleShape,
|
||||
TDShapeType,
|
||||
TDMeta,
|
||||
TDShape,
|
||||
DashStyle,
|
||||
BINDING_DISTANCE,
|
||||
GHOSTED_OPACITY,
|
||||
LABEL_POINT,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TDShapeUtil } from '../TDShapeUtil';
|
||||
import {
|
||||
defaultStyle,
|
||||
getBoundsRectangle,
|
||||
transformRectangle,
|
||||
transformSingleRectangle,
|
||||
getFontStyle,
|
||||
TextLabel,
|
||||
getShapeStyle,
|
||||
} from '../shared';
|
||||
import {
|
||||
intersectBoundsPolygon,
|
||||
intersectLineSegmentPolyline,
|
||||
intersectRayLineSegment,
|
||||
} from '@tldraw/intersect';
|
||||
import Vec from '@tldraw/vec';
|
||||
import { getTriangleCentroid, getTrianglePoints } from './triangle-helpers';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { DrawTriangle } from './components/DrawTriangle';
|
||||
import { DashedTriangle } from './components/DashedTriangle';
|
||||
import { TriangleBindingIndicator } from './components/TriangleBindingIndicator';
|
||||
|
||||
type T = TriangleShape;
|
||||
type E = HTMLDivElement;
|
||||
|
||||
export class TriangleUtil extends TDShapeUtil<T, E> {
|
||||
type = TDShapeType.Triangle as const;
|
||||
|
||||
override canBind = true;
|
||||
|
||||
override canClone = true;
|
||||
|
||||
override canEdit = true;
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
return Utils.deepMerge<T>(
|
||||
{
|
||||
id: 'id',
|
||||
type: TDShapeType.Triangle,
|
||||
name: 'Triangle',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
point: [0, 0],
|
||||
size: [1, 1],
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
label: '',
|
||||
labelPoint: [0.5, 0.5],
|
||||
workspace: props.workspace,
|
||||
},
|
||||
props
|
||||
);
|
||||
};
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
(
|
||||
{
|
||||
shape,
|
||||
bounds,
|
||||
isBinding,
|
||||
isEditing,
|
||||
isSelected,
|
||||
isGhost,
|
||||
meta,
|
||||
events,
|
||||
onShapeChange,
|
||||
onShapeBlur,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const {
|
||||
id,
|
||||
label = '',
|
||||
size,
|
||||
style,
|
||||
labelPoint = LABEL_POINT,
|
||||
} = shape;
|
||||
const font = getFontStyle(style);
|
||||
const styles = getShapeStyle(style, meta.isDarkMode);
|
||||
const Component =
|
||||
style.dash === DashStyle.Draw ? DrawTriangle : DashedTriangle;
|
||||
const handleLabelChange = React.useCallback(
|
||||
(label: string) => onShapeChange?.({ id, label }),
|
||||
[onShapeChange]
|
||||
);
|
||||
const offsetY = React.useMemo(() => {
|
||||
const center = Vec.div(size, 2);
|
||||
const centroid = getTriangleCentroid(size);
|
||||
return (centroid[1] - center[1]) * 0.72;
|
||||
}, [size]);
|
||||
return (
|
||||
<FullWrapper ref={ref} {...events}>
|
||||
<TextLabel
|
||||
font={font}
|
||||
text={label}
|
||||
color={styles.stroke}
|
||||
offsetX={(labelPoint[0] - 0.5) * bounds.width}
|
||||
offsetY={
|
||||
offsetY + (labelPoint[1] - 0.5) * bounds.height
|
||||
}
|
||||
isEditing={isEditing}
|
||||
onChange={handleLabelChange}
|
||||
onBlur={onShapeBlur}
|
||||
/>
|
||||
<SVGContainer
|
||||
id={shape.id + '_svg'}
|
||||
opacity={isGhost ? GHOSTED_OPACITY : 1}
|
||||
>
|
||||
{isBinding && <TriangleBindingIndicator size={size} />}
|
||||
<Component
|
||||
id={id}
|
||||
style={style}
|
||||
size={size}
|
||||
isSelected={isSelected}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
/>
|
||||
</SVGContainer>
|
||||
</FullWrapper>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
|
||||
const { size } = shape;
|
||||
return <polygon points={getTrianglePoints(size).join()} />;
|
||||
});
|
||||
|
||||
private get_points(shape: T) {
|
||||
const {
|
||||
rotation = 0,
|
||||
point: [x, y],
|
||||
size: [w, h],
|
||||
} = shape;
|
||||
return [
|
||||
[x + w / 2, y],
|
||||
[x, y + h],
|
||||
[x + w, y + h],
|
||||
].map(pt => Vec.rotWith(pt, this.getCenter(shape), rotation));
|
||||
}
|
||||
|
||||
override shouldRender = (prev: T, next: T) => {
|
||||
return (
|
||||
next.size !== prev.size ||
|
||||
next.style !== prev.style ||
|
||||
next.label !== prev.label
|
||||
);
|
||||
};
|
||||
|
||||
getBounds = (shape: T) => {
|
||||
return getBoundsRectangle(shape, this.boundsCache);
|
||||
};
|
||||
|
||||
override getExpandedBounds = (shape: T) => {
|
||||
return Utils.getBoundsFromPoints(
|
||||
getTrianglePoints(shape.size, this.bindingDistance).map(pt =>
|
||||
Vec.add(pt, shape.point)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
override hitTestLineSegment = (
|
||||
shape: T,
|
||||
A: number[],
|
||||
B: number[]
|
||||
): boolean => {
|
||||
return intersectLineSegmentPolyline(A, B, this.get_points(shape))
|
||||
.didIntersect;
|
||||
};
|
||||
|
||||
override hitTestBounds = (shape: T, bounds: TLBounds): boolean => {
|
||||
return (
|
||||
Utils.boundsContained(this.getBounds(shape), bounds) ||
|
||||
intersectBoundsPolygon(bounds, this.get_points(shape)).length > 0
|
||||
);
|
||||
};
|
||||
|
||||
override getBindingPoint = <K extends TDShape>(
|
||||
shape: T,
|
||||
fromShape: K,
|
||||
point: number[],
|
||||
origin: number[],
|
||||
direction: number[],
|
||||
bindAnywhere: boolean
|
||||
) => {
|
||||
// Algorithm time! We need to find the binding point (a normalized point inside of the shape, or around the shape, where the arrow will point to) and the distance from the binding shape to the anchor.
|
||||
|
||||
const expandedBounds = this.getExpandedBounds(shape);
|
||||
|
||||
if (!Utils.pointInBounds(point, expandedBounds)) return;
|
||||
|
||||
const points = getTrianglePoints(shape.size).map(pt =>
|
||||
Vec.add(pt, shape.point)
|
||||
);
|
||||
|
||||
const expandedPoints = getTrianglePoints(
|
||||
shape.size,
|
||||
this.bindingDistance
|
||||
).map(pt => Vec.add(pt, shape.point));
|
||||
|
||||
const closestDistanceToEdge = Utils.pointsToLineSegments(points, true)
|
||||
.map(([a, b]) => Vec.distanceToLineSegment(a, b, point))
|
||||
.sort((a, b) => a - b)[0];
|
||||
|
||||
if (
|
||||
!(
|
||||
Utils.pointInPolygon(point, expandedPoints) ||
|
||||
closestDistanceToEdge < this.bindingDistance
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
const intersections = Utils.pointsToLineSegments(
|
||||
expandedPoints.concat([expandedPoints[0]])
|
||||
)
|
||||
.map(segment =>
|
||||
intersectRayLineSegment(
|
||||
origin,
|
||||
direction,
|
||||
segment[0],
|
||||
segment[1]
|
||||
)
|
||||
)
|
||||
.filter(intersection => intersection.didIntersect)
|
||||
.flatMap(intersection => intersection.points);
|
||||
|
||||
if (!intersections.length) return;
|
||||
|
||||
// The center of the triangle
|
||||
const center = Vec.add(getTriangleCentroid(shape.size), shape.point);
|
||||
|
||||
// Find furthest intersection between ray from origin through point and expanded bounds. TODO: What if the shape has a curve? In that case, should we intersect the circle-from-three-points instead?
|
||||
const intersection = intersections.sort(
|
||||
(a, b) => Vec.dist(b, origin) - Vec.dist(a, origin)
|
||||
)[0];
|
||||
|
||||
// The point between the handle and the intersection
|
||||
const middlePoint = Vec.med(point, intersection);
|
||||
|
||||
let anchor: number[];
|
||||
let distance: number;
|
||||
|
||||
if (bindAnywhere) {
|
||||
anchor =
|
||||
Vec.dist(point, center) < BINDING_DISTANCE / 2 ? center : point;
|
||||
distance = 0;
|
||||
} else {
|
||||
if (
|
||||
Vec.distanceToLineSegment(point, middlePoint, center) <
|
||||
BINDING_DISTANCE / 2
|
||||
) {
|
||||
anchor = center;
|
||||
} else {
|
||||
anchor = middlePoint;
|
||||
}
|
||||
|
||||
if (Utils.pointInPolygon(point, points)) {
|
||||
distance = this.bindingDistance;
|
||||
} else {
|
||||
distance = Math.max(
|
||||
this.bindingDistance,
|
||||
closestDistanceToEdge
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const bindingPoint = Vec.divV(
|
||||
Vec.sub(anchor, [expandedBounds.minX, expandedBounds.minY]),
|
||||
[expandedBounds.width, expandedBounds.height]
|
||||
);
|
||||
|
||||
return {
|
||||
point: Vec.clampV(bindingPoint, 0, 1),
|
||||
distance,
|
||||
};
|
||||
};
|
||||
|
||||
override transform = transformRectangle;
|
||||
|
||||
override transformSingle = transformSingleRectangle;
|
||||
}
|
||||
|
||||
const FullWrapper = styled('div')({ width: '100%', height: '100%' });
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import { Utils } from '@tldraw/core';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle } from '../../shared';
|
||||
import { getTrianglePoints } from '../triangle-helpers';
|
||||
import Vec from '@tldraw/vec';
|
||||
|
||||
interface TriangleSvgProps {
|
||||
id: string;
|
||||
size: number[];
|
||||
style: ShapeStyles;
|
||||
isSelected: boolean;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const DashedTriangle = React.memo(function DashedTriangle({
|
||||
id,
|
||||
size,
|
||||
style,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: TriangleSvgProps) {
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode);
|
||||
const sw = 1 + strokeWidth * 1.618;
|
||||
const points = getTrianglePoints(size);
|
||||
const sides = Utils.pointsToLineSegments(points, true);
|
||||
const paths = sides.map(([start, end], i) => {
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
Vec.dist(start, end),
|
||||
strokeWidth * 1.618,
|
||||
style.dash
|
||||
);
|
||||
|
||||
return (
|
||||
<line
|
||||
key={id + '_' + i}
|
||||
x1={start[0]}
|
||||
y1={start[1]}
|
||||
x2={end[0]}
|
||||
y2={end[1]}
|
||||
stroke={stroke}
|
||||
strokeWidth={sw}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const bgPath = points.join();
|
||||
|
||||
return (
|
||||
<>
|
||||
<polygon
|
||||
className={
|
||||
style.isFilled || isSelected
|
||||
? 'tl-fill-hitarea'
|
||||
: 'tl-stroke-hitarea'
|
||||
}
|
||||
points={bgPath}
|
||||
/>
|
||||
{style.isFilled && (
|
||||
<polygon fill={fill} points={bgPath} pointerEvents="none" />
|
||||
)}
|
||||
<g pointerEvents="stroke">{paths}</g>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react';
|
||||
import { getShapeStyle } from '../../shared';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import {
|
||||
getTriangleIndicatorPathTDSnapshot,
|
||||
getTrianglePath,
|
||||
} from '../triangle-helpers';
|
||||
|
||||
interface TriangleSvgProps {
|
||||
id: string;
|
||||
size: number[];
|
||||
style: ShapeStyles;
|
||||
isSelected: boolean;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const DrawTriangle = React.memo(function DrawTriangle({
|
||||
id,
|
||||
size,
|
||||
style,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: TriangleSvgProps) {
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode);
|
||||
const path_td_snapshot = getTrianglePath(id, size, style);
|
||||
const indicatorPath = getTriangleIndicatorPathTDSnapshot(id, size, style);
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
className={
|
||||
style.isFilled || isSelected
|
||||
? 'tl-fill-hitarea'
|
||||
: 'tl-stroke-hitarea'
|
||||
}
|
||||
d={indicatorPath}
|
||||
/>
|
||||
{style.isFilled && (
|
||||
<path d={indicatorPath} fill={fill} pointerEvents="none" />
|
||||
)}
|
||||
<path
|
||||
d={path_td_snapshot}
|
||||
fill={stroke}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import { BINDING_DISTANCE } from '@toeverything/components/board-types';
|
||||
import { getTrianglePoints } from '../triangle-helpers';
|
||||
|
||||
interface TriangleBindingIndicatorProps {
|
||||
size: number[];
|
||||
}
|
||||
|
||||
export function TriangleBindingIndicator({
|
||||
size,
|
||||
}: TriangleBindingIndicatorProps) {
|
||||
const trianglePoints = getTrianglePoints(size).join();
|
||||
return (
|
||||
<polygon
|
||||
className="tl-binding-indicator"
|
||||
points={trianglePoints}
|
||||
strokeWidth={BINDING_DISTANCE * 2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
1
libs/components/board-shapes/src/triangle-util/index.ts
Normal file
1
libs/components/board-shapes/src/triangle-util/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './TriangleUtil';
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Utils } from '@tldraw/core';
|
||||
import Vec from '@tldraw/vec';
|
||||
import getStroke, { getStrokePoints } from 'perfect-freehand';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle, getOffsetPolygon } from '../shared';
|
||||
|
||||
export function getTrianglePoints(size: number[], offset = 0, rotation = 0) {
|
||||
const [w, h] = size;
|
||||
let points = [
|
||||
[w / 2, 0],
|
||||
[w, h],
|
||||
[0, h],
|
||||
];
|
||||
if (offset) points = getOffsetPolygon(points, offset);
|
||||
if (rotation)
|
||||
points = points.map(pt => Vec.rotWith(pt, [w / 2, h / 2], rotation));
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
export function getTriangleCentroid(size: number[]) {
|
||||
const [w, h] = size;
|
||||
const points = [
|
||||
[w / 2, 0],
|
||||
[w, h],
|
||||
[0, h],
|
||||
];
|
||||
return [
|
||||
(points[0][0] + points[1][0] + points[2][0]) / 3,
|
||||
(points[0][1] + points[1][1] + points[2][1]) / 3,
|
||||
];
|
||||
}
|
||||
|
||||
function getTriangleDrawPoints(
|
||||
id: string,
|
||||
size: number[],
|
||||
strokeWidth: number
|
||||
) {
|
||||
const [w, h] = size;
|
||||
const getRandom = Utils.rng(id);
|
||||
// Random corner offsets
|
||||
const offsets = Array.from(Array(3)).map(() => {
|
||||
return [
|
||||
getRandom() * strokeWidth * 0.75,
|
||||
getRandom() * strokeWidth * 0.75,
|
||||
];
|
||||
});
|
||||
// Corners
|
||||
const corners = [
|
||||
Vec.add([w / 2, 0], offsets[0]),
|
||||
Vec.add([w, h], offsets[1]),
|
||||
Vec.add([0, h], offsets[2]),
|
||||
];
|
||||
// Which side to start drawing first
|
||||
const rm = Math.round(Math.abs(getRandom() * 2 * 3));
|
||||
// Number of points per side
|
||||
// Inset each line by the corner radii and let the freehand algo
|
||||
// interpolate points for the corners.
|
||||
const lines = Utils.rotateArray(
|
||||
[
|
||||
Vec.pointsBetween(corners[0], corners[1], 32),
|
||||
Vec.pointsBetween(corners[1], corners[2], 32),
|
||||
Vec.pointsBetween(corners[2], corners[0], 32),
|
||||
],
|
||||
rm
|
||||
);
|
||||
// For the final points, include the first half of the first line again,
|
||||
// so that the line wraps around and avoids ending on a sharp corner.
|
||||
// This has a bit of finesse and magic—if you change the points between
|
||||
// function, then you'll likely need to change this one too.
|
||||
const points = [...lines.flat(), ...lines[0]];
|
||||
return {
|
||||
points,
|
||||
};
|
||||
}
|
||||
|
||||
function getDrawStrokeInfo(id: string, size: number[], style: ShapeStyles) {
|
||||
const { strokeWidth } = getShapeStyle(style);
|
||||
const { points } = getTriangleDrawPoints(id, size, strokeWidth);
|
||||
const options = {
|
||||
size: strokeWidth,
|
||||
thinning: 0.65,
|
||||
streamline: 0.3,
|
||||
smoothing: 1,
|
||||
simulatePressure: false,
|
||||
last: true,
|
||||
};
|
||||
return { points, options };
|
||||
}
|
||||
|
||||
export function getTrianglePath(
|
||||
id: string,
|
||||
size: number[],
|
||||
style: ShapeStyles
|
||||
) {
|
||||
const { points, options } = getDrawStrokeInfo(id, size, style);
|
||||
const stroke = getStroke(points, options);
|
||||
return Utils.getSvgPathFromStroke(stroke);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export function getTriangleIndicatorPathTDSnapshot(
|
||||
id: string,
|
||||
size: number[],
|
||||
style: ShapeStyles
|
||||
) {
|
||||
const { points, options } = getDrawStrokeInfo(id, size, style);
|
||||
const strokePoints = getStrokePoints(points, options);
|
||||
return Utils.getSvgPathFromStroke(
|
||||
strokePoints.map(pt => pt.point.slice(0, 2)),
|
||||
false
|
||||
);
|
||||
}
|
||||
1
libs/components/board-shapes/src/video-util/index.ts
Normal file
1
libs/components/board-shapes/src/video-util/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './video-util';
|
||||
272
libs/components/board-shapes/src/video-util/video-util.tsx
Normal file
272
libs/components/board-shapes/src/video-util/video-util.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import * as React from 'react';
|
||||
import { Utils, HTMLContainer } from '@tldraw/core';
|
||||
import {
|
||||
TDShapeType,
|
||||
TDMeta,
|
||||
VideoShape,
|
||||
TDVideoAsset,
|
||||
GHOSTED_OPACITY,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TDShapeUtil } from '../TDShapeUtil';
|
||||
import {
|
||||
defaultStyle,
|
||||
getBoundsRectangle,
|
||||
transformRectangle,
|
||||
transformSingleRectangle,
|
||||
} from '../shared';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
type T = VideoShape;
|
||||
type E = HTMLDivElement;
|
||||
|
||||
export class VideoUtil extends TDShapeUtil<T, E> {
|
||||
type = TDShapeType.Video as const;
|
||||
override canBind = true;
|
||||
override canEdit = true;
|
||||
override canClone = true;
|
||||
override isAspectRatioLocked = true;
|
||||
override showCloneHandles = true;
|
||||
override isStateful = true; // don't unmount
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
return Utils.deepMerge<T>(
|
||||
{
|
||||
id: 'video',
|
||||
type: TDShapeType.Video,
|
||||
name: 'Video',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
point: [0, 0],
|
||||
size: [1, 1],
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
assetId: 'assetId',
|
||||
isPlaying: true,
|
||||
currentTime: 0,
|
||||
workspace: props.workspace,
|
||||
},
|
||||
props
|
||||
);
|
||||
};
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
(
|
||||
{
|
||||
shape,
|
||||
asset = { src: '' },
|
||||
isBinding,
|
||||
isEditing,
|
||||
isGhost,
|
||||
meta,
|
||||
events,
|
||||
onShapeChange,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const rVideo = React.useRef<HTMLVideoElement>(null);
|
||||
const rWrapper = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const { currentTime = 0, size, isPlaying, style } = shape;
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const wrapper = rWrapper.current;
|
||||
if (!wrapper) return;
|
||||
const [width, height] = size;
|
||||
wrapper.style.width = `${width}px`;
|
||||
wrapper.style.height = `${height}px`;
|
||||
}, [size]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const video = rVideo.current;
|
||||
if (!video) return;
|
||||
if (isPlaying) video.play();
|
||||
// throws error on safari
|
||||
else video.pause();
|
||||
}, [isPlaying]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const video = rVideo.current;
|
||||
if (!video) return;
|
||||
if (currentTime !== video.currentTime) {
|
||||
video.currentTime = currentTime;
|
||||
}
|
||||
}, [currentTime]);
|
||||
|
||||
const handlePlay = React.useCallback(() => {
|
||||
onShapeChange?.({ id: shape.id, isPlaying: true });
|
||||
}, []);
|
||||
|
||||
const handlePause = React.useCallback(() => {
|
||||
onShapeChange?.({ id: shape.id, isPlaying: false });
|
||||
}, []);
|
||||
|
||||
const handleSetCurrentTime = React.useCallback(() => {
|
||||
const video = rVideo.current;
|
||||
if (!video) return;
|
||||
if (!isEditing) return;
|
||||
onShapeChange?.({
|
||||
id: shape.id,
|
||||
currentTime: video.currentTime,
|
||||
});
|
||||
}, [isEditing]);
|
||||
|
||||
return (
|
||||
<HTMLContainer ref={ref} {...events}>
|
||||
{isBinding && (
|
||||
<div
|
||||
className="tl-binding-indicator"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -this.bindingDistance,
|
||||
left: -this.bindingDistance,
|
||||
width: `calc(100% + ${
|
||||
this.bindingDistance * 2
|
||||
}px)`,
|
||||
height: `calc(100% + ${
|
||||
this.bindingDistance * 2
|
||||
}px)`,
|
||||
backgroundColor: 'var(--tl-selectFill)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Wrapper
|
||||
ref={rWrapper}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
isGhost={isGhost}
|
||||
isFilled={style.isFilled}
|
||||
>
|
||||
<VideoElement
|
||||
ref={rVideo}
|
||||
id={shape.id + '_video'}
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
disableRemotePlayback
|
||||
disablePictureInPicture
|
||||
controls={isEditing}
|
||||
autoPlay={isPlaying}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onTimeUpdate={handleSetCurrentTime}
|
||||
>
|
||||
<source src={(asset as TDVideoAsset).src} />
|
||||
</VideoElement>
|
||||
</Wrapper>
|
||||
</HTMLContainer>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
|
||||
const {
|
||||
size: [width, height],
|
||||
} = shape;
|
||||
|
||||
return (
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
rx={2}
|
||||
ry={2}
|
||||
width={Math.max(1, width)}
|
||||
height={Math.max(1, height)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
getBounds = (shape: T) => {
|
||||
return getBoundsRectangle(shape, this.boundsCache);
|
||||
};
|
||||
|
||||
override shouldRender = (prev: T, next: T) => {
|
||||
return (
|
||||
next.size !== prev.size ||
|
||||
next.style !== prev.style ||
|
||||
next.isPlaying !== prev.isPlaying
|
||||
);
|
||||
};
|
||||
|
||||
override getSvgElement = (shape: VideoShape) => {
|
||||
const bounds = this.getBounds(shape);
|
||||
const elm = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'image'
|
||||
);
|
||||
elm.setAttribute('width', `${bounds.width}`);
|
||||
elm.setAttribute('height', `${bounds.height}`);
|
||||
elm.setAttribute('xmlns:xlink', `http://www.w3.org/1999/xlink`);
|
||||
return elm;
|
||||
};
|
||||
|
||||
override transform = transformRectangle;
|
||||
|
||||
override transformSingle = transformSingleRectangle;
|
||||
}
|
||||
|
||||
const Wrapper = styled('div')<{
|
||||
isDarkMode: boolean;
|
||||
isFilled: boolean;
|
||||
isGhost: boolean;
|
||||
}>({
|
||||
pointerEvents: 'all',
|
||||
position: 'relative',
|
||||
fontFamily: 'sans-serif',
|
||||
fontSize: '2em',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
borderRadius: '3px',
|
||||
perspective: '800px',
|
||||
overflow: 'hidden',
|
||||
p: {
|
||||
userSelect: 'none',
|
||||
},
|
||||
img: {
|
||||
userSelect: 'none',
|
||||
},
|
||||
variants: {
|
||||
isGhost: {
|
||||
false: { opacity: 1 },
|
||||
true: { transition: 'opacity .2s', opacity: GHOSTED_OPACITY },
|
||||
},
|
||||
isFilled: {
|
||||
true: {},
|
||||
false: {},
|
||||
},
|
||||
isDarkMode: {
|
||||
true: {},
|
||||
false: {},
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
isFilled: true,
|
||||
isDarkMode: true,
|
||||
css: {
|
||||
boxShadow:
|
||||
'2px 3px 12px -2px rgba(0,0,0,.3), 1px 1px 4px rgba(0,0,0,.3), 1px 1px 2px rgba(0,0,0,.3)',
|
||||
},
|
||||
},
|
||||
{
|
||||
isFilled: true,
|
||||
isDarkMode: false,
|
||||
css: {
|
||||
boxShadow:
|
||||
'2px 3px 12px -2px rgba(0,0,0,.2), 1px 1px 4px rgba(0,0,0,.16), 1px 1px 2px rgba(0,0,0,.16)',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const VideoElement = styled('video')({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
minWidth: '100%',
|
||||
pointerEvents: 'none',
|
||||
objectFit: 'cover',
|
||||
userSelect: 'none',
|
||||
borderRadius: 2,
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import * as React from 'react';
|
||||
import { Utils, SVGContainer, TLBounds } from '@tldraw/core';
|
||||
import {
|
||||
WhiteArrowShape,
|
||||
TDShapeType,
|
||||
TDMeta,
|
||||
TDShape,
|
||||
DashStyle,
|
||||
BINDING_DISTANCE,
|
||||
GHOSTED_OPACITY,
|
||||
LABEL_POINT,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TDShapeUtil } from '../TDShapeUtil';
|
||||
import {
|
||||
defaultStyle,
|
||||
getBoundsRectangle,
|
||||
transformRectangle,
|
||||
transformSingleRectangle,
|
||||
getFontStyle,
|
||||
TextLabel,
|
||||
getShapeStyle,
|
||||
} from '../shared';
|
||||
import {
|
||||
intersectBoundsPolygon,
|
||||
intersectLineSegmentPolyline,
|
||||
intersectRayLineSegment,
|
||||
} from '@tldraw/intersect';
|
||||
import Vec from '@tldraw/vec';
|
||||
import {
|
||||
getWhiteArrowCentroid,
|
||||
getWhiteArrowPoints,
|
||||
} from './white-arrow-helpers';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { DrawWhiteArrow } from './components/DrawWhiteArrow';
|
||||
import { DashedWhiteArrow } from './components/DashedWhiteArrow';
|
||||
import { WhiteArrowBindingIndicator } from './components/WhiteArrowBindingIndicator';
|
||||
|
||||
type T = WhiteArrowShape;
|
||||
type E = HTMLDivElement;
|
||||
|
||||
export class WhiteArrowUtil extends TDShapeUtil<T, E> {
|
||||
type = TDShapeType.WhiteArrow as const;
|
||||
|
||||
override canBind = true;
|
||||
|
||||
override canClone = true;
|
||||
|
||||
override canEdit = true;
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
return Utils.deepMerge<T>(
|
||||
{
|
||||
id: 'id',
|
||||
type: TDShapeType.WhiteArrow,
|
||||
name: 'Triangle',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
point: [0, 0],
|
||||
size: [1, 1],
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
label: '',
|
||||
labelPoint: [0.5, 0.5],
|
||||
workspace: props.workspace,
|
||||
},
|
||||
props
|
||||
);
|
||||
};
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
(
|
||||
{
|
||||
shape,
|
||||
bounds,
|
||||
isBinding,
|
||||
isEditing,
|
||||
isSelected,
|
||||
isGhost,
|
||||
meta,
|
||||
events,
|
||||
onShapeChange,
|
||||
onShapeBlur,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const {
|
||||
id,
|
||||
label = '',
|
||||
size,
|
||||
style,
|
||||
labelPoint = LABEL_POINT,
|
||||
} = shape;
|
||||
const font = getFontStyle(style);
|
||||
const styles = getShapeStyle(style, meta.isDarkMode);
|
||||
const Component =
|
||||
style.dash === DashStyle.Draw
|
||||
? DrawWhiteArrow
|
||||
: DashedWhiteArrow;
|
||||
const handleLabelChange = React.useCallback(
|
||||
(label: string) => onShapeChange?.({ id, label }),
|
||||
[onShapeChange]
|
||||
);
|
||||
const offsetY = React.useMemo(() => {
|
||||
const center = Vec.div(size, 2);
|
||||
const centroid = getWhiteArrowCentroid(size);
|
||||
return (centroid[1] - center[1]) * 0.72;
|
||||
}, [size]);
|
||||
return (
|
||||
<FullWrapper ref={ref} {...events}>
|
||||
<TextLabel
|
||||
font={font}
|
||||
text={label}
|
||||
color={styles.stroke}
|
||||
offsetX={(labelPoint[0] - 0.5) * bounds.width}
|
||||
offsetY={
|
||||
offsetY + (labelPoint[1] - 0.5) * bounds.height
|
||||
}
|
||||
isEditing={isEditing}
|
||||
onChange={handleLabelChange}
|
||||
onBlur={onShapeBlur}
|
||||
/>
|
||||
<SVGContainer
|
||||
id={shape.id + '_svg'}
|
||||
opacity={isGhost ? GHOSTED_OPACITY : 1}
|
||||
>
|
||||
{isBinding && (
|
||||
<WhiteArrowBindingIndicator size={size} />
|
||||
)}
|
||||
<Component
|
||||
id={id}
|
||||
style={style}
|
||||
size={size}
|
||||
isSelected={isSelected}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
/>
|
||||
</SVGContainer>
|
||||
</FullWrapper>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
|
||||
const { size } = shape;
|
||||
return <polygon points={getWhiteArrowPoints(size).join()} />;
|
||||
});
|
||||
|
||||
private get_points(shape: T) {
|
||||
const {
|
||||
rotation = 0,
|
||||
point: [x, y],
|
||||
size: [w, h],
|
||||
} = shape;
|
||||
return [
|
||||
[x + w / 2, y],
|
||||
[x, y + h],
|
||||
[x + w, y + h],
|
||||
].map(pt => Vec.rotWith(pt, this.getCenter(shape), rotation));
|
||||
}
|
||||
|
||||
override shouldRender = (prev: T, next: T) => {
|
||||
return (
|
||||
next.size !== prev.size ||
|
||||
next.style !== prev.style ||
|
||||
next.label !== prev.label
|
||||
);
|
||||
};
|
||||
|
||||
getBounds = (shape: T) => {
|
||||
return getBoundsRectangle(shape, this.boundsCache);
|
||||
};
|
||||
|
||||
override getExpandedBounds = (shape: T) => {
|
||||
return Utils.getBoundsFromPoints(
|
||||
getWhiteArrowPoints(shape.size, this.bindingDistance).map(pt =>
|
||||
Vec.add(pt, shape.point)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
override hitTestLineSegment = (
|
||||
shape: T,
|
||||
A: number[],
|
||||
B: number[]
|
||||
): boolean => {
|
||||
return intersectLineSegmentPolyline(A, B, this.get_points(shape))
|
||||
.didIntersect;
|
||||
};
|
||||
|
||||
override hitTestBounds = (shape: T, bounds: TLBounds): boolean => {
|
||||
return (
|
||||
Utils.boundsContained(this.getBounds(shape), bounds) ||
|
||||
intersectBoundsPolygon(bounds, this.get_points(shape)).length > 0
|
||||
);
|
||||
};
|
||||
|
||||
override getBindingPoint = <K extends TDShape>(
|
||||
shape: T,
|
||||
fromShape: K,
|
||||
point: number[],
|
||||
origin: number[],
|
||||
direction: number[],
|
||||
bindAnywhere: boolean
|
||||
) => {
|
||||
// Algorithm time! We need to find the binding point (a normalized point inside of the shape, or around the shape, where the arrow will point to) and the distance from the binding shape to the anchor.
|
||||
|
||||
const expandedBounds = this.getExpandedBounds(shape);
|
||||
|
||||
if (!Utils.pointInBounds(point, expandedBounds)) return;
|
||||
|
||||
const points = getWhiteArrowPoints(shape.size).map(pt =>
|
||||
Vec.add(pt, shape.point)
|
||||
);
|
||||
|
||||
const expandedPoints = getWhiteArrowPoints(
|
||||
shape.size,
|
||||
this.bindingDistance
|
||||
).map(pt => Vec.add(pt, shape.point));
|
||||
|
||||
const closestDistanceToEdge = Utils.pointsToLineSegments(points, true)
|
||||
.map(([a, b]) => Vec.distanceToLineSegment(a, b, point))
|
||||
.sort((a, b) => a - b)[0];
|
||||
|
||||
if (
|
||||
!(
|
||||
Utils.pointInPolygon(point, expandedPoints) ||
|
||||
closestDistanceToEdge < this.bindingDistance
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
const intersections = Utils.pointsToLineSegments(
|
||||
expandedPoints.concat([expandedPoints[0]])
|
||||
)
|
||||
.map(segment =>
|
||||
intersectRayLineSegment(
|
||||
origin,
|
||||
direction,
|
||||
segment[0],
|
||||
segment[1]
|
||||
)
|
||||
)
|
||||
.filter(intersection => intersection.didIntersect)
|
||||
.flatMap(intersection => intersection.points);
|
||||
|
||||
if (!intersections.length) return;
|
||||
|
||||
// The center of the triangle
|
||||
const center = Vec.add(getWhiteArrowCentroid(shape.size), shape.point);
|
||||
|
||||
// Find furthest intersection between ray from origin through point and expanded bounds. TODO: What if the shape has a curve? In that case, should we intersect the circle-from-three-points instead?
|
||||
const intersection = intersections.sort(
|
||||
(a, b) => Vec.dist(b, origin) - Vec.dist(a, origin)
|
||||
)[0];
|
||||
|
||||
// The point between the handle and the intersection
|
||||
const middlePoint = Vec.med(point, intersection);
|
||||
|
||||
let anchor: number[];
|
||||
let distance: number;
|
||||
|
||||
if (bindAnywhere) {
|
||||
anchor =
|
||||
Vec.dist(point, center) < BINDING_DISTANCE / 2 ? center : point;
|
||||
distance = 0;
|
||||
} else {
|
||||
if (
|
||||
Vec.distanceToLineSegment(point, middlePoint, center) <
|
||||
BINDING_DISTANCE / 2
|
||||
) {
|
||||
anchor = center;
|
||||
} else {
|
||||
anchor = middlePoint;
|
||||
}
|
||||
|
||||
if (Utils.pointInPolygon(point, points)) {
|
||||
distance = this.bindingDistance;
|
||||
} else {
|
||||
distance = Math.max(
|
||||
this.bindingDistance,
|
||||
closestDistanceToEdge
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const bindingPoint = Vec.divV(
|
||||
Vec.sub(anchor, [expandedBounds.minX, expandedBounds.minY]),
|
||||
[expandedBounds.width, expandedBounds.height]
|
||||
);
|
||||
|
||||
return {
|
||||
point: Vec.clampV(bindingPoint, 0, 1),
|
||||
distance,
|
||||
};
|
||||
};
|
||||
|
||||
override transform = transformRectangle;
|
||||
|
||||
override transformSingle = transformSingleRectangle;
|
||||
}
|
||||
|
||||
const FullWrapper = styled('div')({ width: '100%', height: '100%' });
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import { Utils } from '@tldraw/core';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle } from '../../shared';
|
||||
import { getWhiteArrowPoints } from '../white-arrow-helpers';
|
||||
import Vec from '@tldraw/vec';
|
||||
|
||||
interface WhiteArrowSvgProps {
|
||||
id: string;
|
||||
size: number[];
|
||||
style: ShapeStyles;
|
||||
isSelected: boolean;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const DashedWhiteArrow = React.memo(function DashedWhiteArrow({
|
||||
id,
|
||||
size,
|
||||
style,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: WhiteArrowSvgProps) {
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode);
|
||||
const sw = 1 + strokeWidth * 1.618;
|
||||
const points = getWhiteArrowPoints(size);
|
||||
const sides = Utils.pointsToLineSegments(points, true);
|
||||
const paths = sides.map(([start, end], i) => {
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
Vec.dist(start, end),
|
||||
strokeWidth * 1.618,
|
||||
style.dash
|
||||
);
|
||||
|
||||
return (
|
||||
<line
|
||||
key={id + '_' + i}
|
||||
x1={start[0]}
|
||||
y1={start[1]}
|
||||
x2={end[0]}
|
||||
y2={end[1]}
|
||||
stroke={stroke}
|
||||
strokeWidth={sw}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const bgPath = points.join();
|
||||
|
||||
return (
|
||||
<>
|
||||
<polygon
|
||||
className={
|
||||
style.isFilled || isSelected
|
||||
? 'tl-fill-hitarea'
|
||||
: 'tl-stroke-hitarea'
|
||||
}
|
||||
points={bgPath}
|
||||
/>
|
||||
{style.isFilled && (
|
||||
<polygon fill={fill} points={bgPath} pointerEvents="none" />
|
||||
)}
|
||||
<g pointerEvents="stroke">{paths}</g>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react';
|
||||
import { getShapeStyle } from '../../shared';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import {
|
||||
getWhiteArrowIndicatorPathTDSnapshot,
|
||||
getWhiteArrowPath,
|
||||
} from '../white-arrow-helpers';
|
||||
|
||||
interface WhiteArrowSvgProps {
|
||||
id: string;
|
||||
size: number[];
|
||||
style: ShapeStyles;
|
||||
isSelected: boolean;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const DrawWhiteArrow = React.memo(function DrawTriangle({
|
||||
id,
|
||||
size,
|
||||
style,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: WhiteArrowSvgProps) {
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode);
|
||||
const path_td_snapshot = getWhiteArrowPath(id, size, style);
|
||||
const indicatorPath = getWhiteArrowIndicatorPathTDSnapshot(id, size, style);
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
className={
|
||||
style.isFilled || isSelected
|
||||
? 'tl-fill-hitarea'
|
||||
: 'tl-stroke-hitarea'
|
||||
}
|
||||
d={indicatorPath}
|
||||
/>
|
||||
{style.isFilled && (
|
||||
<path d={indicatorPath} fill={fill} pointerEvents="none" />
|
||||
)}
|
||||
<path
|
||||
d={path_td_snapshot}
|
||||
fill={stroke}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import { BINDING_DISTANCE } from '@toeverything/components/board-types';
|
||||
import { getWhiteArrowPoints } from '../white-arrow-helpers';
|
||||
|
||||
interface TriangleBindingIndicatorProps {
|
||||
size: number[];
|
||||
}
|
||||
|
||||
export function WhiteArrowBindingIndicator({
|
||||
size,
|
||||
}: TriangleBindingIndicatorProps) {
|
||||
const trianglePoints = getWhiteArrowPoints(size).join();
|
||||
return (
|
||||
<polygon
|
||||
className="tl-binding-indicator"
|
||||
points={trianglePoints}
|
||||
strokeWidth={BINDING_DISTANCE * 2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './WhiteArrowUtil';
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Utils } from '@tldraw/core';
|
||||
import Vec from '@tldraw/vec';
|
||||
import getStroke, { getStrokePoints } from 'perfect-freehand';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle, getOffsetPolygon } from '../shared';
|
||||
function getPonits(w: number, h: number) {
|
||||
return [
|
||||
[w / 2, 0],
|
||||
[w, h / 2],
|
||||
[(w / 4) * 3, h / 2],
|
||||
[(w / 4) * 3, h],
|
||||
[w / 4, h],
|
||||
[w / 4, h / 2],
|
||||
[0, h / 2],
|
||||
];
|
||||
}
|
||||
|
||||
export function getWhiteArrowPoints(size: number[], offset = 0, rotation = 0) {
|
||||
const [w, h] = size;
|
||||
let points = getPonits(w, h);
|
||||
if (offset) points = getOffsetPolygon(points, offset);
|
||||
if (rotation)
|
||||
points = points.map(pt => Vec.rotWith(pt, [w / 2, h / 2], rotation));
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
export function getWhiteArrowCentroid(size: number[]) {
|
||||
const [w, h] = size;
|
||||
const points = getPonits(w, h);
|
||||
return [
|
||||
(points[0][0] + points[1][0] + points[2][0]) / 3,
|
||||
(points[0][1] + points[1][1] + points[2][1]) / 3,
|
||||
];
|
||||
}
|
||||
|
||||
function getWhiteArrowDrawPoints(
|
||||
id: string,
|
||||
size: number[],
|
||||
strokeWidth: number
|
||||
) {
|
||||
const [w, h] = size;
|
||||
const getRandom = Utils.rng(id);
|
||||
// Random corner offsets
|
||||
const offsets = Array.from(Array(7)).map(() => {
|
||||
return [
|
||||
getRandom() * strokeWidth * 0.75,
|
||||
getRandom() * strokeWidth * 0.75,
|
||||
];
|
||||
});
|
||||
// Corners
|
||||
const point = getPonits(w, h);
|
||||
const corners = [
|
||||
Vec.add(point[0], offsets[0]),
|
||||
Vec.add(point[1], offsets[1]),
|
||||
Vec.add(point[2], offsets[2]),
|
||||
Vec.add(point[3], offsets[3]),
|
||||
Vec.add(point[4], offsets[4]),
|
||||
Vec.add(point[5], offsets[5]),
|
||||
Vec.add(point[6], offsets[6]),
|
||||
];
|
||||
|
||||
// Which side to start drawing first
|
||||
const rm = Math.round(Math.abs(getRandom() * 2 * 3));
|
||||
// Number of points per side
|
||||
// Inset each line by the corner radii and let the freehand algo
|
||||
// interpolate points for the corners.
|
||||
const lines = Utils.rotateArray(
|
||||
[
|
||||
Vec.pointsBetween(corners[0], corners[1], 32),
|
||||
Vec.pointsBetween(corners[1], corners[2], 32),
|
||||
Vec.pointsBetween(corners[2], corners[3], 32),
|
||||
Vec.pointsBetween(corners[3], corners[4], 32),
|
||||
Vec.pointsBetween(corners[4], corners[5], 32),
|
||||
Vec.pointsBetween(corners[5], corners[6], 32),
|
||||
Vec.pointsBetween(corners[6], corners[0], 32),
|
||||
],
|
||||
rm
|
||||
);
|
||||
// For the final points, include the first half of the first line again,
|
||||
// so that the line wraps around and avoids ending on a sharp corner.
|
||||
// This has a bit of finesse and magic—if you change the points between
|
||||
// function, then you'll likely need to change this one too.
|
||||
const points = [...lines.flat(), ...lines[0]];
|
||||
return {
|
||||
points,
|
||||
};
|
||||
}
|
||||
|
||||
function getDrawStrokeInfo(id: string, size: number[], style: ShapeStyles) {
|
||||
const { strokeWidth } = getShapeStyle(style);
|
||||
const { points } = getWhiteArrowDrawPoints(id, size, strokeWidth);
|
||||
const options = {
|
||||
size: strokeWidth,
|
||||
thinning: 0.65,
|
||||
streamline: 0.3,
|
||||
smoothing: 1,
|
||||
simulatePressure: false,
|
||||
last: true,
|
||||
};
|
||||
return { points, options };
|
||||
}
|
||||
|
||||
export function getWhiteArrowPath(
|
||||
id: string,
|
||||
size: number[],
|
||||
style: ShapeStyles
|
||||
) {
|
||||
const { points, options } = getDrawStrokeInfo(id, size, style);
|
||||
const stroke = getStroke(points, options);
|
||||
return Utils.getSvgPathFromStroke(stroke);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export function getWhiteArrowIndicatorPathTDSnapshot(
|
||||
id: string,
|
||||
size: number[],
|
||||
style: ShapeStyles
|
||||
) {
|
||||
const { points, options } = getDrawStrokeInfo(id, size, style);
|
||||
const strokePoints = getStrokePoints(points, options);
|
||||
return Utils.getSvgPathFromStroke(
|
||||
strokePoints.map(pt => pt.point.slice(0, 2)),
|
||||
false
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user