Skip to content
259 changes: 230 additions & 29 deletions src/components/article-creator/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
uploadBytes,
} from "firebase/storage";
import { buttonVariants } from "../ui/button";
import { cn, resolveUploadContentType } from "@/lib/utils";
import { cn, isSvgFileName, resolveUploadContentType } from "@/lib/utils";
import {
mountRichCaptionEditor,
resolveInitialRichCaption,
Expand All @@ -46,6 +46,7 @@ import {
interface EditorImageData {
file: { url?: string; storageRefFullPath?: string; [key: string]: unknown };
caption?: string;
altText?: string;
richCaption?: RichCaption;
withBorder?: boolean;
withBackground?: boolean;
Expand All @@ -67,6 +68,10 @@ type CustomImageTool = {
blocks: {
getBlockIndex(blockId: string): number;
delete(index?: number): void;
update(
blockId: string,
data?: Partial<EditorImageData>,
): Promise<unknown>;
};
};
block: {
Expand All @@ -86,6 +91,192 @@ type CustomImageTool = {

const pendingStorageDeletes = new Set<string>();

const MAX_IMAGE_SIZE = 5 * 1024 * 1024;

async function uploadImageFile(file: File) {
if (!file.type.startsWith("image/") && !isSvgFileName(file.name)) {
throw new Error("Please select an image file.");
}

if (file.size > MAX_IMAGE_SIZE) {
throw new Error(`File "${file.name}" is too large.`);
}

const storage = getStorage();
const storageRef = ref(
storage,
"images/" + new Date().getTime() + "_" + file.name,
);
const contentType = resolveUploadContentType(file);
const snapshot = await uploadBytes(
storageRef,
file,
contentType ? { contentType } : undefined,
);
const downloadURL = await getDownloadURL(snapshot.ref);

return { url: downloadURL, storageRefFullPath: storageRef.fullPath };
}

function openImageReplacementDialog(tool: CustomImageTool) {
const currentUrl = tool._data.file.url;
const trigger =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const dialog = document.createElement("dialog");
dialog.setAttribute("aria-labelledby", "replace-image-title");
dialog.style.maxWidth = "min(42rem, calc(100vw - 2rem))";
dialog.style.width = "100%";
dialog.style.padding = "1.5rem";
dialog.style.borderRadius = "0.5rem";

const title = document.createElement("h2");
title.id = "replace-image-title";
title.textContent = "Replace image";
title.style.marginTop = "0";
const description = document.createElement("p");
description.textContent =
"Your caption, source, styling, and alt text will be kept. Please review the alt text for the new image.";

const previews = document.createElement("div");
previews.style.display = "grid";
previews.style.gridTemplateColumns = "repeat(auto-fit, minmax(12rem, 1fr))";
previews.style.gap = "1rem";
const makePreview = (label: string, url?: string) => {
const wrapper = document.createElement("div");
const heading = document.createElement("strong");
heading.textContent = label;
const image = document.createElement("img");
image.alt = label;
image.style.display = "block";
image.style.marginTop = "0.5rem";
image.style.maxWidth = "100%";
image.style.maxHeight = "15rem";
image.style.objectFit = "contain";
if (url) image.src = url;
wrapper.append(heading, image);
return { wrapper, image };
};
const currentPreview = makePreview("Current image", currentUrl);
const replacementPreview = makePreview("Replacement preview");
previews.append(currentPreview.wrapper, replacementPreview.wrapper);

const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.setAttribute("aria-label", "Choose replacement image");
const altLabel = document.createElement("label");
altLabel.htmlFor = "replacement-image-alt-text";
altLabel.textContent = "Alt text";
altLabel.style.display = "block";
altLabel.style.marginTop = "1rem";
const altText = document.createElement("textarea");
altText.id = "replacement-image-alt-text";
altText.rows = 3;
altText.value = tool._data.altText ?? "";
altText.placeholder = "Describe the image for people who cannot see it";
altText.style.boxSizing = "border-box";
altText.style.width = "100%";
const altTextWarning = document.createElement("p");
altTextWarning.setAttribute("role", "status");
altTextWarning.style.color = "#92400e";
altTextWarning.style.marginBottom = "0";
const updateAltTextWarning = () => {
altTextWarning.textContent =
altText.value.trim().length < 10
? "Add a more descriptive alt text before publishing, unless this image is decorative."
: "";
};
updateAltTextWarning();
altText.addEventListener("input", updateAltTextWarning);
const error = document.createElement("p");
error.setAttribute("role", "alert");
error.style.color = "#b91c1c";
const actions = document.createElement("div");
actions.style.display = "flex";
actions.style.justifyContent = "flex-end";
actions.style.gap = "0.5rem";
actions.style.marginTop = "1rem";
const cancel = document.createElement("button");
cancel.type = "button";
cancel.textContent = "Cancel";
const confirm = document.createElement("button");
confirm.type = "button";
confirm.textContent = "Replace image";
confirm.disabled = true;
actions.append(cancel, confirm);
dialog.append(
title,
description,
previews,
input,
altLabel,
altText,
altTextWarning,
error,
actions,
);
document.body.append(dialog);

let selectedFile: File | undefined;
let previewUrl: string | undefined;
const close = () => dialog.close();
input.addEventListener("change", () => {
selectedFile = input.files?.[0];
error.textContent = "";
confirm.disabled = !selectedFile;
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = selectedFile ? URL.createObjectURL(selectedFile) : undefined;
replacementPreview.image.src = previewUrl ?? "";
});
cancel.addEventListener("click", close);
dialog.addEventListener("cancel", (event) => {
event.preventDefault();
close();
});
dialog.addEventListener("close", () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
dialog.remove();
trigger?.focus();
});
const replaceImage = async () => {
if (!selectedFile) return;

confirm.disabled = true;
cancel.disabled = true;
input.disabled = true;
confirm.textContent = "Uploading...";
try {
const file = await uploadImageFile(selectedFile);
if (tool.api.blocks.getBlockIndex(tool.block.id) === -1) {
close();
return;
}
await tool.api.blocks.update(tool.block.id, {
Comment thread
Famousmaster206 marked this conversation as resolved.
file,
altText: altText.value.trim(),
});
close();
} catch (uploadError) {
console.error("Failed to replace image:", uploadError);
error.textContent =
uploadError instanceof Error
? uploadError.message
: "Could not upload image. Please try again.";
confirm.disabled = false;
cancel.disabled = false;
input.disabled = false;
confirm.textContent = "Replace image";
}
};
confirm.addEventListener("click", () => {
void replaceImage();
});
dialog.showModal();
input.focus();
}
Comment thread
Famousmaster206 marked this conversation as resolved.

function isStorageObjectNotFoundError(error: unknown): boolean {
return (
typeof error === "object" &&
Expand Down Expand Up @@ -116,6 +307,13 @@ class CustomImage extends Image {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const maybeData = (args as unknown as { data?: EditorImageData }).data;
if (maybeData) {
// The base Image tool retains only its known fields when it initializes
// `_data`. Restore our additional persisted fields so a routine editor
// save does not silently discard them.
const data = (this as unknown as { _data: EditorImageData })._data;
data.altText = maybeData.altText;
data.richCaption = maybeData.richCaption;

if (maybeData.richCaption) {
this._richCaption = resolveInitialRichCaption(maybeData.richCaption);
} else if (typeof maybeData.caption === "string" && maybeData.caption) {
Expand All @@ -124,6 +322,26 @@ class CustomImage extends Image {
}
}

render(): HTMLElement {
const baseImageTool = Image as unknown as {
prototype: {
render(this: CustomImageTool): HTMLElement;
};
};
// The Image package's inherited render type is `any`; constrain it at the
// boundary before calling it so the custom tool remains type-safe.
const wrapper = baseImageTool.prototype.render.call(
this as unknown as CustomImageTool,
);

// `blocks.update()` replaces this tool's DOM without dispatching the
// rendered lifecycle hook. Mount on every render as well so replacing an
// image never leaves the caption as an unmanaged native contenteditable.
queueMicrotask(() => this.rendered());

return wrapper;
}

renderSettings(): MenuConfigItemList {
const typedTool = this as unknown as CustomImageTool;
const baseImageTool = Image as unknown as {
Expand All @@ -140,6 +358,13 @@ class CustomImage extends Image {
) as unknown[];

return [
...settingsArray,
{
name: "replaceImage",
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 1-15.5 6.2L3 16"/><path d="M3 21v-5h5"/><path d="M3 12A9 9 0 0 1 18.5 5.8L21 8"/><path d="M16 8h5V3"/></svg>`,
title: "Replace image",
onActivate: () => openImageReplacementDialog(typedTool),
},
{
name: "deleteImage",
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M6 6l1 14h10l1-14"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>`,
Expand All @@ -161,7 +386,6 @@ class CustomImage extends Image {
typedTool.api.blocks.delete(blockIndex);
},
},
...settingsArray,
] as unknown as MenuConfigItemList;
}

Expand Down Expand Up @@ -224,6 +448,7 @@ class CustomImage extends Image {
save(): {
file: EditorImageData["file"];
caption?: string;
altText?: string;
richCaption?: RichCaption;
withBorder?: boolean;
withBackground?: boolean;
Expand All @@ -238,15 +463,11 @@ class CustomImage extends Image {
return {
file: d.file,
caption: richCaptionToPlainText(rich),
...(d.altText === undefined ? {} : { altText: d.altText }),
richCaption: serializeRichCaption(rich),
withBorder: d.withBorder,
withBackground: d.withBackground,
stretched: d.stretched,
// `centerImage` is a custom action, not one of @editorjs/image's built-in
// tunes, so the base tool never initializes it — it stays `undefined`
// until the author toggles "Center image" at least once. Firestore's
// setDoc() rejects any field with a literal `undefined` value, so this
// must be coerced to a boolean before saving.
centerImage: d.centerImage ?? false,
};
}
Expand Down Expand Up @@ -305,32 +526,12 @@ export const EDITOR_TOOLS: EditorConfig["tools"] = {
config: {
uploader: {
async uploadByFile(file: File) {
if (file.size > 5 * 1024 * 1024) {
alert(`File "${file.name}" is too large.`);
return { success: 0 };
}

const storage = getStorage();
const storageRef = ref(
storage,
"images/" + new Date().getTime() + "_" + file.name,
);

try {
const contentType = resolveUploadContentType(file);
const snapshot = await uploadBytes(
storageRef,
file,
contentType ? { contentType } : undefined,
);
const downloadURL = await getDownloadURL(snapshot.ref);
const uploadedFile = await uploadImageFile(file);

return {
success: 1,
file: {
url: downloadURL,
storageRefFullPath: storageRef.fullPath,
},
file: uploadedFile,
};
} catch (err) {
console.log(err);
Expand Down
23 changes: 15 additions & 8 deletions src/components/article-creator/Renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,8 @@ import type { QuestionFormat } from "@/types/questions";
import "@/styles/katexStyling.css";
import styles from "./Renderer.module.css";
import { sanitizeAlignment } from "./alignment";
import {
renderRichCaptionToHtml,
} from "./caption-rich-text/render";
import {
coerceRichCaption,
} from "./caption-rich-text/convert";
import { renderRichCaptionToHtml } from "./caption-rich-text/render";
import { coerceRichCaption } from "./caption-rich-text/convert";

// Tool names the "alignment" BlockTune is registered on (see Editor.tsx).
const ALIGNABLE_TYPES = new Set(["paragraph", "header", "list"]);
Expand Down Expand Up @@ -259,14 +255,25 @@ const customParsers: Record<
} else if (typeof data.caption === "string" && data.caption.length > 0) {
captionBody = decodeEntities(data.caption);
}
const rawAltText =
typeof data.altText === "string"
? data.altText
: typeof data.caption === "string"
? data.caption
: "";
const altText = rawAltText
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");

if (_config.image.use === "img") {
return `<img class="${imageConditions} ${imgClass}" src="${imageSrc}" alt="${data.caption ?? ""}">`;
return `<img class="${imageConditions} ${imgClass}" src="${imageSrc}" alt="${altText}">`;
} 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="${data.caption ?? ""}"><figcaption class="${figCapClass}">${captionBody}</figcaption></figure>`;
return `<figure class="${figureClass}"><img class="${imgClass} ${imageConditions}" src="${imageSrc}" alt="${altText}"><figcaption class="${figCapClass}">${captionBody}</figcaption></figure>`;
}
return "ERROR DISPLAYING IMAGE";
},
Expand Down
Loading
Loading