Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions src/components/article-creator/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ interface EditorImageData {
withBackground?: boolean;
stretched?: boolean;
centerImage?: boolean;
/** Custom width as a percentage (15-100) of the content column. Unset means natural/default sizing. */
width?: number;
}

type MenuConfigItemList = Array<{
Expand Down Expand Up @@ -82,6 +84,7 @@ type CustomImageTool = {
nodes: {
caption?: HTMLElement;
wrapper?: HTMLElement;
imageContainer?: HTMLElement;
};
};
renderSettings(): MenuConfigItemList;
Expand All @@ -93,6 +96,9 @@ const pendingStorageDeletes = new Set<string>();

const MAX_IMAGE_SIZE = 5 * 1024 * 1024;

const MIN_IMAGE_WIDTH_PERCENT = 15;
const MAX_IMAGE_WIDTH_PERCENT = 100;

async function uploadImageFile(file: File) {
if (!file.type.startsWith("image/") && !isSvgFileName(file.name)) {
throw new Error("Please select an image file.");
Expand Down Expand Up @@ -277,6 +283,142 @@ function openImageReplacementDialog(tool: CustomImageTool) {
input.focus();
}

const RESIZE_HANDLE_ATTR = "data-resize-handle";
const CUSTOM_WIDTH_ATTR = "data-custom-width";

/**
* Applies (or clears) a custom width on the image container.
*
* The `data-custom-width` marker is what lets the stylesheet stretch the
* `<img>` to fill the container. The image tool only gives its picture element
* `max-width: 100%`, so without that rule the picture keeps its natural width
* while the container resizes around it — any image narrower than the content
* column could only ever be shrunk, and the drag handle drifted off the image
* into empty space.
*/
function applyContainerWidth(
imageContainer: HTMLElement,
widthPercent: number | undefined,
): void {
if (widthPercent === undefined) {
imageContainer.style.width = "";
imageContainer.removeAttribute(CUSTOM_WIDTH_ATTR);
return;
}

imageContainer.style.width = `${widthPercent}%`;
imageContainer.setAttribute(CUSTOM_WIDTH_ATTR, "true");
}

/**
* Attaches a Google-Docs-style drag handle to the bottom-right corner of the
* image container. Dragging sets `_data.width` as a percentage of the
* block's own width (clamped), preserving aspect ratio. Idempotent: safe to
* call on every render.
*/
function mountResizeHandle(
imageContainer: HTMLElement,
getWidth: () => number | undefined,
onResize: (widthPercent: number) => void,
) {
imageContainer.style.position = "relative";
imageContainer.style.maxWidth = "100%";

applyContainerWidth(imageContainer, getWidth());

let handle = imageContainer.querySelector<HTMLElement>(
`[${RESIZE_HANDLE_ATTR}]`,
);
if (handle) return;

handle = document.createElement("div");
handle.setAttribute(RESIZE_HANDLE_ATTR, "true");
handle.setAttribute("role", "slider");
handle.setAttribute("aria-label", "Resize image");
handle.setAttribute("aria-valuemin", String(MIN_IMAGE_WIDTH_PERCENT));
handle.setAttribute("aria-valuemax", String(MAX_IMAGE_WIDTH_PERCENT));
handle.tabIndex = 0;
handle.style.position = "absolute";
handle.style.right = "4px";
handle.style.bottom = "4px";
handle.style.width = "14px";
handle.style.height = "14px";
handle.style.borderRadius = "9999px";
handle.style.border = "2px solid #fff";
handle.style.background = "#2563eb";
handle.style.boxShadow = "0 1px 3px rgba(0,0,0,0.4)";
handle.style.cursor = "nwse-resize";
handle.style.touchAction = "none";
handle.style.zIndex = "10";

const step = (delta: number) => {
const current = getWidth() ?? 100;
const next = clampWidthPercent(current + delta);
applyContainerWidth(imageContainer, next);
handle?.setAttribute("aria-valuenow", String(next));
onResize(next);
};

handle.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
event.preventDefault();
step(-2);
} else if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault();
step(2);
}
});

handle.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
const pointerId = event.pointerId;
const startX = event.clientX;
const blockContent =
imageContainer.closest<HTMLElement>(".ce-block__content");
const referenceWidth =
blockContent?.clientWidth ?? imageContainer.clientWidth;
const startWidthPercent =
getWidth() ??
Math.min(100, (imageContainer.clientWidth / (referenceWidth || 1)) * 100);
handle?.setPointerCapture(pointerId);

const onPointerMove = (moveEvent: PointerEvent) => {
const deltaX = moveEvent.clientX - startX;
const deltaPercent = (deltaX / (referenceWidth || 1)) * 100;
const next = clampWidthPercent(startWidthPercent + deltaPercent);
applyContainerWidth(imageContainer, next);
handle?.setAttribute("aria-valuenow", String(Math.round(next)));
};

