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);
})(),