diff --git a/CHANGELOG.md b/CHANGELOG.md index 86cf1aba98..289994f2fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,11 @@ and this project adheres to ### Added - ✨(backend) support creating subdoc from file #1987 +- ✨(frontend) justified text alignment in the formatting toolbar ### Fixed +- 🐛(frontend) DOCX export helpers map justified text to OOXML `both` for blocks using app-provided paragraph mappings (e.g. callout, quote, image caption) - 🐛(docs) run migration 0027 without superuser role diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx index c110772e65..a433a812d3 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx @@ -1,6 +1,7 @@ import { FormattingToolbar, FormattingToolbarController, + TextAlignButton, blockTypeSelectItems, getFormattingToolbarItems, useDictionary, @@ -74,6 +75,25 @@ export const BlockNoteToolbar = ({ aiAllowed }: { aiAllowed: boolean }) => { return true; }); + const rightAlignIndex = toolbarItems.findIndex( + (item) => + typeof item === 'object' && + item !== null && + 'key' in item && + item.key === 'textAlignRightButton', + ); + + if (rightAlignIndex !== -1) { + toolbarItems.splice( + rightAlignIndex + 1, + 0, + , + ); + } + return toolbarItems; }, [dict, t]); diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/blockNoteDocxBlockProps.ts b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/blockNoteDocxBlockProps.ts new file mode 100644 index 0000000000..f90ceb7dff --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/blockNoteDocxBlockProps.ts @@ -0,0 +1,57 @@ +import { + COLORS_DEFAULT, + DefaultProps, + UnreachableCaseError, +} from '@blocknote/core'; +import { IParagraphOptions, ShadingType } from 'docx'; + +/** + * Same semantics as `@blocknote/xl-docx-exporter` `blockPropsToStyles`, but + * `textAlignment: justify` maps to OOXML `both` (normal justified paragraphs). + * Upstream maps justify to `distribute`, which uses “distribute all characters equally” + * and does not match browser/Word paragraph justification. + */ +export function blockNoteDocxBlockPropsToStyles( + props: Partial, + colors: typeof COLORS_DEFAULT, +): IParagraphOptions { + return { + shading: + props.backgroundColor === 'default' || !props.backgroundColor + ? undefined + : { + type: ShadingType.CLEAR, + fill: (() => { + const color = colors[props.backgroundColor]?.background; + if (!color) { + return undefined; + } + return color.slice(1); + })(), + }, + run: + props.textColor === 'default' || !props.textColor + ? undefined + : { + color: (() => { + const color = colors[props.textColor]?.text; + if (!color) { + return undefined; + } + return color.slice(1); + })(), + }, + alignment: + !props.textAlignment || props.textAlignment === 'left' + ? undefined + : props.textAlignment === 'center' + ? 'center' + : props.textAlignment === 'right' + ? 'right' + : props.textAlignment === 'justify' + ? 'both' + : (() => { + throw new UnreachableCaseError(props.textAlignment); + })(), + }; +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/defaultBlocksDocxJustify.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/defaultBlocksDocxJustify.tsx new file mode 100644 index 0000000000..e3e817a6fb --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/defaultBlocksDocxJustify.tsx @@ -0,0 +1,69 @@ +import { CheckBox, Paragraph, TextRun } from 'docx'; + +import { DocsExporterDocx } from '../types'; + +import { blockNoteDocxBlockPropsToStyles } from './blockNoteDocxBlockProps'; + +/** Default-schema DOCX blocks with correct `justify` → `both` (see blockNoteDocxBlockProps). */ + +export const blockMappingParagraphDocxJustifyBoth: DocsExporterDocx['mappings']['blockMapping']['paragraph'] = + (block, exporter) => + new Paragraph({ + ...blockNoteDocxBlockPropsToStyles(block.props, exporter.options.colors), + children: exporter.transformInlineContent(block.content), + }); + +export const blockMappingHeadingDocxJustifyBoth: DocsExporterDocx['mappings']['blockMapping']['heading'] = + (block, exporter) => + new Paragraph({ + ...blockNoteDocxBlockPropsToStyles(block.props, exporter.options.colors), + children: exporter.transformInlineContent(block.content), + heading: `Heading${block.props.level as 1 | 2 | 3 | 4 | 5 | 6}`, + }); + +export const blockMappingBulletListItemDocxJustifyBoth: DocsExporterDocx['mappings']['blockMapping']['bulletListItem'] = + (block, exporter, nestingLevel) => + new Paragraph({ + ...blockNoteDocxBlockPropsToStyles(block.props, exporter.options.colors), + children: exporter.transformInlineContent(block.content), + numbering: { + reference: 'blocknote-bullet-list', + level: nestingLevel, + }, + }); + +export const blockMappingNumberedListItemDocxJustifyBoth: DocsExporterDocx['mappings']['blockMapping']['numberedListItem'] = + (block, exporter, nestingLevel) => + new Paragraph({ + ...blockNoteDocxBlockPropsToStyles(block.props, exporter.options.colors), + children: exporter.transformInlineContent(block.content), + numbering: { + reference: 'blocknote-numbered-list', + level: nestingLevel, + }, + }); + +export const blockMappingToggleListItemDocxJustifyBoth: DocsExporterDocx['mappings']['blockMapping']['toggleListItem'] = + (block, exporter) => + new Paragraph({ + ...blockNoteDocxBlockPropsToStyles(block.props, exporter.options.colors), + children: [ + new TextRun({ + children: ['> '], + }), + ...exporter.transformInlineContent(block.content), + ], + }); + +export const blockMappingCheckListItemDocxJustifyBoth: DocsExporterDocx['mappings']['blockMapping']['checkListItem'] = + (block, exporter) => + new Paragraph({ + ...blockNoteDocxBlockPropsToStyles(block.props, exporter.options.colors), + children: [ + new CheckBox({ checked: block.props.checked }), + new TextRun({ + children: [' '], + }), + ...exporter.transformInlineContent(block.content), + ], + }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imageDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imageDocx.tsx index 3eb26bb251..b1ba28c8b2 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imageDocx.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imageDocx.tsx @@ -111,7 +111,7 @@ function blockPropsToStyles( : props.textAlignment === 'right' ? 'right' : props.textAlignment === 'justify' - ? 'distribute' + ? 'both' : (() => { throw new UnreachableCaseError(props.textAlignment); })(), 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 1d98efe889..adcaa260ec 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 @@ -1,4 +1,6 @@ +export * from './blockNoteDocxBlockProps'; export * from './calloutDocx'; +export * from './defaultBlocksDocxJustify'; export * from './calloutODT'; export * from './calloutPDF'; export * from './headingPDF'; 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 dc8afc2f47..11c67239b8 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 @@ -1,9 +1,15 @@ import { docxDefaultSchemaMappings } from '@blocknote/xl-docx-exporter'; import { + blockMappingBulletListItemDocxJustifyBoth, blockMappingCalloutDocx, + blockMappingCheckListItemDocxJustifyBoth, + blockMappingHeadingDocxJustifyBoth, blockMappingImageDocx, + blockMappingNumberedListItemDocxJustifyBoth, + blockMappingParagraphDocxJustifyBoth, blockMappingQuoteDocx, + blockMappingToggleListItemDocxJustifyBoth, blockMappingUploadLoaderDocx, } from './blocks-mapping'; import { inlineContentMappingInterlinkingLinkDocx } from './inline-content-mapping'; @@ -13,6 +19,12 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = { ...docxDefaultSchemaMappings, blockMapping: { ...docxDefaultSchemaMappings.blockMapping, + paragraph: blockMappingParagraphDocxJustifyBoth, + heading: blockMappingHeadingDocxJustifyBoth, + bulletListItem: blockMappingBulletListItemDocxJustifyBoth, + numberedListItem: blockMappingNumberedListItemDocxJustifyBoth, + toggleListItem: blockMappingToggleListItemDocxJustifyBoth, + checkListItem: blockMappingCheckListItemDocxJustifyBoth, callout: blockMappingCalloutDocx, // We're reusing the file block mapping for PDF blocks; both share the same // implementation signature, so we can reuse the handler directly. diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts index 72992ea2a1..1f22966bb6 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts @@ -110,7 +110,7 @@ export function docxBlockPropsToStyles( : props.textAlignment === 'right' ? 'right' : props.textAlignment === 'justify' - ? 'distribute' + ? 'both' : (() => { throw new UnreachableCaseError(props.textAlignment); })(),