feat(editor): add alt text support for images (#2097)

* feat(editor): add alt text support for images
* feat:  extend alt text support to videos and diagrams

---------
Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
This commit is contained in:
Olivier Lambert
2026-05-20 16:45:59 +01:00
committed by GitHub
co-authored by Philipinho
parent 66a754c9eb
commit 1c166c4736
12 changed files with 315 additions and 17 deletions
@@ -277,6 +277,9 @@
"Align left": "Align left", "Align left": "Align left",
"Align right": "Align right", "Align right": "Align right",
"Align center": "Align center", "Align center": "Align center",
"Alt text": "Alt text",
"Describe this for accessibility.": "Describe this for accessibility.",
"Add a description": "Add a description",
"Justify": "Justify", "Justify": "Justify",
"Merge cells": "Merge cells", "Merge cells": "Merge cells",
"Split cell": "Split cell", "Split cell": "Split cell",
@@ -0,0 +1,139 @@
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";
const ALT_MAX_LENGTH = 300;
function sanitizeAlt(value: string): string {
return value
.replace(/[\\\[\]!]/g, "")
.replace(/\s+/g, " ")
.trim();
}
type UseAltTextControlArgs = {
editor: Editor;
nodeName: string;
currentAlt: string;
};
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 };
}
@@ -38,6 +38,7 @@ import {
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils"; import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import { IAttachment } from "@/features/attachments/types/attachment.types"; import { IAttachment } from "@/features/attachments/types/attachment.types";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
export function DrawioMenu({ editor }: EditorMenuProps) { export function DrawioMenu({ editor }: EditorMenuProps) {
@@ -66,6 +67,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
isAlignRight: ctx.editor.isActive("drawio", { align: "right" }), isAlignRight: ctx.editor.isActive("drawio", { align: "right" }),
src: drawioAttr?.src || null, src: drawioAttr?.src || null,
attachmentId: drawioAttr?.attachmentId || null, attachmentId: drawioAttr?.attachmentId || null,
alt: drawioAttr?.alt || "",
}; };
}, },
}); });
@@ -140,6 +142,16 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
editor.commands.deleteSelection(); editor.commands.deleteSelection();
}, [editor]); }, [editor]);
const {
button: altTextButton,
panel: altTextPanel,
isEditing: isEditingAlt,
} = useAltTextControl({
editor,
nodeName: "drawio",
currentAlt: editorState?.alt || "",
});
const saveData = useCallback(async (svgXml: string) => { const saveData = useCallback(async (svgXml: string) => {
if (isSavingRef.current) return; if (isSavingRef.current) return;
@@ -266,7 +278,10 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<div className={classes.toolbar}> {isEditingAlt ? (
altTextPanel
) : (
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Align left")} withinPortal={false}> <Tooltip position="top" label={t("Align left")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={alignLeft} onClick={alignLeft}
@@ -309,6 +324,10 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} /> <div className={classes.divider} />
{altTextButton}
<div className={classes.divider} />
<Tooltip position="top" label={t("Edit")} withinPortal={false}> <Tooltip position="top" label={t("Edit")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={handleOpen} onClick={handleOpen}
@@ -342,7 +361,8 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
<IconTrash size={18} /> <IconTrash size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</div> </div>
)}
</BaseBubbleMenu> </BaseBubbleMenu>
<Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}> <Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
@@ -36,6 +36,7 @@ import { IAttachment } from "@/features/attachments/types/attachment.types";
import ReactClearModal from "react-clear-modal"; import ReactClearModal from "react-clear-modal";
import { useHandleLibrary } from "@excalidraw/excalidraw"; import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts"; import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
const ExcalidrawComponent = lazy(() => const ExcalidrawComponent = lazy(() =>
@@ -77,6 +78,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
isAlignRight: ctx.editor.isActive("excalidraw", { align: "right" }), isAlignRight: ctx.editor.isActive("excalidraw", { align: "right" }),
src: excalidrawAttr?.src || null, src: excalidrawAttr?.src || null,
attachmentId: excalidrawAttr?.attachmentId || null, attachmentId: excalidrawAttr?.attachmentId || null,
alt: excalidrawAttr?.alt || "",
}; };
}, },
}); });
@@ -153,6 +155,16 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
editor.commands.deleteSelection(); editor.commands.deleteSelection();
}, [editor]); }, [editor]);
const {
button: altTextButton,
panel: altTextPanel,
isEditing: isEditingAlt,
} = useAltTextControl({
editor,
nodeName: "excalidraw",
currentAlt: editorState?.alt || "",
});
const handleOpen = useCallback(async () => { const handleOpen = useCallback(async () => {
if (!editorState?.src) return; if (!editorState?.src) return;
@@ -291,7 +303,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<div className={classes.toolbar}> {isEditingAlt ? (
altTextPanel
) : (
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Align left")} withinPortal={false}> <Tooltip position="top" label={t("Align left")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={alignLeft} onClick={alignLeft}
@@ -340,6 +355,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} /> <div className={classes.divider} />
{altTextButton}
<div className={classes.divider} />
<Tooltip position="top" label={t("Edit")} withinPortal={false}> <Tooltip position="top" label={t("Edit")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={handleOpen} onClick={handleOpen}
@@ -373,7 +392,8 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
<IconTrash size={18} /> <IconTrash size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</div> </div>
)}
</BaseBubbleMenu> </BaseBubbleMenu>
<ReactClearModal <ReactClearModal
@@ -20,6 +20,7 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
export function ImageMenu({ editor }: EditorMenuProps) { export function ImageMenu({ editor }: EditorMenuProps) {
@@ -41,6 +42,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
isAlignCenter: ctx.editor.isActive("image", { align: "center" }), isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
isAlignRight: ctx.editor.isActive("image", { align: "right" }), isAlignRight: ctx.editor.isActive("image", { align: "right" }),
src: imageAttrs?.src || null, src: imageAttrs?.src || null,
alt: imageAttrs?.alt || "",
}; };
}, },
}); });
@@ -136,6 +138,16 @@ export function ImageMenu({ editor }: EditorMenuProps) {
editor.commands.deleteSelection(); editor.commands.deleteSelection();
}, [editor]); }, [editor]);
const {
button: altTextButton,
panel: altTextPanel,
isEditing: isEditingAlt,
} = useAltTextControl({
editor,
nodeName: "image",
currentAlt: editorState?.alt || "",
});
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
@@ -149,7 +161,10 @@ export function ImageMenu({ editor }: EditorMenuProps) {
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<div className={classes.toolbar}> {isEditingAlt ? (
altTextPanel
) : (
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Align left")} withinPortal={false}> <Tooltip position="top" label={t("Align left")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={alignImageLeft} onClick={alignImageLeft}
@@ -188,6 +203,10 @@ export function ImageMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} /> <div className={classes.divider} />
{altTextButton}
<div className={classes.divider} />
<Tooltip position="top" label={t("Download")} withinPortal={false}> <Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={handleDownload} onClick={handleDownload}
@@ -220,7 +239,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
<IconTrash size={18} /> <IconTrash size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</div> </div>
)}
<input <input
ref={fileInputRef} ref={fileInputRef}
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
export default function ImageView(props: NodeViewProps) { export default function ImageView(props: NodeViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { editor, node, selected } = props; const { editor, node, selected } = props;
const { src, width, align, title, aspectRatio, placeholder } = node.attrs; const { src, width, align, alt, aspectRatio, placeholder } = node.attrs;
const alignClass = useMemo(() => { const alignClass = useMemo(() => {
if (align === "left") return "alignLeft"; if (align === "left") return "alignLeft";
if (align === "right") return "alignRight"; if (align === "right") return "alignRight";
@@ -42,7 +42,7 @@ export default function ImageView(props: NodeViewProps) {
}} }}
> >
{src && ( {src && (
<Image radius="md" fit="contain" src={getFileUrl(src)} alt={title} /> <Image radius="md" fit="contain" src={getFileUrl(src)} alt={alt} />
)} )}
{!src && previewSrc && ( {!src && previewSrc && (
<Group pos="relative" h="100%" w="100%"> <Group pos="relative" h="100%" w="100%">
@@ -18,6 +18,7 @@ import {
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
export function VideoMenu({ editor }: EditorMenuProps) { export function VideoMenu({ editor }: EditorMenuProps) {
@@ -38,6 +39,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
isAlignCenter: ctx.editor.isActive("video", { align: "center" }), isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
isAlignRight: ctx.editor.isActive("video", { align: "right" }), isAlignRight: ctx.editor.isActive("video", { align: "right" }),
src: videoAttrs?.src || null, src: videoAttrs?.src || null,
alt: videoAttrs?.alt || "",
}; };
}, },
}); });
@@ -112,6 +114,16 @@ export function VideoMenu({ editor }: EditorMenuProps) {
editor.commands.deleteSelection(); editor.commands.deleteSelection();
}, [editor]); }, [editor]);
const {
button: altTextButton,
panel: altTextPanel,
isEditing: isEditingAlt,
} = useAltTextControl({
editor,
nodeName: "video",
currentAlt: editorState?.alt || "",
});
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
@@ -125,7 +137,10 @@ export function VideoMenu({ editor }: EditorMenuProps) {
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<div className={classes.toolbar}> {isEditingAlt ? (
altTextPanel
) : (
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Align left")} withinPortal={false}> <Tooltip position="top" label={t("Align left")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={alignLeft} onClick={alignLeft}
@@ -164,6 +179,10 @@ export function VideoMenu({ editor }: EditorMenuProps) {
<div className={classes.divider} /> <div className={classes.divider} />
{altTextButton}
<div className={classes.divider} />
<Tooltip position="top" label={t("Download")} withinPortal={false}> <Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon <ActionIcon
onClick={handleDownload} onClick={handleDownload}
@@ -185,7 +204,8 @@ export function VideoMenu({ editor }: EditorMenuProps) {
<IconTrash size={18} /> <IconTrash size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</div> </div>
)}
</BaseBubbleMenu> </BaseBubbleMenu>
); );
} }
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
export default function VideoView(props: NodeViewProps) { export default function VideoView(props: NodeViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { editor, node, selected } = props; const { editor, node, selected } = props;
const { src, width, align, aspectRatio, placeholder } = node.attrs; const { src, width, align, alt, aspectRatio, placeholder } = node.attrs;
const alignClass = useMemo(() => { const alignClass = useMemo(() => {
if (align === "left") return "alignLeft"; if (align === "left") return "alignLeft";
if (align === "right") return "alignRight"; if (align === "right") return "alignRight";
@@ -47,7 +47,7 @@ export default function VideoView(props: NodeViewProps) {
preload="metadata" preload="metadata"
controls controls
src={getFileUrl(src)} src={getFileUrl(src)}
aria-label={placeholder?.name || t("Video")} aria-label={alt || undefined}
/> />
)} )}
{!src && previewSrc && ( {!src && previewSrc && (
+18 -2
View File
@@ -28,6 +28,7 @@ export interface DrawioOptions {
export interface DrawioAttributes { export interface DrawioAttributes {
src?: string; src?: string;
title?: string; title?: string;
alt?: string;
size?: number; size?: number;
width?: number | string; width?: number | string;
height?: number; height?: number;
@@ -79,6 +80,13 @@ export const Drawio = Node.create<DrawioOptions>({
"data-title": attributes.title, "data-title": attributes.title,
}), }),
}, },
alt: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-alt"),
renderHTML: (attributes: DrawioAttributes) => ({
"data-alt": attributes.alt,
}),
},
width: { width: {
default: null, default: null,
parseHTML: (element) => { parseHTML: (element) => {
@@ -155,7 +163,7 @@ export const Drawio = Node.create<DrawioOptions>({
"img", "img",
{ {
src: HTMLAttributes["data-src"], src: HTMLAttributes["data-src"],
alt: HTMLAttributes["data-title"], alt: HTMLAttributes["data-alt"] || HTMLAttributes["data-title"],
width: HTMLAttributes["data-width"], width: HTMLAttributes["data-width"],
}, },
], ],
@@ -226,7 +234,7 @@ export const Drawio = Node.create<DrawioOptions>({
const el = document.createElement("img"); const el = document.createElement("img");
el.src = normalizeFileUrl(node.attrs.src); el.src = normalizeFileUrl(node.attrs.src);
el.alt = node.attrs.title || ""; el.alt = node.attrs.alt || node.attrs.title || "";
el.style.display = "block"; el.style.display = "block";
el.style.maxWidth = "100%"; el.style.maxWidth = "100%";
el.style.borderRadius = "8px"; el.style.borderRadius = "8px";
@@ -264,6 +272,14 @@ export const Drawio = Node.create<DrawioOptions>({
el.src = normalizeFileUrl(updatedNode.attrs.src); el.src = normalizeFileUrl(updatedNode.attrs.src);
} }
if (
updatedNode.attrs.alt !== currentNode.attrs.alt ||
updatedNode.attrs.title !== currentNode.attrs.title
) {
el.alt =
updatedNode.attrs.alt || updatedNode.attrs.title || "";
}
const w = updatedNode.attrs.width; const w = updatedNode.attrs.width;
const h = updatedNode.attrs.height; const h = updatedNode.attrs.height;
if (w != null) { if (w != null) {
+18 -2
View File
@@ -28,6 +28,7 @@ export interface ExcalidrawOptions {
export interface ExcalidrawAttributes { export interface ExcalidrawAttributes {
src?: string; src?: string;
title?: string; title?: string;
alt?: string;
size?: number; size?: number;
width?: number | string; width?: number | string;
height?: number; height?: number;
@@ -79,6 +80,13 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
"data-title": attributes.title, "data-title": attributes.title,
}), }),
}, },
alt: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-alt"),
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-alt": attributes.alt,
}),
},
width: { width: {
default: null, default: null,
parseHTML: (element) => { parseHTML: (element) => {
@@ -155,7 +163,7 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
"img", "img",
{ {
src: HTMLAttributes["data-src"], src: HTMLAttributes["data-src"],
alt: HTMLAttributes["data-title"], alt: HTMLAttributes["data-alt"] || HTMLAttributes["data-title"],
width: HTMLAttributes["data-width"], width: HTMLAttributes["data-width"],
}, },
], ],
@@ -226,7 +234,7 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
const el = document.createElement("img"); const el = document.createElement("img");
el.src = normalizeFileUrl(node.attrs.src); el.src = normalizeFileUrl(node.attrs.src);
el.alt = node.attrs.title || ""; el.alt = node.attrs.alt || node.attrs.title || "";
el.style.display = "block"; el.style.display = "block";
el.style.maxWidth = "100%"; el.style.maxWidth = "100%";
el.style.borderRadius = "8px"; el.style.borderRadius = "8px";
@@ -264,6 +272,14 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
el.src = normalizeFileUrl(updatedNode.attrs.src); el.src = normalizeFileUrl(updatedNode.attrs.src);
} }
if (
updatedNode.attrs.alt !== currentNode.attrs.alt ||
updatedNode.attrs.title !== currentNode.attrs.title
) {
el.alt =
updatedNode.attrs.alt || updatedNode.attrs.title || "";
}
const w = updatedNode.attrs.width; const w = updatedNode.attrs.width;
const h = updatedNode.attrs.height; const h = updatedNode.attrs.height;
if (w != null) { if (w != null) {
@@ -5,6 +5,13 @@ import { getBasename } from './basename';
// CJS/ESM interop: .default exists in Vite, not in NestJS // CJS/ESM interop: .default exists in Vite, not in NestJS
const TurndownService = (_TurndownService as any).default || _TurndownService; const TurndownService = (_TurndownService as any).default || _TurndownService;
function sanitizeMdLinkText(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/([\[\]!])/g, '\\$1')
.replace(/[\r\n]+/g, ' ');
}
export function htmlToMarkdown(html: string): string { export function htmlToMarkdown(html: string): string {
const turndownService = new TurndownService({ const turndownService = new TurndownService({
headingStyle: 'atx', headingStyle: 'atx',
@@ -25,6 +32,7 @@ export function htmlToMarkdown(html: string): string {
mathInline, mathInline,
mathBlock, mathBlock,
iframeEmbed, iframeEmbed,
image,
video, video,
]); ]);
return turndownService.turndown(html).replaceAll('<br>', ' '); return turndownService.turndown(html).replaceAll('<br>', ' ');
@@ -181,6 +189,20 @@ function iframeEmbed(turndownService: _TurndownService) {
}); });
} }
function image(turndownService: _TurndownService) {
turndownService.addRule('image', {
filter: 'img',
replacement: function (_content: string, node: HTMLInputElement) {
const src = node.getAttribute('src') || '';
if (!src) return '';
const alt = sanitizeMdLinkText(node.getAttribute('alt') || '');
const title = node.getAttribute('title') || '';
const titlePart = title ? ' "' + title.replace(/"/g, '\\"') + '"' : '';
return '![' + alt + '](' + src + titlePart + ')';
},
});
}
function video(turndownService: _TurndownService) { function video(turndownService: _TurndownService) {
turndownService.addRule('video', { turndownService.addRule('video', {
filter: function (node: HTMLInputElement) { filter: function (node: HTMLInputElement) {
@@ -188,7 +210,10 @@ function video(turndownService: _TurndownService) {
}, },
replacement: function (_content: string, node: HTMLInputElement) { replacement: function (_content: string, node: HTMLInputElement) {
const src = node.getAttribute('src') || ''; const src = node.getAttribute('src') || '';
const name = getBasename(src) || src; const ariaLabel = node.getAttribute('aria-label');
const name = sanitizeMdLinkText(
ariaLabel || getBasename(src) || src,
);
return '[' + name + '](' + src + ')'; return '[' + name + '](' + src + ')';
}, },
}); });
@@ -27,6 +27,7 @@ export interface VideoOptions {
export interface VideoAttributes { export interface VideoAttributes {
src?: string; src?: string;
alt?: string;
align?: string; align?: string;
attachmentId?: string; attachmentId?: string;
size?: number; size?: number;
@@ -79,6 +80,13 @@ export const TiptapVideo = Node.create<VideoOptions>({
src: attributes.src, src: attributes.src,
}), }),
}, },
alt: {
default: undefined,
parseHTML: (element) => element.getAttribute("aria-label"),
renderHTML: (attributes: VideoAttributes) => ({
"aria-label": attributes.alt,
}),
},
attachmentId: { attachmentId: {
default: undefined, default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-id"), parseHTML: (element) => element.getAttribute("data-attachment-id"),
@@ -228,6 +236,9 @@ export const TiptapVideo = Node.create<VideoOptions>({
el.src = normalizeFileUrl(node.attrs.src); el.src = normalizeFileUrl(node.attrs.src);
el.controls = true; el.controls = true;
el.preload = "metadata"; el.preload = "metadata";
if (node.attrs.alt) {
el.setAttribute("aria-label", node.attrs.alt);
}
el.style.display = "block"; el.style.display = "block";
el.style.maxWidth = "100%"; el.style.maxWidth = "100%";
el.style.borderRadius = "8px"; el.style.borderRadius = "8px";
@@ -272,6 +283,14 @@ export const TiptapVideo = Node.create<VideoOptions>({
el.src = normalizeFileUrl(updatedNode.attrs.src); el.src = normalizeFileUrl(updatedNode.attrs.src);
} }
if (updatedNode.attrs.alt !== currentNode.attrs.alt) {
if (updatedNode.attrs.alt) {
el.setAttribute("aria-label", updatedNode.attrs.alt);
} else {
el.removeAttribute("aria-label");
}
}
const w = updatedNode.attrs.width; const w = updatedNode.attrs.width;
const h = updatedNode.attrs.height; const h = updatedNode.attrs.height;
if (w != null) { if (w != null) {