const onPointerUp = () => {
handle?.releasePointerCapture(pointerId);
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
const finalWidth = clampWidthPercent(
Number.parseFloat(imageContainer.style.width) || startWidthPercent,
);
// Keep the width even at 100%: now that the picture fills the container,
// clearing it here would snap an image narrower than the column back to
// its natural size the moment the drag ended.
applyContainerWidth(imageContainer, finalWidth);
onResize(finalWidth);
};

document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp, { once: true });
});

imageContainer.appendChild(handle);
}

function clampWidthPercent(value: number): number {
return Math.min(
MAX_IMAGE_WIDTH_PERCENT,
Math.max(MIN_IMAGE_WIDTH_PERCENT, Math.round(value)),
);
}

function isStorageObjectNotFoundError(error: unknown): boolean {
return (
typeof error === "object" &&
Expand Down Expand Up @@ -314,6 +456,7 @@ class CustomImage extends Image {
data.altText = maybeData.altText;
data.richCaption = maybeData.richCaption;
data.centerImage = maybeData.centerImage;
data.width = maybeData.width;

if (maybeData.richCaption) {
this._richCaption = resolveInitialRichCaption(maybeData.richCaption);
Expand Down Expand Up @@ -343,6 +486,43 @@ class CustomImage extends Image {
return wrapper;
}

/**
* Invokes the base Image tool's `setTune`, which — unlike assigning to
* `_data` — also toggles the tune's CSS class and, for "stretched", the
* block's stretched layout. The inherited member is loosely typed, so it is
* constrained here rather than widening the rest of the file.
*/
private setBaseTune(tuneName: string, value: boolean): void {
const baseImageTool = Image as unknown as {
prototype: {
setTune(this: unknown, tuneName: string, value: boolean): void;
};
};
baseImageTool.prototype.setTune.call(this, tuneName, value);
}

/**
* A custom width and the "stretched" tune are two mutually exclusive ways of
* saying how wide the image should be, so turning stretch on discards any
* custom width. Without this both fields end up set at once, and because the
* renderers prefer an explicit width the stretch became a silent no-op on
* the published article.
*/
setTune(tuneName: string, value: boolean): void {
const data = (this as unknown as { _data: EditorImageData })._data;

if (tuneName === "stretched" && value && data.width !== undefined) {
data.width = undefined;
const imageContainer = (this as unknown as CustomImageTool).ui?.nodes
?.imageContainer;
if (imageContainer) {
applyContainerWidth(imageContainer, undefined);
}
}

this.setBaseTune(tuneName, value);
}

renderSettings(): MenuConfigItemList {
const typedTool = this as unknown as CustomImageTool;
const baseImageTool = Image as unknown as {
Expand Down Expand Up @@ -410,11 +590,32 @@ class CustomImage extends Image {
nodes: {
caption?: HTMLElement;
wrapper?: HTMLElement;
imageContainer?: HTMLElement;
};
};
block: { container: HTMLElement; id: string };
};

const imageContainer = typed.ui?.nodes?.imageContainer ?? null;
if (imageContainer) {
const data = (this as unknown as { _data: EditorImageData })._data;
mountResizeHandle(
imageContainer,
() => data.width,
(nextWidth) => {
data.width = nextWidth;
// Go through the tune rather than writing `_data.stretched`: the
// base tool's setTune also drops the tune's CSS class and the
// block's stretched layout. Setting the field alone left the editor
// showing a full-bleed image that the article rendered at the
// custom width, and desynced the tunes menu's checked state.
if (data.stretched) {
this.setBaseTune("stretched", false);
}
},
);
}

// EditorJS's Image tool keeps its DOM refs on `this.ui.nodes`, not
// `this.nodes` directly — see @editorjs/image's Ui class.
const holder = typed.ui?.nodes?.caption ?? null;
Expand Down Expand Up @@ -455,6 +656,7 @@ class CustomImage extends Image {
withBackground?: boolean;
stretched?: boolean;
centerImage?: boolean;
width?: number;
} {
// Avoid calling the parent save(); it would try to read the caption
// element that we've repurposed, and we already have a structured value
Expand All @@ -470,6 +672,7 @@ class CustomImage extends Image {
withBackground: d.withBackground,
stretched: d.stretched,
centerImage: d.centerImage ?? false,
...(d.width === undefined ? {} : { width: d.width }),
};
}

Expand Down
8 changes: 5 additions & 3 deletions src/components/article-creator/Renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,11 @@ const customParsers: Record<
const isSvg =
storagePath.toLowerCase().endsWith(".svg") ||
(typeof data.url === "string" && data.url.toLowerCase().includes(".svg"));
const imageConditions = `${data.stretched ? "img-fullwidth" : ""} ${
const width = typeof data.width === "number" ? data.width : undefined;
const imageConditions = `${data.stretched && width === undefined ? "img-fullwidth" : ""} ${
data.withBorder ? "img-border" : ""
} ${data.withBackground ? "img-bg" : ""} ${data.centerImage ? "img-center" : ""} ${isSvg ? "img-svg" : ""}`;
const widthStyle = width !== undefined ? ` style="width:${width}%;max-width:100%;"` : "";
const imgClass = _config.image.imgClass ?? "";
let imageSrc;

Expand Down Expand Up @@ -268,12 +270,12 @@ const customParsers: Record<
.replaceAll(">", "&gt;");

if (_config.image.use === "img") {
return `<img class="${imageConditions} ${imgClass}" src="${imageSrc}" alt="${altText}">`;
return `<img class="${imageConditions} ${imgClass}" src="${imageSrc}" alt="${altText}"${widthStyle}>`;
} else if (_config.image.use === "figure") {
const figureClass = _config.image.figureClass ?? "";
const figCapClass = _config.image.figCapClass ?? "";

return `<figure class="${figureClass}"><img class="${imgClass} ${imageConditions}" src="${imageSrc}" alt="${altText}"><figcaption class="${figCapClass}">${captionBody}</figcaption></figure>`;
return `<figure class="${figureClass}"><img class="${imgClass} ${imageConditions}" src="${imageSrc}" alt="${altText}"${widthStyle}><figcaption class="${figCapClass}">${captionBody}</figcaption></figure>`;
}
return "ERROR DISPLAYING IMAGE";
},
Expand Down
8 changes: 5 additions & 3 deletions src/components/article-creator/editorjs-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,11 @@ const customParsers: Record<
const isSvg =
storagePath.toLowerCase().endsWith(".svg") ||
(typeof data.url === "string" && data.url.toLowerCase().includes(".svg"));
const imageConditions = `${data.stretched ? "img-fullwidth" : ""} ${
const width = typeof data.width === "number" ? data.width : undefined;
const imageConditions = `${data.stretched && width === undefined ? "img-fullwidth" : ""} ${
data.withBorder ? "img-border" : ""
} ${data.withBackground ? "img-bg" : ""} ${data.centerImage ? "img-center" : ""} ${isSvg ? "img-svg" : ""}`;
const widthStyle = width !== undefined ? ` style="width:${width}%;max-width:100%;"` : "";
const imgClass = _config.image.imgClass ?? "";
let imageSrc;

Expand Down Expand Up @@ -268,12 +270,12 @@ const customParsers: Record<
.replaceAll(">", "&gt;");

if (_config.image.use === "img") {
return `<img class="${imageConditions} ${imgClass}" src="${imageSrc}" alt="${altText}">`;
return `<img class="${imageConditions} ${imgClass}" src="${imageSrc}" alt="${altText}"${widthStyle}>`;
} else if (_config.image.use === "figure") {
const figureClass = _config.image.figureClass ?? "";
const figCapClass = _config.image.figCapClass ?? "";

return `<figure class="${figureClass}"><img class="${imgClass} ${imageConditions}" src="${imageSrc}" alt="${altText}"><figcaption class="${figCapClass}">${captionBody}</figcaption></figure>`;
return `<figure class="${figureClass}"><img class="${imgClass} ${imageConditions}" src="${imageSrc}" alt="${altText}"${widthStyle}><figcaption class="${figCapClass}">${captionBody}</figcaption></figure>`;
}
return "ERROR DISPLAYING IMAGE";
},
Expand Down
26 changes: 25 additions & 1 deletion src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,31 @@
@apply h-auto w-full max-w-[480px];
}

/* editorjs/image resize handle ─────────────────────────────────────────── */

.image-tool__image {
display: inline-block;
max-width: 100%;
}

/* The image tool gives its picture only `max-width: 100%`, so a container with
a custom width would grow around an image that stayed at its natural size.
Make the picture fill the container it has been resized to, matching how the
public renderers put the width straight onto the <img>. */
.image-tool__image[data-custom-width] .image-tool__image-picture {
width: 100%;
}

.image-tool__image [data-resize-handle] {
opacity: 0;
transition: opacity 0.15s ease;
}

.image-tool__image:hover [data-resize-handle],
.image-tool__image [data-resize-handle]:focus-visible {
opacity: 1;
}

/* image rich-text caption editor ─────────────────────────────────────────── */

.caption-editor-root {
Expand Down Expand Up @@ -265,4 +290,3 @@ figcaption a {
width: calc(100vw - 24px);
}
}

Loading