tldraw
Version:
A tiny little drawing editor.
289 lines (288 loc) • 8.21 kB
JavaScript
import { jsx } from "react/jsx-runtime";
import {
Box,
Rectangle2d,
ShapeUtil,
Vec,
WeakCache,
getDefaultColorTheme,
preventDefault,
textShapeMigrations,
textShapeProps,
toDomPrecision,
useEditor
} from "@tldraw/editor";
import { useCallback } from "react";
import { SvgTextLabel } from "../shared/SvgTextLabel.mjs";
import { TextHelpers } from "../shared/TextHelpers.mjs";
import { TextLabel } from "../shared/TextLabel.mjs";
import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from "../shared/default-shape-constants.mjs";
import { getFontDefForExport } from "../shared/defaultStyleDefs.mjs";
import { resizeScaled } from "../shared/resizeScaled.mjs";
import { useDefaultColorTheme } from "../shared/useDefaultColorTheme.mjs";
const sizeCache = new WeakCache();
class TextShapeUtil extends ShapeUtil {
static type = "text";
static props = textShapeProps;
static migrations = textShapeMigrations;
getDefaultProps() {
return {
color: "black",
size: "m",
w: 8,
text: "",
font: "draw",
textAlign: "start",
autoSize: true,
scale: 1
};
}
getMinDimensions(shape) {
return sizeCache.get(shape.props, (props) => getTextSize(this.editor, props));
}
getGeometry(shape) {
const { scale } = shape.props;
const { width, height } = this.getMinDimensions(shape);
return new Rectangle2d({
width: width * scale,
height: height * scale,
isFilled: true,
isLabel: true
});
}
getText(shape) {
return shape.props.text;
}
canEdit() {
return true;
}
isAspectRatioLocked() {
return true;
}
// WAIT NO THIS IS HARD CODED IN THE RESIZE HANDLER
component(shape) {
const {
id,
props: { font, size, text, color, scale, textAlign }
} = shape;
const { width, height } = this.getMinDimensions(shape);
const isSelected = shape.id === this.editor.getOnlySelectedShapeId();
const theme = useDefaultColorTheme();
const handleKeyDown = useTextShapeKeydownHandler(id);
return /* @__PURE__ */ jsx(
TextLabel,
{
shapeId: id,
classNamePrefix: "tl-text-shape",
type: "text",
font,
fontSize: FONT_SIZES[size],
lineHeight: TEXT_PROPS.lineHeight,
align: textAlign,
verticalAlign: "middle",
text,
labelColor: theme[color].solid,
isSelected,
textWidth: width,
textHeight: height,
style: {
transform: `scale(${scale})`,
transformOrigin: "top left"
},
wrap: true,
onKeyDown: handleKeyDown
}
);
}
indicator(shape) {
const bounds = this.editor.getShapeGeometry(shape).bounds;
const editor = useEditor();
if (shape.props.autoSize && editor.getEditingShapeId() === shape.id) return null;
return /* @__PURE__ */ jsx("rect", { width: toDomPrecision(bounds.width), height: toDomPrecision(bounds.height) });
}
toSvg(shape, ctx) {
if (shape.props.text) ctx.addExportDef(getFontDefForExport(shape.props.font));
const bounds = this.editor.getShapeGeometry(shape).bounds;
const width = bounds.width / (shape.props.scale ?? 1);
const height = bounds.height / (shape.props.scale ?? 1);
const theme = getDefaultColorTheme(ctx);
return /* @__PURE__ */ jsx(
SvgTextLabel,
{
fontSize: FONT_SIZES[shape.props.size],
font: shape.props.font,
align: shape.props.textAlign,
verticalAlign: "middle",
text: shape.props.text,
labelColor: theme[shape.props.color].solid,
bounds: new Box(0, 0, width, height),
padding: 0
}
);
}
onResize(shape, info) {
const { newPoint, initialBounds, initialShape, scaleX, handle } = info;
if (info.mode === "scale_shape" || handle !== "right" && handle !== "left") {
return {
id: shape.id,
type: shape.type,
...resizeScaled(shape, info)
};
} else {
const nextWidth = Math.max(1, Math.abs(initialBounds.width * scaleX));
const { x, y } = scaleX < 0 ? Vec.Sub(newPoint, Vec.FromAngle(shape.rotation).mul(nextWidth)) : newPoint;
return {
id: shape.id,
type: shape.type,
x,
y,
props: {
w: nextWidth / initialShape.props.scale,
autoSize: false
}
};
}
}
onEditEnd(shape) {
const {
id,
type,
props: { text }
} = shape;
const trimmedText = shape.props.text.trimEnd();
if (trimmedText.length === 0) {
this.editor.deleteShapes([shape.id]);
} else {
if (trimmedText !== shape.props.text) {
this.editor.updateShapes([
{
id,
type,
props: {
text: text.trimEnd()
}
}
]);
}
}
}
onBeforeUpdate(prev, next) {
if (!next.props.autoSize) return;
const styleDidChange = prev.props.size !== next.props.size || prev.props.textAlign !== next.props.textAlign || prev.props.font !== next.props.font || prev.props.scale !== 1 && next.props.scale === 1;
const textDidChange = prev.props.text !== next.props.text;
if (!styleDidChange && !textDidChange) return;
const boundsA = this.getMinDimensions(prev);
const boundsB = getTextSize(this.editor, next.props);
const wA = boundsA.width * prev.props.scale;
const hA = boundsA.height * prev.props.scale;
const wB = boundsB.width * next.props.scale;
const hB = boundsB.height * next.props.scale;
let delta;
switch (next.props.textAlign) {
case "middle": {
delta = new Vec((wB - wA) / 2, textDidChange ? 0 : (hB - hA) / 2);
break;
}
case "end": {
delta = new Vec(wB - wA, textDidChange ? 0 : (hB - hA) / 2);
break;
}
default: {
if (textDidChange) break;
delta = new Vec(0, (hB - hA) / 2);
break;
}
}
if (delta) {
delta.rot(next.rotation);
const { x, y } = next;
return {
...next,
x: x - delta.x,
y: y - delta.y,
props: { ...next.props, w: wB }
};
} else {
return {
...next,
props: { ...next.props, w: wB }
};
}
}
// todo: The edge doubleclicking feels like a mistake more often than
// not, especially on multiline text. Removed June 16 2024
// override onDoubleClickEdge = (shape: TLTextShape) => {
// // If the shape has a fixed width, set it to autoSize.
// if (!shape.props.autoSize) {
// return {
// id: shape.id,
// type: shape.type,
// props: {
// autoSize: true,
// },
// }
// }
// // If the shape is scaled, reset the scale to 1.
// if (shape.props.scale !== 1) {
// return {
// id: shape.id,
// type: shape.type,
// props: {
// scale: 1,
// },
// }
// }
// }
}
function getTextSize(editor, props) {
const { font, text, autoSize, size, w } = props;
const minWidth = autoSize ? 16 : Math.max(16, w);
const fontSize = FONT_SIZES[size];
const cw = autoSize ? null : (
// `measureText` floors the number so we need to do the same here to avoid issues.
(Math.floor(Math.max(minWidth, w)))
);
const result = editor.textMeasure.measureText(text, {
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[font],
fontSize,
maxWidth: cw
});
if (autoSize) {
result.w += 1;
}
return {
width: Math.max(minWidth, result.w),
height: Math.max(fontSize, result.h)
};
}
function useTextShapeKeydownHandler(id) {
const editor = useEditor();
return useCallback(
(e) => {
if (editor.getEditingShapeId() !== id) return;
switch (e.key) {
case "Enter": {
if (e.ctrlKey || e.metaKey) {
editor.complete();
}
break;
}
case "Tab": {
preventDefault(e);
if (e.shiftKey) {
TextHelpers.unindent(e.currentTarget);
} else {
TextHelpers.indent(e.currentTarget);
}
break;
}
}
},
[editor, id]
);
}
export {
TextShapeUtil
};
//# sourceMappingURL=TextShapeUtil.mjs.map