wip block placeholders to save resources when encrypted

This commit is contained in:
Thomas Ramé
2026-03-04 19:58:32 +01:00
parent 834ed4226f
commit d685b541c5
11 changed files with 772 additions and 221 deletions
@@ -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<Map<string, string>>(new Map());
const resolveFileUrl = useCallback(
async (url: string): Promise<string> => {
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<string, string> = {
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 (
<Box
ref={refEditorContainer}
$css={css`
${cssEditor};
${cssComments(showComments, currentUserAvatarUrl)}
`}
>
{errorAttachment && (
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
<TextErrors
causes={errorAttachment.cause}
canClose
$textAlign="left"
/>
</Box>
)}
<BlockNoteView
className="--docs--main-editor"
editor={editor}
formattingToolbar={false}
slashMenu={false}
theme="light"
comments={showComments}
aria-label={t('Document editor')}
<EncryptionProvider symmetricKey={symmetricKey}>
<Box
ref={refEditorContainer}
$css={css`
${cssEditor};
${cssComments(showComments, currentUserAvatarUrl)}
`}
>
<BlockNoteSuggestionMenu />
<BlockNoteToolbar />
</BlockNoteView>
</Box>
{errorAttachment && (
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
<TextErrors
causes={errorAttachment.cause}
canClose
$textAlign="left"
/>
</Box>
)}
<BlockNoteView
className="--docs--main-editor"
editor={editor}
formattingToolbar={false}
slashMenu={false}
theme="light"
comments={showComments}
aria-label={t('Document editor')}
>
<BlockNoteSuggestionMenu />
<BlockNoteToolbar />
</BlockNoteView>
</Box>
</EncryptionProvider>
);
};
@@ -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;
@@ -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 (
<Box
$align="center"
$justify="center"
$color="#666"
$background="#f5f5f5"
$border="1px solid #ddd"
$minHeight={minHeight}
$padding="20px"
$css={css`
text-align: center;
`}
contentEditable={false}
>
{errorLabel}
</Box>
);
}
return (
<Box
$align="center"
$justify="center"
$color="#666"
$background="#f5f5f5"
$border="1px solid #ddd"
$minHeight={minHeight}
$padding="20px"
$css={css`
text-align: center;
cursor: ${isLoading ? 'wait' : 'pointer'};
position: relative;
`}
contentEditable={false}
onClick={() => !isLoading && onDecrypt()}
>
<Icon iconName="lock" $size="24px" />
<Box $margin={{ top: '2px' }}>{label}</Box>
{isLoading && (
<Box
$align="center"
$justify="center"
$css={css`
position: absolute;
inset: 0;
background: rgba(245, 245, 245, 0.8);
`}
>
<Loading />
</Box>
)}
</Box>
);
};
@@ -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<string, string> = {
// 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<string>;
}
const DEFAULT_VALUE: EncryptionContextValue = {
isEncrypted: false,
decryptFileUrl: async (url: string) => url,
};
const EncryptionContext = createContext<EncryptionContextValue>(DEFAULT_VALUE);
interface EncryptionProviderProps {
symmetricKey: CryptoKey | undefined;
children: ReactNode;
}
export const EncryptionProvider = ({
symmetricKey,
children,
}: EncryptionProviderProps) => {
const blobUrlCacheRef = useRef<Map<string, string>>(new Map());
const decryptFileUrl = useCallback(
async (url: string): Promise<string> => {
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 (
<EncryptionContext.Provider value={value}>
{children}
</EncryptionContext.Provider>
);
};
export const useEncryption = (): EncryptionContextValue =>
useContext(EncryptionContext);
@@ -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 <figure> and <figcaption> 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 <figure> and <figcaption> elements.
* - The <img> 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<typeof createImageBlockConfig>;
import { Icon, Loading } from '@/components';
export const accessibleImageRender =
(config: ImageOptions) =>
(
block: BlockFromConfig<
CreateImageBlockConfig,
InlineContentSchema,
StyleSchema
>,
editor: BlockNoteEditor<
Record<'image', CreateImageBlockConfig>,
InlineContentSchemaFromSpecs<typeof defaultInlineContentSpecs>,
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<typeof createImageBlockConfig>;
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 (
<figure
style={{ margin: 0 }}
role="img"
aria-label={t('Image: {{title}}', { title: caption })}
>
<img
className="bn-visual-media"
src={src}
alt={caption}
tabIndex={0}
contentEditable={false}
draggable={false}
/>
<figcaption className="bn-file-caption">{caption}</figcaption>
</figure>
);
}
return (
<img
className="bn-visual-media"
src={src}
alt=""
role="presentation"
aria-hidden="true"
tabIndex={-1}
contentEditable={false}
draggable={false}
/>
);
};
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<string | null>(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 <p> caption with <figcaption>
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 <p class="bn-file-caption"> added by ResizableFileBlockWrapper
// when we render our own <figcaption> inside a <figure>.
const wrapperRef = useRef<HTMLElement>(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 (
<ResizableFileBlockWrapper
{...({ editor, block, ...rest } as any)}
buttonIcon={
<Icon iconName="image" $size="24px" $css="line-height: normal;" />
}
>
{isEncrypted && isLoading && !resolvedUrl && !showClickPlaceholder && (
<Loading />
)}
{showEncryptedPlaceholder && (
<EncryptedMediaPlaceholder
label={t('Click to decrypt and view image')}
errorLabel={t('Failed to decrypt image.')}
isLoading={isLoading}
hasError={hasError}
onDecrypt={() => void handleDecrypt()}
/>
)}
{showMedia && (
<span ref={wrapperRef}>
<AccessibleImage src={effectiveUrl} caption={caption} />
</span>
)}
</ResizableFileBlockWrapper>
);
};
// 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 <p>Add image</p>;
}
const accessibleImage = () => {
imgSelector?.setAttribute('alt', '');
imgSelector?.setAttribute('role', 'presentation');
imgSelector?.setAttribute('aria-hidden', 'true');
imgSelector?.setAttribute('tabindex', '-1');
const img = (
<img
src={block.props.url}
alt={block.props.caption || ''}
width={block.props.previewWidth}
/>
);
return {
...imageRenderComputed,
dom,
};
};
if (block.props.caption) {
return (
<figure role="img" aria-label={block.props.caption}>
{img}
<figcaption>{block.props.caption}</figcaption>
</figure>
);
}
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) => <ImageBlockComponent {...(props as any)} />,
parse: imageParse(config),
toExternalHTML: imageToExternalHTML(config),
toExternalHTML: (props) => <ImageToExternalHTML {...(props as any)} />,
runsBefore: ['file'],
}),
);
@@ -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<typeof createAudioBlockConfig>;
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 (
<FileBlockWrapper
{...({ editor, block, ...rest } as any)}
buttonIcon={
<Icon iconName="audiotrack" $size="24px" $css="line-height: normal;" />
}
>
{showPlaceholder && (
<EncryptedMediaPlaceholder
label={t('Click to decrypt and play audio')}
errorLabel={t('Failed to decrypt audio file.')}
minHeight="80px"
isLoading={isLoading}
hasError={hasError}
onDecrypt={() => void decrypt()}
/>
)}
{showMedia && (
<audio
className="bn-audio"
src={resolvedUrl || block.props.url}
controls
contentEditable={false}
draggable={false}
/>
)}
</FileBlockWrapper>
);
};
const AudioToExternalHTML = ({
block,
}: {
block: BlockNoDefaults<
Record<'audio', AudioBlockConfig>,
InlineContentSchema,
StyleSchema
>;
}) => {
if (!block.props.url) {
return <p>Add audio</p>;
}
return <audio src={block.props.url} controls />;
};
export const AudioBlock = createReactBlockSpec(
createAudioBlockConfig,
(config) => ({
render: (props) => <AudioBlockComponent {...(props as any)} />,
parse: audioParse(config),
toExternalHTML: (props) => <AudioToExternalHTML {...(props as any)} />,
runsBefore: ['file'],
}),
);
@@ -21,6 +21,8 @@ import { Box, Icon, Loading } from '@/components';
import { ANALYZE_URL } from '../../conf';
import { DocsBlockNoteEditor } from '../../types';
import { EncryptedMediaPlaceholder } from '../EncryptedMediaPlaceholder';
import { useEncryption } from '../EncryptionProvider';
const PDFBlockStyle = createGlobalStyle`
.bn-block-content[data-content-type="pdf"] .bn-file-block-content-wrapper[style*="fit-content"] {
@@ -67,13 +69,13 @@ const PdfBlockComponent = ({
const pdfUrl = block.props.url;
const { i18n, t } = useTranslation();
const lang = i18n.resolvedLanguage;
const { isEncrypted, decryptFileUrl } = useEncryption();
const [isPDFContent, setIsPDFContent] = useState<boolean | null>(null);
const [isPDFContentLoading, setIsPDFContentLoading] =
useState<boolean>(false);
const [resolvedPdfUrl, setResolvedPdfUrl] = useState<string | null>(null);
const isEncrypted = !!editor.resolveFileUrl;
useEffect(() => {
if (lang && locales[lang as keyof typeof locales]) {
locales[lang as keyof typeof locales].file_blocks.add_button_text['pdf'] =
@@ -89,7 +91,7 @@ const PdfBlockComponent = ({
}
}, [lang, t]);
// For non-encrypted docs, validate PDF content on mount (existing behavior)
// For non-encrypted docs, validate PDF content on mount
useEffect(() => {
if (isEncrypted || !pdfUrl || pdfUrl.includes(ANALYZE_URL)) {
return;
@@ -119,15 +121,14 @@ const PdfBlockComponent = ({
void validatePDFContent();
}, [pdfUrl, isEncrypted]);
// For encrypted docs, decrypt only when user clicks
const handleDecryptPdf = useCallback(async () => {
if (!editor.resolveFileUrl || !pdfUrl) {
if (!pdfUrl) {
return;
}
setIsPDFContentLoading(true);
try {
const blobUrl = await editor.resolveFileUrl(pdfUrl);
const blobUrl = await decryptFileUrl(pdfUrl);
setResolvedPdfUrl(blobUrl);
setIsPDFContent(true);
} catch {
@@ -135,7 +136,7 @@ const PdfBlockComponent = ({
} finally {
setIsPDFContentLoading(false);
}
}, [editor, pdfUrl]);
}, [pdfUrl, decryptFileUrl]);
const showEncryptedPlaceholder =
isEncrypted &&
@@ -148,31 +149,14 @@ const PdfBlockComponent = ({
<PDFBlockStyle />
{!isEncrypted && isPDFContentLoading && <Loading />}
{showEncryptedPlaceholder && (
<Box
$align="center"
$justify="center"
$color="#666"
$background="#f5f5f5"
$border="1px solid #ddd"
$height="300px"
$css={css`
text-align: center;
cursor: ${isPDFContentLoading ? 'wait' : 'pointer'};
`}
contentEditable={false}
onClick={() => !isPDFContentLoading && void handleDecryptPdf()}
>
{isPDFContentLoading ? (
<Loading />
) : (
<>
<Icon iconName="lock" $size="24px" />
<Box $margin={{ top: 'small' }}>
{t('Click to decrypt and view PDF')}
</Box>
</>
)}
</Box>
<EncryptedMediaPlaceholder
label={t('Click to decrypt and view PDF')}
errorLabel={t('Invalid or missing PDF file.')}
minHeight="300px"
isLoading={isPDFContentLoading}
hasError={false}
onDecrypt={() => void handleDecryptPdf()}
/>
)}
{!isPDFContentLoading && isPDFContent !== null && !isPDFContent && (
<Box
@@ -0,0 +1,104 @@
import {
BlockNoDefaults,
BlockNoteEditor,
InlineContentSchema,
StyleSchema,
createVideoBlockConfig,
videoParse,
} from '@blocknote/core';
import {
ResizableFileBlockWrapper,
createReactBlockSpec,
} from '@blocknote/react';
import { useTranslation } from 'react-i18next';
import { Icon } from '@/components';
import { useDecryptMedia } from '../../hook';
import { EncryptedMediaPlaceholder } from '../EncryptedMediaPlaceholder';
type VideoBlockConfig = ReturnType<typeof createVideoBlockConfig>;
interface VideoBlockComponentProps {
block: BlockNoDefaults<
Record<'video', VideoBlockConfig>,
InlineContentSchema,
StyleSchema
>;
contentRef: (node: HTMLElement | null) => void;
editor: BlockNoteEditor<
Record<'video', VideoBlockConfig>,
InlineContentSchema,
StyleSchema
>;
}
const VideoBlockComponent = ({
editor,
block,
...rest
}: VideoBlockComponentProps) => {
const { t } = useTranslation();
const {
showPlaceholder,
showMedia,
isLoading,
hasError,
decrypt,
resolvedUrl,
} = useDecryptMedia(block.props.url);
return (
<ResizableFileBlockWrapper
{...({ editor, block, ...rest } as any)}
buttonIcon={
<Icon iconName="videocam" $size="24px" $css="line-height: normal;" />
}
>
{showPlaceholder && (
<EncryptedMediaPlaceholder
label={t('Click to decrypt and play video')}
errorLabel={t('Failed to decrypt video file.')}
minHeight="300px"
isLoading={isLoading}
hasError={hasError}
onDecrypt={() => void decrypt()}
/>
)}
{showMedia && (
<video
className="bn-visual-media"
src={resolvedUrl || block.props.url}
controls
contentEditable={false}
draggable={false}
/>
)}
</ResizableFileBlockWrapper>
);
};
const VideoToExternalHTML = ({
block,
}: {
block: BlockNoDefaults<
Record<'video', VideoBlockConfig>,
InlineContentSchema,
StyleSchema
>;
}) => {
if (!block.props.url) {
return <p>Add video</p>;
}
return <video src={block.props.url} controls />;
};
export const VideoBlock = createReactBlockSpec(
createVideoBlockConfig,
(config) => ({
render: (props) => <VideoBlockComponent {...(props as any)} />,
parse: videoParse(config),
toExternalHTML: (props) => <VideoToExternalHTML {...(props as any)} />,
}),
);
@@ -1,4 +1,6 @@
export * from './AccessibleImageBlock';
export * from './AudioBlock';
export * from './CalloutBlock';
export * from './PdfBlock';
export * from './UploadLoaderBlock';
export * from './VideoBlock';
@@ -1,3 +1,4 @@
export * from './useDecryptMedia';
export * from './useHeadings';
export * from './useSaveDoc';
export * from './useShortcuts';
@@ -0,0 +1,45 @@
import { useCallback, useState } from 'react';
import { useEncryption } from '../components/EncryptionProvider';
import { ANALYZE_URL } from '../conf';
export const useDecryptMedia = (url: string | undefined) => {
const { isEncrypted, decryptFileUrl } = useEncryption();
const [resolvedUrl, setResolvedUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const isAnalyzing = !!url && url.includes(ANALYZE_URL);
const decrypt = useCallback(async () => {
if (!url || resolvedUrl || isLoading) {
return;
}
setIsLoading(true);
setHasError(false);
try {
const blobUrl = await decryptFileUrl(url);
setResolvedUrl(blobUrl);
} catch {
setHasError(true);
} finally {
setIsLoading(false);
}
}, [url, resolvedUrl, isLoading, decryptFileUrl]);
const showPlaceholder =
isEncrypted && !resolvedUrl && !hasError && !!url && !isAnalyzing;
const showMedia = !!url && !isAnalyzing && (!isEncrypted || !!resolvedUrl);
return {
isEncrypted,
resolvedUrl,
isLoading,
hasError,
decrypt,
showPlaceholder,
showMedia,
};
};