diff --git a/src/components/article-creator/Editor.tsx b/src/components/article-creator/Editor.tsx index dbeb00ee..2ca3c520 100644 --- a/src/components/article-creator/Editor.tsx +++ b/src/components/article-creator/Editor.tsx @@ -31,14 +31,27 @@ 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, + RICH_CAPTION_HOST_ATTR, +} from "./caption-rich-text/mount"; +import type { RichCaption } from "./caption-rich-text/types"; +import { + serializeRichCaption, + richCaptionToPlainText, +} from "./caption-rich-text/convert"; interface EditorImageData { file: { url?: string; storageRefFullPath?: string; [key: string]: unknown }; caption?: string; + altText?: string; + richCaption?: RichCaption; withBorder?: boolean; withBackground?: boolean; stretched?: boolean; + centerImage?: boolean; } type MenuConfigItemList = Array<{ @@ -55,16 +68,215 @@ type CustomImageTool = { blocks: { getBlockIndex(blockId: string): number; delete(index?: number): void; + update( + blockId: string, + data?: Partial, + ): Promise; }; }; block: { id: string; + container: HTMLElement; + }; + ui: { + nodes: { + caption?: HTMLElement; + wrapper?: HTMLElement; + }; }; renderSettings(): MenuConfigItemList; + rendered?(): void; + save?(block: { holder: HTMLElement }): unknown; }; 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" && @@ -77,6 +289,60 @@ function isStorageObjectNotFoundError(error: unknown): boolean { // https://github.com/editor-js/image/issues/54#issuecomment-1546833098 // https://github.com/editor-js/image/issues/27 class CustomImage extends Image { + /** + * Latest snapshot of the rich caption, kept up to date by the mounted + * CaptionRichTextEditor. `this._data.caption` mirrors the plain-text + * representation for backwards compatibility with downstream renderers. + */ + private _richCaption: RichCaption = []; + + // The EditorJS image tool's constructor signature is loosely typed at the + // boundary; the call below is safe because Image's runtime expects this + // shape. + constructor(args: Parameters[0]) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + super(args); + // Capture any richCaption in the initial payload before the base class' + // `set data()` overwrites `_data.caption` with plain text. + // 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) { + this._richCaption = resolveInitialRichCaption(maybeData.caption); + } + } + } + + 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 { @@ -93,6 +359,13 @@ class CustomImage extends Image { ) as unknown[]; return [ + ...settingsArray, + { + name: "replaceImage", + icon: ``, + title: "Replace image", + onActivate: () => openImageReplacementDialog(typedTool), + }, { name: "deleteImage", icon: ``, @@ -114,10 +387,92 @@ class CustomImage extends Image { typedTool.api.blocks.delete(blockIndex); }, }, - ...settingsArray, ] as unknown as MenuConfigItemList; } + /** + * Called after the EditorJS image tool renders its UI. We locate the native + * caption element, replace it with a host node, and mount the React + * CaptionRichTextEditor into it. Repeated renders are idempotent. + */ + rendered(): void { + const initialKnown = + this._richCaption.length > 0 + ? this._richCaption + : resolveInitialRichCaption( + (this as unknown as { _data: EditorImageData })._data.richCaption ?? + (this as unknown as { _data: EditorImageData })._data.caption ?? + "", + ); + + const typed = this as unknown as { + ui: { + nodes: { + caption?: HTMLElement; + wrapper?: HTMLElement; + }; + }; + block: { container: HTMLElement; id: string }; + }; + + // 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; + if (!holder) return; + + let host = holder.querySelector(`[${RICH_CAPTION_HOST_ATTR}]`); + if (!host) { + host = document.createElement("div"); + host.setAttribute(RICH_CAPTION_HOST_ATTR, "true"); + const cdx = holder; + cdx.contentEditable = "false"; + cdx.innerHTML = ""; + cdx.appendChild(host); + } + + const onChange = (next: RichCaption): void => { + this._richCaption = next; + (this as unknown as { _data: EditorImageData })._data.richCaption = + serializeRichCaption(next); + (this as unknown as { _data: EditorImageData })._data.caption = + richCaptionToPlainText(next); + }; + + mountRichCaptionEditor({ + host, + initial: initialKnown, + placeholder: "Enter a caption (select text to format)...", + onChange, + }); + } + + save(): { + file: EditorImageData["file"]; + caption?: string; + altText?: string; + richCaption?: RichCaption; + withBorder?: boolean; + withBackground?: boolean; + stretched?: boolean; + centerImage?: boolean; + } { + // Avoid calling the parent save(); it would try to read the caption + // element that we've repurposed, and we already have a structured value + // stored on `_data`. Read from `this._data` for non-caption fields. + const d = (this as unknown as { _data: EditorImageData })._data; + const rich = this._richCaption; + 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: d.centerImage ?? false, + }; + } + removed() { const { file } = this._data as EditorImageData; @@ -172,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 6359c1fe..3b1d94fe 100644 --- a/src/components/article-creator/Renderer.tsx +++ b/src/components/article-creator/Renderer.tsx @@ -12,6 +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"; // Tool names the "alignment" BlockTune is registered on (see Editor.tsx). const ALIGNABLE_TYPES = new Set(["paragraph", "header", "list"]); @@ -245,13 +247,33 @@ const customParsers: Record< ); } + // Prefer richCaption for the figcaption body; fall back to plain caption. + const rich = (data as { richCaption?: unknown }).richCaption; + let captionBody = ""; + if (Array.isArray(rich) && rich.length > 0) { + captionBody = renderRichCaptionToHtml(coerceRichCaption(rich)); + } 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}
${data.caption}
`; + return `
${altText}
${captionBody}
`; } return "ERROR DISPLAYING IMAGE"; }, diff --git a/src/components/article-creator/caption-rich-text/CaptionRichTextEditor.tsx b/src/components/article-creator/caption-rich-text/CaptionRichTextEditor.tsx new file mode 100644 index 00000000..ee38eeec --- /dev/null +++ b/src/components/article-creator/caption-rich-text/CaptionRichTextEditor.tsx @@ -0,0 +1,508 @@ +"use client"; + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + Bold, + Italic, + Underline, + Highlighter, + Link as LinkIcon, + ExternalLink, + Trash2, +} from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { isValidUrl } from "./sanitize"; +import { htmlToRichCaption, serializeRichCaption } from "./convert"; +import { renderRichCaptionToHtml } from "./render"; +import type { RichCaption, CaptionLinkMark } from "./types"; + +export interface CaptionRichTextEditorProps { + value: RichCaption; + onChange: (next: RichCaption) => void; + placeholder?: string; + className?: string; +} + +interface SelectionState { + range: Range | null; + rect: DOMRect | null; + activeMarks: Set; + linkMark: CaptionLinkMark | null; +} + +const EMPTY_SELECTION: SelectionState = { + range: null, + rect: null, + activeMarks: new Set(), + linkMark: null, +}; + +// Replace an element with its children, preserving their position in the tree. +function unwrapElement(el: Element): void { + const parent = el.parentNode; + if (!parent) return; + while (el.firstChild) parent.insertBefore(el.firstChild, el); + parent.removeChild(el); +} + +// Space (in px) reserved above the selection for the floating toolbar/popover +// before falling back to rendering below the selection instead. +const TOOLBAR_HEIGHT = 44; +const LINK_POPOVER_HEIGHT = 60; +const VIEWPORT_MARGIN = 8; + +export function CaptionRichTextEditor({ + value, + onChange, + placeholder = "Enter a caption...", + className, +}: CaptionRichTextEditorProps) { + const editorRef = useRef(null); + const [selection, setSelection] = useState(EMPTY_SELECTION); + const [linkPopoverOpen, setLinkPopoverOpen] = useState(false); + const [linkDraft, setLinkDraft] = useState(""); + const [linkError, setLinkError] = useState(null); + const lastExternalValue = useRef(value); + + // Re-render DOM content only when value changes from outside (initial load, + // undo, redo). While focused we treat the contentEditable as source of truth. + useEffect(() => { + const el = editorRef.current; + if (!el) return; + if (document.activeElement !== el && lastExternalValue.current !== value) { + el.innerHTML = renderRichCaptionToHtml(value); + lastExternalValue.current = value; + } + }, [value]); + + // Mount initial content when the editor first becomes available. + useEffect(() => { + const el = editorRef.current; + if (el && el.innerHTML === "") { + el.innerHTML = renderRichCaptionToHtml(value); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const commit = useCallback(() => { + const el = editorRef.current; + if (!el) return; + const next = htmlToRichCaption(el.innerHTML); + const serialized = serializeRichCaption(next); + lastExternalValue.current = serialized; + onChange(serialized); + }, [onChange]); + + const refreshSelectionState = useCallback(() => { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { + setSelection(EMPTY_SELECTION); + setLinkPopoverOpen(false); + return; + } + const range = sel.getRangeAt(0); + const root = editorRef.current; + if (!root?.contains(range.commonAncestorContainer)) { + setSelection(EMPTY_SELECTION); + return; + } + let rect: DOMRect = range.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) { + const rects = range.getClientRects(); + const firstRect = rects[0]; + if (firstRect) rect = firstRect; + } + + const activeMarks = new Set(); + let linkMark: CaptionLinkMark | null = null; + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node: Node | null = walker.currentNode; + while (node) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const textNode = node; + if (range.intersectsNode(textNode)) { + let el: Element | null = textNode.parentElement; + while (el && el !== root) { + const tag = el.tagName.toLowerCase(); + if (tag === "strong" || tag === "b") activeMarks.add("bold"); + else if (tag === "em" || tag === "i") activeMarks.add("italic"); + else if (tag === "u") activeMarks.add("underline"); + else if (tag === "mark") activeMarks.add("highlight"); + else if (tag === "a") { + activeMarks.add("link"); + const href = el.getAttribute("href") ?? ""; + if (isValidUrl(href) && !linkMark) { + linkMark = { type: "link", href }; + } + } + el = el.parentElement; + } + } + node = walker.nextNode(); + } + + setSelection({ range, rect, activeMarks, linkMark }); + }, []); + + useEffect(() => { + const handler = () => requestAnimationFrame(refreshSelectionState); + document.addEventListener("selectionchange", handler); + return () => document.removeEventListener("selectionchange", handler); + }, [refreshSelectionState]); + + const handleBlur = useCallback( + (e: React.FocusEvent) => { + // Defer commit so click events on toolbar/popover can still fire first. + requestAnimationFrame(() => { + if (!editorRef.current) return; + if ( + document.activeElement && + editorRef.current.parentElement?.contains(document.activeElement) + ) { + return; + } + commit(); + setSelection(EMPTY_SELECTION); + setLinkPopoverOpen(false); + }); + void e; + }, + [commit], + ); + + const applyCommand = useCallback( + (command: "bold" | "italic" | "underline" | "highlight") => { + editorRef.current?.focus(); + if (selection.range) { + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(selection.range); + } + if (command === "highlight") { + // execCommand("HiliteColor"/"backColor") is unreliable, so is + // applied/removed manually when there's any selected text. Toggling + // off unwraps any the selection touches instead of nesting a + // new one, so "highlight again to remove it" actually works. + const sel = window.getSelection(); + const root = editorRef.current; + if (sel && sel.rangeCount > 0 && !sel.isCollapsed && root) { + const range = sel.getRangeAt(0); + if (selection.activeMarks.has("highlight")) { + root.querySelectorAll("mark").forEach((mark) => { + if (range.intersectsNode(mark)) unwrapElement(mark); + }); + sel.removeAllRanges(); + } else { + const fragment = range.extractContents(); + const mark = document.createElement("mark"); + mark.appendChild(fragment); + range.insertNode(mark); + // Re-select what we just inserted + sel.removeAllRanges(); + const newRange = document.createRange(); + newRange.selectNodeContents(mark); + sel.addRange(newRange); + } + } + } else { + document.execCommand(command); + } + commit(); + refreshSelectionState(); + }, + [selection.range, selection.activeMarks, commit, refreshSelectionState], + ); + + const toggleLink = useCallback(() => { + setLinkError(null); + setLinkDraft(selection.linkMark?.href ?? ""); + setLinkPopoverOpen(true); + }, [selection.linkMark]); + + const confirmLink = useCallback(() => { + setLinkError(null); + const url = linkDraft.trim(); + if (!url) { + setLinkError("URL cannot be empty."); + return; + } + if (!isValidUrl(url)) { + setLinkError("Enter a valid http(s):// or mailto: URL."); + return; + } + if (!selection.range || selection.range.collapsed) { + setLinkError("Select some text to link first."); + return; + } + + editorRef.current?.focus(); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(selection.range); + + document.execCommand("createLink", false, url); + const root = editorRef.current; + if (root) { + const anchors = root.querySelectorAll("a[href]"); + anchors.forEach((a) => { + const href = a.getAttribute("href") ?? ""; + if (isValidUrl(href)) { + a.setAttribute("target", "_blank"); + a.setAttribute("rel", "noopener noreferrer"); + } + }); + } + setLinkPopoverOpen(false); + setLinkDraft(""); + commit(); + refreshSelectionState(); + }, [linkDraft, selection.range, commit, refreshSelectionState]); + + const removeLink = useCallback(() => { + editorRef.current?.focus(); + if (selection.range) { + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(selection.range); + } + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + let anchor: HTMLAnchorElement | null = null; + const node = sel.anchorNode; + if (node?.parentElement) { + anchor = node.parentElement.closest("a"); + } + if (anchor && editorRef.current?.contains(anchor)) { + const r = document.createRange(); + r.selectNodeContents(anchor); + sel.removeAllRanges(); + sel.addRange(r); + } + } + document.execCommand("unlink"); + setLinkPopoverOpen(false); + commit(); + refreshSelectionState(); + }, [selection.range, commit, refreshSelectionState]); + + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + e.preventDefault(); + const html = e.clipboardData.getData("text/html"); + const text = e.clipboardData.getData("text/plain"); + const source = html || text; + if (!source) return; + const fragments = htmlToRichCaption(source); + const safe = renderRichCaptionToHtml(fragments); + document.execCommand("insertHTML", false, safe); + commit(); + }, + [commit], + ); + + // These use position: fixed (see globals.css), so they're positioned + // directly from the selection's viewport-relative rect with no scrollY + // offset. Adding scrollY here would double-count the page's scroll + // position against a `position: absolute` ancestor that generally isn't + // anchored to the document origin, pushing the toolbar off-screen for any + // caption that isn't at the very top of the page. + // + // Prefer rendering above the selection, but flip below it when there isn't + // enough room above (e.g. the caption sits near the top of the viewport) so + // the toolbar/popover doesn't clamp to the viewport edge and cover the text + // the author just selected. + const toolbarStyle: React.CSSProperties = selection.rect + ? { + top: + selection.rect.top - TOOLBAR_HEIGHT >= VIEWPORT_MARGIN + ? selection.rect.top - TOOLBAR_HEIGHT + : selection.rect.bottom + VIEWPORT_MARGIN, + left: Math.min( + window.innerWidth - 280, + Math.max(8, selection.rect.left + selection.rect.width / 2 - 140), + ), + } + : { display: "none" }; + + const linkStyle: React.CSSProperties = selection.rect + ? { + top: + selection.rect.top - LINK_POPOVER_HEIGHT >= VIEWPORT_MARGIN + ? selection.rect.top - LINK_POPOVER_HEIGHT + : selection.rect.bottom + VIEWPORT_MARGIN, + left: Math.min( + window.innerWidth - 340, + Math.max(8, selection.rect.left + selection.rect.width / 2 - 160), + ), + } + : {}; + + return ( +
+
+ + {selection.rect && !linkPopoverOpen && ( +
+
+ )} + + {linkPopoverOpen && ( +
+ + setLinkDraft(e.target.value)} + placeholder="https://example.com" + aria-invalid={!!linkError} + aria-describedby={linkError ? "caption-link-error" : undefined} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + confirmLink(); + } else if (e.key === "Escape") { + e.preventDefault(); + setLinkPopoverOpen(false); + editorRef.current?.focus(); + } + }} + autoFocus + /> + {linkError && ( + + )} +
+ + + {selection.linkMark && ( + + )} + {selection.linkMark && ( + + + )} +
+
+ )} +
+ ); +} + +interface ToolbarButtonProps { + label: string; + icon: React.ReactNode; + active: boolean; + onClick: () => void; +} + +function ToolbarButton({ label, icon, active, onClick }: ToolbarButtonProps) { + return ( + + ); +} diff --git a/src/components/article-creator/caption-rich-text/convert.ts b/src/components/article-creator/caption-rich-text/convert.ts new file mode 100644 index 00000000..d6171874 --- /dev/null +++ b/src/components/article-creator/caption-rich-text/convert.ts @@ -0,0 +1,146 @@ +import type { RichCaption, CaptionSegment, CaptionMark } from "./types"; +import { isValidUrl } from "./sanitize"; + +/** + * Convert a plain string caption (legacy format) into a RichCaption with one + * segment carrying no marks. This keeps old plain-text captions loadable. + */ +export function plainTextToRichCaption(text: string): RichCaption { + if (!text) return []; + return [{ text, marks: [] }]; +} + +/** + * Extract the plain-text representation of a RichCaption (used for the image's + * `alt` attribute and for validation checks visible to authors). + */ +export function richCaptionToPlainText(richCaption: RichCaption | undefined): string { + if (!richCaption || richCaption.length === 0) return ""; + return richCaption.map((segment) => segment.text).join(""); +} + +/** + * Parse an HTML fragment (produced by editing or pasting) into a RichCaption. + * Only the supported marks (/, /, , , ) are + * retained; everything else is flattened to its text content. + */ +export function htmlToRichCaption(html: string): RichCaption { + if (!html) return []; + + // Parse in a detached document so we never touch the live DOM. + const doc = document.implementation.createHTMLDocument(""); + const container = doc.createElement("div"); + container.innerHTML = html; + + const segments: CaptionSegment[] = []; + + function walk(node: Node, inheritedMarks: CaptionMark[]) { + node.childNodes.forEach((child) => { + if (child.nodeType === Node.TEXT_NODE) { + const text = child.textContent ?? ""; + if (text.length > 0) { + segments.push({ text, marks: inheritedMarks.slice() }); + } + return; + } + if (child.nodeType !== Node.ELEMENT_NODE) return; + + const el = child as Element; + const tag = el.tagName.toLowerCase(); + let marks = inheritedMarks; + + switch (tag) { + case "strong": + case "b": + marks = marks.concat({ type: "bold" }); + break; + case "em": + case "i": + marks = marks.concat({ type: "italic" }); + break; + case "u": + marks = marks.concat({ type: "underline" }); + break; + case "mark": + marks = marks.concat({ type: "highlight" }); + break; + case "a": { + const href = el.getAttribute("href") ?? ""; + if (isValidUrl(href)) { + marks = marks.concat({ type: "link", href }); + } + break; + } + default: + // Unknown tags: descend but inherit no new marks. + break; + } + walk(el, marks); + }); + } + + walk(container, []); + return segments; +} + +/** + * Serialize a RichCaption to a JSON-safe representation for storage. Currently + * the RichCaption is already JSON-safe, but this indirection keeps the storage + * shape stable if internal types change later. + */ +export function serializeRichCaption(rich: RichCaption): RichCaption { + return rich.map((segment) => ({ + text: segment.text, + marks: segment.marks.map((mark) => { + if (mark.type === "link") { + const href = isValidUrl(mark.href) ? mark.href : ""; + return { type: "link", href }; + } + return { type: mark.type }; + }), + })); +} + +/** + * Coerce an unknown persisted value into a safe RichCaption. Handles legacy + * plain strings, malformed arrays, and partial segment data defensively. + */ +export function coerceRichCaption(value: unknown): RichCaption { + if (typeof value === "string") return plainTextToRichCaption(value); + if (!Array.isArray(value)) return []; + + const segments: CaptionSegment[] = []; + for (const item of value) { + if (!item || typeof item !== "object") continue; + const record = item as Record; + const text = typeof record.text === "string" ? record.text : ""; + if (!text) continue; + const rawMarks = record.marks; + const marks: CaptionMark[] = []; + if (Array.isArray(rawMarks)) { + for (const raw of rawMarks) { + if (!raw || typeof raw !== "object") continue; + const markRecord = raw as Record; + const type = markRecord.type; + switch (type) { + case "bold": + case "italic": + case "underline": + case "highlight": + marks.push({ type }); + break; + case "link": { + const href = + typeof markRecord.href === "string" ? markRecord.href : ""; + if (isValidUrl(href)) marks.push({ type: "link", href }); + break; + } + default: + break; + } + } + } + segments.push({ text, marks }); + } + return segments; +} diff --git a/src/components/article-creator/caption-rich-text/index.ts b/src/components/article-creator/caption-rich-text/index.ts new file mode 100644 index 00000000..de697ea9 --- /dev/null +++ b/src/components/article-creator/caption-rich-text/index.ts @@ -0,0 +1,6 @@ +export * from "./types"; +export * from "./sanitize"; +export * from "./render"; +export * from "./convert"; +export { CaptionRichTextEditor } from "./CaptionRichTextEditor"; +export type { CaptionRichTextEditorProps } from "./CaptionRichTextEditor"; diff --git a/src/components/article-creator/caption-rich-text/mount.tsx b/src/components/article-creator/caption-rich-text/mount.tsx new file mode 100644 index 00000000..9b5d5efa --- /dev/null +++ b/src/components/article-creator/caption-rich-text/mount.tsx @@ -0,0 +1,65 @@ +import { createRoot, type Root } from "react-dom/client"; +import { CaptionRichTextEditor } from "./CaptionRichTextEditor"; +import { coerceRichCaption } from "./convert"; +import type { RichCaption } from "./types"; + +/** + * Generated to mark placeholder caption containers that we mount React into. + * This CSS class lets CustomImage quickly locate the active node within a block. + */ +export const RICH_CAPTION_HOST_ATTR = "data-rich-caption-host"; + +/** + * Mount the CaptionRichTextEditor React component into a DOM host and surface + * the live value through a callback. The host replaces EditorJS' native + * contenteditable caption element so the rich-text editor becomes the new + * source of truth. Returns an unmount function. + */ +export function mountRichCaptionEditor(args: { + host: HTMLElement; + initial: RichCaption; + placeholder?: string; + onChange: (next: RichCaption) => void; +}): { unmount(): void } { + let root: Root | null = null; + // Reuse an existing React root if we're remounting into the same host. + if (!args.host.hasAttribute(RICH_CAPTION_HOST_ATTR) || !rootCache.has(args.host)) { + root = createRoot(args.host); + rootCache.set(args.host, root); + args.host.setAttribute(RICH_CAPTION_HOST_ATTR, "true"); + } else { + root = rootCache.get(args.host) ?? null; + } + + const safeCaption: RichCaption = coerceRichCaption(args.initial); + + root?.render( + args.onChange(next)} + />, + ); + + return { + unmount() { + const cached = rootCache.get(args.host); + if (cached) { + cached.unmount(); + rootCache.delete(args.host); + args.host.removeAttribute(RICH_CAPTION_HOST_ATTR); + } + }, + }; +} + +const rootCache = new WeakMap(); + +/** + * Convert a (possibly legacy) caption value into the initial React render. + * Accepts either a string (legacy plain text) or a RichCaption array. + */ +export function resolveInitialRichCaption(value: unknown): RichCaption { + if (!value) return []; + return coerceRichCaption(value); +} diff --git a/src/components/article-creator/caption-rich-text/render.ts b/src/components/article-creator/caption-rich-text/render.ts new file mode 100644 index 00000000..932c80f5 --- /dev/null +++ b/src/components/article-creator/caption-rich-text/render.ts @@ -0,0 +1,67 @@ +import { sanitizeCaptionHtml, isValidUrl, sanitizeUrl } from "./sanitize"; +import type { RichCaption, CaptionSegment, CaptionLinkMark } from "./types"; + +const AMP = String.fromCharCode(38); +const LT = String.fromCharCode(60); +const GT = String.fromCharCode(62); +const QUOT = String.fromCharCode(34); + +function htmlEncode(str: string): string { + return str + .replace(/&/g, AMP + "amp;") + .replace(//g, AMP + "gt;") + .replace(/"/g, AMP + "quot;"); +} + +function wrap(tag: string, inner: string): string { + return LT + tag + GT + inner + LT + "/" + tag + GT; +} + +// Emits the same semantic tags (////) that +// convert.ts's htmlToRichCaption parses back into marks, so a segment +// survives a render -> re-parse round trip (e.g. on the next keystroke) +// without losing formatting. Marks nest inside one another so a link can +// combine with bold/italic/underline/highlight instead of one replacing +// the others. +function renderSegmentToHtml(segment: CaptionSegment): string { + let html = htmlEncode(segment.text); + const has = (type: string) => segment.marks.some((m) => m.type === type); + + if (has("bold")) html = wrap("strong", html); + if (has("italic")) html = wrap("em", html); + if (has("underline")) html = wrap("u", html); + if (has("highlight")) html = wrap("mark", html); + + const linkMark = segment.marks.find( + (m): m is CaptionLinkMark => m.type === "link", + ); + if (linkMark && isValidUrl(linkMark.href)) { + const safeHref = sanitizeUrl(linkMark.href); + const open = + LT + + "a href=" + + QUOT + + safeHref + + QUOT + + " target=" + + QUOT + + "_blank" + + QUOT + + " rel=" + + QUOT + + "noopener noreferrer" + + QUOT + + GT; + html = open + html + LT + "/a" + GT; + } + + return html; +} + +export function renderRichCaptionToHtml(richCaption: RichCaption): string { + const html = richCaption + .map((segment) => renderSegmentToHtml(segment)) + .join(""); + return sanitizeCaptionHtml(html); +} diff --git a/src/components/article-creator/caption-rich-text/sanitize.ts b/src/components/article-creator/caption-rich-text/sanitize.ts new file mode 100644 index 00000000..3585630c --- /dev/null +++ b/src/components/article-creator/caption-rich-text/sanitize.ts @@ -0,0 +1,87 @@ +// Only allow these HTML elements and attributes for rich-text captions +const ALLOWED_TAGS = new Set(["strong", "em", "u", "mark", "a"]); +const ALLOWED_ATTRS = new Set(["href", "rel", "target"]); + +// Regex patterns for URL validation +const DANGEROUS_PROTOCOLS = /^(javascript|data|vbscript|file):/i; +const SCHEME_PREFIX = /^(https?:|mailto:)/i; + +/** + * Validate a caption link URL. Beyond rejecting dangerous schemes, this uses + * the URL parser to reject malformed URLs (e.g. missing host) and to resolve + * the *actual* scheme rather than trusting the raw string prefix. Any + * embedded whitespace is rejected outright, which also defeats tricks like + * sneaking "javascript:" after a leading space or tab. Bare "//host/path" + * links are rejected too, since browsers resolve those to an arbitrary + * external host using the current page's scheme (open-redirect / phishing + * risk) even though they start with what looks like a single "/". The same + * applies to a leading "/\" (or any second character of "/" or "\"): the + * WHATWG URL parser treats "\" exactly like "/" for http(s) URLs, so + * "/\evil.com" resolves to "https://evil.com" in every major browser even + * though it isn't a "//" prefix. + */ +export function isValidUrl(url: string): boolean { + const trimmed = url.trim(); + if (!trimmed) return false; + if (/\s/.test(trimmed)) return false; + if (DANGEROUS_PROTOCOLS.test(trimmed)) return false; + + if (trimmed.startsWith("/") && trimmed[1] !== "/" && trimmed[1] !== "\\") { + return true; + } + + if (!SCHEME_PREFIX.test(trimmed)) return false; + + try { + const parsed = new URL(trimmed); + return ( + parsed.protocol === "http:" || + parsed.protocol === "https:" || + parsed.protocol === "mailto:" + ); + } catch { + return false; + } +} + +export function sanitizeUrl(url: string): string { + const trimmed = url.trim(); + if (!isValidUrl(trimmed)) return ""; + return trimmed; +} + +/** + * Strip every tag and attribute we don't allow from an HTML fragment. Runs + * without a DOM so it can be used during server rendering. This is a deliberate + * whitelist implementation (no DOMPurify dependency) so the same module is + * usable from both client and server code paths. + */ +export function sanitizeCaptionHtml(html: string): string { + if (!html) return ""; + + const tagPattern = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g; + return html.replace(tagPattern, (full, rawTag: string, attrs: string) => { + const tag = rawTag.toLowerCase(); + const isClosing = full.startsWith("`; + + // Whitelist attributes: only href, rel, target on . + const attrPattern = + /([a-zA-Z][a-zA-Z0-9_-]*)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g; + let cleanedAttrs = ""; + let m: RegExpExecArray | null; + while ((m = attrPattern.exec(attrs)) !== null) { + const name = (m[1] ?? "").toLowerCase(); + const value = m[2] ?? m[3] ?? m[4] ?? ""; + if (!ALLOWED_ATTRS.has(name)) continue; + if (name === "href") { + if (!isValidUrl(value)) continue; + cleanedAttrs += ` href="${value.replace(/"/g, "")}"`; + } else { + cleanedAttrs += ` ${name}="${value.replace(/"/g, "")}"`; + } + } + return `<${tag}${cleanedAttrs}>`; + }); +} diff --git a/src/components/article-creator/caption-rich-text/types.ts b/src/components/article-creator/caption-rich-text/types.ts new file mode 100644 index 00000000..649518a3 --- /dev/null +++ b/src/components/article-creator/caption-rich-text/types.ts @@ -0,0 +1,19 @@ +/** A formatting mark that can be applied to a range of caption text. */ +export type CaptionMark = + | { type: "bold" } + | { type: "italic" } + | { type: "underline" } + | { type: "highlight" } + | { type: "link"; href: string }; + +/** A link formatting mark (a CaptionMark variant extractor type). */ +export type CaptionLinkMark = Extract; + +/** A segment of caption text with optional formatting marks. */ +export interface CaptionSegment { + text: string; + marks: CaptionMark[]; +} + +/** The structured rich-text representation of a caption. */ +export type RichCaption = CaptionSegment[]; \ No newline at end of file diff --git a/src/components/article-creator/custom_questions/AdvancedTextbox.tsx b/src/components/article-creator/custom_questions/AdvancedTextbox.tsx index a6784cdc..58295904 100644 --- a/src/components/article-creator/custom_questions/AdvancedTextbox.tsx +++ b/src/components/article-creator/custom_questions/AdvancedTextbox.tsx @@ -4,7 +4,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ import React, { useRef, useState, useEffect } from "react"; -import { Textarea } from "@/components/ui/textarea"; import type { QuestionFile, QuestionFormat } from "@/types/questions"; import { Paperclip, Trash, ChevronLeft, ChevronRight } from "lucide-react"; import { @@ -17,6 +16,7 @@ import { import { getUser } from "@/components/hooks/users"; import { getFileFromIndexedDB } from "./RenderAdvancedTextbox"; import { isSvgFileName, resolveUploadContentType } from "@/lib/utils"; +import RichTextEditor from "./RichTextEditor"; interface Props { questions: QuestionFormat[]; @@ -177,7 +177,7 @@ export default function AdvancedTextbox({ > >({}); - const textareaRef = useRef(null); + const editorRef = useRef(null); const fileInputRef = useRef(null); // Sync state when loaded @@ -209,8 +209,9 @@ export default function AdvancedTextbox({ } }, [questionInstance, oIndex, origin]); - // Handle keys logic - const handleKeyDown = (e: React.KeyboardEvent) => { + // Keep navigation keys inside the block editor rather than letting the host + // EditorJS instance handle them. + const handleKeyDown = (e: React.KeyboardEvent) => { const key = e.key; if ( @@ -224,90 +225,7 @@ export default function AdvancedTextbox({ e.stopPropagation(); } - if (key === "Enter") { - const textarea = textareaRef.current; - if (!textarea) return; - - const cursorPosition = textarea.selectionStart; - const textBeforeCursor = currentText.substring(0, cursorPosition); - const textAfterCursor = currentText.substring(textarea.selectionEnd); - - const lastNewLineIndex = textBeforeCursor.lastIndexOf("\n"); - const currentLine = textBeforeCursor.substring(lastNewLineIndex + 1); - - const match = currentLine.match(/^\s*/); - const leadingWhitespace = match ? match[0] : ""; - - if (leadingWhitespace) { - e.preventDefault(); - const newText = - textBeforeCursor + "\n" + leadingWhitespace + textAfterCursor; - updateQuestionText(newText); - - setTimeout(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = - textareaRef.current.selectionEnd = - cursorPosition + 1 + leadingWhitespace.length; - } - }, 0); - } - } - - if (key === "Backspace") { - const textarea = textareaRef.current; - if (!textarea) return; - - const cursorPosition = textarea.selectionStart; - if (cursorPosition === textarea.selectionEnd && cursorPosition > 0) { - const textBeforeCursor = currentText.substring(0, cursorPosition); - const lastNewLineIndex = textBeforeCursor.lastIndexOf("\n"); - const currentLine = textBeforeCursor.substring(lastNewLineIndex + 1); - - if (currentLine.length > 0 && /^\s+$/.test(currentLine)) { - e.preventDefault(); - - const spacesToDelete = currentLine.length % 2 !== 0 ? 1 : 2; - - const newText = - currentText.substring(0, cursorPosition - spacesToDelete) + - currentText.substring(cursorPosition); - - updateQuestionText(newText); - - setTimeout(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = - textareaRef.current.selectionEnd = - cursorPosition - spacesToDelete; - } - }, 0); - } - } - } - - if (key === "Tab") { - e.stopPropagation(); - e.preventDefault(); - - const textarea = textareaRef.current; - if (!textarea) return; - - const start = textarea.selectionStart; - const end = textarea.selectionEnd; - const indent = " "; - const newText = - currentText.substring(0, start) + indent + currentText.substring(end); - - updateQuestionText(newText); - - setTimeout(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = - textareaRef.current.selectionEnd = start + indent.length; - } - }, 0); - } + if (key === "Tab") e.stopPropagation(); }; const updateQuestionsWithFiles = (newFiles: QuestionFile[]) => { @@ -381,10 +299,6 @@ export default function AdvancedTextbox({ setQuestions(updatedQuestions); }; - const handleTextChange = (e: React.ChangeEvent) => { - updateQuestionText(e.target.value); - }; - const handleUploadClick = () => { fileInputRef.current?.click(); }; @@ -568,35 +482,18 @@ export default function AdvancedTextbox({ }; const insertPlaceholder = (file: QuestionFile, index: number) => { - const textarea = textareaRef.current; - if (!textarea) return; - const placeholderText = `[image:${index + 1}]`; - const start = textarea.selectionStart; - const end = textarea.selectionEnd; - - const newText = - currentText.substring(0, start) + - placeholderText + - currentText.substring(end); - - updateQuestionText(newText); - - setTimeout(() => { - textarea.focus(); - textarea.setSelectionRange( - start + placeholderText.length, - start + placeholderText.length, - ); - }, 0); + editorRef.current?.focus(); + document.execCommand("insertText", false, placeholderText); + if (editorRef.current) updateQuestionText(editorRef.current.innerHTML); }; return (
-