tldraw
Version:
A tiny little drawing editor.
438 lines (437 loc) • 13.7 kB
JavaScript
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