Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
appId: swmansion.enriched.markdown.example
tags:
- input
- smoke
- unordered-list
- ordered-list
- block
---
- launchApp

- runFlow:
file: '../../../../subflows/move_to_playground.yaml'

# Toggling one list type on an item carrying the other must replace the marker
# while keeping the item's nesting depth: build a two-item bullet list with the
# second item indented, then toggle the indented item to an ordered list.
- tapOn:
id: focus-button
- waitForAnimationToEnd

# The list buttons sit past H1-H6 in the horizontally scrolling toolbar and are
# offscreen on phone-width viewports; swipe the toolbar left to reveal them.
- swipe:
from:
id: toolbar-bold
direction: LEFT
- swipe:
from:
id: toolbar-h2
direction: LEFT
- waitForAnimationToEnd

- tapOn:
id: toolbar-unordered-list
- waitForAnimationToEnd

- inputText: 'Bullet item'
- waitForAnimationToEnd

- pressKey: Enter
- waitForAnimationToEnd

- inputText: 'Nested item'
- waitForAnimationToEnd

- tapOn:
id: toolbar-indent
- waitForAnimationToEnd

# Switch the nested bullet to a numbered item — the depth must be preserved.
- tapOn:
id: toolbar-ordered-list
- waitForAnimationToEnd

- tapOn:
id: blur-button

- runFlow:
file: '../../../subflows/capture_or_assert_screenshot.yaml'
env:
SCREENSHOT_NAME: 'list_toggle_input'
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
appId: swmansion.enriched.markdown.example
tags:
- input
- smoke
- ordered-list
- block
---
- launchApp

- runFlow:
file: '../../../../subflows/move_to_playground.yaml'

# Start a numbered list and type three items separated by Return. Return on a
# non-empty item must continue the list (next ordinal at the same depth). The
# second item is indented (its ordinal restarts at 1 on the deeper level, and
# the third item must renumber to 2 at depth 0) and then outdented back, which
# must restore 1./2./3. numbering.
- tapOn:
id: focus-button
- waitForAnimationToEnd

# The list buttons sit past H1-H6 in the horizontally scrolling toolbar and are
# offscreen on phone-width viewports; swipe the toolbar left to reveal them.
- swipe:
from:
id: toolbar-bold
direction: LEFT
- swipe:
from:
id: toolbar-h2
direction: LEFT
- waitForAnimationToEnd

- tapOn:
id: toolbar-ordered-list
- waitForAnimationToEnd

- inputText: 'First item'
- waitForAnimationToEnd

- pressKey: Enter
- waitForAnimationToEnd

- inputText: 'Second item'
- waitForAnimationToEnd

- pressKey: Enter
- waitForAnimationToEnd

- inputText: 'Third item'
- waitForAnimationToEnd

# Indent the third item one level (ordinal restarts at 1 on the deeper level),
# then outdent it back so the run renumbers to 1./2./3.
- tapOn:
id: toolbar-indent
- waitForAnimationToEnd

- tapOn:
id: toolbar-outdent
- waitForAnimationToEnd

- tapOn:
id: blur-button

- runFlow:
file: '../../../subflows/capture_or_assert_screenshot.yaml'
env:
SCREENSHOT_NAME: 'ordered_list_input'
6 changes: 3 additions & 3 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,7 @@ Style configuration for formatted text in the input.

### `listItemSpacing`

Vertical spacing (points) added above each bullet list item so items read as separate rows. iOS applies it via `paragraphSpacingBefore`; Android via a `LineHeightSpan`.
Vertical spacing (points) added above each list item (bullet and numbered alike) so items read as separate rows. iOS applies it via `paragraphSpacingBefore`; Android via a `LineHeightSpan`.

| Type | Default Value | Platform |
| -------- | ------------- | -------- |
Expand Down Expand Up @@ -934,11 +934,11 @@ Toggles a heading of the given level (`1`–`6`) on the cursor's paragraph. Call

### `toggleUnorderedList()`

