diff --git a/src/components/article-creator/Editor.tsx b/src/components/article-creator/Editor.tsx index 59b07d6a..2ca3c520 100644 --- a/src/components/article-creator/Editor.tsx +++ b/src/components/article-creator/Editor.tsx @@ -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, @@ -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; @@ -67,6 +68,10 @@ type CustomImageTool = { blocks: { getBlockIndex(blockId: string): number; delete(index?: number): void; + update( + blockId: string, + data?: Partial, + ): Promise; }; }; block: { @@ -86,6 +91,192 @@ type CustomImageTool = { const pendingStorageDeletes = new Set(); +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, { + 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(); +} + function isStorageObjectNotFoundError(error: unknown): boolean { return ( typeof error === "object" && @@ -116,6 +307,14 @@ 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; + data.centerImage = maybeData.centerImage; + if (maybeData.richCaption) { this._richCaption = resolveInitialRichCaption(maybeData.richCaption); } else if (typeof maybeData.caption === "string" && maybeData.caption) { @@ -124,6 +323,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 { @@ -140,6 +359,13 @@ class CustomImage extends Image { ) as unknown[]; return [ + ...settingsArray, + { + name: "replaceImage", + icon: ``, + title: "Replace image", + onActivate: () => openImageReplacementDialog(typedTool), + }, { name: "deleteImage", icon: ``, @@ -161,7 +387,6 @@ class CustomImage extends Image { typedTool.api.blocks.delete(blockIndex); }, }, - ...settingsArray, ] as unknown as MenuConfigItemList; } @@ -224,6 +449,7 @@ class CustomImage extends Image { save(): { file: EditorImageData["file"]; caption?: string; + altText?: string; richCaption?: RichCaption; withBorder?: boolean; withBackground?: boolean; @@ -238,15 +464,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, }; } @@ -305,32 +527,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); diff --git a/src/components/article-creator/Renderer.tsx b/src/components/article-creator/Renderer.tsx index bebd45bf..3b1d94fe 100644 --- a/src/components/article-creator/Renderer.tsx +++ b/src/components/article-creator/Renderer.tsx @@ -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"]); @@ -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("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); if (_config.image.use === "img") { - return `${data.caption ?? `; + return `${altText}`; } else if (_config.image.use === "figure") { const figureClass = _config.image.figureClass ?? ""; const figCapClass = _config.image.figCapClass ?? ""; - return `
${data.caption ??
${captionBody}
`; + return `
${altText}
${captionBody}
`; } return "ERROR DISPLAYING IMAGE"; }, diff --git a/src/components/article-creator/editorjs-render.ts b/src/components/article-creator/editorjs-render.ts index 0ebcd9a7..795450a8 100644 --- a/src/components/article-creator/editorjs-render.ts +++ b/src/components/article-creator/editorjs-render.ts @@ -10,12 +10,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"]); @@ -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("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); if (_config.image.use === "img") { - return `${data.caption ?? `; + return `${altText}`; } else if (_config.image.use === "figure") { const figureClass = _config.image.figureClass ?? ""; const figCapClass = _config.image.figCapClass ?? ""; - return `
${data.caption ??
${captionBody}
`; + return `
${altText}
${captionBody}
`; } return "ERROR DISPLAYING IMAGE"; },