diff --git a/.playwright/screenshots/alignment-mixed.png b/.playwright/screenshots/alignment-mixed.png new file mode 100644 index 000000000..e4bd7250d Binary files /dev/null and b/.playwright/screenshots/alignment-mixed.png differ 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, + '' + ); + const html = await getSerializedHtml(page); + expect(html).toContain('Quote
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, + '' + + '' + + '' + + '' + ); + const html = await getSerializedHtml(page); + expect(html).toContain('Quote center
' + + 'Quote left
' + + '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 onwrapper', async ({ + page, + }) => { + await setEditorHtml(page, '
'); + await page.locator('.eti-editor ol li p').click(); + await alignBtn(page, 'center').click(); + const html = await getSerializedHtml(page); + expect(html).toContain('
- Item
'); + expect(html).not.toMatch(/
- ]*style[^>]*>/); + }); + + test('clicking center on unordered list sets alignment on
wrapper', async ({ + page, + }) => { + await setEditorHtml(page, '
'); + await page.locator('.eti-editor ul li p').click(); + await alignBtn(page, 'center').click(); + const html = await getSerializedHtml(page); + expect(html).toContain('
- Bullet
'); + 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, + '' + ); + 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, + '
- Item
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, + '
' + ); + await page.locator('.eti-editor ol li p').click(); + await toolbarButton(page, 'orderedList').click(); + const html = await getSerializedHtml(page); + expect(html).toContain('
- Hello
Hello
'); + }); + + test('right alignment preserved when toggling unordered list to paragraph', async ({ + page, + }) => { + await setEditorHtml( + page, + '' + ); + await page.locator('.eti-editor ul li p').click(); + await toolbarButton(page, 'unorderedList').click(); + const html = await getSerializedHtml(page); + expect(html).toContain('
- Hello
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
' + + '' + + '' + ); + await expect(editorLocator(page)).toHaveScreenshot('alignment-mixed.png'); + }); +}); 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 (
- Element 1
- Element 2
diff --git a/docs/INPUT_API_REFERENCE.md b/docs/INPUT_API_REFERENCE.md index 52af2af56..47640bfa1 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 @@ -935,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/docs/WEB.md b/docs/WEB.md index 7284773c8..51600545e 100644 --- a/docs/WEB.md +++ b/docs/WEB.md @@ -18,6 +18,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 diff --git a/package.json b/package.json index 8f331211f..18f76f7f6 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 3f7e35281..c938f85c9 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -61,6 +61,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'; @@ -231,6 +232,7 @@ export const EnrichedTextInput = ({ EnrichedUnorderedList, EnrichedOrderedList, EnrichedCheckboxList, + EnrichedTextAlign, StripMarksInCodeBlockPlugin, StripMarksOnImagePlugin, StripBoldInStyledHeadingsPlugin.configure({ @@ -386,7 +388,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/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@@ -218,6 +228,18 @@ export function Toolbar({ }} /> ))} + {alignmentItems.map((item) => ( +{ + editorRef.current?.setTextAlignment(item.value); + }} + /> + ))} { @@ -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/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..cda0b604a 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.clearNodes().setParagraph() + ); } return applyWrappingListToSelection( diff --git a/src/web/formats/EnrichedTextAlign.ts b/src/web/formats/EnrichedTextAlign.ts new file mode 100644 index 000000000..be4f396ab --- /dev/null +++ b/src/web/formats/EnrichedTextAlign.ts @@ -0,0 +1,88 @@ +import { isTextSelection } from '@tiptap/core'; +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: typeof $from.parent | null = 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) ?? false; + }, + }; + }, + + 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', + '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..a08029130 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.clearNodes().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..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 { @@ -77,8 +76,53 @@ 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(); + if (isActive()) { + return withPreservedAlignment(editor, chain(), (c) => { + deactivate(); + return c; + }); + } + + return withPreservedAlignment(editor, chain(), (c) => + activate(c.clearNodes()) + ); +} + +export function getCurrentAlignment(editor: Editor): string | null { + 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) { + 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 - but native expects
text
- 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/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(() => {}); 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 80391e6ea..5e1de328e 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"