Turns the cursor's paragraph(s) into bullet list items, or back into regular paragraphs if they already are. Operates on the whole paragraph.
Turns the cursor's paragraph(s) into bullet list items, or back into regular paragraphs if they already are. Operates on the whole paragraph. List items are single-line — each item is exactly one paragraph, and Markdown imported with multi-paragraph (loose) items keeps only each item's first line as a list item.

### `toggleOrderedList()`

Turns the cursor's paragraph(s) into numbered list items, or back into regular paragraphs if they already are. Numbering derives from an item's position among its adjacent same-depth siblings. Toggling one list type on a line carrying the other replaces it.
Turns the cursor's paragraph(s) into numbered list items, or back into regular paragraphs if they already are. Numbering derives from an item's position among its adjacent same-depth siblings. Toggling one list type on a line carrying the other replaces it, keeping the item's nesting depth.

### `indentList()`

Expand Down
5 changes: 3 additions & 2 deletions docs/INPUT.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Behavior notes:

Heading font sizes, weights, and colors are configurable per level — see [Customizing Styles](#customizing-enrichedmarkdowntextinput--styles).

## Bullet Lists
## Lists

`EnrichedMarkdownTextInput` supports nested unordered (bullet) and ordered (numbered) lists, serialized to Markdown as `- ` / `1. ` with three spaces of indentation per nesting level. Ordered items are numbered by their position among adjacent same-depth siblings, so numbering stays correct as items are added, removed, or reordered. Like headings, list items are block-level — they apply to whole paragraphs. Manage them through the component ref:

Expand All @@ -102,10 +102,11 @@ Behavior notes:
- **Backspace at the start of an item** outdents it; at depth 0 it removes the bullet.
- An emptied list line stays a list item (its bullet keeps showing) until you toggle it off or Backspace out of it, so text you type next continues in the list.
- The bullet marker exists only in the serialized Markdown and the rendered glyph — it is never part of the editor's text, so it won't collide with `-`-prefixed text such as mentions.
- **List items are single-line.** Each item is exactly one paragraph. Importing or pasting Markdown with *loose* list items (an item containing multiple paragraphs) keeps only the item's first line as a list item — continuation paragraphs become regular paragraphs, not list-styled blocks.

Whether the cursor's paragraph is a list item, and its nesting depth, are reported through the [`onChangeState`](API_REFERENCE.md#onchangestate) payload as `unorderedList` / `orderedList` (`{ isActive, depth }`), so you can highlight the list buttons in your toolbar.

Use the [`listItemSpacing`](API_REFERENCE.md#listitemspacing) prop to add vertical spacing between items so they read as separate rows.
Use the [`listItemSpacing`](API_REFERENCE.md#listitemspacing) prop to add vertical spacing between list items (bullet and numbered alike) so they read as separate rows.

## Links

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -683,16 +683,21 @@ class EnrichedMarkdownTextInputView(

/**
* Toggles a list of [type] on the cursor's paragraph(s): turns the touched lines
* into depth-0 items (replacing an item of the other list type), or clears them
* back to plain paragraphs when the cursor's line already carries [type].
* into depth-0 items (an item of the other list type is replaced keeping its
* depth), or clears them back to plain paragraphs when the cursor's line already
* carries [type].
*/
private fun toggleListType(type: BlockType) {
val editable = text ?: return
val turningOff = listBlockAtCursor()?.type == type
if (turningOff) {
forEachSelectedLine { ls, le -> blockStore.removeBlock(ls, le, editable) }
} else {
setListBlockOnLines(type, 0)
forEachSelectedLine { ls, le ->
// Switching unordered <-> ordered keeps each line's nesting depth.
val existing = blockStore.allRanges.firstOrNull { it.start == ls && it.type in BlockType.LIST_ITEMS }
blockStore.setBlock(type, existing?.level ?: 0, ls, le, editable)
}
}
blockStore.normalizeToLineBounds(editable)
applyFormattingAndEmit()
Expand Down Expand Up @@ -729,17 +734,6 @@ class EnrichedMarkdownTextInputView(
syncEmptyListAnchor()
}

/** Sets a [type] list block at [depth] on every line the selection touches. */
private fun setListBlockOnLines(
type: BlockType,
depth: Int,
) {
val editable = text ?: return
forEachSelectedLine { ls, le ->
blockStore.setBlock(type, depth, ls, le, editable)
}
}

/** Runs [action] with the `[lineStart, lineEnd)` content bounds of each line the selection touches. */
private inline fun forEachSelectedLine(action: (lineStart: Int, lineEnd: Int) -> Unit) {
val editable = text ?: return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import com.swmansion.enriched.markdown.input.model.BlockType
import com.swmansion.enriched.markdown.input.model.CaretRect
import com.swmansion.enriched.markdown.input.model.StyleType

/** Empty-list-line anchor character; see EnrichedMarkdownTextInputView's ZWSP. */
private const val ZWSP = "\u200B"

class InputEventEmitter(
private val view: EnrichedMarkdownTextInputView,
) {
Expand All @@ -33,7 +36,9 @@ class InputEventEmitter(
private var prevCaretRect: CaretRect? = null

fun emitChangeText() {
val plainText = view.text?.toString() ?: ""
// The empty-list-line ZWSP anchor is an internal editing detail — never leak
// it to JS (the markdown serializer already strips it from that payload).
val plainText = (view.text?.toString() ?: "").replace(ZWSP, "")
dispatch(OnChangeTextEvent(surfaceId(), view.id, plainText))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ class InputOrderedListMarkerSpan(
// line where this span actually begins.
if (text is Spanned && text.getSpanStart(this) != start) return

val label = "$ordinal."
// RTL (dir < 0) mirrors the marker to the trailing side and flips the label
// to ".3", matching the readonly renderer's OrderedListSpan.
val label = if (dir < 0) ".$ordinal" else "$ordinal."
val originalAlign = paint.textAlign
paint.textAlign = Paint.Align.RIGHT
val markerRight = x + (depth * indentPerDepth + markerWidth - markerGap) * dir
canvas.drawText(label, markerRight, baseline.toFloat(), paint)
paint.textAlign = if (dir < 0) Paint.Align.LEFT else Paint.Align.RIGHT
val markerEdge = x + (depth * indentPerDepth + markerWidth - markerGap) * dir
canvas.drawText(label, markerEdge, baseline.toFloat(), paint)
paint.textAlign = originalAlign
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,9 +222,14 @@ - (void)recomputeListMetadata
memset(counters, 0, sizeof(counters));
memset(counterTypes, 0, sizeof(counterTypes));
}
NSInteger maxDepth = adjacent ? prevDepth + 1 : 0;
// Coerce into [0, kENRMMaxListDepth] before ancestry-clamping: parser and
// indent commands cap their depths, but this pass indexes the counter
// arrays by depth, so an out-of-bounds level must never survive it.
NSInteger maxDepth = MIN(adjacent ? prevDepth + 1 : 0, kENRMMaxListDepth);
if (blockRange.level > maxDepth) {
blockRange.level = maxDepth;
} else if (blockRange.level < 0) {
blockRange.level = 0;
}
NSInteger depth = blockRange.level;
for (NSInteger i = depth + 1; i <= kENRMMaxListDepth + 1; i++) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, strong, nullable) UIFont *emptyBulletFont;
@property (nonatomic, strong, nullable) UIColor *emptyBulletColor;

/// Whether the empty list line lays out right-to-left (no character to resolve
/// the paragraph direction from), so its marker mirrors to the trailing edge.
@property (nonatomic, assign) BOOL emptyBulletRTL;

/// Leading spacing (points) applied above list items, so the empty-line marker's
/// baseline can account for the spacing that pushes the text down in the fragment.
@property (nonatomic, assign) CGFloat listItemSpacing;
Expand Down
Loading