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 17:45:59 +02:00
committed by GitHub
parent 66a754c9eb
commit 1c166c4736
12 changed files with 315 additions and 17 deletions
+18 -2
View File
@@ -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<DrawioOptions>({
"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<DrawioOptions>({
"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<DrawioOptions>({
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<DrawioOptions>({
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) {
+18 -2
View File
@@ -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<ExcalidrawOptions>({
"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<ExcalidrawOptions>({
"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<ExcalidrawOptions>({
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<ExcalidrawOptions>({
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) {
@@ -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('<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) {
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 + ')';
},
});
@@ -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<VideoOptions>({
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<VideoOptions>({
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<VideoOptions>({
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) {