diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 45234831..e96e7651 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -286,6 +286,9 @@ "Alt text": "Alt text", "Describe this for accessibility.": "Describe this for accessibility.", "Add a description": "Add a description", + "Caption": "Caption", + "Add a caption": "Add a caption", + "Shown below the image.": "Shown below the image.", "Justify": "Justify", "Merge cells": "Merge cells", "Split cell": "Split cell", diff --git a/apps/client/src/features/editor/components/common/use-caption-control.tsx b/apps/client/src/features/editor/components/common/use-caption-control.tsx new file mode 100644 index 00000000..96944b55 --- /dev/null +++ b/apps/client/src/features/editor/components/common/use-caption-control.tsx @@ -0,0 +1,141 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Editor } from "@tiptap/react"; +import { + ActionIcon, + Button, + Group, + Paper, + Text, + Textarea, + Tooltip, +} from "@mantine/core"; +import { IconTextCaption } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; + +const CAPTION_MAX_LENGTH = 500; + +// Caption is plain visible text (not a markdown link target like alt), so it is +// sanitized more softly than alt: collapse runs of whitespace/newlines into a +// single space and trim, keeping the limit generous. +function sanitizeCaption(value: string): string { + return value.replace(/\s+/g, " ").trim().slice(0, CAPTION_MAX_LENGTH); +} + +type UseCaptionControlArgs = { + editor: Editor; + nodeName: string; + currentCaption: string; +}; + +export function useCaptionControl({ + editor, + nodeName, + currentCaption, +}: UseCaptionControlArgs) { + const { t } = useTranslation(); + const [showInput, setShowInput] = useState(false); + const [draft, setDraft] = useState(""); + + const open = useCallback(() => { + setDraft(currentCaption || ""); + setShowInput(true); + }, [currentCaption]); + + useEffect(() => { + const handler = () => { + if (!editor.isActive(nodeName)) { + setShowInput(false); + } + }; + editor.on("selectionUpdate", handler); + return () => { + editor.off("selectionUpdate", handler); + }; + }, [editor, nodeName]); + + const cancel = useCallback(() => { + setShowInput(false); + }, []); + + const save = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .updateAttributes(nodeName, { + caption: sanitizeCaption(draft) || undefined, + }) + .run(); + setShowInput(false); + }, [editor, nodeName, draft]); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + save(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancel(); + } + }, + [save, cancel], + ); + + const button = ( + + + + + + ); + + const panel = showInput ? ( + + + {t("Caption")} + + + {t("Shown below the image.")} + +