feat: Allow specifying exact pixel width for images (#9288)

* Allow specifying exact pixel width for images

* resize height

* Math.round

* handle natural width, debounce error state
This commit is contained in:
Hemachandar
2025-05-31 21:06:36 +05:30
committed by GitHub
parent e51d2f643e
commit e7b7eb7818
8 changed files with 303 additions and 13 deletions
+252
View File
@@ -0,0 +1,252 @@
import { NodeSelection } from "prosemirror-state";
import { useCallback, useEffect, useRef, useState } from "react";
import styled from "styled-components";
import Flex from "@shared/components/Flex";
import Text from "@shared/components/Text";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { extraArea } from "@shared/styles";
import Input, { NativeInput, Outline } from "~/components/Input";
import { useEditor } from "./EditorContext";
type Dimension = {
width: string;
height: string;
changed: "width" | "height" | "none";
};
export function MediaDimension() {
const ref = useRef<HTMLDivElement>(null);
const boundsRef = useRef<{
width: { min: number; max: number };
height: { min: number; max: number };
}>();
const { view, commands } = useEditor();
const { state } = view;
const { selection } = state;
// This component will be rendered only when the selection is image or video (NodeSelection types).
const node = (selection as NodeSelection).node;
const nodeType = node.type.name,
width = node.attrs.width as number,
height = node.attrs.height as number;
const [localDimension, setLocalDimension] = useState<Dimension>(() => ({
width: String(width),
height: String(height),
changed: "none",
}));
const [error, setError] = useState<{ width: boolean; height: boolean }>({
width: false,
height: false,
});
if (!boundsRef.current && ref.current) {
const docWidth = parseInt(
getComputedStyle(ref.current).getPropertyValue("--document-width")
);
const maxWidth = docWidth - EditorStyleHelper.padding * 2;
const constrainedWidth = Math.min(width, maxWidth); // Ensure media width does not exceed the max width of the editor.
const aspectRatio = height / constrainedWidth;
const maxHeight = Math.round(maxWidth * aspectRatio);
boundsRef.current = {
width: { min: 50, max: maxWidth },
height: { min: 50, max: maxHeight },
};
}
const reset = useCallback(() => {
setLocalDimension({
width: String(width),
height: String(height),
changed: "none",
});
setError({ width: false, height: false });
}, [width, height]);
const isOutsideBounds = useCallback(
(type: "width" | "height", value: number) => {
const bounds = boundsRef.current!;
return value < bounds[type].min || value > bounds[type].max;
},
[]
);
const handleChange = useCallback(
(type: "width" | "height") => (e: React.ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
const isNumber = /^\d+$/.test(value);
if (value && (!isNumber || value === "0")) {
return;
}
setError((prev) => {
if (!prev.width && !prev.height) {
return prev;
}
return { width: false, height: false };
});
setLocalDimension((prev) => {
if (type === "width") {
return {
...prev,
width: value,
changed: "width",
};
}
return {
...prev,
height: value,
changed: "height",
};
});
},
[]
);
const handleBlur = useCallback(() => {
const localWidthAsNumber = localDimension.width
? parseInt(localDimension.width, 10)
: undefined,
localHeightAsNumber = localDimension.height
? parseInt(localDimension.height, 10)
: undefined;
const isUnchanged =
!localWidthAsNumber ||
!localHeightAsNumber ||
(localWidthAsNumber === width && localHeightAsNumber === height);
const isError =
error.width ||
error.height ||
(localDimension.changed === "width" &&
localWidthAsNumber &&
isOutsideBounds("width", localWidthAsNumber)); // check width bounds here since 'onChange' error checker is debounced.
if (isUnchanged || isError) {
reset();
return;
}
const maxWidth = boundsRef.current!.width.max;
// For images resized to the full width of the editor, natural width will be shown in the toolbar.
// So, we constrain it here for computing aspect ratio.
const constrainedWidth = Math.min(width, maxWidth);
const aspectRatio =
localDimension.changed === "width"
? height / constrainedWidth
: constrainedWidth / height;
const finalWidth =
localDimension.changed === "width"
? localWidthAsNumber
: Math.round(aspectRatio * localHeightAsNumber);
const finalHeight =
localDimension.changed === "height"
? localHeightAsNumber
: Math.round(aspectRatio * localWidthAsNumber);
if (nodeType === "image") {
commands["resizeImage"]({
width: finalWidth,
height: finalHeight,
});
}
}, [commands, width, height, localDimension, nodeType, error, reset]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleBlur();
} else if (e.key === "Escape") {
reset();
}
},
[handleBlur, reset]
);
// Sync dimension changes from outside.
useEffect(() => {
if (
width !== Number(localDimension.width) ||
height !== Number(localDimension.height)
) {
reset();
}
}, [width, height, reset]);
// hacky debounce for checking error.
useEffect(() => {
const timeout = setTimeout(() => {
const isWidthError = localDimension.width
? Number(localDimension.width) !== width &&
isOutsideBounds("width", Number(localDimension.width))
: false;
const isHeightError = localDimension.height
? Number(localDimension.height) !== height &&
isOutsideBounds("height", Number(localDimension.height))
: false;
if (isWidthError || isHeightError) {
setError({
width: isWidthError,
height: isHeightError,
});
}
}, 200);
return () => clearTimeout(timeout);
}, [width, height, localDimension, isOutsideBounds]);
return (
<StyledFlex ref={ref} align="center">
<StyledInput
value={localDimension.width}
onChange={handleChange("width")}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
$error={error.width}
/>
<Text size="xsmall" type="tertiary">
x
</Text>
<StyledInput
value={localDimension.height}
onChange={handleChange("height")}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
$error={error.height}
/>
</StyledFlex>
);
}
const StyledFlex = styled(Flex)`
pointer-events: all;
position: relative;
${extraArea(4)}
`;
const StyledInput = styled(Input)<{ $error?: boolean }>`
width: 50px;
z-index: 1;
${Outline} {
margin: 0;
background: transparent;
border-color: transparent;
}
${NativeInput} {
height: 24px;
padding: 0;
text-align: center;
${(props) => props.$error && `color: ${props.theme.danger}`};
}
`;
@@ -221,6 +221,9 @@ export default function SelectionToolbar(props: Props) {
if (item.name === "separator") {
return true;
}
if (item.name === "dimensions") {
return item.visible ?? false;
}
if (item.name && !commands[item.name]) {
return false;
}
+5 -2
View File
@@ -10,6 +10,7 @@ import Template from "~/components/ContextMenu/Template";
import { TooltipProvider } from "~/components/TooltipContext";
import { MenuItem as TMenuItem } from "~/types";
import { useEditor } from "./EditorContext";
import { MediaDimension } from "./MediaDimension";
import ToolbarButton from "./ToolbarButton";
import ToolbarSeparator from "./ToolbarSeparator";
import Tooltip from "./Tooltip";
@@ -98,7 +99,7 @@ function ToolbarMenu(props: Props) {
if (item.name === "separator" && item.visible !== false) {
return <ToolbarSeparator key={index} />;
}
if (item.visible === false || !item.icon) {
if (item.visible === false || (!item.skipIcon && !item.icon)) {
return null;
}
const isActive = item.active ? item.active(state) : false;
@@ -109,7 +110,9 @@ function ToolbarMenu(props: Props) {
shortcut={item.shortcut}
content={item.label === item.tooltip ? undefined : item.tooltip}
>
{item.children ? (
{item.name === "dimensions" ? (
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown active={isActive && !item.label} item={item} />
) : (
<ToolbarButton
+9
View File
@@ -59,6 +59,15 @@ export default function imageMenuItems(
{
name: "separator",
},
{
name: "dimensions",
tooltip: dictionary.dimensions,
visible: !isFullWidthAligned(state),
skipIcon: true,
},
{
name: "separator",
},
{
name: "downloadImage",
tooltip: dictionary.downloadImage,
+1
View File
@@ -35,6 +35,7 @@ export default function useDictionary() {
deleteRow: t("Delete"),
deleteTable: t("Delete table"),
deleteAttachment: t("Delete file"),
dimensions: t("Width x Height"),
download: t("Download"),
downloadAttachment: t("Download file"),
replaceAttachment: t("Replace file"),
+31 -11
View File
@@ -221,19 +221,17 @@ export default class Image extends SimpleImage {
handleChangeSize =
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
({ width, height }: { width: number; height?: number }) => {
const { view } = this.editor;
const { tr } = view.state;
const { view, commands } = this.editor;
const { doc, tr } = view.state;
const pos = getPos();
const transaction = tr
.setNodeMarkup(pos, undefined, {
...node.attrs,
width,
height,
})
.setMeta("addToHistory", true);
const $pos = transaction.doc.resolve(getPos());
view.dispatch(transaction.setSelection(new NodeSelection($pos)));
const $pos = doc.resolve(pos);
view.dispatch(tr.setSelection(new NodeSelection($pos)));
commands["resizeImage"]({
width,
height: height || node.attrs.height,
});
};
handleDownload =
@@ -430,6 +428,28 @@ export default class Image extends SimpleImage {
dispatch?.(state.tr.setNodeMarkup(selection.from, undefined, attrs));
return true;
},
resizeImage:
({ width, height }: { width: number; height: number }): Command =>
(state, dispatch) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
const { selection } = state;
const transformedAttrs = {
...state.selection.node.attrs,
width,
height,
};
const tr = state.tr
.setNodeMarkup(selection.from, undefined, transformedAttrs)
.setMeta("addToHistory", true);
const $pos = tr.doc.resolve(selection.from);
dispatch?.(tr.setSelection(new NodeSelection($pos)));
return true;
},
};
}
+1
View File
@@ -40,6 +40,7 @@ export type MenuItem = {
visible?: boolean;
active?: (state: EditorState) => boolean;
appendSpace?: boolean;
skipIcon?: boolean;
};
export type ComponentProps = {
@@ -451,6 +451,7 @@
"Create a new child doc": "Create a new child doc",
"Delete table": "Delete table",
"Delete file": "Delete file",
"Width x Height": "Width x Height",
"Download file": "Download file",
"Replace file": "Replace file",
"Delete image": "Delete image",