From 2172edae304e7bedae2c8b4eb6cf6ba23b1756ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Wed, 24 Jun 2026 11:01:10 +0200 Subject: [PATCH 1/8] feat(web): text alignment --- apps/example-web/src/components/Toolbar.tsx | 22 +++++++ package.json | 1 + src/web/EnrichedTextInput.tsx | 10 +++- src/web/formats/EnrichedBlockquote.ts | 3 +- src/web/formats/EnrichedCodeBlock.ts | 3 +- src/web/formats/EnrichedHeading.ts | 3 +- src/web/formats/EnrichedOrderedList.ts | 7 ++- src/web/formats/EnrichedTextAlign.ts | 58 +++++++++++++++++++ src/web/formats/EnrichedUnorderedList.ts | 7 ++- .../formats/applyWrappingListToSelection.ts | 8 +-- src/web/formats/formatRules.ts | 35 ++++++++++- src/web/formats/listKeyboard.ts | 7 ++- src/web/normalization/tiptapHtmlNormalizer.ts | 5 +- src/web/useOnChangeState.ts | 12 ++-- yarn.lock | 10 ++++ 15 files changed, 170 insertions(+), 21 deletions(-) create mode 100644 src/web/formats/EnrichedTextAlign.ts diff --git a/apps/example-web/src/components/Toolbar.tsx b/apps/example-web/src/components/Toolbar.tsx index 42241fae6..3291a5bca 100644 --- a/apps/example-web/src/components/Toolbar.tsx +++ b/apps/example-web/src/components/Toolbar.tsx @@ -202,6 +202,16 @@ export function Toolbar({ onPress: (editor: EnrichedTextInputInstance | null) => void; }[]; + const alignmentItems: { + label: string; + value: 'left' | 'center' | 'right' | 'justify'; + }[] = [ + { label: '←', value: 'left' }, + { label: '↔', value: 'center' }, + { label: '→', value: 'right' }, + { label: '≡', value: 'justify' }, + ]; + return (
@@ -218,6 +228,18 @@ export function Toolbar({ }} /> ))} + {alignmentItems.map((item) => ( + { + editorRef.current?.setTextAlignment(item.value); + }} + /> + ))}
diff --git a/package.json b/package.json index 0fbf32ef7..2d868b792 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "@tiptap/extension-paragraph": "3.20.4", "@tiptap/extension-strike": "3.20.4", "@tiptap/extension-text": "3.20.4", + "@tiptap/extension-text-align": "3.20.4", "@tiptap/extension-underline": "3.20.4", "@tiptap/extensions": "3.20.4", "@tiptap/pm": "3.20.4", diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index 06a0c264a..d21f3904b 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -60,6 +60,7 @@ import { EnrichedUnorderedList } from './formats/EnrichedUnorderedList'; import { EnrichedOrderedList } from './formats/EnrichedOrderedList'; import { EnrichedCheckboxItem } from './formats/EnrichedCheckboxItem'; import { EnrichedCheckboxList } from './formats/EnrichedCheckboxList'; +import { EnrichedTextAlign } from './formats/EnrichedTextAlign'; import { StripBoldInStyledHeadingsPlugin } from './pmPlugins/StripBoldInStyledHeadingsPlugin'; import { StrictMarksPlugin } from './pmPlugins/StrictMarksPlugin'; import { MergeAdjacentSameKindBlocksPlugin } from './pmPlugins/MergeAdjacentSameKindBlocksPlugin'; @@ -215,6 +216,7 @@ export const EnrichedTextInput = ({ EnrichedUnorderedList, EnrichedOrderedList, EnrichedCheckboxList, + EnrichedTextAlign, StripMarksInCodeBlockPlugin, StripMarksOnImagePlugin, StripBoldInStyledHeadingsPlugin.configure({ @@ -367,7 +369,13 @@ export const EnrichedTextInput = ({ measureInWindow: () => {}, measureLayout: () => {}, setNativeProps: () => {}, - setTextAlignment: () => {}, + setTextAlignment: (alignment) => { + if (alignment === 'auto') { + runFocused(editor, (c) => c.unsetTextAlign()); + } else { + runFocused(editor, (c) => c.setTextAlign(alignment)); + } + }, }), [editor] ); diff --git a/src/web/formats/EnrichedBlockquote.ts b/src/web/formats/EnrichedBlockquote.ts index a85525a9d..f909a5840 100644 --- a/src/web/formats/EnrichedBlockquote.ts +++ b/src/web/formats/EnrichedBlockquote.ts @@ -29,7 +29,8 @@ export const EnrichedBlockquote = Blockquote.extend({ () => editor.isActive('blockquote'), () => commands.lift('blockquote'), (c) => c.toggleWrap('blockquote'), - chain + chain, + editor ), }; }, diff --git a/src/web/formats/EnrichedCodeBlock.ts b/src/web/formats/EnrichedCodeBlock.ts index afac3ef72..61cc348de 100644 --- a/src/web/formats/EnrichedCodeBlock.ts +++ b/src/web/formats/EnrichedCodeBlock.ts @@ -35,7 +35,8 @@ export const EnrichedCodeBlock = Blockquote.extend({ () => editor.isActive('codeBlock'), () => commands.lift('codeBlock'), (c) => c.toggleWrap('codeBlock'), - chain + chain, + editor ), }; }, diff --git a/src/web/formats/EnrichedHeading.ts b/src/web/formats/EnrichedHeading.ts index 968376351..377923fa9 100644 --- a/src/web/formats/EnrichedHeading.ts +++ b/src/web/formats/EnrichedHeading.ts @@ -29,7 +29,8 @@ export const EnrichedHeading = Heading.configure({ () => editor.isActive('heading', attrs), () => commands.setParagraph(), (c) => c.setHeading(attrs), - chain + chain, + editor ), }; }, diff --git a/src/web/formats/EnrichedOrderedList.ts b/src/web/formats/EnrichedOrderedList.ts index e438a3199..c9e190a79 100644 --- a/src/web/formats/EnrichedOrderedList.ts +++ b/src/web/formats/EnrichedOrderedList.ts @@ -2,6 +2,7 @@ import { wrappingInputRule } from '@tiptap/core'; import { OrderedList } from '@tiptap/extension-list'; import { applyWrappingListToSelection } from './applyWrappingListToSelection'; +import { withPreservedAlignment } from './formatRules'; const ORDERED_LIST_INPUT_REGEX = /^1\.\s$/; @@ -23,9 +24,11 @@ export const EnrichedOrderedList = OrderedList.extend({ return { toggleOrderedList: () => - ({ editor, commands, chain }) => { + ({ editor, chain }) => { if (editor.isActive('orderedList')) { - return commands.setParagraph(); + return withPreservedAlignment(editor, chain(), (c) => + c.setParagraph() + ); } return applyWrappingListToSelection( diff --git a/src/web/formats/EnrichedTextAlign.ts b/src/web/formats/EnrichedTextAlign.ts new file mode 100644 index 000000000..7c22330fb --- /dev/null +++ b/src/web/formats/EnrichedTextAlign.ts @@ -0,0 +1,58 @@ +import { TextAlign } from '@tiptap/extension-text-align'; + +export const EnrichedTextAlign = TextAlign.extend({ + addCommands() { + return { + ...this.parent?.(), + setTextAlign: (alignment) => (props) => { + const { state, dispatch, tr } = props; + + if (!this.options.alignments.includes(alignment)) { + return false; + } + + const { $from } = state.selection; + let listNode = null; + let listPos = -1; + + // Walk up the tree to see if the cursor is inside a list wrapper + for (let depth = $from.depth; depth > 0; depth--) { + const node = $from.node(depth); + if ( + ['orderedList', 'unorderedList', 'checkboxList'].includes( + node.type.name + ) + ) { + listNode = node; + listPos = $from.before(depth); + break; + } + } + + // If in a list, manually apply the alignment to the parent wrapper + if (listNode) { + if (dispatch) { + tr.setNodeMarkup(listPos, undefined, { + ...listNode.attrs, + textAlign: alignment, + }); + } + return true; + } + + // If not in a list, fire the original Tiptap command + return this.parent?.()?.setTextAlign?.(alignment)(props); + }, + }; + }, +}).configure({ + types: [ + 'paragraph', + 'heading', + 'orderedList', + 'unorderedList', + 'checkboxList', + ], + defaultAlignment: null, + alignments: ['left', 'center', 'right', 'justify'], +}); diff --git a/src/web/formats/EnrichedUnorderedList.ts b/src/web/formats/EnrichedUnorderedList.ts index 1b12d2796..5b151cc95 100644 --- a/src/web/formats/EnrichedUnorderedList.ts +++ b/src/web/formats/EnrichedUnorderedList.ts @@ -2,6 +2,7 @@ import { wrappingInputRule, type CommandProps } from '@tiptap/core'; import { BulletList } from '@tiptap/extension-list'; import { applyWrappingListToSelection } from './applyWrappingListToSelection'; +import { withPreservedAlignment } from './formatRules'; declare module '@tiptap/core' { interface Commands { @@ -33,9 +34,11 @@ export const EnrichedUnorderedList = BulletList.extend({ return { toggleUnorderedList: () => - ({ editor, commands, chain }: CommandProps) => { + ({ editor, chain }: CommandProps) => { if (editor.isActive('unorderedList')) { - return commands.setParagraph(); + return withPreservedAlignment(editor, chain(), (c) => + c.setParagraph() + ); } return applyWrappingListToSelection( diff --git a/src/web/formats/applyWrappingListToSelection.ts b/src/web/formats/applyWrappingListToSelection.ts index be552d927..fc06442b7 100644 --- a/src/web/formats/applyWrappingListToSelection.ts +++ b/src/web/formats/applyWrappingListToSelection.ts @@ -4,6 +4,7 @@ import { Fragment } from '@tiptap/pm/model'; import { TextSelection } from '@tiptap/pm/state'; import { nativePosToTiptapPos, tiptapPosToNativePos } from '../positionMapping'; +import { withPreservedAlignment } from './formatRules'; /** * Clears block styling with `clearNodes`, then wraps the selection’s blocks in a flat @@ -31,9 +32,8 @@ export function applyWrappingListToSelection( const nativeAnchor = tiptapPosToNativePos(docBefore, selBefore.anchor); const nativeHead = tiptapPosToNativePos(docBefore, selBefore.head); - return chain() - .clearNodes() - .command(({ tr, state }) => { + return withPreservedAlignment(editor, chain(), (c) => + c.clearNodes().command(({ tr, state }) => { const listType = state.schema.nodes[listTypeName]; const itemType = state.schema.nodes[itemTypeName]; if (!listType || !itemType) { @@ -72,5 +72,5 @@ export function applyWrappingListToSelection( ); return true; }) - .run(); + ); } diff --git a/src/web/formats/formatRules.ts b/src/web/formats/formatRules.ts index eb188a75a..504ef23a9 100644 --- a/src/web/formats/formatRules.ts +++ b/src/web/formats/formatRules.ts @@ -77,8 +77,39 @@ export function toggleParagraphFormat( isActive: () => boolean, deactivate: () => boolean, activate: (c: ChainedCommands) => ChainedCommands, - chain: () => ChainedCommands + chain: () => ChainedCommands, + editor: Editor ): boolean { if (isActive()) return deactivate(); - return activate(chain().clearNodes()).run(); + + return withPreservedAlignment(editor, chain(), (c) => + activate(c.clearNodes()) + ); +} + +export function getCurrentAlignment(editor: Editor): string | null { + const { $from } = editor.state.selection; + for (let depth = $from.depth; depth >= 0; depth--) { + const node = $from.node(depth); + if (node.attrs.textAlign) { + return node.attrs.textAlign; + } + } + return null; +} + +export function withPreservedAlignment( + editor: Editor, + chain: ChainedCommands, + mutateChain: (c: ChainedCommands) => ChainedCommands +): boolean { + const currentAlignment = getCurrentAlignment(editor); + + const c = mutateChain(chain); + + if (currentAlignment && currentAlignment !== 'auto') { + c.setTextAlign(currentAlignment); + } + + return c.run(); } diff --git a/src/web/formats/listKeyboard.ts b/src/web/formats/listKeyboard.ts index 17fe0617f..1d1bbaf59 100644 --- a/src/web/formats/listKeyboard.ts +++ b/src/web/formats/listKeyboard.ts @@ -1,6 +1,7 @@ import { isTextSelection, type Editor, type JSONContent } from '@tiptap/core'; import { lineStartBackspace } from './wrappedBlockKeyboard'; +import { withPreservedAlignment } from './formatRules'; function emptyListItemContent(itemName: string): JSONContent { return { @@ -51,7 +52,11 @@ export function listBackspace( ): boolean { return lineStartBackspace(editor, { isActive: () => editor.isActive(itemName), - lift: () => editor.chain().focus().liftListItem(itemName).run(), + lift: () => { + return withPreservedAlignment(editor, editor.chain(), (c) => + c.focus().liftListItem(itemName) + ); + }, shouldJoinBefore: (beforeName) => beforeName != null && wrapperNames.includes(beforeName), }); diff --git a/src/web/normalization/tiptapHtmlNormalizer.ts b/src/web/normalization/tiptapHtmlNormalizer.ts index 9ebd2c653..5831db4ac 100644 --- a/src/web/normalization/tiptapHtmlNormalizer.ts +++ b/src/web/normalization/tiptapHtmlNormalizer.ts @@ -23,7 +23,10 @@ export function normalizeHtmlFromTiptap(html: string): string { // TipTap renders
  • text

  • but native expects
  • text
  • . // This regex is safe because EnrichedListItem.content is 'paragraph', which // prevents TipTap from ever emitting nested lists - html = html.replace(/]*)>

    (.*?)<\/p><\/li>/gs, '$2'); + html = html.replace( + /]*)>\s*]*>(.*?)<\/p>\s*<\/li>/gs, + '$2' + ); // Convert remaining empty

    to
    (outside of lists) html = html.replace(/

    <\/p>/g, '
    '); diff --git a/src/web/useOnChangeState.ts b/src/web/useOnChangeState.ts index 439f685de..3d36dc8ee 100644 --- a/src/web/useOnChangeState.ts +++ b/src/web/useOnChangeState.ts @@ -4,6 +4,7 @@ import type { OnChangeStateEvent } from '../types'; import type { NativeSyntheticEvent } from 'react-native'; import { adaptWebToNativeEvent } from './adaptWebToNativeEvent'; import { + getCurrentAlignment, isAnyParagraphFormatActive, isFormatBlocked, } from './formats/formatRules'; @@ -97,21 +98,22 @@ function buildState( isConflicting: editor.isActive('link'), isBlocking: isFormatBlocked('image', editor, htmlStyle), }, - alignment: 'left', + alignment: getCurrentAlignment(editor) ?? 'auto', }; } function hashState(state: OnChangeStateEvent): string { return Object.values(state) - .map((formatState) => - String( + .map((formatState) => { + if (typeof formatState === 'string') return formatState; + return String( getFormatHash( formatState.isActive, formatState.isConflicting, formatState.isBlocking ) - ) - ) + ); + }) .join(''); } diff --git a/yarn.lock b/yarn.lock index 3422547d6..4895eb36f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4225,6 +4225,15 @@ __metadata: languageName: node linkType: hard +"@tiptap/extension-text-align@npm:3.20.4": + version: 3.20.4 + resolution: "@tiptap/extension-text-align@npm:3.20.4" + peerDependencies: + "@tiptap/core": ^3.20.4 + checksum: 10c0/cf25977fa5748dbdae82d791b025b3d636c28d91efd83095f14aac4ec57ac577fd6fc839ba6695193edac7af88e3757299295850e8a0a913052e3426e3b5ba15 + languageName: node + linkType: hard + "@tiptap/extension-text@npm:3.20.4": version: 3.20.4 resolution: "@tiptap/extension-text@npm:3.20.4" @@ -12871,6 +12880,7 @@ __metadata: "@tiptap/extension-paragraph": "npm:3.20.4" "@tiptap/extension-strike": "npm:3.20.4" "@tiptap/extension-text": "npm:3.20.4" + "@tiptap/extension-text-align": "npm:3.20.4" "@tiptap/extension-underline": "npm:3.20.4" "@tiptap/extensions": "npm:3.20.4" "@tiptap/pm": "npm:3.20.4" From 06a84ce90de95830faf0267bfa5aac96f5467c9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Wed, 24 Jun 2026 11:17:08 +0200 Subject: [PATCH 2/8] fix: handle backspace on empty input with alignment --- src/web/formats/EnrichedTextAlign.ts | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/web/formats/EnrichedTextAlign.ts b/src/web/formats/EnrichedTextAlign.ts index 7c22330fb..6a1790e72 100644 --- a/src/web/formats/EnrichedTextAlign.ts +++ b/src/web/formats/EnrichedTextAlign.ts @@ -1,3 +1,4 @@ +import { isTextSelection } from '@tiptap/core'; import { TextAlign } from '@tiptap/extension-text-align'; export const EnrichedTextAlign = TextAlign.extend({ @@ -45,6 +46,35 @@ export const EnrichedTextAlign = TextAlign.extend({ }, }; }, + + addKeyboardShortcuts() { + return { + ...this.parent?.(), + Backspace: () => { + const { selection } = this.editor.state; + + if (!selection.empty || !isTextSelection(selection)) { + return false; + } + + const { $cursor } = selection; + + if (!$cursor || !this.editor.isEmpty) { + return false; + } + + const currentAlignment = $cursor.parent.attrs.textAlign; + const hasAlignment = currentAlignment && currentAlignment !== 'auto'; + + // If the input is empty and has an alignment, clear the alignment + if (hasAlignment) { + return this.editor.commands.unsetTextAlign(); + } + + return false; + }, + }; + }, }).configure({ types: [ 'paragraph', From 75f38fe75c3fd9dba79eb16af1872a409b47f3f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Wed, 24 Jun 2026 11:57:22 +0200 Subject: [PATCH 3/8] fix: make e2e tests pass after adding alignment --- src/web/formats/EnrichedCheckboxList.ts | 7 +++++-- src/web/formats/EnrichedOrderedList.ts | 2 +- src/web/formats/EnrichedUnorderedList.ts | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/web/formats/EnrichedCheckboxList.ts b/src/web/formats/EnrichedCheckboxList.ts index 2ec2e579c..b2c6b13d3 100644 --- a/src/web/formats/EnrichedCheckboxList.ts +++ b/src/web/formats/EnrichedCheckboxList.ts @@ -2,6 +2,7 @@ import { type CommandProps } from '@tiptap/core'; import { TaskList } from '@tiptap/extension-list'; import { applyWrappingListToSelection } from './applyWrappingListToSelection'; +import { withPreservedAlignment } from './formatRules'; declare module '@tiptap/core' { interface Commands { @@ -24,9 +25,11 @@ export const EnrichedCheckboxList = TaskList.extend({ addCommands() { return { toggleCheckboxList: (checked: boolean) => { - return ({ editor, commands, chain }: CommandProps): boolean => { + return ({ editor, chain }: CommandProps): boolean => { if (editor.isActive('checkboxList')) { - return commands.setParagraph(); + return withPreservedAlignment(editor, chain(), (c) => + c.clearNodes().setParagraph() + ); } return applyWrappingListToSelection( diff --git a/src/web/formats/EnrichedOrderedList.ts b/src/web/formats/EnrichedOrderedList.ts index c9e190a79..cda0b604a 100644 --- a/src/web/formats/EnrichedOrderedList.ts +++ b/src/web/formats/EnrichedOrderedList.ts @@ -27,7 +27,7 @@ export const EnrichedOrderedList = OrderedList.extend({ ({ editor, chain }) => { if (editor.isActive('orderedList')) { return withPreservedAlignment(editor, chain(), (c) => - c.setParagraph() + c.clearNodes().setParagraph() ); } diff --git a/src/web/formats/EnrichedUnorderedList.ts b/src/web/formats/EnrichedUnorderedList.ts index 5b151cc95..a08029130 100644 --- a/src/web/formats/EnrichedUnorderedList.ts +++ b/src/web/formats/EnrichedUnorderedList.ts @@ -37,7 +37,7 @@ export const EnrichedUnorderedList = BulletList.extend({ ({ editor, chain }: CommandProps) => { if (editor.isActive('unorderedList')) { return withPreservedAlignment(editor, chain(), (c) => - c.setParagraph() + c.clearNodes().setParagraph() ); } From ea48c86c23bf23be95e5648e8a2e2962c365b15b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Wed, 24 Jun 2026 12:13:09 +0200 Subject: [PATCH 4/8] test: add tests for alignment --- .playwright/screenshots/alignment-mixed.png | Bin 0 -> 9479 bytes .playwright/tests/textAlignment.spec.ts | 342 ++++++++++++++++++++ 2 files changed, 342 insertions(+) create mode 100644 .playwright/screenshots/alignment-mixed.png create mode 100644 .playwright/tests/textAlignment.spec.ts diff --git a/.playwright/screenshots/alignment-mixed.png b/.playwright/screenshots/alignment-mixed.png new file mode 100644 index 0000000000000000000000000000000000000000..e4bd7250ded711ca966ae008433b5bc4cbe507d8 GIT binary patch literal 9479 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 IlQazYUlN3Si~s-t literal 0 HcmV?d00001 diff --git a/.playwright/tests/textAlignment.spec.ts b/.playwright/tests/textAlignment.spec.ts new file mode 100644 index 000000000..2ea1b6d13 --- /dev/null +++ b/.playwright/tests/textAlignment.spec.ts @@ -0,0 +1,342 @@ +import { test, expect, type Page } from '@playwright/test'; + +import { toolbarButton } from '../helpers/toolbar'; +import { + editorLocator, + getSerializedHtml, + gotoVisualRegression, + setEditorHtml, +} from '../helpers/visual-regression'; + +// Toolbar shorthand for alignment buttons (testId: toolbar-button-alignment-{value}) +function alignBtn( + page: Page, + alignment: 'left' | 'center' | 'right' | 'justify' +) { + return toolbarButton(page, `alignment-${alignment}`); +} + +test.describe('alignment html round-trip', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + test('center-aligned paragraph is preserved', async ({ page }) => { + await setEditorHtml( + page, + '

    Center

    ' + ); + const html = await getSerializedHtml(page); + expect(html).toContain('

    Center

    '); + }); + + test('right-aligned heading is preserved', async ({ page }) => { + await setEditorHtml( + page, + '

    Right Heading

    ' + ); + const html = await getSerializedHtml(page); + expect(html).toContain('

    Right Heading

    '); + }); + + test('ordered list alignment is on the
      wrapper, not on
    1. ', async ({ + page, + }) => { + await setEditorHtml( + page, + '
      1. Item 1
      2. Item 2
      ' + ); + const html = await getSerializedHtml(page); + expect(html).toContain('
        '); + expect(html).not.toMatch(/]*style[^>]*>/); + }); + + test('unordered list alignment is on the
          wrapper', async ({ page }) => { + await setEditorHtml( + page, + '
          • Bullet
          ' + ); + const html = await getSerializedHtml(page); + expect(html).toContain('
            '); + expect(html).not.toMatch(/]*style[^>]*>/); + }); + + test('aligned paragraph inside blockquote is preserved', async ({ page }) => { + await setEditorHtml( + page, + '

            Quote

            ' + ); + const html = await getSerializedHtml(page); + expect(html).toContain('

            Quote

            '); + expect(html).toContain('
            '); + }); + + test('multiple paragraphs with different alignments are each preserved', async ({ + page, + }) => { + await setEditorHtml( + page, + '' + + '

            Default

            ' + + '

            Center

            ' + + '

            Right

            ' + + '' + ); + const html = await getSerializedHtml(page); + expect(html).toContain('

            Default

            '); + expect(html).toContain('

            Center

            '); + expect(html).toContain('

            Right

            '); + }); + + test('blockquote paragraphs can have independent alignments', async ({ + page, + }) => { + await setEditorHtml( + page, + '' + + '
            ' + + '

            Quote center

            ' + + '

            Quote left

            ' + + '
            ' + + '' + ); + const html = await getSerializedHtml(page); + expect(html).toContain('

            Quote center

            '); + expect(html).toContain('

            Quote left

            '); + }); +}); + +test.describe('alignment toolbar interaction', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + test('clicking center sets center alignment on paragraph', async ({ + page, + }) => { + await setEditorHtml(page, '

            Hello

            '); + await page.locator('.eti-editor p').click(); + await alignBtn(page, 'center').click(); + const html = await getSerializedHtml(page); + expect(html).toContain('

            Hello

            '); + }); + + test('clicking right sets right alignment on paragraph', async ({ page }) => { + await setEditorHtml(page, '

            Hello

            '); + await page.locator('.eti-editor p').click(); + await alignBtn(page, 'right').click(); + const html = await getSerializedHtml(page); + expect(html).toContain('

            Hello

            '); + }); + + test('clicking justify sets justify alignment on paragraph', async ({ + page, + }) => { + await setEditorHtml(page, '

            Hello

            '); + await page.locator('.eti-editor p').click(); + await alignBtn(page, 'justify').click(); + const html = await getSerializedHtml(page); + expect(html).toContain('

            Hello

            '); + }); + + test('clicking center on ordered list sets alignment on
              wrapper', async ({ + page, + }) => { + await setEditorHtml(page, '
              1. Item
              '); + await page.locator('.eti-editor ol li p').click(); + await alignBtn(page, 'center').click(); + const html = await getSerializedHtml(page); + expect(html).toContain('
                '); + expect(html).not.toMatch(/]*style[^>]*>/); + }); + + test('clicking center on unordered list sets alignment on
                  wrapper', async ({ + page, + }) => { + await setEditorHtml(page, '
                  • Bullet
                  '); + await page.locator('.eti-editor ul li p').click(); + await alignBtn(page, 'center').click(); + const html = await getSerializedHtml(page); + expect(html).toContain('
                    '); + expect(html).not.toMatch(/]*style[^>]*>/); + }); + + test('clicking a different alignment replaces the previous one', async ({ + page, + }) => { + await setEditorHtml( + page, + '

                    Text

                    ' + ); + await page.locator('.eti-editor p').click(); + await alignBtn(page, 'right').click(); + const html = await getSerializedHtml(page); + expect(html).not.toContain('text-align: center'); + expect(html).toContain('

                    Text

                    '); + }); + + test('alignment toolbar button is active when cursor is on aligned paragraph', async ({ + page, + }) => { + await setEditorHtml( + page, + '

                    Center

                    ' + ); + await page.locator('.eti-editor p').click(); + await expect(alignBtn(page, 'center')).toHaveClass(/toolbar-btn--active/); + await expect(alignBtn(page, 'left')).not.toHaveClass(/toolbar-btn--active/); + await expect(alignBtn(page, 'right')).not.toHaveClass( + /toolbar-btn--active/ + ); + }); + + test('alignment toolbar button is active when cursor is inside aligned list', async ({ + page, + }) => { + await setEditorHtml( + page, + '
                    1. Item
                    ' + ); + await page.locator('.eti-editor ol li p').click(); + await expect(alignBtn(page, 'right')).toHaveClass(/toolbar-btn--active/); + await expect(alignBtn(page, 'center')).not.toHaveClass( + /toolbar-btn--active/ + ); + }); +}); + +test.describe('alignment preserved through block style toggle', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + test('center alignment preserved when toggling paragraph to H1', async ({ + page, + }) => { + await setEditorHtml( + page, + '

                    Hello

                    ' + ); + await page.locator('.eti-editor p').click(); + await toolbarButton(page, 'h1').click(); + const html = await getSerializedHtml(page); + expect(html).toContain('

                    Hello

                    '); + }); + + test('right alignment preserved when toggling paragraph to H3', async ({ + page, + }) => { + await setEditorHtml( + page, + '

                    Hello

                    ' + ); + await page.locator('.eti-editor p').click(); + await toolbarButton(page, 'h3').click(); + const html = await getSerializedHtml(page); + expect(html).toContain('

                    Hello

                    '); + }); + + test('center alignment preserved when toggling paragraph to ordered list', async ({ + page, + }) => { + await setEditorHtml( + page, + '

                    Hello

                    ' + ); + await page.locator('.eti-editor p').click(); + await toolbarButton(page, 'orderedList').click(); + const html = await getSerializedHtml(page); + expect(html).toContain('
                      '); + expect(html).toContain('Hello'); + }); + + test('center alignment preserved when toggling paragraph to unordered list', async ({ + page, + }) => { + await setEditorHtml( + page, + '

                      Hello

                      ' + ); + await page.locator('.eti-editor p').click(); + await toolbarButton(page, 'unorderedList').click(); + const html = await getSerializedHtml(page); + expect(html).toContain('
                        '); + expect(html).toContain('Hello'); + }); + + test('center alignment preserved when toggling ordered list to paragraph', async ({ + page, + }) => { + await setEditorHtml( + page, + '
                        1. Hello
                        ' + ); + await page.locator('.eti-editor ol li p').click(); + await toolbarButton(page, 'orderedList').click(); + const html = await getSerializedHtml(page); + expect(html).toContain('

                        Hello

                        '); + }); + + test('right alignment preserved when toggling unordered list to paragraph', async ({ + page, + }) => { + await setEditorHtml( + page, + '
                        • Hello
                        ' + ); + await page.locator('.eti-editor ul li p').click(); + await toolbarButton(page, 'unorderedList').click(); + const html = await getSerializedHtml(page); + expect(html).toContain('

                        Hello

                        '); + }); + + test('right alignment preserved when toggling paragraph to blockquote', async ({ + page, + }) => { + await setEditorHtml( + page, + '

                        Hello

                        ' + ); + await page.locator('.eti-editor p').click(); + await toolbarButton(page, 'blockQuote').click(); + const html = await getSerializedHtml(page); + expect(html).toContain('
                        '); + expect(html).toContain('

                        Hello

                        '); + }); + + test('center alignment preserved when toggling H1 to ordered list', async ({ + page, + }) => { + await setEditorHtml( + page, + '

                        Hello

                        ' + ); + await page.locator('.eti-editor h1').click(); + await toolbarButton(page, 'orderedList').click(); + const html = await getSerializedHtml(page); + expect(html).toContain('
                          '); + expect(html).toContain('Hello'); + }); +}); + +test.describe('alignment visual', () => { + test.beforeEach(async ({ page }) => { + await gotoVisualRegression(page); + }); + + test('mixed paragraph, heading, and list alignments render correctly', async ({ + page, + }) => { + await setEditorHtml( + page, + '' + + '

                          Left aligned

                          ' + + '

                          Centre aligned

                          ' + + '
                          Heading 6 Centre
                          ' + + '

                          Right aligned

                          ' + + '
                          1. Element 1
                          2. Element 2
                          ' + + '' + ); + await expect(editorLocator(page)).toHaveScreenshot('alignment-mixed.png'); + }); +}); From c65b995753128c560006bbfd8aac49ee2d121d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Wed, 24 Jun 2026 12:26:01 +0200 Subject: [PATCH 5/8] fix: code review comments --- src/web/formats/EnrichedTextAlign.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/formats/EnrichedTextAlign.ts b/src/web/formats/EnrichedTextAlign.ts index 6a1790e72..be4f396ab 100644 --- a/src/web/formats/EnrichedTextAlign.ts +++ b/src/web/formats/EnrichedTextAlign.ts @@ -13,7 +13,7 @@ export const EnrichedTextAlign = TextAlign.extend({ } const { $from } = state.selection; - let listNode = null; + let listNode: typeof $from.parent | null = null; let listPos = -1; // Walk up the tree to see if the cursor is inside a list wrapper @@ -42,7 +42,7 @@ export const EnrichedTextAlign = TextAlign.extend({ } // If not in a list, fire the original Tiptap command - return this.parent?.()?.setTextAlign?.(alignment)(props); + return this.parent?.().setTextAlign?.(alignment)(props) ?? false; }, }; }, From bd92c512c26fb572b9b0c577e91be98f0aadc49d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Wed, 24 Jun 2026 17:15:08 +0200 Subject: [PATCH 6/8] fix: preserving alignment --- src/web/formats/formatRules.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/web/formats/formatRules.ts b/src/web/formats/formatRules.ts index 504ef23a9..01fac39b2 100644 --- a/src/web/formats/formatRules.ts +++ b/src/web/formats/formatRules.ts @@ -88,7 +88,16 @@ export function toggleParagraphFormat( } export function getCurrentAlignment(editor: Editor): string | null { - const { $from } = editor.state.selection; + const { doc, selection } = editor.state; + let { $from } = selection; + + // If the user presses Cmd+A, the selection anchors to the document root (depth 0). + // We resolve position '1' to step exactly inside the first paragraph node + // so the loop can correctly read its alignment. + if ($from.depth === 0 && doc.content.size > 0) { + $from = doc.resolve(1); + } + for (let depth = $from.depth; depth >= 0; depth--) { const node = $from.node(depth); if (node.attrs.textAlign) { From 50dacbe4afa0db5b0ad4724432ad19d1aa0baf5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Wed, 24 Jun 2026 17:27:29 +0200 Subject: [PATCH 7/8] fix: update docs --- docs/INPUT_API_REFERENCE.md | 3 --- docs/WEB.md | 1 + 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/INPUT_API_REFERENCE.md b/docs/INPUT_API_REFERENCE.md index 52af2af56..2933e5bee 100644 --- a/docs/INPUT_API_REFERENCE.md +++ b/docs/INPUT_API_REFERENCE.md @@ -771,9 +771,6 @@ Sets text alignment for the paragraph(s) at the current selection. When inside a > [!NOTE] > On Android, `'justify'` is not supported. Calling `setTextAlignment('justify')` does not apply justified text — the paragraph ends up with natural alignment, the same as `'auto'`. On iOS, justified alignment works as expected. -> [!NOTE] -> On Web text alignment is not supported. Calling `setTextAlignment()` has no effect. - ### `.startMention()` ```ts diff --git a/docs/WEB.md b/docs/WEB.md index 0b1f167cf..f23e75110 100644 --- a/docs/WEB.md +++ b/docs/WEB.md @@ -17,6 +17,7 @@ Web support is still experimental. APIs and behavior can change in future releas - Input theming via `placeholderTextColor`, `cursorColor` and `selectionColor` props - Keyboard shortcuts for formatting - `useHtmlNormalizer` +- Setting text alignment via `setTextAlignment()` ## Keyboard shortcuts From 602b12ef63ee15e5ba01cf5c32039554aac9152b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20=C5=BB=C3=B3=C5=82kiewski?= Date: Thu, 25 Jun 2026 10:40:03 +0200 Subject: [PATCH 8/8] fix: handle selection all --- docs/INPUT_API_REFERENCE.md | 1 + src/web/formats/formatRules.ts | 8 ++++++-- src/web/pmPlugins/ShortcutPlugin.ts | 7 +++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/INPUT_API_REFERENCE.md b/docs/INPUT_API_REFERENCE.md index 2933e5bee..47640bfa1 100644 --- a/docs/INPUT_API_REFERENCE.md +++ b/docs/INPUT_API_REFERENCE.md @@ -932,6 +932,7 @@ The following keyboard shortcuts are available on Web. `Mod` is `⌘` on macOS a | Paste as plain text | ⌘ Shift+V | Ctrl+Shift+V | | Undo | ⌘ Z | Ctrl+Z | | Redo | ⌘ Shift+Z | Ctrl+Shift+Z | +| Select all | ⌘ A | Ctrl+A | ## HtmlStyle type diff --git a/src/web/formats/formatRules.ts b/src/web/formats/formatRules.ts index 01fac39b2..ae581ccd6 100644 --- a/src/web/formats/formatRules.ts +++ b/src/web/formats/formatRules.ts @@ -1,7 +1,6 @@ import type { Editor } from '@tiptap/core'; import type { HtmlStyle } from '../../types'; import { HEADING_LEVELS, HEADING_TAGS } from './EnrichedHeading'; - type ChainedCommands = ReturnType; export function isAnyParagraphFormatActive(editor: Editor): boolean { @@ -80,7 +79,12 @@ export function toggleParagraphFormat( chain: () => ChainedCommands, editor: Editor ): boolean { - if (isActive()) return deactivate(); + if (isActive()) { + return withPreservedAlignment(editor, chain(), (c) => { + deactivate(); + return c; + }); + } return withPreservedAlignment(editor, chain(), (c) => activate(c.clearNodes()) diff --git a/src/web/pmPlugins/ShortcutPlugin.ts b/src/web/pmPlugins/ShortcutPlugin.ts index 850796ff6..9f4322547 100644 --- a/src/web/pmPlugins/ShortcutPlugin.ts +++ b/src/web/pmPlugins/ShortcutPlugin.ts @@ -39,6 +39,13 @@ export const ShortcutPlugin = Extension.create({ }; return { + 'Mod-a': ({ editor }) => { + const { doc } = editor.state; + return editor.commands.setTextSelection({ + from: 0, + to: doc.content.size, + }); + }, 'Mod-Shift-v': ({ editor }) => { if (!editor.isEditable) return false; insertPlainTextFromClipboard(editor).catch(() => {});