wip block placeholders to save resources when encrypted
This commit is contained in:
+45
-87
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+11
-14
@@ -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;
|
||||
|
||||
+76
@@ -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>
|
||||
);
|
||||
};
|
||||
+118
@@ -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);
|
||||
+252
-88
@@ -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'],
|
||||
}),
|
||||
);
|
||||
|
||||
+102
@@ -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'],
|
||||
}),
|
||||
);
|
||||
+16
-32
@@ -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
|
||||
|
||||
+104
@@ -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)} />,
|
||||
}),
|
||||
);
|
||||
+2
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user