mirror of
https://github.com/docmost/docmost.git
synced 2026-08-26 00:07:04 +08:00
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:
co-authored by
Philipinho
parent
66a754c9eb
commit
1c166c4736
@@ -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 && (
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 '';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user