diff --git a/CHANGELOG.md b/CHANGELOG.md index 47679f4b89..3ff5e5e32e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to ### Added - ✨(helm) allow all keys in configMap as env var #1872 +- ✨(frontend) Optionally include title in export #1837 ### Changed diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx index 985aceeabd..8dc277b1d1 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx @@ -1,8 +1,10 @@ +import { SpecificBlock } from '@blocknote/core'; import { DOCXExporter } from '@blocknote/xl-docx-exporter'; import { ODTExporter } from '@blocknote/xl-odt-exporter'; import { PDFExporter } from '@blocknote/xl-pdf-exporter'; import { Button, + Checkbox, Loader, Modal, ModalSize, @@ -20,9 +22,15 @@ import { css } from 'styled-components'; import { Box, ButtonCloseModal, Text } from '@/components'; import { useMediaUrl } from '@/core'; -import { useEditorStore } from '@/docs/doc-editor'; +import { + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema, + useEditorStore, +} from '@/docs/doc-editor'; import { Doc, useTrans } from '@/docs/doc-management'; import { fallbackLng } from '@/i18n/config'; +import { safeLocalStorage } from '@/utils/storages'; import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl'; import { docxDocsSchemaMappings } from '../mappingDocx'; @@ -57,6 +65,18 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { const [format, setFormat] = useState( DocDownloadFormat.PDF, ); + const documentHasH1 = + editor?.document.some( + (block) => block.type === 'heading' && block.props.level === 1, + ) ?? false; + + const [withTitle, setWithTitle] = useState(() => { + const stored = safeLocalStorage.getItem(`export-with-title-${doc.id}`); + if (stored === null) { + return !documentHasH1; + } + return stored !== 'false'; + }); const { untitledDocument } = useTrans(); const mediaUrl = useMediaUrl(); @@ -84,7 +104,27 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { const documentTitle = doc.title || untitledDocument; - const exportDocument = editor.document; + const titleBlock: SpecificBlock< + DocsBlockSchema, + 'heading', + DocsInlineContentSchema, + DocsStyleSchema + > = { + id: crypto.randomUUID(), + type: 'heading', + props: { + level: 1, + textColor: 'default', + backgroundColor: 'default', + textAlignment: 'left', + }, + content: [{ type: 'text', text: documentTitle, styles: {} }], + children: [], + }; + + const exportDocument = withTitle + ? [titleBlock, ...editor.document] + : editor.document; let blobExport: Blob; if (format === DocDownloadFormat.PDF) { const exporter = new PDFExporter(editor.schema, pdfDocsSchemaMappings, { @@ -135,7 +175,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { blobExport = await exporter.toODTDocument(exportDocument); } else if (format === DocDownloadFormat.HTML) { // Use BlockNote "full HTML" export so that we stay closer to the editor rendering. - const fullHtml = await editor.blocksToFullHTML(); + const fullHtml = await editor.blocksToFullHTML(exportDocument); // Parse HTML and fetch media so that we can package a fully offline HTML document in a ZIP. const domParser = new DOMParser(); @@ -143,7 +183,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { const zip = new JSZip(); - improveHtmlAccessibility(parsedDocument, documentTitle); + improveHtmlAccessibility(parsedDocument); await addMediaFilesToZip(parsedDocument, zip, mediaUrl); const lang = i18next.language || fallbackLng; @@ -273,6 +313,20 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { } /> + {format !== DocDownloadFormat.PRINT && ( + { + setWithTitle(e.target.checked); + safeLocalStorage.setItem( + `export-with-title-${doc.id}`, + String(e.target.checked), + ); + }} + /> + )} + {isExporting && ( { +export const improveHtmlAccessibility = (parsedDocument: Document) => { const body = parsedDocument.body; if (!body) { return; @@ -362,11 +359,8 @@ export const improveHtmlAccessibility = ( // 8) Wrap content in an article with a title landmark if none exists const existingH1 = body.querySelector('h1'); - if (!existingH1) { - const titleHeading = parsedDocument.createElement('h1'); - titleHeading.id = 'doc-title'; - titleHeading.textContent = documentTitle; - body.insertBefore(titleHeading, body.firstChild); + if (existingH1) { + existingH1.id = 'doc-title'; } // If there is no article, group the body content inside one for better semantics. @@ -374,7 +368,9 @@ export const improveHtmlAccessibility = ( if (!hasArticle) { const article = parsedDocument.createElement('article'); article.setAttribute('role', 'document'); - article.setAttribute('aria-labelledby', 'doc-title'); + if (existingH1) { + article.setAttribute('aria-labelledby', 'doc-title'); + } while (body.firstChild) { article.appendChild(body.firstChild); }