tldraw
Version:
A tiny little drawing editor.
522 lines (521 loc) • 16.3 kB
JavaScript
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import {
BaseBoxShapeUtil,
Box,
EMPTY_ARRAY,
Group2d,
HTMLContainer,
Rectangle2d,
SVGContainer,
Vec,
WeakCache,
exhaustiveSwitchError,
geoShapeMigrations,
geoShapeProps,
getColorValue,
getDefaultColorTheme,
getFontsFromRichText,
isEqual,
lerp,
toRichText,
useValue
} from "@tldraw/editor";
import {
isEmptyRichText,
renderHtmlFromRichTextForMeasurement,
renderPlaintextFromRichText
} from "../../utils/text/richText.mjs";
import { HyperlinkButton } from "../shared/HyperlinkButton.mjs";
import { RichTextLabel, RichTextSVG } from "../shared/RichTextLabel.mjs";
import {
FONT_FAMILIES,
LABEL_FONT_SIZES,
LABEL_PADDING,
STROKE_SIZES,
TEXT_PROPS
} from "../shared/default-shape-constants.mjs";
import { getFillDefForCanvas, getFillDefForExport } from "../shared/defaultStyleDefs.mjs";
import { useDefaultColorTheme } from "../shared/useDefaultColorTheme.mjs";
import { useIsReadyForEditing } from "../shared/useEditablePlainText.mjs";
import { useEfficientZoomThreshold } from "../shared/useEfficientZoomThreshold.mjs";
import { GeoShapeBody } from "./components/GeoShapeBody.mjs";
import { getGeoShapePath } from "./getGeoShapePath.mjs";
const MIN_SIZE_WITH_LABEL = 17 * 3;
class GeoShapeUtil extends BaseBoxShapeUtil {
static type = "geo";
static props = geoShapeProps;
static migrations = geoShapeMigrations;
options = {
showTextOutline: true
};
canEdit() {
return true;
}
getDefaultProps() {
return {
w: 100,
h: 100,
geo: "rectangle",
dash: "draw",
growY: 0,
url: "",
scale: 1,
// Text properties
color: "black",
labelColor: "black",
fill: "none",
size: "m",
font: "draw",
align: "middle",
verticalAlign: "middle",
richText: toRichText("")
};
}
getGeometry(shape) {
const { props } = shape;
const { scale } = props;
const path = getGeoShapePath(shape);
const pathGeometry = path.toGeometry();
const scaledW = Math.max(1, props.w);
const scaledH = Math.max(1, props.h + props.growY);
const unscaledW = scaledW / scale;
const unscaledH = scaledH / scale;
const isEmptyLabel = isEmptyRichText(props.richText);
const unscaledLabelSize = isEmptyLabel ? EMPTY_LABEL_SIZE : getUnscaledLabelSize(this.editor, shape);
const labelBounds = getLabelBounds(
unscaledW,
unscaledH,
unscaledLabelSize,
props.size,
props.align,
props.verticalAlign,
scale
);
return new Group2d({
children: [
pathGeometry,
new Rectangle2d({
...labelBounds,
isFilled: true,
isLabel: true,
excludeFromShapeBounds: true,
isEmptyLabel
})
]
});
}
getHandleSnapGeometry(shape) {
const geometry = this.getGeometry(shape);
const outline = geometry.children[0];
switch (shape.props.geo) {
case "arrow-down":
case "arrow-left":
case "arrow-right":
case "arrow-up":
case "check-box":
case "diamond":
case "hexagon":
case "octagon":
case "pentagon":
case "rectangle":
case "rhombus":
case "rhombus-2":
case "star":
case "trapezoid":
case "triangle":
case "x-box":
return { outline, points: [...outline.vertices, geometry.bounds.center] };
case "cloud":
case "ellipse":
case "heart":
case "oval":
return { outline, points: [geometry.bounds.center] };
default:
exhaustiveSwitchError(shape.props.geo);
}
}
getText(shape) {
return renderPlaintextFromRichText(this.editor, shape.props.richText);
}
getFontFaces(shape) {
if (isEmptyRichText(shape.props.richText)) {
return EMPTY_ARRAY;
}
return getFontsFromRichText(this.editor, shape.props.richText, {
family: `tldraw_${shape.props.font}`,
weight: "normal",
style: "normal"
});
}
component(shape) {
const { id, type, props } = shape;
const { fill, font, align, verticalAlign, size, richText } = props;
const theme = useDefaultColorTheme();
const { editor } = this;
const isOnlySelected = useValue(
"isGeoOnlySelected",
() => shape.id === editor.getOnlySelectedShapeId(),
[editor]
);
const isReadyForEditing = useIsReadyForEditing(editor, shape.id);
const isEmpty = isEmptyRichText(shape.props.richText);
const showHtmlContainer = isReadyForEditing || !isEmpty;
const isForceSolid = useEfficientZoomThreshold(0.25 / shape.props.scale);
return /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(SVGContainer, { children: /* @__PURE__ */ jsx(GeoShapeBody, { shape, shouldScale: true, forceSolid: isForceSolid }) }),
showHtmlContainer && /* @__PURE__ */ jsx(
HTMLContainer,
{
style: {
overflow: "hidden",
width: shape.props.w,
height: shape.props.h + props.growY
},
children: /* @__PURE__ */ jsx(
RichTextLabel,
{
shapeId: id,
type,
font,
fontSize: LABEL_FONT_SIZES[size] * shape.props.scale,
lineHeight: TEXT_PROPS.lineHeight,
padding: LABEL_PADDING * shape.props.scale,
fill,
align,
verticalAlign,
richText,
isSelected: isOnlySelected,
labelColor: getColorValue(theme, props.labelColor, "solid"),
wrap: true,
showTextOutline: this.options.showTextOutline
}
)
}
),
shape.props.url && /* @__PURE__ */ jsx(HyperlinkButton, { url: shape.props.url })
] });
}
indicator(shape) {
const isZoomedOut = useEfficientZoomThreshold(0.25 / shape.props.scale);
const { size, dash, scale } = shape.props;
const strokeWidth = STROKE_SIZES[size];
const path = getGeoShapePath(shape);
return path.toSvg({
style: dash === "draw" ? "draw" : "solid",
strokeWidth: 1,
passes: 1,
randomSeed: shape.id,
offset: 0,
roundness: strokeWidth * 2 * scale,
props: { strokeWidth: void 0 },
forceSolid: isZoomedOut
});
}
useLegacyIndicator() {
return false;
}
getIndicatorPath(shape) {
const isForceSolid = this.editor.getEfficientZoomLevel() < 0.25 / shape.props.scale;
const { size, dash, scale } = shape.props;
const strokeWidth = STROKE_SIZES[size];
const path = getGeoShapePath(shape);
return path.toPath2D({
style: dash === "draw" ? "draw" : "solid",
strokeWidth: 1,
passes: 1,
randomSeed: shape.id,
offset: 0,
roundness: strokeWidth * 2 * scale,
forceSolid: isForceSolid
});
}
toSvg(shape, ctx) {
const scale = shape.props.scale;
const newShape = {
...shape,
props: {
...shape.props,
w: shape.props.w / scale,
h: (shape.props.h + shape.props.growY) / scale,
growY: 0
// growY throws off the path calculations, so we set it to 0
}
};
const props = newShape.props;
ctx.addExportDef(getFillDefForExport(props.fill));
let textEl;
if (!isEmptyRichText(props.richText)) {
const theme = getDefaultColorTheme(ctx);
const bounds = new Box(0, 0, props.w, (shape.props.h + shape.props.growY) / scale);
textEl = /* @__PURE__ */ jsx(
RichTextSVG,
{
fontSize: LABEL_FONT_SIZES[props.size],
font: props.font,
align: props.align,
verticalAlign: props.verticalAlign,
richText: props.richText,
labelColor: getColorValue(theme, props.labelColor, "solid"),
bounds,
padding: LABEL_PADDING,
showTextOutline: this.options.showTextOutline
}
);
}
return /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(GeoShapeBody, { shouldScale: false, shape: newShape, forceSolid: false }),
textEl
] });
}
getCanvasSvgDefs() {
return [getFillDefForCanvas()];
}
onResize(shape, { handle, newPoint, scaleX, scaleY, initialShape }) {
const unscaledInitial = getUnscaledGeoProps(initialShape.props);
let unscaledW = unscaledInitial.w * scaleX;
let unscaledH = (unscaledInitial.h + unscaledInitial.growY) * scaleY;
let overShrinkX = 0;
let overShrinkY = 0;
if (!isEmptyRichText(shape.props.richText)) {
const absUnscaledW = Math.abs(unscaledW);
const absUnscaledH = Math.abs(unscaledH);
const measureW = Math.max(absUnscaledW, MIN_SIZE_WITH_LABEL);
const measureH = Math.max(absUnscaledH, MIN_SIZE_WITH_LABEL);
const unscaledLabelSize = measureUnscaledLabelSize(this.editor, {
...shape,
props: {
...shape.props,
w: measureW * shape.props.scale,
h: measureH * shape.props.scale
}
});
const constrainedW = Math.max(absUnscaledW, unscaledLabelSize.w);
const constrainedH = Math.max(absUnscaledH, unscaledLabelSize.h);
overShrinkX = constrainedW - absUnscaledW;
overShrinkY = constrainedH - absUnscaledH;
unscaledW = constrainedW * Math.sign(unscaledW || 1);
unscaledH = constrainedH * Math.sign(unscaledH || 1);
}
const scaledW = unscaledW * shape.props.scale;
const scaledH = unscaledH * shape.props.scale;
const offset = new Vec(0, 0);
if (scaleX < 0) {
offset.x += scaledW;
}
if (handle === "left" || handle === "top_left" || handle === "bottom_left") {
offset.x += scaleX < 0 ? overShrinkX : -overShrinkX;
}
if (scaleY < 0) {
offset.y += scaledH;
}
if (handle === "top" || handle === "top_left" || handle === "top_right") {
offset.y += scaleY < 0 ? overShrinkY : -overShrinkY;
}
const { x, y } = offset.rot(shape.rotation).add(newPoint);
return {
x,
y,
props: {
w: Math.max(Math.abs(scaledW), 1),
h: Math.max(Math.abs(scaledH), 1),
growY: 0
}
};
}
onBeforeCreate(shape) {
const { props } = shape;
if (isEmptyRichText(props.richText)) {
return props.growY !== 0 ? { ...shape, props: { ...props, growY: 0 } } : void 0;
}
const unscaledShapeH = props.h / props.scale;
const unscaledLabelH = getUnscaledLabelSize(this.editor, shape).h;
const unscaledGrowY = calculateGrowY(unscaledShapeH, unscaledLabelH, props.growY / props.scale);
if (unscaledGrowY !== null) {
return {
...shape,
props: { ...props, growY: unscaledGrowY * props.scale }
};
}
return void 0;
}
onBeforeUpdate(prev, next) {
const { props: prevProps } = prev;
const { props: nextProps } = next;
if (isEqual(prevProps.richText, nextProps.richText) && prevProps.font === nextProps.font && prevProps.size === nextProps.size) {
return void 0;
}
const wasEmpty = isEmptyRichText(prevProps.richText);
const isEmpty = isEmptyRichText(nextProps.richText);
if (wasEmpty && isEmpty) {
return void 0;
}
if (isEmpty) {
return nextProps.growY !== 0 ? { ...next, props: { ...nextProps, growY: 0 } } : void 0;
}
const unscaledPrev = getUnscaledGeoProps(prevProps);
const unscaledLabelSize = getUnscaledLabelSize(this.editor, next);
const { scale } = nextProps;
if (wasEmpty && !isEmpty) {
const expanded = expandShapeForFirstLabel(unscaledPrev.w, unscaledPrev.h, unscaledLabelSize);
return {
...next,
props: {
...nextProps,
w: expanded.w * scale,
h: expanded.h * scale,
growY: 0
}
};
}
const unscaledNextW = next.props.w / scale;
const needsWidthExpand = unscaledLabelSize.w > unscaledNextW;
const unscaledGrowY = calculateGrowY(unscaledPrev.h, unscaledLabelSize.h, unscaledPrev.growY);
if (unscaledGrowY !== null || needsWidthExpand) {
return {
...next,
props: {
...nextProps,
growY: (unscaledGrowY ?? unscaledPrev.growY) * scale,
w: Math.max(unscaledNextW, unscaledLabelSize.w) * scale
}
};
}
return void 0;
}
onDoubleClick(shape) {
if (this.editor.inputs.getAltKey()) {
switch (shape.props.geo) {
case "rectangle": {
return {
...shape,
props: {
geo: "check-box"
}
};
}
case "check-box": {
return {
...shape,
props: {
geo: "rectangle"
}
};
}
}
}
return;
}
getInterpolatedProps(startShape, endShape, t) {
return {
...(t > 0.5 ? endShape.props : startShape.props),
w: lerp(startShape.props.w, endShape.props.w, t),
h: lerp(startShape.props.h, endShape.props.h, t),
scale: lerp(startShape.props.scale, endShape.props.scale, t)
};
}
}
const MIN_WIDTHS = Object.freeze({
s: 12,
m: 14,
l: 16,
xl: 20
});
const EXTRA_PADDINGS = Object.freeze({
s: 2,
m: 3.5,
l: 5,
xl: 10
});
const EMPTY_LABEL_SIZE = Object.freeze({ w: 0, h: 0 });
const LABEL_EDGE_MARGIN = 8;
function getLabelBounds(unscaledShapeW, unscaledShapeH, unscaledLabelSize, size, align, verticalAlign, scale) {
const unscaledMinWidth = Math.min(100, unscaledShapeW / 2);
const unscaledMinHeight = Math.min(
LABEL_FONT_SIZES[size] * TEXT_PROPS.lineHeight + LABEL_PADDING * 2,
unscaledShapeH / 2
);
const unscaledLabelW = Math.min(
unscaledShapeW,
Math.max(
unscaledLabelSize.w,
Math.min(unscaledMinWidth, Math.max(1, unscaledShapeW - LABEL_EDGE_MARGIN))
)
);
const unscaledLabelH = Math.min(
unscaledShapeH,
Math.max(
unscaledLabelSize.h,
Math.min(unscaledMinHeight, Math.max(1, unscaledShapeH - LABEL_EDGE_MARGIN))
)
);
const unscaledX = align === "start" ? 0 : align === "end" ? unscaledShapeW - unscaledLabelW : (unscaledShapeW - unscaledLabelW) / 2;
const unscaledY = verticalAlign === "start" ? 0 : verticalAlign === "end" ? unscaledShapeH - unscaledLabelH : (unscaledShapeH - unscaledLabelH) / 2;
return {
x: unscaledX * scale,
y: unscaledY * scale,
width: unscaledLabelW * scale,
height: unscaledLabelH * scale
};
}
function getUnscaledGeoProps(props) {
const { w, h, growY, scale } = props;
return {
w: w / scale,
h: h / scale,
growY: growY / scale
};
}
function calculateGrowY(unscaledShapeH, unscaledLabelH, unscaledCurrentGrowY) {
if (unscaledLabelH > unscaledShapeH) {
return unscaledLabelH - unscaledShapeH;
}
if (unscaledCurrentGrowY > 0) {
return 0;
}
return null;
}
function expandShapeForFirstLabel(unscaledW, unscaledH, unscaledLabelSize) {
let w = Math.max(unscaledW, unscaledLabelSize.w);
let h = Math.max(unscaledH, unscaledLabelSize.h);
if (unscaledW < MIN_SIZE_WITH_LABEL && unscaledH < MIN_SIZE_WITH_LABEL) {
w = Math.max(w, MIN_SIZE_WITH_LABEL);
h = Math.max(h, MIN_SIZE_WITH_LABEL);
const maxDim = Math.max(w, h);
w = maxDim;
h = maxDim;
}
return { w, h };
}
const labelSizesForGeo = new WeakCache();
function getUnscaledLabelSize(editor, shape) {
return labelSizesForGeo.get(shape, () => {
return measureUnscaledLabelSize(editor, shape);
});
}
function measureUnscaledLabelSize(editor, shape) {
const { richText, font, size, w } = shape.props;
const minWidth = MIN_WIDTHS[size];
const html = renderHtmlFromRichTextForMeasurement(editor, richText);
const textSize = editor.textMeasure.measureHtml(html, {
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[font],
fontSize: LABEL_FONT_SIZES[size],
minWidth,
maxWidth: Math.max(
// Guard because a DOM nodes can't be less 0
0,
// A 'w' width that we're setting as the min-width
Math.ceil(minWidth + EXTRA_PADDINGS[size]),
// The actual text size
Math.ceil(w / shape.props.scale - LABEL_PADDING * 2)
)
});
return {
w: textSize.w + LABEL_PADDING * 2,
h: textSize.h + LABEL_PADDING * 2
};
}
export {
GeoShapeUtil
};
//# sourceMappingURL=GeoShapeUtil.mjs.map