UNPKG

tldraw

Version:

A tiny little drawing editor.

438 lines (437 loc) • 13.7 kB
import { Fragment, jsx, jsxs } from "react/jsx-runtime"; import { Box, Group2d, Rectangle2d, ShapeUtil, Vec, WeakCache, exhaustiveSwitchError, getDefaultColorTheme, getFontsFromRichText, lerp, noteShapeMigrations, noteShapeProps, resizeScaled, rng, toDomPrecision, toRichText, useEditor, useValue } from "@tldraw/editor"; import { useCallback } from "react"; import { useCurrentTranslation } from "../../ui/hooks/useTranslation/useTranslation.mjs"; import { isRightToLeftLanguage } from "../../utils/text/text.mjs"; import { HyperlinkButton } from "../shared/HyperlinkButton.mjs"; import { RichTextLabel, RichTextSVG } from "../shared/RichTextLabel.mjs"; import { FONT_FAMILIES, LABEL_FONT_SIZES, LABEL_PADDING, TEXT_PROPS } from "../shared/default-shape-constants.mjs"; import { startEditingShapeWithLabel } from "../../tools/SelectTool/selectHelpers.mjs"; import isEqual from "lodash.isequal"; import { isEmptyRichText, renderHtmlFromRichTextForMeasurement, renderPlaintextFromRichText } from "../../utils/text/richText.mjs"; import { useDefaultColorTheme } from "../shared/useDefaultColorTheme.mjs"; import { useIsReadyForEditing } from "../shared/useEditablePlainText.mjs"; import { CLONE_HANDLE_MARGIN, NOTE_CENTER_OFFSET, NOTE_SIZE, getNoteShapeForAdjacentPosition } from "./noteHelpers.mjs"; class NoteShapeUtil extends ShapeUtil { static type = "note"; static props = noteShapeProps; static migrations = noteShapeMigrations; options = { resizeMode: "none" }; canEdit() { return true; } hideResizeHandles() { const { resizeMode } = this.options; switch (resizeMode) { case "none": { return true; } case "scale": { return false; } default: { throw exhaustiveSwitchError(resizeMode); } } } isAspectRatioLocked() { return this.options.resizeMode === "scale"; } hideSelectionBoundsFg() { return false; } getDefaultProps() { return { color: "black", richText: toRichText(""), size: "m", font: "draw", align: "middle", verticalAlign: "middle", labelColor: "black", growY: 0, fontSizeAdjustment: 0, url: "", scale: 1 }; } getGeometry(shape) { const { labelHeight, labelWidth } = getLabelSize(this.editor, shape); const { scale } = shape.props; const lh = labelHeight * scale; const lw = labelWidth * scale; const nw = NOTE_SIZE * scale; const nh = getNoteHeight(shape); return new Group2d({ children: [ new Rectangle2d({ width: nw, height: nh, isFilled: true }), new Rectangle2d({ x: shape.props.align === "start" ? 0 : shape.props.align === "end" ? nw - lw : (nw - lw) / 2, y: shape.props.verticalAlign === "start" ? 0 : shape.props.verticalAlign === "end" ? nh - lh : (nh - lh) / 2, width: lw, height: lh, isFilled: true, isLabel: true }) ] }); } getHandles(shape) { const { scale } = shape.props; const isCoarsePointer = this.editor.getInstanceState().isCoarsePointer; if (isCoarsePointer) return []; const zoom = this.editor.getZoomLevel(); if (zoom * scale < 0.25) return []; const nh = getNoteHeight(shape); const nw = NOTE_SIZE * scale; const offset = CLONE_HANDLE_MARGIN / zoom * scale; if (zoom * scale < 0.5) { return [ { id: "bottom", index: "a3", type: "clone", x: nw / 2, y: nh + offset } ]; } return [ { id: "top", index: "a1", type: "clone", x: nw / 2, y: -offset }, { id: "right", index: "a2", type: "clone", x: nw + offset, y: nh / 2 }, { id: "bottom", index: "a3", type: "clone", x: nw / 2, y: nh + offset }, { id: "left", index: "a4", type: "clone", x: -offset, y: nh / 2 } ]; } onResize(shape, info) { const { resizeMode } = this.options; switch (resizeMode) { case "none": { return void 0; } case "scale": { return resizeScaled(shape, info); } default: { throw exhaustiveSwitchError(resizeMode); } } } getText(shape) { return renderPlaintextFromRichText(this.editor, shape.props.richText); } getFontFaces(shape) { return getFontsFromRichText(this.editor, shape.props.richText, { family: `tldraw_${shape.props.font}`, weight: "normal", style: "normal" }); } component(shape) { const { id, type, props: { labelColor, scale, color, font, size, align, richText, verticalAlign, fontSizeAdjustment } } = shape; const handleKeyDown = useNoteKeydownHandler(id); const theme = useDefaultColorTheme(); const nw = NOTE_SIZE * scale; const nh = getNoteHeight(shape); const rotation = useValue( "shape rotation", () => this.editor.getShapePageTransform(id)?.rotation() ?? 0, [this.editor] ); const hideShadows = useValue("zoom", () => this.editor.getZoomLevel() < 0.35 / scale, [ scale, this.editor ]); const isDarkMode = useValue("dark mode", () => this.editor.user.getIsDarkMode(), [this.editor]); const isSelected = shape.id === this.editor.getOnlySelectedShapeId(); const isReadyForEditing = useIsReadyForEditing(this.editor, shape.id); const isEmpty = isEmptyRichText(richText); return /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx( "div", { id, className: "tl-note__container", style: { width: nw, height: nh, backgroundColor: theme[color].note.fill, borderBottom: hideShadows ? isDarkMode ? `${2 * scale}px solid rgb(20, 20, 20)` : `${2 * scale}px solid rgb(144, 144, 144)` : "none", boxShadow: hideShadows ? "none" : getNoteShadow(shape.id, rotation, scale) }, children: (isSelected || isReadyForEditing || !isEmpty) && /* @__PURE__ */ jsx( RichTextLabel, { shapeId: id, type, font, fontSize: (fontSizeAdjustment || LABEL_FONT_SIZES[size]) * scale, lineHeight: TEXT_PROPS.lineHeight, align, verticalAlign, richText, isSelected, labelColor: labelColor === "black" ? theme[color].note.text : theme[labelColor].fill, wrap: true, padding: LABEL_PADDING * scale, hasCustomTabBehavior: true, onKeyDown: handleKeyDown } ) } ), "url" in shape.props && shape.props.url && /* @__PURE__ */ jsx(HyperlinkButton, { url: shape.props.url }) ] }); } indicator(shape) { const { scale } = shape.props; return /* @__PURE__ */ jsx( "rect", { rx: scale, width: toDomPrecision(NOTE_SIZE * scale), height: toDomPrecision(getNoteHeight(shape)) } ); } toSvg(shape, ctx) { const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode }); const bounds = getBoundsForSVG(shape); const textLabel = /* @__PURE__ */ jsx( RichTextSVG, { fontSize: shape.props.fontSizeAdjustment || LABEL_FONT_SIZES[shape.props.size], font: shape.props.font, align: shape.props.align, verticalAlign: shape.props.verticalAlign, richText: shape.props.richText, labelColor: theme[shape.props.color].note.text, bounds, padding: LABEL_PADDING * shape.props.scale } ); return /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx("rect", { x: 5, y: 5, rx: 1, width: NOTE_SIZE - 10, height: bounds.h, fill: "rgba(0,0,0,.1)" }), /* @__PURE__ */ jsx( "rect", { rx: 1, width: NOTE_SIZE, height: bounds.h, fill: theme[shape.props.color].note.fill } ), textLabel ] }); } onBeforeCreate(next) { return getNoteSizeAdjustments(this.editor, next); } onBeforeUpdate(prev, next) { if (isEqual(prev.props.richText, next.props.richText) && prev.props.font === next.props.font && prev.props.size === next.props.size) { return; } return getNoteSizeAdjustments(this.editor, next); } getInterpolatedProps(startShape, endShape, t) { return { ...(t > 0.5 ? endShape.props : startShape.props), scale: lerp(startShape.props.scale, endShape.props.scale, t) }; } } function getNoteSizeAdjustments(editor, shape) { const { labelHeight, fontSizeAdjustment } = getLabelSize(editor, shape); const growY = Math.max(0, labelHeight - NOTE_SIZE); if (growY !== shape.props.growY || fontSizeAdjustment !== shape.props.fontSizeAdjustment) { return { ...shape, props: { ...shape.props, growY, fontSizeAdjustment } }; } } function getNoteLabelSize(editor, shape) { const { richText } = shape.props; if (isEmptyRichText(richText)) { const minHeight = LABEL_FONT_SIZES[shape.props.size] * TEXT_PROPS.lineHeight + LABEL_PADDING * 2; return { labelHeight: minHeight, labelWidth: 100, fontSizeAdjustment: 0 }; } const unadjustedFontSize = LABEL_FONT_SIZES[shape.props.size]; let fontSizeAdjustment = 0; let iterations = 0; let labelHeight = NOTE_SIZE; let labelWidth = NOTE_SIZE; const FUZZ = 1; do { fontSizeAdjustment = Math.min(unadjustedFontSize, unadjustedFontSize - iterations); const html = renderHtmlFromRichTextForMeasurement(editor, richText); const nextTextSize = editor.textMeasure.measureHtml(html, { ...TEXT_PROPS, fontFamily: FONT_FAMILIES[shape.props.font], fontSize: fontSizeAdjustment, maxWidth: NOTE_SIZE - LABEL_PADDING * 2 - FUZZ, disableOverflowWrapBreaking: true }); labelHeight = nextTextSize.h + LABEL_PADDING * 2; labelWidth = nextTextSize.w + LABEL_PADDING * 2; if (fontSizeAdjustment <= 14) { const html2 = renderHtmlFromRichTextForMeasurement(editor, richText); const nextTextSizeWithOverflowBreak = editor.textMeasure.measureHtml(html2, { ...TEXT_PROPS, fontFamily: FONT_FAMILIES[shape.props.font], fontSize: fontSizeAdjustment, maxWidth: NOTE_SIZE - LABEL_PADDING * 2 - FUZZ }); labelHeight = nextTextSizeWithOverflowBreak.h + LABEL_PADDING * 2; labelWidth = nextTextSizeWithOverflowBreak.w + LABEL_PADDING * 2; break; } if (nextTextSize.scrollWidth.toFixed(0) === nextTextSize.w.toFixed(0)) { break; } } while (iterations++ < 50); return { labelHeight, labelWidth, fontSizeAdjustment }; } const labelSizesForNote = new WeakCache(); function getLabelSize(editor, shape) { return labelSizesForNote.get(shape, () => getNoteLabelSize(editor, shape)); } function useNoteKeydownHandler(id) { const editor = useEditor(); const translation = useCurrentTranslation(); return useCallback( (e) => { const shape = editor.getShape(id); if (!shape) return; const isTab = e.key === "Tab"; const isCmdEnter = (e.metaKey || e.ctrlKey) && e.key === "Enter"; if (isTab || isCmdEnter) { e.preventDefault(); const pageTransform = editor.getShapePageTransform(id); const pageRotation = pageTransform.rotation(); const isRTL = !!(translation.dir === "rtl" || // todo: can we check a partial of the text, so that we don't have to render the whole thing? isRightToLeftLanguage(renderPlaintextFromRichText(editor, shape.props.richText))); const offsetLength = (NOTE_SIZE + editor.options.adjacentShapeMargin + // If we're growing down, we need to account for the current shape's growY (isCmdEnter && !e.shiftKey ? shape.props.growY : 0)) * shape.props.scale; const adjacentCenter = new Vec( isTab ? e.shiftKey != isRTL ? -1 : 1 : 0, isCmdEnter ? e.shiftKey ? -1 : 1 : 0 ).mul(offsetLength).add(NOTE_CENTER_OFFSET.clone().mul(shape.props.scale)).rot(pageRotation).add(pageTransform.point()); const newNote = getNoteShapeForAdjacentPosition(editor, shape, adjacentCenter, pageRotation); if (newNote) { editor.markHistoryStoppingPoint("editing adjacent shape"); startEditingShapeWithLabel( editor, newNote, true /* selectAll */ ); } } }, [id, editor, translation.dir] ); } function getNoteHeight(shape) { return (NOTE_SIZE + shape.props.growY) * shape.props.scale; } function getNoteShadow(id, rotation, scale) { const random = rng(id); const lift = Math.abs(random()) + 0.5; const oy = Math.cos(rotation); const a = 5 * scale; const b = 4 * scale; const c = 6 * scale; const d = 7 * scale; return `0px ${a - lift}px ${a}px -${a}px rgba(15, 23, 31, .6), 0px ${(b + lift * d) * Math.max(0, oy)}px ${c + lift * d}px -${b + lift * c}px rgba(15, 23, 31, ${(0.3 + lift * 0.1).toFixed(2)}), 0px ${48 * scale}px ${10 * scale}px -${10 * scale}px inset rgba(15, 23, 44, ${((0.022 + random() * 5e-3) * ((1 + oy) / 2)).toFixed(2)})`; } function getBoundsForSVG(shape) { return new Box(0, 0, NOTE_SIZE, NOTE_SIZE + shape.props.growY); } export { NoteShapeUtil }; //# sourceMappingURL=NoteShapeUtil.mjs.map