tldraw
Version:
A tiny little drawing editor.
512 lines (511 loc) • 18.5 kB
JavaScript
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var GeoShapeUtil_exports = {};
__export(GeoShapeUtil_exports, {
GeoShapeUtil: () => GeoShapeUtil
});
module.exports = __toCommonJS(GeoShapeUtil_exports);
var import_jsx_runtime = require("react/jsx-runtime");
var import_editor = require("@tldraw/editor");
var import_richText = require("../../utils/text/richText");
var import_HyperlinkButton = require("../shared/HyperlinkButton");
var import_RichTextLabel = require("../shared/RichTextLabel");
var import_default_shape_constants = require("../shared/default-shape-constants");
var import_defaultStyleDefs = require("../shared/defaultStyleDefs");
var import_useDefaultColorTheme = require("../shared/useDefaultColorTheme");
var import_useEditablePlainText = require("../shared/useEditablePlainText");
var import_useEfficientZoomThreshold = require("../shared/useEfficientZoomThreshold");
var import_GeoShapeBody = require("./components/GeoShapeBody");
var import_getGeoShapePath = require("./getGeoShapePath");
const MIN_SIZE_WITH_LABEL = 17 * 3;
class GeoShapeUtil extends import_editor.BaseBoxShapeUtil {
static type = "geo";
static props = import_editor.geoShapeProps;
static migrations = import_editor.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: (0, import_editor.toRichText)("")
};
}
getGeometry(shape) {
const { props } = shape;
const { scale } = props;
const path = (0, import_getGeoShapePath.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 = (0, import_richText.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 import_editor.Group2d({
children: [
pathGeometry,
new import_editor.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:
(0, import_editor.exhaustiveSwitchError)(shape.props.geo);
}
}
getText(shape) {
return (0, import_richText.renderPlaintextFromRichText)(this.editor, shape.props.richText);
}
getFontFaces(shape) {
if ((0, import_richText.isEmptyRichText)(shape.props.richText)) {
return import_editor.EMPTY_ARRAY;
}
return (0, import_editor.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 = (0, import_useDefaultColorTheme.useDefaultColorTheme)();
const { editor } = this;
const isOnlySelected = (0, import_editor.useValue)(
"isGeoOnlySelected",
() => shape.id === editor.getOnlySelectedShapeId(),
[editor]
);
const isReadyForEditing = (0, import_useEditablePlainText.useIsReadyForEditing)(editor, shape.id);
const isEmpty = (0, import_richText.isEmptyRichText)(shape.props.richText);
const showHtmlContainer = isReadyForEditing || !isEmpty;
const isForceSolid = (0, import_useEfficientZoomThreshold.useEfficientZoomThreshold)(0.25 / shape.props.scale);
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_editor.SVGContainer, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_GeoShapeBody.GeoShapeBody, { shape, shouldScale: true, forceSolid: isForceSolid }) }),
showHtmlContainer && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
import_editor.HTMLContainer,
{
style: {
overflow: "hidden",
width: shape.props.w,
height: shape.props.h + props.growY
},
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
import_RichTextLabel.RichTextLabel,
{
shapeId: id,
type,
font,
fontSize: import_default_shape_constants.LABEL_FONT_SIZES[size] * shape.props.scale,
lineHeight: import_default_shape_constants.TEXT_PROPS.lineHeight,
padding: import_default_shape_constants.LABEL_PADDING * shape.props.scale,
fill,
align,
verticalAlign,
richText,
isSelected: isOnlySelected,
labelColor: (0, import_editor.getColorValue)(theme, props.labelColor, "solid"),
wrap: true,
showTextOutline: this.options.showTextOutline
}
)
}
),
shape.props.url && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_HyperlinkButton.HyperlinkButton, { url: shape.props.url })
] });
}
indicator(shape) {
const isZoomedOut = (0, import_useEfficientZoomThreshold.useEfficientZoomThreshold)(0.25 / shape.props.scale);
const { size, dash, scale } = shape.props;
const strokeWidth = import_default_shape_constants.STROKE_SIZES[size];
const path = (0, import_getGeoShapePath.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 = import_default_shape_constants.STROKE_SIZES[size];
const path = (0, import_getGeoShapePath.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((0, import_defaultStyleDefs.getFillDefForExport)(props.fill));
let textEl;
if (!(0, import_richText.isEmptyRichText)(props.richText)) {
const theme = (0, import_editor.getDefaultColorTheme)(ctx);
const bounds = new import_editor.Box(0, 0, props.w, (shape.props.h + shape.props.growY) / scale);
textEl = /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
import_RichTextLabel.RichTextSVG,
{
fontSize: import_default_shape_constants.LABEL_FONT_SIZES[props.size],
font: props.font,
align: props.align,
verticalAlign: props.verticalAlign,
richText: props.richText,
labelColor: (0, import_editor.getColorValue)(theme, props.labelColor, "solid"),
bounds,
padding: import_default_shape_constants.LABEL_PADDING,
showTextOutline: this.options.showTextOutline
}
);
}
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_GeoShapeBody.GeoShapeBody, { shouldScale: false, shape: newShape, forceSolid: false }),
textEl
] });
}
getCanvasSvgDefs() {
return [(0, import_defaultStyleDefs.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 (!(0, import_richText.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 import_editor.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 ((0, import_richText.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 ((0, import_editor.isEqual)(prevProps.richText, nextProps.richText) && prevProps.font === nextProps.font && prevProps.size === nextProps.size) {
return void 0;
}
const wasEmpty = (0, import_richText.isEmptyRichText)(prevProps.richText);
const isEmpty = (0, import_richText.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: (0, import_editor.lerp)(startShape.props.w, endShape.props.w, t),
h: (0, import_editor.lerp)(startShape.props.h, endShape.props.h, t),
scale: (0, import_editor.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(
import_default_shape_constants.LABEL_FONT_SIZES[size] * import_default_shape_constants.TEXT_PROPS.lineHeight + import_default_shape_constants.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 import_editor.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 = (0, import_richText.renderHtmlFromRichTextForMeasurement)(editor, richText);
const textSize = editor.textMeasure.measureHtml(html, {
...import_default_shape_constants.TEXT_PROPS,
fontFamily: import_default_shape_constants.FONT_FAMILIES[font],
fontSize: import_default_shape_constants.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 - import_default_shape_constants.LABEL_PADDING * 2)
)
});
return {
w: textSize.w + import_default_shape_constants.LABEL_PADDING * 2,
h: textSize.h + import_default_shape_constants.LABEL_PADDING * 2
};
}
//# sourceMappingURL=GeoShapeUtil.js.map