refactor(editor): dedupe alt/caption controls via shared hook (F4)
Extract the ~110 duplicated lines into one parameterized
useImageTextFieldControl and make useAltTextControl/useCaptionControl
thin wrappers. Behavior identical; t("...") literals stay in the
wrappers so i18n extraction keeps working. sanitizeCaption still
exported for its unit test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 = (
|
||||
<Tooltip position="top" label={t("Alt text")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={open}
|
||||
size="lg"
|
||||
aria-label={t("Alt text")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconAlt size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const panel = showInput ? (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius={6}
|
||||
p="sm"
|
||||
w={320}
|
||||
style={{ position: "relative", zIndex: 100 }}
|
||||
>
|
||||
<Text size="sm" fw={600} mb={2}>
|
||||
{t("Alt text")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t("Describe this for accessibility.")}
|
||||
</Text>
|
||||
<Textarea
|
||||
size="xs"
|
||||
placeholder={t("Add a description")}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoFocus
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={5}
|
||||
maxLength={ALT_MAX_LENGTH}
|
||||
/>
|
||||
<Group justify="space-between" align="center" mt="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{draft.length}/{ALT_MAX_LENGTH}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button size="compact-xs" variant="default" onClick={cancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="compact-xs" onClick={save}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null;
|
||||
|
||||
return { button, panel, isEditing: showInput };
|
||||
return useImageTextFieldControl({
|
||||
editor,
|
||||
nodeName,
|
||||
currentValue: currentAlt,
|
||||
attrName: "alt",
|
||||
sanitize: sanitizeAlt,
|
||||
maxLength: ALT_MAX_LENGTH,
|
||||
icon: <IconAlt size={18} />,
|
||||
label: t("Alt text"),
|
||||
description: t("Describe this for accessibility."),
|
||||
placeholder: t("Add a description"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
<Tooltip position="top" label={t("Caption")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={open}
|
||||
size="lg"
|
||||
aria-label={t("Caption")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconTextCaption size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const panel = showInput ? (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius={6}
|
||||
p="sm"
|
||||
w={320}
|
||||
style={{ position: "relative", zIndex: 100 }}
|
||||
>
|
||||
<Text size="sm" fw={600} mb={2}>
|
||||
{t("Caption")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t("Shown below the image.")}
|
||||
</Text>
|
||||
<Textarea
|
||||
size="xs"
|
||||
placeholder={t("Add a caption")}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoFocus
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={5}
|
||||
maxLength={CAPTION_MAX_LENGTH}
|
||||
/>
|
||||
<Group justify="space-between" align="center" mt="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{draft.length}/{CAPTION_MAX_LENGTH}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button size="compact-xs" variant="default" onClick={cancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="compact-xs" onClick={save}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null;
|
||||
|
||||
return { button, panel, isEditing: showInput };
|
||||
return useImageTextFieldControl({
|
||||
editor,
|
||||
nodeName,
|
||||
currentValue: currentCaption,
|
||||
attrName: "caption",
|
||||
sanitize: sanitizeCaption,
|
||||
maxLength: CAPTION_MAX_LENGTH,
|
||||
icon: <IconTextCaption size={18} />,
|
||||
label: t("Caption"),
|
||||
description: t("Shown below the image."),
|
||||
placeholder: t("Add a caption"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
<Tooltip position="top" label={label} withinPortal={false}>
|
||||
<ActionIcon onClick={open} size="lg" aria-label={label} variant="subtle">
|
||||
{icon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const panel = showInput ? (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius={6}
|
||||
p="sm"
|
||||
w={320}
|
||||
style={{ position: "relative", zIndex: 100 }}
|
||||
>
|
||||
<Text size="sm" fw={600} mb={2}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{description}
|
||||
</Text>
|
||||
<Textarea
|
||||
size="xs"
|
||||
placeholder={placeholder}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoFocus
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={5}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
<Group justify="space-between" align="center" mt="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{draft.length}/{maxLength}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button size="compact-xs" variant="default" onClick={cancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="compact-xs" onClick={save}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null;
|
||||
|
||||
return { button, panel, isEditing: showInput };
|
||||
}
|
||||
Reference in New Issue
Block a user