diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index 8184285a..d2efbefe 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -12,7 +12,7 @@ import * as locales from '@blocknote/core/locales'; import { BlockNoteView } from '@blocknote/mantine'; import '@blocknote/mantine/style.css'; import { useCreateBlockNote } from '@blocknote/react'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; import type { Awareness } from 'y-protocols/awareness'; @@ -20,7 +20,6 @@ import * as Y from 'yjs'; import { Box, TextErrors } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { decryptContent } from '@/docs/doc-collaboration/encryption'; import { Doc, SwitchableProvider, @@ -41,13 +40,16 @@ import { DocsBlockNoteEditor } from '../types'; import { randomColor } from '../utils'; import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; +import { EncryptionProvider } from './EncryptionProvider'; import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar'; import { cssComments, useComments } from './comments/'; import { AccessibleImageBlock, + AudioBlock, CalloutBlock, PdfBlock, UploadLoaderBlock, + VideoBlock, } from './custom-blocks'; import { InterlinkingLinkInlineContent, @@ -62,11 +64,13 @@ const baseBlockNoteSchema = withPageBreak( BlockNoteSchema.create({ blockSpecs: { ...defaultBlockSpecs, + audio: AudioBlock(), callout: CalloutBlock(), codeBlock: createCodeBlockSpec(codeBlockOptions), image: AccessibleImageBlock(), pdf: PdfBlock(), uploadLoader: UploadLoaderBlock(), + video: VideoBlock(), }, inlineContentSpecs: { ...defaultInlineContentSpecs, @@ -118,61 +122,6 @@ export const BlockNoteEditor = ({ const symmetricKey = documentEncryptionSettings?.documentSymmetricKey; const { uploadFile, errorAttachment } = useUploadFile(doc.id, symmetricKey); - // Cache for decrypted blob URLs (URL → blob URL), persists across renders - const blobUrlCacheRef = useRef>(new Map()); - - const resolveFileUrl = useCallback( - async (url: string): Promise => { - if (!symmetricKey) { - return url; - } - - const cached = blobUrlCacheRef.current.get(url); - if (cached) { - return cached; - } - - const response = await fetch(url, { credentials: 'include' }); - if (!response.ok) { - throw new Error( - `Failed to fetch encrypted attachment: ${response.status}`, - ); - } - - const encryptedBytes = new Uint8Array(await response.arrayBuffer()); - const decryptedBytes = await decryptContent(encryptedBytes, symmetricKey); - - const ext = url.split('.').pop()?.toLowerCase() || ''; - const mimeMap: Record = { - png: 'image/png', - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - gif: 'image/gif', - webp: 'image/webp', - svg: 'image/svg+xml', - pdf: 'application/pdf', - }; - const mime = mimeMap[ext] || 'application/octet-stream'; - - const blob = new Blob([decryptedBytes as BlobPart], { type: mime }); - const blobUrl = URL.createObjectURL(blob); - blobUrlCacheRef.current.set(url, blobUrl); - - return blobUrl; - }, - [symmetricKey], - ); - - // Revoke blob URLs on unmount or when symmetric key changes - useEffect(() => { - return () => { - blobUrlCacheRef.current.forEach((blobUrl) => - URL.revokeObjectURL(blobUrl), - ); - blobUrlCacheRef.current.clear(); - }; - }, [symmetricKey]); - const collabName = user?.full_name || user?.email; const cursorName = collabName || t('Anonymous'); const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity'; @@ -271,10 +220,17 @@ export const BlockNoteEditor = ({ headers: true, }, uploadFile, - resolveFileUrl: symmetricKey ? resolveFileUrl : undefined, schema: blockNoteSchema, }, - [cursorName, lang, provider, uploadFile, resolveFileUrl, symmetricKey, threadStore, resolveUsers], + [ + cursorName, + lang, + provider, + uploadFile, + symmetricKey, + threadStore, + resolveUsers, + ], ); useHeadings(editor); @@ -292,35 +248,37 @@ export const BlockNoteEditor = ({ }, [setEditor, editor]); return ( - - {errorAttachment && ( - - - - )} - + - - - - + {errorAttachment && ( + + + + )} + + + + + + ); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/FileDownloadButton.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/FileDownloadButton.tsx index c7ed6711..832e9a4c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/FileDownloadButton.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/FileDownloadButton.tsx @@ -24,6 +24,7 @@ import { DocsInlineContentSchema, DocsStyleSchema, } from '../../types'; +import { useEncryption } from '../EncryptionProvider'; export const FileDownloadButton = ({ open, @@ -32,6 +33,7 @@ export const FileDownloadButton = ({ }) => { const dict = useDictionary(); const Components = useComponentsContext(); + const { isEncrypted, decryptFileUrl } = useEncryption(); const editor = useBlockNoteEditor< DocsBlockSchema, @@ -75,27 +77,22 @@ export const FileDownloadButton = ({ /** * If not hosted on our domain, means not a file uploaded by the user, * we do what Blocknote was doing initially. + * + * For this case, no need of adding decryption logic */ if (!url.includes(window.location.hostname) && !url.includes('base64')) { - if (!editor.resolveFileUrl) { - if (!isSafeUrl(url)) { - return; - } - - window.open(url, '_blank', 'noopener,noreferrer'); - } else { - void editor - .resolveFileUrl(url) - .then((downloadUrl) => window.open(downloadUrl)); + if (!isSafeUrl(url)) { + return; } + window.open(url, '_blank', 'noopener,noreferrer'); + return; } const fetchAndDownload = async (fileName: string) => { - if (editor.resolveFileUrl) { - // Encrypted: decrypt via resolveFileUrl then download the blob - const blobUrl = await editor.resolveFileUrl(url); + if (isEncrypted) { + const blobUrl = await decryptFileUrl(url); const blob = await fetch(blobUrl).then((r) => r.blob()); downloadFile(blob, fileName); } else { @@ -121,7 +118,7 @@ export const FileDownloadButton = ({ open(onConfirm); } } - }, [editor, fileBlock, open]); + }, [editor, fileBlock, open, isEncrypted, decryptFileUrl]); if (!fileBlock || fileBlock.props.url === '' || !Components) { return null; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/EncryptedMediaPlaceholder.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/EncryptedMediaPlaceholder.tsx new file mode 100644 index 00000000..adb08a4b --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/EncryptedMediaPlaceholder.tsx @@ -0,0 +1,76 @@ +import { css } from 'styled-components'; + +import { Box, Icon, Loading } from '@/components'; + +interface EncryptedMediaPlaceholderProps { + label: string; + errorLabel: string; + minHeight?: string; + isLoading: boolean; + hasError: boolean; + onDecrypt: () => void; +} + +export const EncryptedMediaPlaceholder = ({ + label, + errorLabel, + minHeight = '200px', + isLoading, + hasError, + onDecrypt, +}: EncryptedMediaPlaceholderProps) => { + if (hasError) { + return ( + + {errorLabel} + + ); + } + + return ( + !isLoading && onDecrypt()} + > + + {label} + {isLoading && ( + + + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/EncryptionProvider.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/EncryptionProvider.tsx new file mode 100644 index 00000000..2d08563f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/EncryptionProvider.tsx @@ -0,0 +1,118 @@ +import { + ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from 'react'; + +import { decryptContent } from '@/docs/doc-collaboration/encryption'; + +const MIME_MAP: Record = { + // Images + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + // Audio + mp3: 'audio/mpeg', + wav: 'audio/wav', + ogg: 'audio/ogg', + flac: 'audio/flac', + aac: 'audio/aac', + // Video + mp4: 'video/mp4', + webm: 'video/webm', + ogv: 'video/ogg', + mov: 'video/quicktime', + avi: 'video/x-msvideo', + // PDF + pdf: 'application/pdf', +}; + +interface EncryptionContextValue { + isEncrypted: boolean; + decryptFileUrl: (url: string) => Promise; +} + +const DEFAULT_VALUE: EncryptionContextValue = { + isEncrypted: false, + decryptFileUrl: async (url: string) => url, +}; + +const EncryptionContext = createContext(DEFAULT_VALUE); + +interface EncryptionProviderProps { + symmetricKey: CryptoKey | undefined; + children: ReactNode; +} + +export const EncryptionProvider = ({ + symmetricKey, + children, +}: EncryptionProviderProps) => { + const blobUrlCacheRef = useRef>(new Map()); + + const decryptFileUrl = useCallback( + async (url: string): Promise => { + if (!symmetricKey) { + return url; + } + + const cached = blobUrlCacheRef.current.get(url); + if (cached) { + return cached; + } + + const response = await fetch(url, { credentials: 'include' }); + if (!response.ok) { + throw new Error( + `Failed to fetch encrypted attachment: ${response.status}`, + ); + } + + const fileBytes = new Uint8Array(await response.arrayBuffer()); + const decryptedBytes = await decryptContent(fileBytes, symmetricKey); + + const ext = url.split('.').pop()?.toLowerCase() || ''; + const mime = MIME_MAP[ext] || 'application/octet-stream'; + + const blob = new Blob([decryptedBytes as BlobPart], { type: mime }); + const blobUrl = URL.createObjectURL(blob); + blobUrlCacheRef.current.set(url, blobUrl); + + return blobUrl; + }, + [symmetricKey], + ); + + useEffect(() => { + return () => { + blobUrlCacheRef.current.forEach((blobUrl) => + URL.revokeObjectURL(blobUrl), + ); + blobUrlCacheRef.current.clear(); + }; + }, [symmetricKey]); + + const value = useMemo( + () => ({ + isEncrypted: !!symmetricKey, + decryptFileUrl, + }), + [symmetricKey, decryptFileUrl], + ); + + return ( + + {children} + + ); +}; + +export const useEncryption = (): EncryptionContextValue => + useContext(EncryptionContext); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/AccessibleImageBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/AccessibleImageBlock.tsx index e43119e6..eac47b16 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/AccessibleImageBlock.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/AccessibleImageBlock.tsx @@ -1,130 +1,294 @@ /** * AccessibleImageBlock.tsx * - * This file defines a custom BlockNote block specification for an accessible image block. - * It extends the default image block to ensure compliance with accessibility standards, - * specifically RGAA 1.9.1, by using
and
elements when a caption is provided. + * Custom BlockNote block for accessible images with encryption support. * - * The accessible image block ensures that: + * Accessibility (RGAA 1.9.1): * - Images with captions are wrapped in
and
elements. * - The element has an appropriate alt attribute based on the caption. - * - Accessibility attributes such as role and aria-label are added for better screen reader support. * - Images without captions have alt="" and are marked as decorative with aria-hidden="true". * - * This implementation leverages BlockNote's existing image block functionality while enhancing it for accessibility. + * Encryption: + * - Images < 2MB are auto-decrypted inline. + * - Images >= 2MB show a "click to decrypt" placeholder. + * * https://github.com/TypeCellOS/BlockNote/blob/main/packages/core/src/blocks/Image/block.ts */ import { - BlockFromConfig, + BlockNoDefaults, BlockNoteEditor, - ImageOptions, InlineContentSchema, - InlineContentSchemaFromSpecs, StyleSchema, - createBlockSpec, createImageBlockConfig, - defaultInlineContentSpecs, imageParse, - imageRender, - imageToExternalHTML, } from '@blocknote/core'; -import { t } from 'i18next'; +import { + ResizableFileBlockWrapper, + createReactBlockSpec, +} from '@blocknote/react'; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; -type CreateImageBlockConfig = ReturnType; +import { Icon, Loading } from '@/components'; -export const accessibleImageRender = - (config: ImageOptions) => - ( - block: BlockFromConfig< - CreateImageBlockConfig, - InlineContentSchema, - StyleSchema - >, - editor: BlockNoteEditor< - Record<'image', CreateImageBlockConfig>, - InlineContentSchemaFromSpecs, - StyleSchema - >, - ) => { - const imageRenderComputed = imageRender(config); - const dom = imageRenderComputed(block, editor).dom; - const imgSelector = dom.querySelector('img'); +import { ANALYZE_URL } from '../../conf'; +import { EncryptedMediaPlaceholder } from '../EncryptedMediaPlaceholder'; +import { useEncryption } from '../EncryptionProvider'; - // Fix RGAA 1.9.1: Convert to figure/figcaption structure if caption exists - const accessibleImageWithCaption = () => { - imgSelector?.setAttribute('alt', block.props.caption); - imgSelector?.removeAttribute('aria-hidden'); - imgSelector?.setAttribute('tabindex', '0'); +type ImageBlockConfig = ReturnType; - const figureElement = document.createElement('figure'); +const AUTOMATIC_DECRYPTION_MAX_SIZE = 2 * 1024 * 1024; // 2 MB - // Copy all attributes from the original div - figureElement.className = dom.className; - const styleAttr = dom.getAttribute('style'); - if (styleAttr) { - figureElement.setAttribute('style', styleAttr); - } - figureElement.style.setProperty('margin', '0'); +interface AccessibleImageProps { + src: string; + caption: string; +} - Array.from(dom.children).forEach((child) => { - figureElement.appendChild(child.cloneNode(true)); +const AccessibleImage = ({ src, caption }: AccessibleImageProps) => { + const { t } = useTranslation(); + + if (caption) { + return ( +
+ {caption} +
{caption}
+
+ ); + } + + return ( + + ); +}; + +interface ImageBlockComponentProps { + block: BlockNoDefaults< + Record<'image', ImageBlockConfig>, + InlineContentSchema, + StyleSchema + >; + contentRef: (node: HTMLElement | null) => void; + editor: BlockNoteEditor< + Record<'image', ImageBlockConfig>, + InlineContentSchema, + StyleSchema + >; +} + +const ImageBlockComponent = ({ + editor, + block, + ...rest +}: ImageBlockComponentProps) => { + const { t } = useTranslation(); + const { isEncrypted, decryptFileUrl } = useEncryption(); + + const url = block.props.url; + const caption = block.props.caption || ''; + const isAnalyzing = !!url && url.includes(ANALYZE_URL); + + // Encrypted state + const [resolvedUrl, setResolvedUrl] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [hasError, setHasError] = useState(false); + const [showClickPlaceholder, setShowClickPlaceholder] = useState(false); + + // Auto-decrypt small files, show placeholder for large ones + useEffect(() => { + if (!isEncrypted || !url || isAnalyzing) { + return; + } + + let cancelled = false; + setIsLoading(true); + + fetch(url, { method: 'HEAD', credentials: 'include' }) + .then(async (headResponse) => { + if (cancelled) { + return; + } + + const contentLength = Number( + headResponse.headers.get('content-length'), + ); + + // Larger images show a "click to decrypt" placeholder instead to save decryption processing + // (needed since photos taken from a smartphone can easily be over 15MB) + if (contentLength < AUTOMATIC_DECRYPTION_MAX_SIZE) { + try { + const blobUrl = await decryptFileUrl(url); + + if (!cancelled) { + setResolvedUrl(blobUrl); + } + } catch { + if (!cancelled) { + setShowClickPlaceholder(true); + } + } + } else { + if (!cancelled) { + setShowClickPlaceholder(true); + } + } + }) + .catch(() => { + if (!cancelled) { + setShowClickPlaceholder(true); + } + }) + .finally(() => { + if (!cancelled) { + setIsLoading(false); + } }); - // Replace the

caption with

- const figcaptionElement = document.createElement('figcaption'); - const originalCaption = figureElement.querySelector('.bn-file-caption'); - if (originalCaption) { - figcaptionElement.className = originalCaption.className; - figcaptionElement.textContent = originalCaption.textContent; - originalCaption.parentNode?.replaceChild( - figcaptionElement, - originalCaption, - ); + return () => { + cancelled = true; + }; + }, [isEncrypted, url, isAnalyzing, decryptFileUrl]); - // Add explicit role and aria-label for better screen reader support - figureElement.setAttribute('role', 'img'); - figureElement.setAttribute( - 'aria-label', - t(`Image: {{title}}`, { title: figcaptionElement.textContent }), - ); + const handleDecrypt = useCallback(async () => { + if (!url) { + return; + } + + setIsLoading(true); + setHasError(false); + try { + const blobUrl = await decryptFileUrl(url); + setResolvedUrl(blobUrl); + setShowClickPlaceholder(false); + } catch { + setHasError(true); + } finally { + setIsLoading(false); + } + }, [url, decryptFileUrl]); + + // Remove the duplicate

added by ResizableFileBlockWrapper + // when we render our own

inside a
. + const wrapperRef = useRef(null); + useLayoutEffect(() => { + if (!wrapperRef.current || !caption) { + return; + } + + const wrapper = wrapperRef.current.closest( + '.bn-file-block-content-wrapper', + ); + if (!wrapper) { + return; + } + + const pCaption = wrapper.querySelector(':scope > p.bn-file-caption'); + if (pCaption) { + pCaption.remove(); + } + }, [caption]); + + const effectiveUrl = isEncrypted ? resolvedUrl : url; + const showMedia = !!effectiveUrl && !isAnalyzing; + const showEncryptedPlaceholder = + isEncrypted && (showClickPlaceholder || hasError) && !resolvedUrl; + + return ( + } + > + {isEncrypted && isLoading && !resolvedUrl && !showClickPlaceholder && ( + + )} + {showEncryptedPlaceholder && ( + void handleDecrypt()} + /> + )} + {showMedia && ( + + + + )} + + ); +}; - // Return the figure element as the new dom - return { - ...imageRenderComputed, - dom: figureElement, - }; - }; +const ImageToExternalHTML = ({ + block, +}: { + block: BlockNoDefaults< + Record<'image', ImageBlockConfig>, + InlineContentSchema, + StyleSchema + >; +}) => { + if (!block.props.url) { + return

Add image

; + } - const accessibleImage = () => { - imgSelector?.setAttribute('alt', ''); - imgSelector?.setAttribute('role', 'presentation'); - imgSelector?.setAttribute('aria-hidden', 'true'); - imgSelector?.setAttribute('tabindex', '-1'); + const img = ( + {block.props.caption + ); - return { - ...imageRenderComputed, - dom, - }; - }; + if (block.props.caption) { + return ( +
+ {img} +
{block.props.caption}
+
+ ); + } - const withCaption = - block.props.caption && dom.querySelector('.bn-file-caption'); + return img; +}; - // Set accessibility attributes for the image - return withCaption ? accessibleImageWithCaption() : accessibleImage(); - }; - -export const AccessibleImageBlock = createBlockSpec( +export const AccessibleImageBlock = createReactBlockSpec( createImageBlockConfig, (config) => ({ meta: { fileBlockAccept: ['image/*'], }, - render: accessibleImageRender(config), + render: (props) => , parse: imageParse(config), - toExternalHTML: imageToExternalHTML(config), + toExternalHTML: (props) => , runsBefore: ['file'], }), ); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/AudioBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/AudioBlock.tsx new file mode 100644 index 00000000..1a9aea07 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/AudioBlock.tsx @@ -0,0 +1,102 @@ +import { + BlockNoDefaults, + BlockNoteEditor, + InlineContentSchema, + StyleSchema, + audioParse, + createAudioBlockConfig, +} from '@blocknote/core'; +import { FileBlockWrapper, createReactBlockSpec } from '@blocknote/react'; +import { useTranslation } from 'react-i18next'; + +import { Icon } from '@/components'; + +import { useDecryptMedia } from '../../hook'; +import { EncryptedMediaPlaceholder } from '../EncryptedMediaPlaceholder'; + +type AudioBlockConfig = ReturnType; + +interface AudioBlockComponentProps { + block: BlockNoDefaults< + Record<'audio', AudioBlockConfig>, + InlineContentSchema, + StyleSchema + >; + contentRef: (node: HTMLElement | null) => void; + editor: BlockNoteEditor< + Record<'audio', AudioBlockConfig>, + InlineContentSchema, + StyleSchema + >; +} + +const AudioBlockComponent = ({ + editor, + block, + ...rest +}: AudioBlockComponentProps) => { + const { t } = useTranslation(); + const { + showPlaceholder, + showMedia, + isLoading, + hasError, + decrypt, + resolvedUrl, + } = useDecryptMedia(block.props.url); + + return ( + + } + > + {showPlaceholder && ( + void decrypt()} + /> + )} + {showMedia && ( + + ); +}; + +const AudioToExternalHTML = ({ + block, +}: { + block: BlockNoDefaults< + Record<'audio', AudioBlockConfig>, + InlineContentSchema, + StyleSchema + >; +}) => { + if (!block.props.url) { + return

Add audio

; + } + + return