diff --git a/apps/client/src/features/editor/components/common/use-alt-text-control.tsx b/apps/client/src/features/editor/components/common/use-alt-text-control.tsx
index 1a43f9d7..2f1e8eb5 100644
--- a/apps/client/src/features/editor/components/common/use-alt-text-control.tsx
+++ b/apps/client/src/features/editor/components/common/use-alt-text-control.tsx
@@ -1,16 +1,7 @@
-import React, { useCallback, useEffect, useState } from "react";
import { Editor } from "@tiptap/react";
-import {
- ActionIcon,
- Button,
- Group,
- Paper,
- Text,
- Textarea,
- Tooltip,
-} from "@mantine/core";
import { IconAlt } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
+import { useImageTextFieldControl } from "@/features/editor/components/common/use-image-text-field-control.tsx";
const ALT_MAX_LENGTH = 300;
@@ -27,113 +18,25 @@ type UseAltTextControlArgs = {
currentAlt: string;
};
+// Thin wrapper over the shared image text-field popover; see
+// useImageTextFieldControl. The t("...") literals stay here so they remain
+// statically extractable for i18n.
export function useAltTextControl({
editor,
nodeName,
currentAlt,
}: UseAltTextControlArgs) {
const { t } = useTranslation();
- const [showInput, setShowInput] = useState(false);
- const [draft, setDraft] = useState("");
-
- const open = useCallback(() => {
- setDraft(currentAlt || "");
- setShowInput(true);
- }, [currentAlt]);
-
- 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, { alt: sanitizeAlt(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("Alt text")}
-
-
- {t("Describe this for accessibility.")}
-
-
- ) : null;
-
- return { button, panel, isEditing: showInput };
+ return useImageTextFieldControl({
+ editor,
+ nodeName,
+ currentValue: currentAlt,
+ attrName: "alt",
+ sanitize: sanitizeAlt,
+ maxLength: ALT_MAX_LENGTH,
+ icon: ,
+ label: t("Alt text"),
+ description: t("Describe this for accessibility."),
+ placeholder: t("Add a description"),
+ });
}
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
index bde44700..12a12fca 100644
--- a/apps/client/src/features/editor/components/common/use-caption-control.tsx
+++ b/apps/client/src/features/editor/components/common/use-caption-control.tsx
@@ -1,16 +1,7 @@
-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";
+import { useImageTextFieldControl } from "@/features/editor/components/common/use-image-text-field-control.tsx";
const CAPTION_MAX_LENGTH = 500;
@@ -27,115 +18,25 @@ type UseCaptionControlArgs = {
currentCaption: string;
};
+// Thin wrapper over the shared image text-field popover; see
+// useImageTextFieldControl. The t("...") literals stay here so they remain
+// statically extractable for i18n.
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.")}
-
-
- ) : null;
-
- return { button, panel, isEditing: showInput };
+ return useImageTextFieldControl({
+ editor,
+ nodeName,
+ currentValue: currentCaption,
+ attrName: "caption",
+ sanitize: sanitizeCaption,
+ maxLength: CAPTION_MAX_LENGTH,
+ icon: ,
+ label: t("Caption"),
+ description: t("Shown below the image."),
+ placeholder: t("Add a caption"),
+ });
}
diff --git a/apps/client/src/features/editor/components/common/use-image-text-field-control.tsx b/apps/client/src/features/editor/components/common/use-image-text-field-control.tsx
new file mode 100644
index 00000000..c492e127
--- /dev/null
+++ b/apps/client/src/features/editor/components/common/use-image-text-field-control.tsx
@@ -0,0 +1,145 @@
+import React, { useCallback, useEffect, useState } from "react";
+import { Editor } from "@tiptap/react";
+import {
+ ActionIcon,
+ Button,
+ Group,
+ Paper,
+ Text,
+ Textarea,
+ Tooltip,
+} from "@mantine/core";
+import { useTranslation } from "react-i18next";
+
+// Shared logic+UI for the image bubble-menu text-field popovers (alt text,
+// caption, ...). Each field is the same popover — an ActionIcon that opens a
+// titled Paper with a counted Textarea and Cancel/Save — differing only in the
+// node attribute it writes, its sanitizer, length cap, icon and labels. The
+// label/description/placeholder are passed already translated so the literal
+// t("...") calls stay in the thin wrappers and remain extractable; the shared
+// Cancel/Save strings are translated here.
+type UseImageTextFieldControlArgs = {
+ editor: Editor;
+ nodeName: string;
+ currentValue: string;
+ attrName: string;
+ sanitize: (value: string) => string;
+ maxLength: number;
+ icon: React.ReactNode;
+ label: string;
+ description: string;
+ placeholder: string;
+};
+
+export function useImageTextFieldControl({
+ editor,
+ nodeName,
+ currentValue,
+ attrName,
+ sanitize,
+ maxLength,
+ icon,
+ label,
+ description,
+ placeholder,
+}: UseImageTextFieldControlArgs) {
+ const { t } = useTranslation();
+ const [showInput, setShowInput] = useState(false);
+ const [draft, setDraft] = useState("");
+
+ const open = useCallback(() => {
+ setDraft(currentValue || "");
+ setShowInput(true);
+ }, [currentValue]);
+
+ 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, { [attrName]: sanitize(draft) || undefined })
+ .run();
+ setShowInput(false);
+ }, [editor, nodeName, attrName, sanitize, 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 = (
+
+
+ {icon}
+
+
+ );
+
+ const panel = showInput ? (
+
+
+ {label}
+
+
+ {description}
+
+
+ ) : null;
+
+ return { button, panel, isEditing: showInput };
+}