diff --git a/CHANGELOG.md b/CHANGELOG.md index b7fc30cbb0..3271f6e79c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to - #1244 - #1270 - #1282 +- ✨(frontend) add pdf block to the editor #1293 ### Fixed diff --git a/docker/files/etc/nginx/conf.d/default.conf b/docker/files/etc/nginx/conf.d/default.conf index 072fdc28f1..0c69c3107a 100644 --- a/docker/files/etc/nginx/conf.d/default.conf +++ b/docker/files/etc/nginx/conf.d/default.conf @@ -21,6 +21,9 @@ server { proxy_pass http://minio:9000/impress-media-storage/; proxy_set_header Host minio:9000; + proxy_hide_header Content-Disposition; + add_header Content-Disposition "inline"; + add_header Content-Security-Policy "default-src 'none'" always; } diff --git a/docker/files/production/etc/nginx/conf.d/default.conf.template b/docker/files/production/etc/nginx/conf.d/default.conf.template index d1c4a8e3e4..4b3deb7e47 100644 --- a/docker/files/production/etc/nginx/conf.d/default.conf.template +++ b/docker/files/production/etc/nginx/conf.d/default.conf.template @@ -96,6 +96,9 @@ server { proxy_ssl_name ${S3_HOST}; + proxy_hide_header Content-Disposition; + add_header Content-Disposition "inline"; + add_header Content-Security-Policy "default-src 'none'" always; } 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 04238f48a4..707fd48bdf 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 @@ -37,6 +37,7 @@ import { AccessibleImageBlock, CalloutBlock, DividerBlock, + PdfBlock, } from './custom-blocks'; import { InterlinkingLinkInlineContent, @@ -55,6 +56,7 @@ const baseBlockNoteSchema = withPageBreak( callout: CalloutBlock, divider: DividerBlock, image: AccessibleImageBlock, + pdf: PdfBlock, }, inlineContentSpecs: { ...defaultInlineContentSpecs, diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx index 4e8c6e3091..62999dc885 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx @@ -18,6 +18,7 @@ import { import { getCalloutReactSlashMenuItems, getDividerReactSlashMenuItems, + getPdfReactSlashMenuItems, } from './custom-blocks'; import { useGetInterlinkingMenuItems } from './custom-inline-content'; import XLMultiColumn from './xl-multi-column'; @@ -32,7 +33,10 @@ export const BlockNoteSuggestionMenu = () => { DocsStyleSchema >(); const { t } = useTranslation(); - const basicBlocksName = useDictionary().slash_menu.page_break.group; + const dictionaryDate = useDictionary(); + const basicBlocksName = dictionaryDate.slash_menu.page_break.group; + const fileBlocksName = dictionaryDate.slash_menu.file.group; + const getInterlinkingMenuItems = useGetInterlinkingMenuItems(); const getSlashMenuItems = useMemo(() => { @@ -56,11 +60,12 @@ export const BlockNoteSuggestionMenu = () => { getMultiColumnSlashMenuItems?.(editor) || [], getPageBreakReactSlashMenuItems(editor), getDividerReactSlashMenuItems(editor, t, basicBlocksName), + getPdfReactSlashMenuItems(editor, t, fileBlocksName), ), query, ), ); - }, [basicBlocksName, editor, getInterlinkingMenuItems, t]); + }, [basicBlocksName, editor, getInterlinkingMenuItems, t, fileBlocksName]); return ( [0]['editor']; + +export const PdfBlock = createReactBlockSpec( + { + type: 'pdf', + content: 'none', + propSchema: { + name: { default: '' as const }, + url: { default: '' as const }, + caption: { default: '' as const }, + showPreview: { default: true }, + previewWidth: { default: undefined, type: 'number' }, + }, + isFileBlock: true, + fileBlockAccept: ['application/pdf'], + }, + { + render: ({ editor, block, contentRef }) => { + const pdfUrl = block.props.url; + + const { t } = useTranslation(); + + const { isMobile } = useResponsiveStore(); + + const handleResizeEnd = useCallback( + (width: number) => { + editor.updateBlock(block, { + props: { previewWidth: width }, + }); + }, + [editor, block], + ); + + const { wrapperRef, pdfWidth, handlePointerDown } = usePdfResizer( + block.props.previewWidth ?? 100, + handleResizeEnd, + ); + + return ( +
+ {pdfUrl === '' ? ( + } + /> + ) : ( + + {isMobile ? ( + + + + {block.props.name} + + + ) : ( + + + editor.setTextCursorPosition(block)} + /> + {/* Right-edge drag handle */} +
e.preventDefault()} + /> + + + )} + + )} +
+ ); + }, + }, +); + +export const getPdfReactSlashMenuItems = ( + editor: DocsBlockNoteEditor, + t: TFunction<'translation', undefined>, + group: string, +) => [ + { + title: t('PDF'), + onItemClick: () => { + insertOrUpdateBlock(editor, { type: 'pdf' }); + }, + aliases: ['pdf', 'document', 'embed', 'file'], + group, + icon: , + subtext: t('Embed a PDF file'), + }, +]; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts index 99c1ee271e..1a2ea21e7a 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts @@ -1,3 +1,4 @@ export * from './AccessibleImageBlock'; export * from './CalloutBlock'; export * from './DividerBlock'; +export * from './PdfBlock'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/usePdfResizer.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/usePdfResizer.tsx new file mode 100644 index 0000000000..d334df0a7f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/usePdfResizer.tsx @@ -0,0 +1,85 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +const MAX_WIDTH_PERCENTAGE = 100; +const MIN_WIDTH_PERCENTAGE = 40; + +export const usePdfResizer = ( + initialWidth: number = 100, + onResizeEnd: (width: number) => void, +) => { + const dragOffsetRef = useRef(0); + const wrapperRef = useRef(null); + + const [pdfWidth, setPdfWidth] = useState(initialWidth); + const [dragging, setDragging] = useState(false); + + const clamp = (v: number) => + Math.max(MIN_WIDTH_PERCENTAGE, Math.min(MAX_WIDTH_PERCENTAGE, v)); + + const onPointerMove = useCallback( + (e: PointerEvent) => { + if (!dragging || !wrapperRef.current) { + return; + } + + const rect = wrapperRef.current.getBoundingClientRect(); + + const pct = + ((e.clientX - rect.left - dragOffsetRef.current) / rect.width) * 100; + + setPdfWidth(clamp(pct)); + }, + [dragging], + ); + + const stopDragging = useCallback(() => { + onResizeEnd(pdfWidth); + setDragging(false); + }, [onResizeEnd, pdfWidth]); + + useEffect(() => { + if (!dragging) { + return; + } + + const prevUserSelect = document.body.style.userSelect; + document.body.style.userSelect = 'none'; + + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', stopDragging); + window.addEventListener('pointercancel', stopDragging); + + return () => { + document.body.style.userSelect = prevUserSelect; + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', stopDragging); + window.removeEventListener('pointercancel', stopDragging); + }; + }, [dragging, onPointerMove, stopDragging]); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (wrapperRef.current) { + const rect = wrapperRef.current.getBoundingClientRect(); + const currentRight = rect.left + (pdfWidth / 100) * rect.width; + + // capture grab offset + dragOffsetRef.current = e.clientX - currentRight; + } + + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + setDragging(true); + }, + [pdfWidth], + ); + + return { + pdfWidth, + wrapperRef, + dragging, + handlePointerDown, + } as const; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts index e3e766dbcb..4be8f8f315 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts @@ -6,6 +6,8 @@ export * from './headingPDF'; export * from './imageDocx'; export * from './imagePDF'; export * from './paragraphPDF'; +export * from './pdfDocx'; +export * from './pdfPDF'; export * from './quoteDocx'; export * from './quotePDF'; export * from './tablePDF'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/pdfDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/pdfDocx.tsx new file mode 100644 index 0000000000..c8e94373f9 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/pdfDocx.tsx @@ -0,0 +1,62 @@ +import { Paragraph, TextRun } from 'docx'; + +import { DocsExporterDocx } from '../types'; + +export const blockMappingPdfDocx: DocsExporterDocx['mappings']['blockMapping']['pdf'] = + (block) => { + const pdfName = block.props.name || 'PDF Document'; + const pdfUrl = block.props.url; + + const children: TextRun[] = [ + new TextRun({ + text: '📄 ', + size: 20, + }), + new TextRun({ + text: pdfName, + bold: true, + size: 24, + }), + ]; + + if (pdfUrl) { + children.push( + new TextRun({ + text: '\nSource: ', + size: 20, + color: '666666', + }), + new TextRun({ + text: pdfUrl, + size: 20, + color: '666666', + }), + ); + } + + children.push( + new TextRun({ + text: '\n[PDF content cannot be embedded in exported DOCX]', + size: 18, + color: '999999', + italics: true, + }), + ); + + return new Paragraph({ + children, + spacing: { + before: 200, + after: 200, + }, + border: { + top: { size: 1, color: 'CCCCCC', style: 'single' }, + bottom: { size: 1, color: 'CCCCCC', style: 'single' }, + left: { size: 1, color: 'CCCCCC', style: 'single' }, + right: { size: 1, color: 'CCCCCC', style: 'single' }, + }, + shading: { + fill: 'F9F9F9', + }, + }); + }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/pdfPDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/pdfPDF.tsx new file mode 100644 index 0000000000..d8f949746e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/pdfPDF.tsx @@ -0,0 +1,31 @@ +import { Text, View } from '@react-pdf/renderer'; + +import { DocsExporterPDF } from '../types'; + +export const blockMappingPdfPDF: DocsExporterPDF['mappings']['blockMapping']['pdf'] = + (block) => { + const pdfName = block.props.name || 'PDF Document'; + const pdfUrl = block.props.url; + + return ( + + + 📄 {pdfName} + + {pdfUrl && ( + Source: {pdfUrl} + )} + + [PDF content cannot be embedded in exported PDF] + + + ); + }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx index 46263b92c6..720e4c27cc 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx @@ -5,6 +5,7 @@ import { blockMappingCalloutDocx, blockMappingDividerDocx, blockMappingImageDocx, + blockMappingPdfDocx, blockMappingQuoteDocx, } from './blocks-mapping'; import { inlineContentMappingInterlinkingLinkDocx } from './inline-content-mapping'; @@ -16,6 +17,7 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = { ...docxDefaultSchemaMappings.blockMapping, callout: blockMappingCalloutDocx, divider: blockMappingDividerDocx, + pdf: blockMappingPdfDocx, quote: blockMappingQuoteDocx, image: blockMappingImageDocx, }, diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx index 53cc90618e..ad854889f4 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx @@ -6,6 +6,7 @@ import { blockMappingHeadingPDF, blockMappingImagePDF, blockMappingParagraphPDF, + blockMappingPdfPDF, blockMappingQuotePDF, blockMappingTablePDF, } from './blocks-mapping'; @@ -23,6 +24,7 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = { divider: blockMappingDividerPDF, quote: blockMappingQuotePDF, table: blockMappingTablePDF, + pdf: blockMappingPdfPDF, }, inlineContentMapping: { ...pdfDefaultSchemaMappings.inlineContentMapping,