diff --git a/.maestro/enrichedInput/flows/insert_value.yaml b/.maestro/enrichedInput/flows/insert_value.yaml new file mode 100644 index 000000000..bf7caefaa --- /dev/null +++ b/.maestro/enrichedInput/flows/insert_value.yaml @@ -0,0 +1,134 @@ +appId: swmansion.enriched.example +--- +# Verifies insertValue behaviour for different scenarios + +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +# ── Scenario 1: plain text into empty editor ───────────────────────────────── +- tapOn: + id: 'focus-button' + +- runFlow: + file: '../subflows/insert_editor_value.yaml' + env: + VALUE: 'Hello, world!' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'insert_value_plain_into_empty' + +# ── Scenario 2: plain text inherits active inline style ─────────────────────── +- tapOn: + id: 'clear-button' +- tapOn: + id: 'focus-button' + +- inputText: 'before' +- tapOn: + id: 'toolbar-bold' + +- runFlow: + file: '../subflows/insert_editor_value.yaml' + env: + VALUE: 'BOLD' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'insert_value_inherits_inline_style' + +# ── Scenario 3: plain text into empty paragraph-styled line ─────────────────── +- tapOn: + id: 'clear-button' +- tapOn: + id: 'focus-button' + +- tapOn: + id: 'toolbar-heading-1' + +- runFlow: + file: '../subflows/insert_editor_value.yaml' + env: + VALUE: 'Heading' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'insert_value_into_empty_paragraph_style' + +# ── Scenario 4: heading HTML into empty line ────────────────────────────────── +- tapOn: + id: 'clear-button' +- tapOn: + id: 'focus-button' + +- runFlow: + file: '../subflows/insert_editor_value.yaml' + env: + VALUE: '
normal
' + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'insert_value_multi_paragraph' diff --git a/.maestro/enrichedInput/screenshots/android/insert_value_bold_blocked_in_codeblock.png b/.maestro/enrichedInput/screenshots/android/insert_value_bold_blocked_in_codeblock.png new file mode 100644 index 000000000..6e2e9f2a5 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/insert_value_bold_blocked_in_codeblock.png differ diff --git a/.maestro/enrichedInput/screenshots/android/insert_value_heading_into_empty_line.png b/.maestro/enrichedInput/screenshots/android/insert_value_heading_into_empty_line.png new file mode 100644 index 000000000..bbc8db6e7 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/insert_value_heading_into_empty_line.png differ diff --git a/.maestro/enrichedInput/screenshots/android/insert_value_heading_into_nonempty_line.png b/.maestro/enrichedInput/screenshots/android/insert_value_heading_into_nonempty_line.png new file mode 100644 index 000000000..4b2bfd539 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/insert_value_heading_into_nonempty_line.png differ diff --git a/.maestro/enrichedInput/screenshots/android/insert_value_inherits_inline_style.png b/.maestro/enrichedInput/screenshots/android/insert_value_inherits_inline_style.png new file mode 100644 index 000000000..775e4f288 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/insert_value_inherits_inline_style.png differ diff --git a/.maestro/enrichedInput/screenshots/android/insert_value_into_empty_paragraph_style.png b/.maestro/enrichedInput/screenshots/android/insert_value_into_empty_paragraph_style.png new file mode 100644 index 000000000..9c8f0add7 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/insert_value_into_empty_paragraph_style.png differ diff --git a/.maestro/enrichedInput/screenshots/android/insert_value_multi_paragraph.png b/.maestro/enrichedInput/screenshots/android/insert_value_multi_paragraph.png new file mode 100644 index 000000000..30b45e745 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/insert_value_multi_paragraph.png differ diff --git a/.maestro/enrichedInput/screenshots/android/insert_value_plain_into_empty.png b/.maestro/enrichedInput/screenshots/android/insert_value_plain_into_empty.png new file mode 100644 index 000000000..38ded58b8 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/insert_value_plain_into_empty.png differ diff --git a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png index 74947bcfd..81b973b2d 100644 Binary files a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png and b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_1.png differ diff --git a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png index 7a053e6c5..9cb43ad99 100644 Binary files a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png and b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_2.png differ diff --git a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png index d80b9a756..419c4545e 100644 Binary files a/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png and b/.maestro/enrichedInput/screenshots/android/mention_popup_closing_on_cursor_travel_3.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/insert_value_bold_blocked_in_codeblock.png b/.maestro/enrichedInput/screenshots/ios/insert_value_bold_blocked_in_codeblock.png new file mode 100644 index 000000000..a8c341ddc Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/insert_value_bold_blocked_in_codeblock.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/insert_value_heading_into_empty_line.png b/.maestro/enrichedInput/screenshots/ios/insert_value_heading_into_empty_line.png new file mode 100644 index 000000000..12b90e130 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/insert_value_heading_into_empty_line.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/insert_value_heading_into_nonempty_line.png b/.maestro/enrichedInput/screenshots/ios/insert_value_heading_into_nonempty_line.png new file mode 100644 index 000000000..3a88651b8 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/insert_value_heading_into_nonempty_line.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/insert_value_inherits_inline_style.png b/.maestro/enrichedInput/screenshots/ios/insert_value_inherits_inline_style.png new file mode 100644 index 000000000..6185fc852 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/insert_value_inherits_inline_style.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/insert_value_into_empty_paragraph_style.png b/.maestro/enrichedInput/screenshots/ios/insert_value_into_empty_paragraph_style.png new file mode 100644 index 000000000..c9b43ede8 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/insert_value_into_empty_paragraph_style.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/insert_value_multi_paragraph.png b/.maestro/enrichedInput/screenshots/ios/insert_value_multi_paragraph.png new file mode 100644 index 000000000..d89f574c2 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/insert_value_multi_paragraph.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/insert_value_plain_into_empty.png b/.maestro/enrichedInput/screenshots/ios/insert_value_plain_into_empty.png new file mode 100644 index 000000000..ba480fd1b Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/insert_value_plain_into_empty.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png index 7f0cfabe9..15e234824 100644 Binary files a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png and b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_1.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png index a32731ece..b12d1ed25 100644 Binary files a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png and b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_2.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png index b0c1a44ca..51a3213d2 100644 Binary files a/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png and b/.maestro/enrichedInput/screenshots/ios/mention_popup_closing_on_cursor_travel_3.png differ diff --git a/.maestro/enrichedInput/subflows/insert_editor_value.yaml b/.maestro/enrichedInput/subflows/insert_editor_value.yaml new file mode 100644 index 000000000..c91c205f0 --- /dev/null +++ b/.maestro/enrichedInput/subflows/insert_editor_value.yaml @@ -0,0 +1,18 @@ +appId: swmansion.enriched.example +--- +- tapOn: + id: 'insert-value-button' + +- waitForAnimationToEnd +- extendedWaitUntil: + visible: + id: 'value-modal-input' + timeout: 10000 + +- tapOn: + id: 'value-modal-input' + +- inputText: ${VALUE} + +- tapOn: + id: 'value-modal-submit-button' diff --git a/.playwright/helpers/insertValue.ts b/.playwright/helpers/insertValue.ts new file mode 100644 index 000000000..af8996c73 --- /dev/null +++ b/.playwright/helpers/insertValue.ts @@ -0,0 +1,114 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +import { toolbarButton } from './toolbar'; + +export const insertValueSelectors = { + editor: '[data-testid="insert-value-editor"]', + editorInner: '[data-testid="insert-value-editor"] .eti-editor', + htmlOutput: '[data-testid="insert-value-html-output"]', + setupHtmlInput: '[data-testid="setup-html-input"]', + setupSetValueButton: '[data-testid="setup-set-value-button"]', + selectionStart: '[data-testid="insert-value-selection-start"]', + selectionEnd: '[data-testid="insert-value-selection-end"]', + applySelectionButton: '[data-testid="insert-value-apply-selection-button"]', + insertInput: '[data-testid="insert-value-input"]', + insertSubmit: '[data-testid="insert-value-submit-button"]', + focusButton: '[data-testid="focus-button"]', + clearButton: '[data-testid="clear-button"]', + selectionEndResult: '[data-testid="insert-value-current-selection-end"]', +} as const; + +export function insertValueEditorLocator(page: Page): Locator { + return page.locator(insertValueSelectors.editorInner); +} + +export async function gotoInsertValue(page: Page): Promisenormal
'); + + await expect(insertValueEditorLocator(page)).toHaveScreenshot( + 'insert_value_multi_paragraph.png' + ); + }); + + test('codeblock merges into a list item without extra blank lines', async ({ + page, + }) => { + await gotoInsertValue(page); + await setInsertValueEditorHtml( + page, + 'asdfgh
' + ); + await insertValueAt( + page, + '', + 3 + ); + + const html = await getInsertValueHtml(page); + expect(html).toMatch( + /World
[\s\S]*[\s\S]*asdWorldfgh[\s\S]*<\/p>[\s\S]*<\/blockquote>/i + ); + + await expect(insertValueEditorLocator(page)).toHaveScreenshot( + 'insert_value_blockquote_inherits_alignment.png' + ); + }); +}); diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt index 023cc20fc..b39f4e134 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -70,6 +70,7 @@ import com.swmansion.enriched.textinput.utils.EnrichedSelection import com.swmansion.enriched.textinput.utils.EnrichedSpanState import com.swmansion.enriched.textinput.utils.RichContentReceiver import com.swmansion.enriched.textinput.utils.ShortcutsHandler +import com.swmansion.enriched.textinput.utils.isStyleBlockedOnRange import com.swmansion.enriched.textinput.utils.mergeSpannables import com.swmansion.enriched.textinput.utils.setCheckboxClickListener import com.swmansion.enriched.textinput.utils.zwsCountBefore @@ -469,6 +470,68 @@ class EnrichedTextInputView : setSelection(actualStart, actualEnd) } + fun insertValue( + value: String, + start: Int, + end: Int, + ) { + val currentText = text as Editable + val textLength = currentText.length + + // Capture active inline styles before setValue, which runs as a transaction and skips the + // text watcher style pass that would otherwise materialise them. + val activeInlineStyles = EnrichedSpans.inlineSpans.keys.filter { spanState?.getStart(it) != null } + + if (textLength == 0) { + setValue(value) + applyActiveInlineStyles(activeInlineStyles, 0, text?.length ?: 0) + return + } + + // Use coerceIn to ensure indices are within [0, textLength] and that start <= end + val safeStart = getActualIndex(start).coerceIn(0, textLength) + val safeEnd = getActualIndex(end).coerceIn(safeStart, textLength) + + // Skip past ZWS anchors so inserted text lands inside paragraph spans rather than before them. + var adjustedStart = safeStart + while (adjustedStart < textLength && currentText[adjustedStart] == EnrichedConstants.ZWS) { + adjustedStart++ + } + val adjustedEnd = safeEnd.coerceAtLeast(adjustedStart) + + val newText = parseText(value) as Spannable + + val finalText = currentText.mergeSpannables(adjustedStart, adjustedEnd, newText, htmlStyle) + setValue(finalText, false) + + // replacement-safe: oldLength - removed + inserted + val insertedLength = finalText.length - (textLength - (adjustedEnd - adjustedStart)) + val insertedEnd = (adjustedStart + insertedLength).coerceIn(0, finalText.length) + setSelection(insertedEnd) + + applyActiveInlineStyles(activeInlineStyles, adjustedStart, insertedEnd) + } + + /** + * Re-applies inline styles that were active before an insertion onto the inserted range, + * mirroring what InlineStyles.afterTextChanged does for regularly typed text. + */ + private fun applyActiveInlineStyles( + styles: List
, + start: Int, + end: Int, + ) { + if (styles.isEmpty() || start >= end) return + val spannable = text as? Spannable ?: return + + for (style in styles) { + if (isStyleBlockedOnRange(style, start, end, spannable, htmlStyle)) continue + inlineStyles?.applyStyleOnRange(style, start, end) + } + + selection?.validateStyles() + } + // Helper: Walks through the string skipping ZWSPs to find the Nth visible character private fun getActualIndex(visibleIndex: Int): Int { val currentText = text as Spannable diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt index 9dcbb8244..000b1eaf9 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -354,6 +354,15 @@ class EnrichedTextInputViewManager : view?.setCustomSelection(start, end) } + override fun insertValue( + view: EnrichedTextInputView?, + value: String, + start: Int, + end: Int, + ) { + view?.insertValue(value, start, end) + } + override fun toggleBold(view: EnrichedTextInputView?) { view?.verifyAndToggleStyle(EnrichedSpans.BOLD) } diff --git a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt index 576e10218..abeda6eb3 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/utils/EnrichedSpannable.kt @@ -3,11 +3,15 @@ package com.swmansion.enriched.textinput.utils import android.text.Spannable import android.text.SpannableString import android.text.SpannableStringBuilder +import com.swmansion.enriched.common.EnrichedConstants import com.swmansion.enriched.common.EnrichedSpanFlags +import com.swmansion.enriched.common.spans.EnrichedImageSpan +import com.swmansion.enriched.common.spans.EnrichedMentionSpan import com.swmansion.enriched.common.spans.interfaces.EnrichedBlockSpan import com.swmansion.enriched.common.spans.interfaces.EnrichedParagraphSpan import com.swmansion.enriched.common.spans.interfaces.EnrichedSpan import com.swmansion.enriched.textinput.spans.EnrichedSpans +import com.swmansion.enriched.textinput.spans.interfaces.EnrichedInputSpan import com.swmansion.enriched.textinput.styles.HtmlStyle fun Spannable.getSafeSpanBoundaries( @@ -62,25 +66,25 @@ private fun getStyleForSpan(span: EnrichedSpan): String? = config.clazz.isInstance(span) }?.key -private fun Spannable.removeBlockedPasteStyles( - start: Int, - pastedSpannable: Spannable, +private fun Spannable.removeBlockedIncomingStyles( + insertStart: Int, + incomingSpannable: Spannable, htmlStyle: HtmlStyle, ): Spannable { - val pastedSpans = pastedSpannable.getSpans(0, pastedSpannable.length, EnrichedSpan::class.java) + val incomingSpans = incomingSpannable.getSpans(0, incomingSpannable.length, EnrichedSpan::class.java) - for (span in pastedSpans) { + for (span in incomingSpans) { val style = getStyleForSpan(span) ?: continue val blockingStyles = EnrichedSpans.getMergingConfigForStyle(style, htmlStyle)?.blockingStyles ?: continue if (blockingStyles.isEmpty()) continue - val spanStart = pastedSpannable.getSpanStart(span) - val spanEnd = pastedSpannable.getSpanEnd(span) + val spanStart = incomingSpannable.getSpanStart(span) + val spanEnd = incomingSpannable.getSpanEnd(span) if (spanStart == -1 || spanEnd == -1 || spanStart == spanEnd) continue - val pastedStart = start + spanStart - val pastedEnd = start + spanEnd - if (blockingStyles.any { hasStyleInRange(it, pastedStart, pastedEnd) }) { + val absoluteStart = insertStart + spanStart + val absoluteEnd = insertStart + spanEnd + if (blockingStyles.any { hasStyleInRange(it, absoluteStart, absoluteEnd) }) { removeSpan(span) } } @@ -88,50 +92,142 @@ private fun Spannable.removeBlockedPasteStyles( return this } +private fun isBlockLevel(span: Any): Boolean = span is EnrichedParagraphSpan || span is EnrichedBlockSpan + +/** + * Clears a conflicting span over [rangeStart, rangeEnd), keeping whatever falls outside it so that + * untouched text does not lose its styling. A continuous block style spanning several paragraphs + * therefore only loses the affected ones. Mentions and images carry a single entity and cannot be + * split, so they are dropped as a whole. + */ +private fun SpannableStringBuilder.clearConflictingSpan( + span: EnrichedSpan, + rangeStart: Int, + rangeEnd: Int, + htmlStyle: HtmlStyle, +) { + val spanStart = getSpanStart(span) + val spanEnd = getSpanEnd(span) + val flags = getSpanFlags(span) + removeSpan(span) + + if (span !is EnrichedInputSpan || span is EnrichedMentionSpan || span is EnrichedImageSpan) return + + // Block level styles own whole lines, so their leftovers must not swallow the separating newline + val isBlock = isBlockLevel(span) + val leftEnd = if (isBlock && rangeStart > 0 && this[rangeStart - 1] == '\n') rangeStart - 1 else rangeStart + val rightStart = if (isBlock && rangeEnd < length && this[rangeEnd] == '\n') rangeEnd + 1 else rangeEnd + + if (spanStart < leftEnd) { + val left = span.rebuildWithStyle(htmlStyle) + setSpan(left, spanStart, leftEnd, EnrichedSpanFlags.forSpan(left, flags)) + } + + if (rightStart < spanEnd) { + val right = span.rebuildWithStyle(htmlStyle) + setSpan(right, rightStart, spanEnd, EnrichedSpanFlags.forSpan(right, flags)) + } +} + +/** + * Drops the document styles that the merged in content conflicts with, mirroring how toggling a + * style clears its conflicts: block level styles resolve over the whole paragraph, inline ones only + * over the range they cover. Spans that arrived with [mergedSpannable] are left alone. + */ +private fun SpannableStringBuilder.removeConflictingStyles( + mergedSpannable: Spannable, + htmlStyle: HtmlStyle, +) { + val mergedSpans = mergedSpannable.getSpans(0, mergedSpannable.length, EnrichedSpan::class.java) + + for (span in mergedSpans) { + val spanStart = getSpanStart(span) + val spanEnd = getSpanEnd(span) + + if (spanStart == -1 || spanEnd == -1 || spanStart == spanEnd) continue + + val style = getStyleForSpan(span) ?: continue + val conflictingStyles = EnrichedSpans.getMergingConfigForStyle(style, htmlStyle)?.conflictingStyles ?: continue + if (conflictingStyles.isEmpty()) continue + + val (rangeStart, rangeEnd) = + if (isBlockLevel(span)) getParagraphBounds(spanStart, spanEnd) else Pair(spanStart, spanEnd) + + for (conflictingStyle in conflictingStyles) { + val type = EnrichedSpans.allSpans[conflictingStyle]?.clazz ?: continue + + for (existing in getSpans(rangeStart, rangeEnd, type)) { + if (existing !is EnrichedSpan || mergedSpans.any { it === existing }) { + continue + } + clearConflictingSpan(existing, rangeStart, rangeEnd, htmlStyle) + } + } + } +} + fun Spannable.mergeSpannables( start: Int, end: Int, string: String, - htmlStyle: HtmlStyle? = null, + htmlStyle: HtmlStyle, ): Spannable = this.mergeSpannables(start, end, SpannableString(string), htmlStyle) fun Spannable.mergeSpannables( start: Int, end: Int, spannable: Spannable, - htmlStyle: HtmlStyle? = null, + htmlStyle: HtmlStyle, ): Spannable { var finalStart = start var finalEnd = end - val builder = SpannableStringBuilder(this) - val startBlockSpans = spannable.getSpans(0, 0, EnrichedBlockSpan::class.java) - val startParagraphSpans = spannable.getSpans(0, 0, EnrichedParagraphSpan::class.java) - val endBlockSpans = spannable.getSpans(this.length, this.length, EnrichedBlockSpan::class.java) - val endParagraphSpans = spannable.getSpans(this.length, this.length, EnrichedParagraphSpan::class.java) val (paragraphStart, paragraphEnd) = this.getParagraphBounds(start, end) - val isNewLineStart = startBlockSpans.isNotEmpty() || startParagraphSpans.isNotEmpty() - val isNewLineEnd = endBlockSpans.isNotEmpty() || endParagraphSpans.isNotEmpty() - val pastedHasOwnStyles = + val incomingHasOwnBlockStyles = spannable.getSpans(0, spannable.length, EnrichedBlockSpan::class.java).isNotEmpty() || spannable.getSpans(0, spannable.length, EnrichedParagraphSpan::class.java).isNotEmpty() - if (isNewLineStart && start != paragraphStart) { - builder.insert(start, "\n") - finalStart = start + 1 - finalEnd = end + 1 - } + // ZWS anchors are not content, so a line holding nothing else still counts as empty + val hasContentBefore = (paragraphStart until start).any { this[it] != EnrichedConstants.ZWS } + val hasContentAfter = (end until paragraphEnd).any { this[it] != EnrichedConstants.ZWS } - if (isNewLineEnd && end != paragraphEnd) { - builder.insert(finalEnd, "\n") + if (incomingHasOwnBlockStyles && !hasContentBefore && !hasContentAfter) { + finalStart = paragraphStart + finalEnd = paragraphEnd } builder.replace(finalStart, finalEnd, spannable) - // Manually extend existing paragraph/block spans to cover the pasted text. - if (!pastedHasOwnStyles) { - val pasteEnd = finalStart + spannable.length + if (incomingHasOwnBlockStyles) { + // Extend each incoming block/paragraph span to cover its own paragraph so the style applies + // to existing text on the same line, matching toggle semantics + val insertEnd = finalStart + spannable.length + + val incomingBlockSpans = builder.getSpans(finalStart, insertEnd, EnrichedBlockSpan::class.java) + val incomingParagraphSpans = builder.getSpans(finalStart, insertEnd, EnrichedParagraphSpan::class.java) + val incomingSpans = incomingBlockSpans.toList() + incomingParagraphSpans.toList() + + for (span in incomingSpans) { + val spanStart = builder.getSpanStart(span) + val spanEnd = builder.getSpanEnd(span) + if (spanStart == -1) continue + + val (spanParaStart, spanParaEnd) = builder.getParagraphBounds(spanStart, spanEnd) + if (spanStart <= spanParaStart && spanEnd >= spanParaEnd) continue + + val flags = builder.getSpanFlags(span) + builder.removeSpan(span) + builder.setSpan( + span, + spanParaStart.coerceAtMost(spanStart), + spanParaEnd.coerceAtLeast(spanEnd), + EnrichedSpanFlags.forSpan(span, flags), + ) + } + } else { + // No own styles - extend existing paragraph/block spans to cover the inserted text + val insertEnd = finalStart + spannable.length val affectedParagraphSpans = builder.getSpans(finalStart, finalStart, EnrichedParagraphSpan::class.java) val affectedBlockSpans = builder.getSpans(finalStart, finalStart, EnrichedBlockSpan::class.java) @@ -140,18 +236,18 @@ fun Spannable.mergeSpannables( for (span in affectedSpans) { val spanStart = builder.getSpanStart(span) val spanEnd = builder.getSpanEnd(span) - if (spanStart == -1 || spanEnd >= pasteEnd) continue + if (spanStart == -1 || spanEnd >= insertEnd) continue - val (_, newParagraphEnd) = builder.getParagraphBounds(spanStart, pasteEnd) + val (_, newParagraphEnd) = builder.getParagraphBounds(spanStart, insertEnd) val flags = builder.getSpanFlags(span) builder.removeSpan(span) builder.setSpan(span, spanStart, newParagraphEnd, EnrichedSpanFlags.forSpan(span, flags)) } } - htmlStyle?.let { - builder.removeBlockedPasteStyles(finalStart, spannable, it) - } + // Blocking runs first so styles that never made it in are not treated as conflict winners + builder.removeBlockedIncomingStyles(finalStart, spannable, htmlStyle) + builder.removeConflictingStyles(spannable, htmlStyle) return builder } diff --git a/apps/example-web/src/RouteSelector.tsx b/apps/example-web/src/RouteSelector.tsx index e40322d84..e0e2d543a 100644 --- a/apps/example-web/src/RouteSelector.tsx +++ b/apps/example-web/src/RouteSelector.tsx @@ -6,6 +6,7 @@ import { VisualRegression } from './testScreens/VisualRegression'; import { TestSubmitProps } from './testScreens/TestSubmitProps'; import { TestEnrichedText } from './testScreens/TestEnrichedText'; import { TestEllipsize } from './testScreens/TestEllipsize'; +import { TestInsertValue } from './testScreens/TestInsertValue'; import { useEffect, useState } from 'react'; export default function RouteSelector() { @@ -50,5 +51,9 @@ export default function RouteSelector() { return ; } + if (path === '/test-insert-value') { + return ; + } + return ; } diff --git a/apps/example-web/src/testScreens/TestInsertValue.tsx b/apps/example-web/src/testScreens/TestInsertValue.tsx new file mode 100644 index 000000000..f9c80d577 --- /dev/null +++ b/apps/example-web/src/testScreens/TestInsertValue.tsx @@ -0,0 +1,235 @@ +import { useRef, useState, type ChangeEvent } from 'react'; +import { + EnrichedTextInput, + type EnrichedInputStyle, + type EnrichedTextInputInstance, + type OnChangeSelectionEvent, + type OnChangeStateEvent, +} from 'react-native-enriched-html'; +import { Toolbar } from '../components/Toolbar'; +import { WEB_DEFAULT_HTML_STYLE } from '../defaultHtmlStyle'; + +function toInteger(value: string): number { + const parsed = parseInt(value, 10); + return isNaN(parsed) ? 0 : parsed; +} + +export function TestInsertValue() { + const ref = useRef (null); + const [setupHtmlInput, setSetupHtmlInput] = useState(''); + const [insertInput, setInsertInput] = useState(''); + const [selectionStartInput, setSelectionStartInput] = useState('0'); + const [selectionEndInput, setSelectionEndInput] = useState('0'); + const [editorState, setEditorState] = useState ( + null + ); + const [selection, setSelection] = useState ( + null + ); + const [editorHtml, setEditorHtml] = useState(''); + + const handleClear = () => { + ref.current?.setValue(''); + setSetupHtmlInput(''); + setInsertInput(''); + setSelectionStartInput('0'); + setSelectionEndInput('0'); + setEditorHtml(''); + }; + + const handleSetValue = () => { + ref.current?.setValue(setupHtmlInput); + }; + + const handleApplySelection = () => { + ref.current?.setSelection( + toInteger(selectionStartInput), + toInteger(selectionEndInput) + ); + }; + + const handleInsert = () => { + ref.current?.insertValue( + insertInput, + selection?.start ?? 0, + selection?.end ?? 0 + ); + }; + + return ( + ++ ); +} + +const styles = { + container: { + padding: '16px', + }, + controls: { + display: 'flex', + gap: '8px', + marginTop: '12px', + marginBottom: '12px', + }, + selectionControls: { + display: 'flex', + flexWrap: 'wrap' as const, + gap: '8px', + alignItems: 'center' as const, + marginBottom: '12px', + }, + textInput: { + display: 'block', + width: '100%', + marginTop: '4px', + marginBottom: '8px', + fontFamily: 'ui-monospace, monospace', + fontSize: 12, + }, + htmlOutput: { + position: 'absolute', + width: 1, + height: 1, + overflow: 'hidden', + clip: 'rect(0 0 0 0)', + whiteSpace: 'pre-wrap', + }, +} as const; + +const enrichedInputStyle: EnrichedInputStyle = { + width: '100%', + minHeight: 24, + maxWidth: 350, + paddingVertical: 10, + paddingHorizontal: 12, + backgroundColor: 'gainsboro', + fontSize: 16, + lineHeight: 22, + fontFamily: 'Helvetica Neue', +}; diff --git a/apps/example/src/components/ModalShell.tsx b/apps/example/src/components/ModalShell.tsx index 748b44827..69befc8aa 100644 --- a/apps/example/src/components/ModalShell.tsx +++ b/apps/example/src/components/ModalShell.tsx @@ -36,5 +36,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(0, 0, 0, 0.5)', + // without that during iOS e2e tests the modal is not visible when opened for the second time + zIndex: 2, }, }); diff --git a/apps/example/src/hooks/useEditorState.ts b/apps/example/src/hooks/useEditorState.ts index 69dcc3048..b16dc230b 100644 --- a/apps/example/src/hooks/useEditorState.ts +++ b/apps/example/src/hooks/useEditorState.ts @@ -43,6 +43,7 @@ export function useEditorState() { const [isLinkModalOpen, setIsLinkModalOpen] = useState(false); const [isImageModalOpen, setIsImageModalOpen] = useState(false); const [isValueModalOpen, setIsValueModalOpen] = useState(false); + const [isInsertValueModalOpen, setIsInsertValueModalOpen] = useState(false); const [currentHtml, setCurrentHtml] = useState(''); const [selection, setSelection] = useStateref.current?.focus()} + > ++ +{ + setEditorHtml(e.nativeEvent.value); + }} + onChangeState={(e) => { + setEditorState(e.nativeEvent); + }} + onChangeSelection={(e) => { + setSelection({ + start: e.nativeEvent.start, + end: e.nativeEvent.end, + text: e.nativeEvent.text, + }); + }} + /> + {}} + onOpenImageModal={() => {}} + /> + + + + ++ + +(); const [stylesState, setStylesState] = useState (DEFAULT_STYLES); @@ -91,6 +92,8 @@ export function useEditorState() { const closeImageModal = () => setIsImageModalOpen(false); const openValueModal = () => setIsValueModalOpen(true); const closeValueModal = () => setIsValueModalOpen(false); + const openInsertValueModal = () => setIsInsertValueModalOpen(true); + const closeInsertValueModal = () => setIsInsertValueModalOpen(false); const openUserMentionPopup = () => setIsUserPopupOpen(true); const closeUserMentionPopup = () => { @@ -198,6 +201,11 @@ export function useEditorState() { closeValueModal(); }; + const submitInsertValue = (value: string) => { + ref.current?.insertValue(value, selection?.start ?? 0, selection?.end ?? 0); + closeInsertValueModal(); + }; + const selectImage = async ( width: number | undefined, height: number | undefined, @@ -247,6 +255,7 @@ export function useEditorState() { isLinkModalOpen, isImageModalOpen, isValueModalOpen, + isInsertValueModalOpen, isUserPopupOpen, isChannelPopupOpen, userMention, @@ -257,6 +266,8 @@ export function useEditorState() { closeImageModal, openValueModal, closeValueModal, + openInsertValueModal, + closeInsertValueModal, handleFocus, handleBlur, handleClear, @@ -277,6 +288,7 @@ export function useEditorState() { handleSubmitEditingEvent, submitLink, submitSetValue, + submitInsertValue, selectImage, }; } diff --git a/apps/example/src/screens/TestScreen.tsx b/apps/example/src/screens/TestScreen.tsx index 160fda327..89d4c8f03 100644 --- a/apps/example/src/screens/TestScreen.tsx +++ b/apps/example/src/screens/TestScreen.tsx @@ -113,6 +113,14 @@ export function TestScreen({ style={styles.rowButton} testID="set-value-button" /> + + + + Promise ; Returns a Promise that resolves with the current HTML content of the input. This is useful when you need to get the HTML on-demand (e.g., when saving) without the performance overhead of continuous HTML parsing via `onChangeHtml`. +### `.insertValue()` + +```ts +insertValue: (value: string, start: number, end: number) => void; +``` + +Inserts (or replaces) content at the visible character range `[start, end)`. + +- `value: string` - the content to insert. Can be a plain text string or an HTML string wrapped in `…` (e.g. ` Hello
`). +- `start: number` - starting index of the range to replace (inclusive). If `start` equals `end`, the content is inserted at that position without replacing anything. +- `end: number` - ending index of the range to replace (exclusive). + +> [!NOTE] +> Style rules apply to inserted content: if the target style is **blocked** by another currently active style (e.g. bold inside a codeblock), the incoming style is stripped. If the incoming content brings a style that **conflicts** with an existing one, the existing conflicting style is removed. See the [inline](../README.md#inline-tags) and [paragraph](../README.md#paragraph-tags) tag tables for the full conflict and blocking rules. + ### `.setImage()` ```ts diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index a899aa629..ede51019b 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -1296,6 +1296,12 @@ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args { if (!_placeholderLabel.isHidden) { [self refreshPlaceholderLabelStyles]; } + } else if ([commandName isEqualToString:@"insertValue"]) { + NSString *value = (NSString *)args[0]; + NSInteger start = [((NSNumber *)args[1]) integerValue]; + NSInteger end = [((NSNumber *)args[2]) integerValue]; + + [self insertValue:value start:start end:end]; } } @@ -1344,6 +1350,55 @@ - (void)setCustomSelection:(NSInteger)visibleStart end:(NSInteger)visibleEnd { textView.selectedRange = NSMakeRange(actualStart, actualEnd - actualStart); } +- (void)insertValue:(NSString *)value + start:(NSInteger)visibleStart + end:(NSInteger)visibleEnd { + if (value == nil) { + return; + } + + NSString *currentText = textView.textStorage.string; + NSInteger textLength = currentText.length; + + if (textLength == 0) { + [self setValue:value]; + return; + } + + NSUInteger actualStart = [self getActualIndex:visibleStart text:currentText]; + NSUInteger actualEnd = [self getActualIndex:visibleEnd text:currentText]; + + // Use MIN/MAX to ensure indices are within [0, textLength] + // and that start <= end. + NSUInteger start = MIN(actualStart, textLength); + NSUInteger end = MIN(MAX(start, actualEnd), textLength); + NSRange range = NSMakeRange(start, end - start); + + NSString *initiallyProcessedHtml = [parser initiallyProcessHtml:value]; + if (initiallyProcessedHtml == nullptr) { + // just plain text + range.length > 0 ? [TextInsertionUtils replaceText:value + at:range + additionalAttributes:nil + host:self + withSelection:YES] + : [TextInsertionUtils insertText:value + at:range.location + additionalAttributes:nil + host:self + withSelection:YES]; + } else { + // we've got some seemingly proper html + range.length > 0 + ? [parser replaceFromHtml:initiallyProcessedHtml range:range] + : [parser insertFromHtml:initiallyProcessedHtml + location:range.location]; + } + + // set recentlyChangedRange and check for changes + [self anyTextMayHaveBeenModified]; +} + // Helper: Walks through the string skipping ZWSPs to find the Nth visible // character - (NSUInteger)getActualIndex:(NSInteger)visibleIndex text:(NSString *)text { diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index feffab4cc..d3428f923 100644 --- a/src/native/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -277,6 +277,9 @@ export const EnrichedTextInput = ({ ) => { Commands.setTextAlignment(nullthrows(nativeRef.current), alignment); }, + insertValue: (value: string, start: number, end: number) => { + Commands.insertValue(nullthrows(nativeRef.current), value, start, end); + }, })); const handleMentionEvent = (e: NativeSyntheticEvent) => { diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index 20e9d12b4..6d5ae90af 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -425,6 +425,12 @@ interface NativeCommands { start: Int32, end: Int32 ) => void; + insertValue: ( + viewRef: React.ElementRef , + value: string, + start: Int32, + end: Int32 + ) => void; // Text formatting commands toggleBold: (viewRef: React.ElementRef ) => void; @@ -491,6 +497,7 @@ export const Commands: NativeCommands = codegenNativeCommands ({ 'blur', 'setValue', 'setSelection', + 'insertValue', // Text formatting commands 'toggleBold', diff --git a/src/types.ts b/src/types.ts index d4d0dfd8b..3201fff34 100644 --- a/src/types.ts +++ b/src/types.ts @@ -564,6 +564,13 @@ export interface EnrichedTextInputInstance extends NativeMethods { setTextAlignment: ( alignment: 'left' | 'center' | 'right' | 'justify' | 'auto' ) => void; + + /** Inserts (or replaces) content at the visible character range `[start, end)`. + * @param value - Content to insert. + * @param start - Start index of the range to insert the content at. + * @param end - End index of the range to insert the content at. + */ + insertValue: (value: string, start: number, end: number) => void; } export interface ContextMenuItem { diff --git a/src/web/EnrichedTextInput.tsx b/src/web/EnrichedTextInput.tsx index 692dec9e7..fe238bca1 100644 --- a/src/web/EnrichedTextInput.tsx +++ b/src/web/EnrichedTextInput.tsx @@ -7,7 +7,13 @@ import { type CSSProperties, } from 'react'; import './EnrichedText.css'; -import type { Node } from '@tiptap/pm/model'; +import type { Fragment, Node } from '@tiptap/pm/model'; +import { createNodeFromContent } from '@tiptap/core'; +import { + insertBlockContent, + applyMarksToContent, + isPlainEmptyDoc, +} from './insertBlockContent'; import type { EnrichedTextInputInstance, EnrichedTextInputProps, @@ -406,6 +412,61 @@ export const EnrichedTextInput = ({ measureInWindow: () => {}, measureLayout: () => {}, setNativeProps: () => {}, + insertValue: (value: string, start: number, end: number) => { + if (!value) return; + + const doc = editor.state.doc; + const docLength = nativeLeafText(doc, 0, doc.content.size).length; + + if (docLength === 0 && isPlainEmptyDoc(doc)) { + editor.commands.setContent( + prepareHtmlForTiptap(value, useHtmlNormalizerRef.current) + ); + return; + } + + const from = nativePosToTiptapPos(doc, Math.min(start, docLength)); + const to = nativePosToTiptapPos( + doc, + Math.min(Math.max(start, end), docLength) + ); + const content = prepareHtmlForTiptap( + value, + useHtmlNormalizerRef.current + ); + + const parsedRaw = createNodeFromContent(content, editor.schema, { + parseOptions: { preserveWhitespace: 'full' as const }, + }) as Fragment; + + // Inherit the editor's active inline styles (toggled marks or the marks at the + // caret) so inserted text picks them up. + const activeMarks = + editor.state.storedMarks ?? editor.state.selection.$head.marks(); + const parsed = applyMarksToContent(parsedRaw, activeMarks); + + const blocks: Node[] = []; + parsed.forEach((node) => blocks.push(node)); + + const hasBlockContent = blocks.some((n) => n.isBlock); + + // Inline-only content (plain text, marks) inserts cleanly at the caret. + if (!hasBlockContent) { + editor.chain().focus().insertContentAt({ from, to }, parsed).run(); + return; + } + + // Block content merges the incoming blocks with the current line. + // Fall back to TipTap's insertion when the surrounding + // structure can't accept the reconstructed blocks. + const tr = editor.state.tr; + if (insertBlockContent(tr, from, to, blocks)) { + editor.view.focus(); + editor.view.dispatch(tr); + } else { + editor.chain().focus().insertContentAt({ from, to }, parsed).run(); + } + }, setTextAlignment: (alignment) => { if (alignment === 'auto') { runFocused(editor, (c) => c.unsetTextAlign()); diff --git a/src/web/__tests__/insertBlockContent.test.ts b/src/web/__tests__/insertBlockContent.test.ts new file mode 100644 index 000000000..583abc45f --- /dev/null +++ b/src/web/__tests__/insertBlockContent.test.ts @@ -0,0 +1,317 @@ +import { getSchema, createNodeFromContent } from '@tiptap/core'; +import { EditorState } from '@tiptap/pm/state'; +import type { Fragment, Node as PmNode } from '@tiptap/pm/model'; +import Document from '@tiptap/extension-document'; +import Paragraph from '@tiptap/extension-paragraph'; +import Text from '@tiptap/extension-text'; +import { EnrichedBold } from '../formats/EnrichedBold'; +import { EnrichedItalic } from '../formats/EnrichedItalic'; +import { EnrichedUnderline } from '../formats/EnrichedUnderline'; +import { EnrichedStrike } from '../formats/EnrichedStrike'; +import { EnrichedCode } from '../formats/EnrichedCode'; +import { EnrichedLink } from '../formats/EnrichedLink'; +import { EnrichedImage } from '../formats/EnrichedImage'; +import { EnrichedMention } from '../formats/EnrichedMention'; +import { EnrichedHeading } from '../formats/EnrichedHeading'; +import { EnrichedBlockquote } from '../formats/EnrichedBlockquote'; +import { EnrichedCodeBlock } from '../formats/EnrichedCodeBlock'; +import { EnrichedListItem } from '../formats/EnrichedListItem'; +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 { insertBlockContent, isPlainEmptyDoc } from '../insertBlockContent'; +import { prepareHtmlForTiptap } from '../normalization/tiptapHtmlNormalizer'; +import { nativePosToTiptapPos } from '../positionMapping'; + +const schema = getSchema([ + Document, + Paragraph, + Text, + EnrichedBold, + EnrichedItalic, + EnrichedUnderline, + EnrichedStrike, + EnrichedCode, + EnrichedLink.configure({ getLinkRegex: () => undefined }), + EnrichedImage, + EnrichedMention, + EnrichedHeading, + EnrichedBlockquote, + EnrichedCodeBlock, + EnrichedListItem, + EnrichedCheckboxItem, + EnrichedUnorderedList, + EnrichedOrderedList, + EnrichedCheckboxList, + EnrichedTextAlign, +]); + +function makeDoc(html: string): PmNode { + return createNodeFromContent(prepareHtmlForTiptap(html, false), schema, { + slice: false, + parseOptions: { preserveWhitespace: 'full' }, + }) as PmNode; +} + +function parseValue(value: string): PmNode[] { + const content = prepareHtmlForTiptap(value, false); + const parsed = createNodeFromContent(content, schema, { + parseOptions: { preserveWhitespace: 'full' }, + }) as Fragment; + const blocks: PmNode[] = []; + parsed.forEach((node) => blocks.push(node)); + return blocks; +} + +/** Runs insertBlockContent at a native (plain-text) caret position, like insertValue does. */ +function runInsert( + docHtml: string, + value: string, + nativeStart: number, + nativeEnd = nativeStart +): { ok: boolean; doc: PmNode } { + const doc = makeDoc(docHtml); + const state = EditorState.create({ schema, doc }); + const from = nativePosToTiptapPos(doc, nativeStart); + const to = nativePosToTiptapPos(doc, nativeEnd); + const tr = state.tr; + const ok = insertBlockContent(tr, from, to, parseValue(value)); + return { ok, doc: ok ? tr.doc : doc }; +} + +const CODEBLOCK_WORLD = ' World '; + +describe('insertBlockContent', () => { + describe('block content into list items', () => { + it('merges a codeblock into the middle item, keeping the other items listed', () => { + const { ok, doc } = runInsert( + '', + CODEBLOCK_WORLD, + 4 + 3 // "one\n" + "asd" + ); + expect(ok).toBe(true); + expect(doc.toString()).toBe( + 'doc(unorderedList(listItem(paragraph("one"))), codeBlock(paragraph("asdWorldfgh")), unorderedList(listItem(paragraph("two"))))' + ); + }); + + it('merges a codeblock into the first item without leaving an empty list', () => { + const { ok, doc } = runInsert( + '
- one
- asdfgh
- two
', + CODEBLOCK_WORLD, + 3 + ); + expect(ok).toBe(true); + expect(doc.toString()).toBe( + 'doc(codeBlock(paragraph("asdWorldfgh")), unorderedList(listItem(paragraph("two"))))' + ); + }); + + it('merges a codeblock into the last item without leaving an empty list', () => { + const { ok, doc } = runInsert( + '
- asdfgh
- two
', + CODEBLOCK_WORLD, + 4 + 3 + ); + expect(ok).toBe(true); + expect(doc.toString()).toBe( + 'doc(unorderedList(listItem(paragraph("one"))), codeBlock(paragraph("asdWorldfgh")))' + ); + }); + + it('replaces a single-item list when its only line absorbs the codeblock', () => { + const { ok, doc } = runInsert( + '
- one
- asdfgh
', + CODEBLOCK_WORLD, + 3 + ); + expect(ok).toBe(true); + expect(doc.toString()).toBe('doc(codeBlock(paragraph("asdWorldfgh")))'); + }); + + it('merges at the start and end edges of a middle item', () => { + const atStart = runInsert( + '
- asdfgh
', + CODEBLOCK_WORLD, + 4 + ); + expect(atStart.ok).toBe(true); + expect(atStart.doc.toString()).toBe( + 'doc(unorderedList(listItem(paragraph("one"))), codeBlock(paragraph("Worldasdfgh")), unorderedList(listItem(paragraph("two"))))' + ); + + const atEnd = runInsert( + '
- one
- asdfgh
- two
', + CODEBLOCK_WORLD, + 4 + 6 + ); + expect(atEnd.ok).toBe(true); + expect(atEnd.doc.toString()).toBe( + 'doc(unorderedList(listItem(paragraph("one"))), codeBlock(paragraph("asdfghWorld")), unorderedList(listItem(paragraph("two"))))' + ); + }); + + it('keeps a transparent paragraph inside the list item it merges into', () => { + const { ok, doc } = runInsert( + '
- one
- asdfgh
- two
', + '
- one
- asdfgh
- two
World
', + 4 + 3 + ); + expect(ok).toBe(true); + expect(doc.toString()).toBe( + 'doc(unorderedList(listItem(paragraph("one")), listItem(paragraph("asdWorldfgh")), listItem(paragraph("two"))))' + ); + }); + + it('splits multi-paragraph content into list items (later joined by the merge plugin)', () => { + const { ok, doc } = runInsert( + '', + '
- one
- asdfgh
- two
AA
BB
', + 4 + 3 + ); + expect(ok).toBe(true); + // Adjacent unorderedLists are joined back into one list by + // MergeAdjacentSameKindBlocksPlugin when dispatched through the editor. + expect(doc.toString()).toBe( + 'doc(unorderedList(listItem(paragraph("one"))), unorderedList(listItem(paragraph("asdAA"))), unorderedList(listItem(paragraph("BBfgh"))), unorderedList(listItem(paragraph("two"))))' + ); + }); + }); + + describe('block content into blockquote', () => { + it('lifts the affected line out, keeping siblings quoted', () => { + const { ok, doc } = runInsert( + '', + CODEBLOCK_WORLD, + 4 + 3 + ); + expect(ok).toBe(true); + expect(doc.toString()).toBe( + 'doc(blockquote(paragraph("one")), codeBlock(paragraph("asdWorldfgh")), blockquote(paragraph("two")))' + ); + }); + }); + + describe('alignment preservation', () => { + it('keeps the alignment of the line a transparent paragraph merges into', () => { + const { ok, doc } = runInsert( + 'one
asdfgh
two
asdfgh
', + 'World
', + 3 + ); + expect(ok).toBe(true); + expect(doc.childCount).toBe(1); + expect(doc.firstChild!.textContent).toBe('asdWorldfgh'); + expect(doc.firstChild!.attrs.textAlign).toBe('center'); + }); + + it('keeps the alignment of an aligned list receiving plain text', () => { + const { ok, doc } = runInsert( + '', + '
- asdfgh
World
', + 3 + ); + expect(ok).toBe(true); + expect(doc.toString()).toBe( + 'doc(unorderedList(listItem(paragraph("asdWorldfgh"))))' + ); + expect(doc.firstChild!.attrs.textAlign).toBe('center'); + }); + + it('codeblock inserted into an aligned line inherits the alignment on its inner paragraph', () => { + const { ok, doc } = runInsert( + 'asdfgh
', + CODEBLOCK_WORLD, + 3 + ); + expect(ok).toBe(true); + expect(doc.firstChild!.type.name).toBe('codeBlock'); + expect(doc.firstChild!.firstChild!.textContent).toBe('asdWorldfgh'); + expect(doc.firstChild!.firstChild!.attrs.textAlign).toBe('center'); + }); + + it('blockquote inserted into an aligned line inherits the alignment on its inner paragraph', () => { + const { ok, doc } = runInsert( + 'asdfgh
', + '', + 3 + ); + expect(ok).toBe(true); + expect(doc.firstChild!.type.name).toBe('blockquote'); + expect(doc.firstChild!.firstChild!.textContent).toBe('asdWorldfgh'); + expect(doc.firstChild!.firstChild!.attrs.textAlign).toBe('center'); + }); + + it('codeblock inserted into an aligned list item inherits the list alignment', () => { + const { ok, doc } = runInsert( + 'World
', + CODEBLOCK_WORLD, + 3 + ); + expect(ok).toBe(true); + expect(doc.firstChild!.type.name).toBe('codeBlock'); + expect(doc.firstChild!.firstChild!.attrs.textAlign).toBe('center'); + }); + + it('an incoming block with its own alignment keeps it', () => { + const { ok, doc } = runInsert( + '
- asdfgh
asdfgh
', + 'World
', + 3 + ); + expect(ok).toBe(true); + expect(doc.firstChild!.type.name).toBe('heading'); + expect(doc.firstChild!.attrs.textAlign).toBe('right'); + }); + + it('spreads alignment onto both halves of a multi-block insert', () => { + const { ok, doc } = runInsert( + 'asdfgh
', + 'AA
BB
', + 3 + ); + expect(ok).toBe(true); + expect(doc.childCount).toBe(2); + expect(doc.child(0).attrs.textAlign).toBe('center'); + expect(doc.child(1).attrs.textAlign).toBe('center'); + }); + }); + + describe('other block styles', () => { + it('converts the line when a heading is inserted into a paragraph', () => { + const { ok, doc } = runInsert( + 'asdfgh
', + 'World
', + 3 + ); + expect(ok).toBe(true); + expect(doc.toString()).toBe('doc(heading("asdWorldfgh"))'); + }); + }); +}); + +describe('isPlainEmptyDoc', () => { + it('is true for a single unstyled empty paragraph', () => { + expect(isPlainEmptyDoc(makeDoc(''))).toBe(true); + }); + + it('is false for an empty but aligned paragraph', () => { + expect(isPlainEmptyDoc(makeDoc(''))).toBe( + false + ); + }); + + it('is false for an empty heading', () => { + expect(isPlainEmptyDoc(makeDoc(''))).toBe(false); + }); + + it('is false for an empty list item', () => { + expect(isPlainEmptyDoc(makeDoc(''))).toBe(false); + }); + + it('is false for a non-empty paragraph', () => { + expect(isPlainEmptyDoc(makeDoc('
hi
'))).toBe(false); + }); +}); diff --git a/src/web/insertBlockContent.ts b/src/web/insertBlockContent.ts new file mode 100644 index 000000000..3096c80a8 --- /dev/null +++ b/src/web/insertBlockContent.ts @@ -0,0 +1,313 @@ +import { + Fragment, + type Attrs, + type Mark, + type Node as PmNode, + type NodeType, +} from '@tiptap/pm/model'; +import { TextSelection, type Transaction } from '@tiptap/pm/state'; + +function applyMarksToNode(node: PmNode, marks: readonly Mark[]): PmNode { + if (node.isText) { + let set = node.marks; + for (const mark of marks) set = mark.addToSet(set); + return node.mark(set); + } + if (node.isLeaf || node.childCount === 0) return node; + const children: PmNode[] = []; + node.forEach((child) => children.push(applyMarksToNode(child, marks))); + return node.copy(Fragment.fromArray(children)); +} + +/** + * Unions the given marks into every text node of the inline content, so inserted text inherits + * the editor's active inline styles (toggled marks or the marks at the caret), matching the + * native platforms. Marks that end up illegal for their context (e.g. inside a code block) are + * cleaned up afterwards by the existing strip plugins. + */ +export function applyMarksToContent( + content: Fragment, + marks: readonly Mark[] +): Fragment { + if (marks.length === 0) return content; + const mapped: PmNode[] = []; + content.forEach((node) => mapped.push(applyMarksToNode(node, marks))); + return Fragment.fromArray(mapped); +} + +function hasLeadingTextblock(block: PmNode): boolean { + if (block.isTextblock) return true; + if (block.childCount === 0) return false; + return hasLeadingTextblock(block.child(0)); +} + +function hasTrailingTextblock(block: PmNode): boolean { + if (block.isTextblock) return true; + if (block.childCount === 0) return false; + return hasTrailingTextblock(block.child(block.childCount - 1)); +} + +// Prepends inline content into the first leaf textblock of a block, descending through +// wrappers (blockquote, codeblock) so the text joins the line rather than the wrapper. +function prependInline(block: PmNode, inline: Fragment): PmNode { + if (inline.size === 0) return block; + if (block.isTextblock) { + return block.copy(inline.append(block.content)); + } + const children: PmNode[] = []; + block.forEach((child) => children.push(child)); + children[0] = prependInline(children[0]!, inline); + return block.copy(Fragment.fromArray(children)); +} + +function appendInline(block: PmNode, inline: Fragment): PmNode { + if (inline.size === 0) return block; + if (block.isTextblock) { + return block.copy(block.content.append(inline)); + } + const children: PmNode[] = []; + block.forEach((child) => children.push(child)); + const lastIdx = children.length - 1; + children[lastIdx] = appendInline(children[lastIdx]!, inline); + return block.copy(Fragment.fromArray(children)); +} + +// A default paragraph carries no block styling, so it is "transparent" and should not override +// the line it merges into (heading level, alignment, etc.), matching the native platforms. +function isPlainParagraph(block: PmNode): boolean { + return ( + block.type.name === 'paragraph' && block.sameMarkup(block.type.create()) + ); +} + +/** + * True when the document is a single unstyled empty paragraph. Only then may an insert replace + * the document wholesale + */ +export function isPlainEmptyDoc(doc: PmNode): boolean { + return ( + doc.childCount === 1 && + doc.firstChild!.content.size === 0 && + isPlainParagraph(doc.firstChild!) + ); +} + +// A styled incoming block keeps its own type but inherits the line's alignment when it doesn't +// specify one, so inserting e.g. a heading into a centered line stays centered. Wrapper blocks +// (blockquote, code block) carry no alignment attribute themselves, so it is applied to their +// descendants at the outermost level that supports it, mirroring where toggling puts it. +function alignStyledBlock(block: PmNode, align: string | null): PmNode { + if (!align) return block; + if ('textAlign' in block.attrs) { + if (block.attrs.textAlign) return block; + return block.type.create( + { ...block.attrs, textAlign: align }, + block.content, + block.marks + ); + } + if (block.isLeaf || block.childCount === 0) return block; + const children: PmNode[] = []; + block.forEach((child) => children.push(alignStyledBlock(child, align))); + return block.copy(Fragment.fromArray(children)); +} + +/** + * Inserts block-level content at the range [from, to) so the incoming blocks merge with the + * current line the way the native platforms do: the text before the caret joins the first + * incoming block, the text after the caret joins the last incoming block, and each incoming + * block keeps its own type. + * + * Block context is matched to the target line's flat-model behaviour: + * - Incoming wrappers of the same type as an ancestor (a code block pasted into a code block) + * are unwrapped so their lines merge into the existing wrapper rather than nesting/breaking it. + * - A transparent (plain) edge that joins the line adopts the line's block style - including its + * wrapper (code block/blockquote/list) and alignment — while a trailing plain block with no + * text after it stays a plain paragraph. + * - A block that can't live in the current wrapper (a heading inside a code block, a code block + * inside a blockquote, …) lifts the line out by splitting the wrapper, leaving siblings wrapped. + * + * Returns false without meaningfully mutating when the structure can't accept the reconstructed + * blocks, so the caller can fall back to TipTap's default insertion. + */ +export function insertBlockContent( + tr: Transaction, + from: number, + to: number, + blocks: PmNode[] +): boolean { + if (blocks.length === 0) return false; + + try { + if (from !== to) { + tr.delete(from, to); + } + + const $pos = tr.doc.resolve(from); + const depth = $pos.depth; + const line = $pos.parent; + + if (depth < 1 || !line.isTextblock) return false; + + const offset = from - $pos.start(depth); + const before = line.content.cut(0, offset); + const after = line.content.cut(offset); + + // The line's block context: its wrapper chain (innermost first) and inner textblock. Used to + // keep inserted content in the line's block style (code block, blockquote, list, alignment…). + const wrappers: { type: NodeType; attrs: Attrs }[] = []; + for (let d = depth - 1; d >= 1; d--) { + const w = $pos.node(d); + wrappers.push({ type: w.type, attrs: w.attrs }); + } + const wrapperNames = new Set(wrappers.map((w) => w.type.name)); + + // The alignment governing the line: its own attribute or the nearest wrapper's (lists + // carry alignment on the list node rather than the inner paragraph). + const lineAlign = (line.attrs.textAlign ?? + wrappers.find((w) => w.attrs.textAlign)?.attrs.textAlign ?? + null) as string | null; + + const wrapInLineContext = ( + content: Fragment, + marks: readonly Mark[] + ): PmNode => { + let node = line.type.create(line.attrs, content, marks); + for (const w of wrappers) { + node = w.type.create(w.attrs, Fragment.from(node)); + } + return node; + }; + + // Same-type wrappers coming in (e.g. a code block pasted into a code block) are unwrapped so + // their lines merge into the existing wrapper instead of nesting or breaking out of it. + const flatten = (block: PmNode): PmNode[] => { + if (block.isTextblock) return [block]; + if (wrapperNames.has(block.type.name)) { + const out: PmNode[] = []; + block.forEach((child) => out.push(...flatten(child))); + return out; + } + return [block]; + }; + const flatBlocks: PmNode[] = []; + for (const b of blocks) flatBlocks.push(...flatten(b)); + if (flatBlocks.length === 0) return false; + + const firstBlock = flatBlocks[0]!; + const lastBlock = flatBlocks[flatBlocks.length - 1]!; + + // Bail out if the edge blocks can't absorb the surrounding text (would silently drop it). + if (before.size > 0 && !hasLeadingTextblock(firstBlock)) return false; + if (after.size > 0 && !hasTrailingTextblock(lastBlock)) return false; + + const single = flatBlocks.length === 1; + // A transparent (plain) edge adopts the line's block style only when it actually joins the + // line: the sole block replacing the line, or an edge that absorbs surrounding text. A + // trailing plain block with nothing after it stays a plain paragraph (a genuinely new line). + const firstInherits = + isPlainParagraph(firstBlock) && (single || before.size > 0); + const lastInherits = + isPlainParagraph(lastBlock) && (single || after.size > 0); + + const buildBlocks = (rewrap: boolean): PmNode[] => { + const makeEdge = (block: PmNode, inherits: boolean): PmNode => { + if (!inherits) return alignStyledBlock(block, lineAlign); + // Rewrap keeps the wrapper (blockquote/list) around content that is being lifted out; + // in place the inner textblock is enough since it stays inside the existing wrapper. + if (rewrap) return wrapInLineContext(block.content, block.marks); + return line.type.create(line.attrs, block.content, block.marks); + }; + + const first = makeEdge(firstBlock, firstInherits); + const out: PmNode[] = []; + if (single) { + out.push(appendInline(prependInline(first, before), after)); + } else { + const last = makeEdge(lastBlock, lastInherits); + out.push(prependInline(first, before)); + for (let i = 1; i < flatBlocks.length - 1; i++) { + out.push(flatBlocks[i]!); + } + out.push(appendInline(last, after)); + } + return out; + }; + + // Shallowest ancestor whose parent can hold the blocks: the line itself means they fit in + // place, otherwise the wrappers between are split so the line is lifted out to that level. + const fitDepth = (candidate: PmNode[]): number => { + const frag = Fragment.fromArray(candidate); + let d = depth; + while (d > 1) { + const parent = $pos.node(d - 1); + const index = $pos.index(d - 1); + if (parent.canReplace(index, index + 1, frag)) break; + d--; + } + return d; + }; + + // Build assuming the content stays in place; if it must be lifted out of a wrapper, rebuild + // the transparent edges wrapped in the line's context so they keep the wrapper's style. + let newBlocks = buildBlocks(false); + let childDepth = fitDepth(newBlocks); + if (childDepth < depth && depth > 1) { + newBlocks = buildBlocks(true); + childDepth = fitDepth(newBlocks); + } + + if (depth - childDepth > 0) { + // Only split on a side that actually has sibling lines within the wrapper(s); otherwise + // we'd leave empty wrappers behind. Check each wrapper level. + let isFirst = true; + let isLast = true; + for (let d = depth; d > childDepth; d--) { + const index = $pos.index(d - 1); + if (index > 0) isFirst = false; + if (index < $pos.node(d - 1).childCount - 1) isLast = false; + } + + // A split can't pass through a wrapper level it would leave empty on one side (e.g. a + // list item holds exactly one paragraph, so cutting at the paragraph's edge must happen + // at the list-item boundary instead). Raise the cut while the line's chain sits at the + // relevant edge of its parent, then split the remaining levels down to childDepth. + if (!isLast) { + let cutDepth = depth; + while ( + cutDepth > childDepth + 1 && + $pos.index(cutDepth - 1) === $pos.node(cutDepth - 1).childCount - 1 + ) { + cutDepth--; + } + tr.split($pos.after(cutDepth), cutDepth - childDepth); + } + if (!isFirst) { + let cutDepth = depth; + while (cutDepth > childDepth + 1 && $pos.index(cutDepth - 1) === 0) { + cutDepth--; + } + tr.split($pos.before(cutDepth), cutDepth - childDepth); + } + } + + const $inner = tr.doc.resolve(tr.mapping.map(from)); + tr.replaceWith( + $inner.before(childDepth), + $inner.after(childDepth), + newBlocks + ); + + // Place the caret just before the trailing (absorbed) text; near() snaps into the textblock. + const insertionEnd = tr.mapping.map(from, 1); + const caretPos = Math.max( + 0, + Math.min(insertionEnd - after.size, tr.doc.content.size) + ); + tr.setSelection(TextSelection.near(tr.doc.resolve(caretPos), -1)); + + return true; + } catch { + return false; + } +}