diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 62927f66a..c0b67e9d1 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -277,6 +277,9 @@
"Align left": "Align left",
"Align right": "Align right",
"Align center": "Align center",
+ "Alt text": "Alt text",
+ "Describe this for accessibility.": "Describe this for accessibility.",
+ "Add a description": "Add a description",
"Justify": "Justify",
"Merge cells": "Merge cells",
"Split cell": "Split cell",
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
new file mode 100644
index 000000000..1a43f9d79
--- /dev/null
+++ b/apps/client/src/features/editor/components/common/use-alt-text-control.tsx
@@ -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 = (
+
+
+
+
+
+ );
+
+ const panel = showInput ? (
+
+
+ {t("Alt text")}
+
+
+ {t("Describe this for accessibility.")}
+
+
+ ) : null;
+
+ return { button, panel, isEditing: showInput };
+}
diff --git a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx
index 877911750..260da1b64 100644
--- a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx
+++ b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx
@@ -38,6 +38,7 @@ import {
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import { IAttachment } from "@/features/attachments/types/attachment.types";
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";
export function DrawioMenu({ editor }: EditorMenuProps) {
@@ -66,6 +67,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
isAlignRight: ctx.editor.isActive("drawio", { align: "right" }),
src: drawioAttr?.src || null,
attachmentId: drawioAttr?.attachmentId || null,
+ alt: drawioAttr?.alt || "",
};
},
});
@@ -140,6 +142,16 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
editor.commands.deleteSelection();
}, [editor]);
+ const {
+ button: altTextButton,
+ panel: altTextPanel,
+ isEditing: isEditingAlt,
+ } = useAltTextControl({
+ editor,
+ nodeName: "drawio",
+ currentAlt: editorState?.alt || "",
+ });
+
const saveData = useCallback(async (svgXml: string) => {
if (isSavingRef.current) return;
@@ -266,7 +278,10 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
}}
shouldShow={shouldShow}
>
-
+ {isEditingAlt ? (
+ altTextPanel
+ ) : (
+
+ {altTextButton}
+
+
+
-
+
+ )}
diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx
index 823c2c213..507d736ef 100644
--- a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx
+++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx
@@ -36,6 +36,7 @@ import { IAttachment } from "@/features/attachments/types/attachment.types";
import ReactClearModal from "react-clear-modal";
import { useHandleLibrary } from "@excalidraw/excalidraw";
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";
const ExcalidrawComponent = lazy(() =>
@@ -77,6 +78,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
isAlignRight: ctx.editor.isActive("excalidraw", { align: "right" }),
src: excalidrawAttr?.src || null,
attachmentId: excalidrawAttr?.attachmentId || null,
+ alt: excalidrawAttr?.alt || "",
};
},
});
@@ -153,6 +155,16 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
editor.commands.deleteSelection();
}, [editor]);
+ const {
+ button: altTextButton,
+ panel: altTextPanel,
+ isEditing: isEditingAlt,
+ } = useAltTextControl({
+ editor,
+ nodeName: "excalidraw",
+ currentAlt: editorState?.alt || "",
+ });
+
const handleOpen = useCallback(async () => {
if (!editorState?.src) return;
@@ -291,7 +303,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
}}
shouldShow={shouldShow}
>
-
+ {isEditingAlt ? (
+ altTextPanel
+ ) : (
+
+ {altTextButton}
+
+
+
-
+
+ )}
-
+ {isEditingAlt ? (
+ altTextPanel
+ ) : (
+
+ {altTextButton}
+
+
+
-
+
+ )}
{
if (align === "left") return "alignLeft";
if (align === "right") return "alignRight";
@@ -42,7 +42,7 @@ export default function ImageView(props: NodeViewProps) {
}}
>
{src && (
-
+
)}
{!src && previewSrc && (
diff --git a/apps/client/src/features/editor/components/video/video-menu.tsx b/apps/client/src/features/editor/components/video/video-menu.tsx
index 429e02f87..bfbaf27ad 100644
--- a/apps/client/src/features/editor/components/video/video-menu.tsx
+++ b/apps/client/src/features/editor/components/video/video-menu.tsx
@@ -18,6 +18,7 @@ import {
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
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";
export function VideoMenu({ editor }: EditorMenuProps) {
@@ -38,6 +39,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
isAlignRight: ctx.editor.isActive("video", { align: "right" }),
src: videoAttrs?.src || null,
+ alt: videoAttrs?.alt || "",
};
},
});
@@ -112,6 +114,16 @@ export function VideoMenu({ editor }: EditorMenuProps) {
editor.commands.deleteSelection();
}, [editor]);
+ const {
+ button: altTextButton,
+ panel: altTextPanel,
+ isEditing: isEditingAlt,
+ } = useAltTextControl({
+ editor,
+ nodeName: "video",
+ currentAlt: editorState?.alt || "",
+ });
+
return (
-
+ {isEditingAlt ? (
+ altTextPanel
+ ) : (
+
+ {altTextButton}
+
+
+
-
+
+ )}
);
}
diff --git a/apps/client/src/features/editor/components/video/video-view.tsx b/apps/client/src/features/editor/components/video/video-view.tsx
index 9a67533b1..d6c37a0cd 100644
--- a/apps/client/src/features/editor/components/video/video-view.tsx
+++ b/apps/client/src/features/editor/components/video/video-view.tsx
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
export default function VideoView(props: NodeViewProps) {
const { t } = useTranslation();
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(() => {
if (align === "left") return "alignLeft";
if (align === "right") return "alignRight";
@@ -47,7 +47,7 @@ export default function VideoView(props: NodeViewProps) {
preload="metadata"
controls
src={getFileUrl(src)}
- aria-label={placeholder?.name || t("Video")}
+ aria-label={alt || undefined}
/>
)}
{!src && previewSrc && (
diff --git a/packages/editor-ext/src/lib/drawio.ts b/packages/editor-ext/src/lib/drawio.ts
index 4968912be..14d70f394 100644
--- a/packages/editor-ext/src/lib/drawio.ts
+++ b/packages/editor-ext/src/lib/drawio.ts
@@ -28,6 +28,7 @@ export interface DrawioOptions {
export interface DrawioAttributes {
src?: string;
title?: string;
+ alt?: string;
size?: number;
width?: number | string;
height?: number;
@@ -79,6 +80,13 @@ export const Drawio = Node.create({
"data-title": attributes.title,
}),
},
+ alt: {
+ default: undefined,
+ parseHTML: (element) => element.getAttribute("data-alt"),
+ renderHTML: (attributes: DrawioAttributes) => ({
+ "data-alt": attributes.alt,
+ }),
+ },
width: {
default: null,
parseHTML: (element) => {
@@ -155,7 +163,7 @@ export const Drawio = Node.create({
"img",
{
src: HTMLAttributes["data-src"],
- alt: HTMLAttributes["data-title"],
+ alt: HTMLAttributes["data-alt"] || HTMLAttributes["data-title"],
width: HTMLAttributes["data-width"],
},
],
@@ -226,7 +234,7 @@ export const Drawio = Node.create({
const el = document.createElement("img");
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.maxWidth = "100%";
el.style.borderRadius = "8px";
@@ -264,6 +272,14 @@ export const Drawio = Node.create({
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 h = updatedNode.attrs.height;
if (w != null) {
diff --git a/packages/editor-ext/src/lib/excalidraw.ts b/packages/editor-ext/src/lib/excalidraw.ts
index 71c881f17..59b28f7fa 100644
--- a/packages/editor-ext/src/lib/excalidraw.ts
+++ b/packages/editor-ext/src/lib/excalidraw.ts
@@ -28,6 +28,7 @@ export interface ExcalidrawOptions {
export interface ExcalidrawAttributes {
src?: string;
title?: string;
+ alt?: string;
size?: number;
width?: number | string;
height?: number;
@@ -79,6 +80,13 @@ export const Excalidraw = Node.create({
"data-title": attributes.title,
}),
},
+ alt: {
+ default: undefined,
+ parseHTML: (element) => element.getAttribute("data-alt"),
+ renderHTML: (attributes: ExcalidrawAttributes) => ({
+ "data-alt": attributes.alt,
+ }),
+ },
width: {
default: null,
parseHTML: (element) => {
@@ -155,7 +163,7 @@ export const Excalidraw = Node.create({
"img",
{
src: HTMLAttributes["data-src"],
- alt: HTMLAttributes["data-title"],
+ alt: HTMLAttributes["data-alt"] || HTMLAttributes["data-title"],
width: HTMLAttributes["data-width"],
},
],
@@ -226,7 +234,7 @@ export const Excalidraw = Node.create({
const el = document.createElement("img");
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.maxWidth = "100%";
el.style.borderRadius = "8px";
@@ -264,6 +272,14 @@ export const Excalidraw = Node.create({
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 h = updatedNode.attrs.height;
if (w != null) {
diff --git a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts
index 635983df9..ebfc3423e 100644
--- a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts
+++ b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts
@@ -5,6 +5,13 @@ import { getBasename } from './basename';
// CJS/ESM interop: .default exists in Vite, not in NestJS
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 {
const turndownService = new TurndownService({
headingStyle: 'atx',
@@ -25,6 +32,7 @@ export function htmlToMarkdown(html: string): string {
mathInline,
mathBlock,
iframeEmbed,
+ image,
video,
]);
return turndownService.turndown(html).replaceAll('
', ' ');
@@ -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 '';
+ },
+ });
+}
+
function video(turndownService: _TurndownService) {
turndownService.addRule('video', {
filter: function (node: HTMLInputElement) {
@@ -188,7 +210,10 @@ function video(turndownService: _TurndownService) {
},
replacement: function (_content: string, node: HTMLInputElement) {
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 + ')';
},
});
diff --git a/packages/editor-ext/src/lib/video/video.ts b/packages/editor-ext/src/lib/video/video.ts
index c6d7356b0..5bf248cd7 100644
--- a/packages/editor-ext/src/lib/video/video.ts
+++ b/packages/editor-ext/src/lib/video/video.ts
@@ -27,6 +27,7 @@ export interface VideoOptions {
export interface VideoAttributes {
src?: string;
+ alt?: string;
align?: string;
attachmentId?: string;
size?: number;
@@ -79,6 +80,13 @@ export const TiptapVideo = Node.create({
src: attributes.src,
}),
},
+ alt: {
+ default: undefined,
+ parseHTML: (element) => element.getAttribute("aria-label"),
+ renderHTML: (attributes: VideoAttributes) => ({
+ "aria-label": attributes.alt,
+ }),
+ },
attachmentId: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-id"),
@@ -228,6 +236,9 @@ export const TiptapVideo = Node.create({
el.src = normalizeFileUrl(node.attrs.src);
el.controls = true;
el.preload = "metadata";
+ if (node.attrs.alt) {
+ el.setAttribute("aria-label", node.attrs.alt);
+ }
el.style.display = "block";
el.style.maxWidth = "100%";
el.style.borderRadius = "8px";
@@ -272,6 +283,14 @@ export const TiptapVideo = Node.create({
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 h = updatedNode.attrs.height;
if (w != null) {