mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user