UNPKG

tldraw

Version:

A tiny little drawing editor.

522 lines (521 loc) • 16.3 kB
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