tldraw
Version:
A tiny little drawing editor.
285 lines (284 loc) • 9.69 kB
JavaScript
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import {
approximately,
isEqual,
kickoutOccludedShapes,
modulate,
track,
useEditor,
useValue
} from "@tldraw/editor";
import { useCallback, useEffect, useRef, useState } from "react";
import {
ASPECT_RATIO_OPTIONS,
ASPECT_RATIO_TO_VALUE,
getCroppedImageDataForAspectRatio,
getCroppedImageDataWhenZooming,
getDefaultCrop,
MAX_ZOOM
} from "../../../shapes/shared/crop.mjs";
import { useActions } from "../../context/actions.mjs";
import { useUiEvents } from "../../context/events.mjs";
import { useTranslation } from "../../hooks/useTranslation/useTranslation.mjs";
import { TldrawUiButtonIcon } from "../primitives/Button/TldrawUiButtonIcon.mjs";
import { TldrawUiButtonLabel } from "../primitives/Button/TldrawUiButtonLabel.mjs";
import {
TldrawUiDropdownMenuCheckboxItem,
TldrawUiDropdownMenuContent,
TldrawUiDropdownMenuRoot,
TldrawUiDropdownMenuTrigger
} from "../primitives/TldrawUiDropdownMenu.mjs";
import { TldrawUiSlider } from "../primitives/TldrawUiSlider.mjs";
import { TldrawUiToolbarButton } from "../primitives/TldrawUiToolbar.mjs";
const DefaultImageToolbarContent = track(function DefaultImageToolbarContent2({
imageShapeId,
isManipulating,
onEditAltTextStart,
onManipulatingStart,
onManipulatingEnd
}) {
const editor = useEditor();
const trackEvent = useUiEvents();
const msg = useTranslation();
const source = "image-toolbar";
const sliderRef = useRef(null);
const isReadonly = editor.getIsReadonly();
const crop = useValue("crop", () => editor.getShape(imageShapeId).props.crop, [
editor,
imageShapeId
]);
const zoom = crop ? Math.min(1 - (crop.bottomRight.x - crop.topLeft.x), 1 - (crop.bottomRight.y - crop.topLeft.y)) : 0;
const [maxZoom, setMaxZoom] = useState(
crop ? Math.max(zoom, 1 - 1 / MAX_ZOOM) : MAX_ZOOM
);
const actions = useActions();
useEffect(() => {
setMaxZoom(crop ? Math.max(zoom, 1 - 1 / MAX_ZOOM) : MAX_ZOOM);
}, [crop, zoom, maxZoom]);
const onHistoryMark = useCallback((id) => editor.markHistoryStoppingPoint(id), [editor]);
const easeZoom = useCallback((value, maxValue) => {
const maxRatioConversion = MAX_ZOOM / (MAX_ZOOM - 1);
return Math.pow(value / maxValue, maxRatioConversion) * maxValue;
}, []);
const displayValue = crop && maxZoom ? modulate(
easeZoom(zoom, maxZoom),
[0, maxZoom],
[0, 100],
true
/* clamp */
) : 0;
const handleZoomChange = useCallback(
(value) => {
editor.setCurrentTool("select.crop.idle");
const sliderPercent = value / 100;
const maxDimension = 1 - 1 / MAX_ZOOM;
const clampedMaxZoom = Math.min(maxDimension, maxZoom ?? maxDimension);
const maxRatioConversion = MAX_ZOOM / (MAX_ZOOM - 1);
const zOut = Math.pow(sliderPercent, 1 / maxRatioConversion) * clampedMaxZoom;
const zoom2 = zOut >= 1 ? 1 : zOut / (2 * (1 - zOut));
const imageShape = editor.getShape(imageShapeId);
if (!imageShape) return;
const change = getCroppedImageDataWhenZooming(zoom2, imageShape, maxZoom);
editor.updateShape({
id: imageShape.id,
type: imageShape.type,
x: change.x,
y: change.y,
props: {
w: change.w,
h: change.h,
crop: change.crop
}
});
trackEvent("set-style", { source: "image-toolbar", id: "zoom", value });
},
[editor, trackEvent, imageShapeId, maxZoom]
);
const handleImageReplace = useCallback(
() => actions["image-replace"].onSelect("image-toolbar"),
[actions]
);
const handleImageDownload = useCallback(
() => actions["download-original"].onSelect("image-toolbar"),
[actions]
);
const handleAspectRatioChange = (aspectRatio) => {
const imageShape = editor.getShape(imageShapeId);
if (!imageShape) return;
editor.run(() => {
editor.setCurrentTool("select.crop.idle");
const change = getCroppedImageDataForAspectRatio(aspectRatio, imageShape);
editor.markHistoryStoppingPoint("aspect ratio");
editor.updateShape({
id: imageShapeId,
type: "image",
x: change.x,
y: change.y,
props: {
crop: change.crop,
w: change.w,
h: change.h
}
});
kickoutOccludedShapes(editor, [imageShapeId]);
});
};
const altText = useValue(
"altText",
() => editor.getShape(imageShapeId).props.altText,
[editor, imageShapeId]
);
const shapeAspectRatio = useValue(
"shapeAspectRatio",
() => {
const imageShape = editor.getShape(imageShapeId);
return imageShape.props.w / imageShape.props.h;
},
[editor, imageShapeId]
);
const isOriginalCrop = !crop || isEqual(crop, getDefaultCrop());
useEffect(() => {
if (isManipulating) {
editor.timers.setTimeout(() => sliderRef.current?.focus(), 0);
}
}, [editor, isManipulating]);
useEffect(() => {
function handleKeyDown(e) {
if (isManipulating) {
if (e.key === "Escape") {
editor.cancel();
onManipulatingEnd();
} else if (e.key === "Enter") {
editor.complete();
onManipulatingEnd();
}
}
}
const elm = sliderRef.current;
if (elm) {
elm.addEventListener("keydown", handleKeyDown);
}
return () => {
if (elm) {
elm.removeEventListener("keydown", handleKeyDown);
}
};
}, [editor, isManipulating, onManipulatingEnd]);
if (isManipulating) {
return /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(
TldrawUiSlider,
{
ref: sliderRef,
value: displayValue,
label: "tool.image-zoom",
onValueChange: handleZoomChange,
onHistoryMark,
min: 0,
steps: 100,
"data-testid": "tool.image-zoom",
title: msg("tool.image-zoom")
}
),
/* @__PURE__ */ jsxs(TldrawUiDropdownMenuRoot, { id: "image-toolbar-aspect-ratio", children: [
/* @__PURE__ */ jsx(TldrawUiDropdownMenuTrigger, { children: /* @__PURE__ */ jsx(
TldrawUiToolbarButton,
{
title: msg("tool.aspect-ratio"),
type: "icon",
"data-testid": "tool.image-aspect-ratio",
children: /* @__PURE__ */ jsx(TldrawUiButtonIcon, { icon: "corners" })
}
) }),
/* @__PURE__ */ jsx(TldrawUiDropdownMenuContent, { side: "top", align: "center", children: ASPECT_RATIO_OPTIONS.map((aspectRatio) => {
let checked = false;
if (isOriginalCrop) {
if (aspectRatio === "original") {
checked = true;
}
} else {
if (aspectRatio === "circle") {
checked = !!crop.isCircle;
} else if (aspectRatio === "square") {
checked = !crop?.isCircle && approximately(shapeAspectRatio, ASPECT_RATIO_TO_VALUE[aspectRatio], 0.1);
} else if (aspectRatio === "original") {
checked = false;
} else {
checked = !isOriginalCrop && approximately(shapeAspectRatio, ASPECT_RATIO_TO_VALUE[aspectRatio], 0.01);
}
}
return /* @__PURE__ */ jsx(
TldrawUiDropdownMenuCheckboxItem,
{
onSelect: () => handleAspectRatioChange(aspectRatio),
checked,
title: msg(`tool.aspect-ratio.${aspectRatio}`),
children: /* @__PURE__ */ jsx(TldrawUiButtonLabel, { children: msg(`tool.aspect-ratio.${aspectRatio}`) })
},
aspectRatio
);
}) })
] }),
/* @__PURE__ */ jsx(
TldrawUiToolbarButton,
{
type: "icon",
onClick: onManipulatingEnd,
"data-testid": "tool.image-crop-confirm",
style: { borderLeft: "1px solid var(--tl-color-divider)", marginLeft: "2px" },
title: msg("tool.image-crop-confirm"),
children: /* @__PURE__ */ jsx(TldrawUiButtonIcon, { small: true, icon: "check" })
}
)
] });
}
return /* @__PURE__ */ jsxs(Fragment, { children: [
!isReadonly && /* @__PURE__ */ jsx(
TldrawUiToolbarButton,
{
type: "icon",
"data-testid": "tool.image-replace",
onClick: handleImageReplace,
title: msg("tool.replace-media"),
children: /* @__PURE__ */ jsx(TldrawUiButtonIcon, { small: true, icon: "tool-media" })
}
),
!isReadonly && /* @__PURE__ */ jsx(
TldrawUiToolbarButton,
{
type: "icon",
title: msg("tool.image-crop"),
onClick: onManipulatingStart,
"data-testid": "tool.image-crop",
children: /* @__PURE__ */ jsx(TldrawUiButtonIcon, { small: true, icon: "crop" })
}
),
/* @__PURE__ */ jsx(
TldrawUiToolbarButton,
{
type: "icon",
title: msg("action.download-original"),
onClick: handleImageDownload,
"data-testid": "tool.image-download",
children: /* @__PURE__ */ jsx(TldrawUiButtonIcon, { small: true, icon: "download" })
}
),
(altText || !isReadonly) && /* @__PURE__ */ jsx(
TldrawUiToolbarButton,
{
type: "icon",
title: msg("tool.media-alt-text"),
"data-testid": "tool.image-alt-text",
onClick: () => {
trackEvent("alt-text-start", { source });
onEditAltTextStart();
},
children: /* @__PURE__ */ jsx(TldrawUiButtonIcon, { small: true, icon: "alt" })
}
)
] });
});
export {
DefaultImageToolbarContent
};
//# sourceMappingURL=DefaultImageToolbarContent.mjs.map