tldraw
Version:
A tiny little drawing editor.
350 lines (349 loc) • 12.2 kB
JavaScript
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import {
BaseBoxShapeUtil,
DefaultColorStyle,
Group2d,
Rectangle2d,
SVGContainer,
clamp,
compact,
frameShapeMigrations,
frameShapeProps,
getColorValue,
getDefaultColorTheme,
lerp,
resizeBox,
toDomPrecision,
useValue
} from "@tldraw/editor";
import classNames from "classnames";
import { fitFrameToContent, getFrameChildrenBounds } from "../../utils/frames/frames.mjs";
import {
createTextJsxFromSpans
} from "../shared/createTextJsxFromSpans.mjs";
import { useDefaultColorTheme } from "../shared/useDefaultColorTheme.mjs";
import { FrameHeading } from "./components/FrameHeading.mjs";
import {
defaultEmptyAs,
getFrameHeadingOpts,
getFrameHeadingSide,
getFrameHeadingSize,
getFrameHeadingTranslation
} from "./frameHelpers.mjs";
const FRAME_HEADING_EXTRA_WIDTH = 12;
const FRAME_HEADING_MIN_WIDTH = 32;
const FRAME_HEADING_NOCOLORS_OFFSET_X = -7;
const FRAME_HEADING_OFFSET_Y = 4;
class FrameShapeUtil extends BaseBoxShapeUtil {
static type = "frame";
static props = frameShapeProps;
static migrations = frameShapeMigrations;
options = {
showColors: false,
resizeChildren: false
};
// evil crimes :)
// By default, showColors is off. Because they use style props, which are picked up
// automatically, we don't have DefaultColorStyle in the props in the schema by default.
// Instead, when someone calls .configure to turn the option on, we manually add in the color
// style here so it plays nicely with the other editor APIs.
static configure(options) {
const withOptions = super.configure.call(this, options);
if (options.showColors) {
;
withOptions.props = { ...withOptions.props, color: DefaultColorStyle };
}
return withOptions;
}
canEdit(shape, info) {
return info.type === "click-header" || info.type === "unknown";
}
canResize() {
return true;
}
canResizeChildren() {
return this.options.resizeChildren;
}
isExportBoundsContainer() {
return true;
}
getDefaultProps() {
return { w: 160 * 2, h: 90 * 2, name: "", color: "black" };
}
getAriaDescriptor(shape) {
return shape.props.name;
}
getGeometry(shape) {
const { editor } = this;
const z = editor.getEfficientZoomLevel();
const labelSide = getFrameHeadingSide(editor, shape);
const isVertical = labelSide % 2 === 1;
const rotatedTopEdgeWidth = isVertical ? shape.props.h : shape.props.w;
const opts = getFrameHeadingOpts(rotatedTopEdgeWidth, false);
const headingSize = getFrameHeadingSize(editor, shape, opts);
const isShowingFrameColors = this.options.showColors;
const extraWidth = FRAME_HEADING_EXTRA_WIDTH / z;
const minWidth = FRAME_HEADING_MIN_WIDTH / z;
const maxWidth = rotatedTopEdgeWidth + (isShowingFrameColors ? 1 : extraWidth);
const labelWidth = headingSize.w / z;
const labelHeight = headingSize.h / z;
const clampedLabelWidth = clamp(labelWidth + extraWidth, minWidth, maxWidth);
const offsetX = (isShowingFrameColors ? -1 : FRAME_HEADING_NOCOLORS_OFFSET_X) / z;
const offsetY = FRAME_HEADING_OFFSET_Y / z;
const width = isVertical ? labelHeight : clampedLabelWidth;
const height = isVertical ? clampedLabelWidth : labelHeight;
let x, y;
switch (labelSide) {
case 0: {
x = offsetX;
y = -(labelHeight + offsetY);
break;
}
case 1: {
x = -(labelHeight + offsetY);
y = shape.props.h - (offsetX + clampedLabelWidth);
break;
}
case 2: {
x = shape.props.w - (offsetX + clampedLabelWidth);
y = shape.props.h + offsetY;
break;
}
case 3: {
x = shape.props.w + offsetY;
y = offsetX;
break;
}
}
return new Group2d({
children: [
new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: false
}),
new Rectangle2d({
x,
y,
width,
height,
isFilled: true,
isLabel: true,
excludeFromShapeBounds: true
})
]
});
}
getText(shape) {
return shape.props.name;
}
component(shape) {
const theme = useDefaultColorTheme();
const isCreating = useValue(
"is creating this shape",
() => {
const resizingState = this.editor.getStateDescendant("select.resizing");
if (!resizingState) return false;
if (!resizingState.getIsActive()) return false;
const info = resizingState?.info;
if (!info) return false;
return info.isCreating && this.editor.getOnlySelectedShapeId() === shape.id;
},
[shape.id]
);
const showFrameColors = this.options.showColors;
const colorToUse = showFrameColors ? shape.props.color : "black";
const frameFill = getColorValue(theme, colorToUse, "frameFill");
const frameStroke = getColorValue(theme, colorToUse, "frameStroke");
const frameHeadingStroke = showFrameColors ? getColorValue(theme, colorToUse, "frameHeadingStroke") : theme.background;
const frameHeadingFill = showFrameColors ? getColorValue(theme, colorToUse, "frameHeadingFill") : theme.background;
const frameHeadingText = getColorValue(theme, colorToUse, "frameText");
return /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(SVGContainer, { children: /* @__PURE__ */ jsx(
"rect",
{
className: classNames("tl-frame__body", { "tl-frame__creating": isCreating }),
fill: frameFill,
stroke: frameStroke,
style: {
width: `calc(${shape.props.w}px + 1px / var(--tl-zoom))`,
height: `calc(${shape.props.h}px + 1px / var(--tl-zoom))`,
transform: `translate(calc(-0.5px / var(--tl-zoom)), calc(-0.5px / var(--tl-zoom)))`
}
}
) }),
isCreating ? null : /* @__PURE__ */ jsx(
FrameHeading,
{
id: shape.id,
name: shape.props.name,
fill: frameHeadingFill,
stroke: frameHeadingStroke,
color: frameHeadingText,
width: shape.props.w,
height: shape.props.h,
offsetX: showFrameColors ? -1 : -7,
showColors: this.options.showColors
}
)
] });
}
toSvg(shape, ctx) {
const theme = getDefaultColorTheme({ isDarkMode: ctx.isDarkMode });
const labelSide = getFrameHeadingSide(this.editor, shape);
const isVertical = labelSide % 2 === 1;
const rotatedTopEdgeWidth = isVertical ? shape.props.h : shape.props.w;
const labelTranslate = getFrameHeadingTranslation(shape, labelSide, true);
const opts = getFrameHeadingOpts(rotatedTopEdgeWidth - 12, true);
const frameTitle = defaultEmptyAs(shape.props.name, "Frame") + String.fromCharCode(8203);
const labelBounds = getFrameHeadingSize(this.editor, shape, opts);
const spans = this.editor.textMeasure.measureTextSpans(frameTitle, opts);
const text = createTextJsxFromSpans(this.editor, spans, opts);
const showFrameColors = this.options.showColors;
const colorToUse = showFrameColors ? shape.props.color : "black";
const frameFill = getColorValue(theme, colorToUse, "frameFill");
const frameStroke = getColorValue(theme, colorToUse, "frameStroke");
const frameHeadingStroke = showFrameColors ? getColorValue(theme, colorToUse, "frameHeadingStroke") : theme.background;
const frameHeadingFill = showFrameColors ? getColorValue(theme, colorToUse, "frameHeadingFill") : theme.background;
const frameHeadingText = getColorValue(theme, colorToUse, "frameText");
return /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(
"rect",
{
width: shape.props.w,
height: shape.props.h,
fill: frameFill,
stroke: frameStroke,
strokeWidth: 1,
x: 0,
rx: 0,
ry: 0
}
),
/* @__PURE__ */ jsxs("g", { fill: frameHeadingText, transform: labelTranslate, children: [
/* @__PURE__ */ jsx(
"rect",
{
x: labelBounds.x - (showFrameColors ? 0 : 6),
y: labelBounds.y - 6,
width: Math.min(rotatedTopEdgeWidth, labelBounds.width + 12),
height: labelBounds.height,
fill: frameHeadingFill,
stroke: frameHeadingStroke,
rx: 4,
ry: 4
}
),
/* @__PURE__ */ jsx("g", { transform: `translate(${showFrameColors ? 8 : 0}, 4)`, children: text })
] })
] });
}
indicator(shape) {
return /* @__PURE__ */ jsx("rect", { width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h) });
}
useLegacyIndicator() {
return false;
}
getIndicatorPath(shape) {
const path = new Path2D();
path.rect(0, 0, shape.props.w, shape.props.h);
return path;
}
providesBackgroundForChildren() {
return true;
}
getClipPath(shape) {
return this.editor.getShapeGeometry(shape.id).vertices;
}
canReceiveNewChildrenOfType(shape) {
return !shape.isLocked;
}
onResize(shape, info) {
return resizeBox(shape, info);
}
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)
};
}
onDoubleClickEdge(shape, info) {
if (info.target !== "selection") return;
const { handle } = info;
if (!handle) return;
const isHorizontalEdge = handle === "left" || handle === "right";
const isVerticalEdge = handle === "top" || handle === "bottom";
const childIds = this.editor.getSortedChildIdsForParent(shape.id);
const children = compact(childIds.map((id) => this.editor.getShape(id)));
if (!children.length) return;
const { dx, dy, w, h } = getFrameChildrenBounds(children, this.editor, { padding: 10 });
this.editor.run(() => {
const changes = childIds.map((childId) => {
const childShape = this.editor.getShape(childId);
return {
id: childShape.id,
type: childShape.type,
x: isHorizontalEdge ? childShape.x + dx : childShape.x,
y: isVerticalEdge ? childShape.y + dy : childShape.y
};
});
this.editor.updateShapes(changes);
});
return {
id: shape.id,
type: shape.type,
props: {
w: isHorizontalEdge ? w : shape.props.w,
h: isVerticalEdge ? h : shape.props.h
}
};
}
onDoubleClickCorner(shape) {
fitFrameToContent(this.editor, shape.id, { padding: 10 });
return {
id: shape.id,
type: shape.type
};
}
onDragShapesIn(shape, draggingShapes, { initialParentIds, initialIndices }) {
const { editor } = this;
if (draggingShapes.every((s) => s.parentId === shape.id)) return;
let canRestoreOriginalIndices = false;
const previousChildren = draggingShapes.filter((s) => shape.id === initialParentIds.get(s.id));
if (previousChildren.length > 0) {
const currentChildren = compact(
editor.getSortedChildIdsForParent(shape).map((id) => editor.getShape(id))
);
if (previousChildren.every((s) => !currentChildren.find((c) => c.index === s.index))) {
canRestoreOriginalIndices = true;
}
}
if (draggingShapes.some((s) => editor.hasAncestor(shape, s.id))) return;
editor.reparentShapes(draggingShapes, shape.id);
if (canRestoreOriginalIndices) {
for (const shape2 of previousChildren) {
editor.updateShape({
id: shape2.id,
type: shape2.type,
index: initialIndices.get(shape2.id)
});
}
}
}
onDragShapesOut(shape, draggingShapes, info) {
const { editor } = this;
if (!info.nextDraggingOverShapeId) {
editor.reparentShapes(
draggingShapes.filter(
(s) => s.parentId === shape.id && this.canReceiveNewChildrenOfType(s)
),
editor.getCurrentPageId()
);
}
}
}
export {
FrameShapeUtil
};
//# sourceMappingURL=FrameShapeUtil.mjs.map