tldraw
Version:
A tiny little drawing editor.
294 lines (293 loc) • 8.86 kB
JavaScript
import { jsx } from "react/jsx-runtime";
import {
Box,
Rectangle2d,
ShapeUtil,
Vec,
createComputedCache,
getColorValue,
getDefaultColorTheme,
getFontsFromRichText,
isEqual,
resizeScaled,
textShapeMigrations,
textShapeProps,
toDomPrecision,
toRichText,
useEditor
} from "@tldraw/editor";
import { useCallback } from "react";
import {
renderHtmlFromRichTextForMeasurement,
renderPlaintextFromRichText
} from "../../utils/text/richText.mjs";
import { RichTextLabel, RichTextSVG } from "../shared/RichTextLabel.mjs";
import { FONT_FAMILIES, FONT_SIZES, TEXT_PROPS } from "../shared/default-shape-constants.mjs";
import { useDefaultColorTheme } from "../shared/useDefaultColorTheme.mjs";
const sizeCache = createComputedCache(
"text size",
(editor, shape) => {
editor.fonts.trackFontsForShape(shape);
return getTextSize(editor, shape.props);
},
{ areRecordsEqual: (a, b) => a.props === b.props }
);
class TextShapeUtil extends ShapeUtil {
static type = "text";
static props = textShapeProps;
static migrations = textShapeMigrations;
options = {
extraArrowHorizontalPadding: 10,
showTextOutline: true
};
getDefaultProps() {
return {
color: "black",
size: "m",
w: 8,
font: "draw",
textAlign: "start",
autoSize: true,
scale: 1,
richText: toRichText("")
};
}
getMinDimensions(shape) {
return sizeCache.get(this.editor, shape.id);
}
getGeometry(shape, opts) {
const { scale } = shape.props;
const { width, height } = this.getMinDimensions(shape);
const context = opts?.context ?? "none";
return new Rectangle2d({
x: (context === "@tldraw/arrow-without-arrowhead" ? -this.options.extraArrowHorizontalPadding : 0) * scale,
width: (width + (context === "@tldraw/arrow-without-arrowhead" ? this.options.extraArrowHorizontalPadding * 2 : 0)) * scale,
height: height * scale,
isFilled: true,
isLabel: true
});
}
getFontFaces(shape) {
return getFontsFromRichText(this.editor, shape.props.richText, {
family: `tldraw_${shape.props.font}`,
weight: "normal",
style: "normal"
});
}
getText(shape) {
return renderPlaintextFromRichText(this.editor, shape.props.richText);
}
canEdit() {
return true;
}
isAspectRatioLocked() {
return true;
}
// WAIT NO THIS IS HARD CODED IN THE RESIZE HANDLER
component(shape) {
const {
id,
props: { font, size, richText, 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(
RichTextLabel,
{
shapeId: id,
classNamePrefix: "tl-text-shape",
type: "text",
font,
fontSize: FONT_SIZES[size],
lineHeight: TEXT_PROPS.lineHeight,
align: textAlign,
verticalAlign: "middle",
richText,
labelColor: getColorValue(theme, color, "solid"),
isSelected,
textWidth: width,
textHeight: height,
showTextOutline: this.options.showTextOutline,
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) });
}
useLegacyIndicator() {
return false;
}
getIndicatorPath(shape) {
if (shape.props.autoSize && this.editor.getEditingShapeId() === shape.id) return void 0;
const bounds = this.editor.getShapeGeometry(shape).bounds;
const path = new Path2D();
path.rect(0, 0, bounds.width, bounds.height);
return path;
}
toSvg(shape, ctx) {
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);
const exportBounds = new Box(0, 0, width, height);
return /* @__PURE__ */ jsx(
RichTextSVG,
{
fontSize: FONT_SIZES[shape.props.size],
font: shape.props.font,
align: shape.props.textAlign,
verticalAlign: "middle",
richText: shape.props.richText,
labelColor: getColorValue(theme, shape.props.color, "solid"),
bounds: exportBounds,
padding: 0,
showTextOutline: this.options.showTextOutline
}
);
}
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 trimmedText = renderPlaintextFromRichText(this.editor, shape.props.richText).trimEnd();
if (trimmedText.length === 0) {
this.editor.deleteShapes([shape.id]);
}
}
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 = !isEqual(prev.props.richText, next.props.richText);
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, richText, size, w } = props;
const minWidth = 16;
const fontSize = FONT_SIZES[size];
const maybeFixedWidth = props.autoSize ? null : Math.max(minWidth, Math.floor(w));
const html = renderHtmlFromRichTextForMeasurement(editor, richText);
const result = editor.textMeasure.measureHtml(html, {
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[font],
fontSize,
maxWidth: maybeFixedWidth
});
return {
width: maybeFixedWidth ?? Math.max(minWidth, result.w + 1),
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;
}
}
},
[editor, id]
);
}
export {
TextShapeUtil
};
//# sourceMappingURL=TextShapeUtil.mjs.map