Skip to content

Commit 596a75b

Browse files
feat(ios): add textAlignment (#403)
# Summary This PR: 1. Adds textAlignment support on iOS 2. Applies a minimal NSKernAttributeName to zero-width spaces (\u200B). - ZWS has zero advance width, NSLayoutManager produces a zero-width usedRect for ZWS-only lines, which under right/center alignment collapses the line height. The minimal kerning gives the glyph a non-zero advance, preserving line geometry while remaining invisible. ## Test Plan Run example app and play with `textAlignment` when different styles are toggled. Check if paragraphs/lists with different `textAlignment` than `left` are properly parsed and contain `style="text-align:center|right|justify"` inside HTML ## Screenshots / Videos https://github.com/user-attachments/assets/cf9be29d-8808-45a9-b5d3-53b6d28fab86 ## Compatibility | OS | Implemented | | ------- | :---------: | | iOS | ✅ | | Android | ❌ | --------- Co-authored-by: Mikołaj Szydłowski <9szydlowski9@gmail.com>
1 parent 2f20578 commit 596a75b

39 files changed

Lines changed: 1092 additions & 122 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
appId: swmansion.enriched.example
2+
tags:
3+
- ios-only
4+
---
5+
# Visually validates text alignment rendering
6+
- launchApp
7+
8+
- tapOn:
9+
id: 'toggle-screen-button'
10+
11+
- tapOn:
12+
id: 'editor-input'
13+
14+
# Left-aligned text
15+
- inputText: 'Left aligned'
16+
- pressKey: Enter
17+
18+
# Centre-aligned
19+
- tapOn:
20+
id: 'toolbar-align-center'
21+
- inputText: 'Centre aligned'
22+
- pressKey: Enter
23+
24+
# Heading 6
25+
- tapOn:
26+
id: 'toolbar-heading-6'
27+
- inputText: 'Heading 6'
28+
- pressKey: Enter
29+
30+
# Right-aligned text
31+
- tapOn:
32+
id: 'toolbar-align-right'
33+
- inputText: 'Right aligned'
34+
- pressKey: Enter
35+
36+
# List with right-aligned items
37+
- tapOn:
38+
id: 'toolbar-ordered-list'
39+
- inputText: 'Element 1'
40+
- pressKey: Enter
41+
- inputText: 'Element 2'
42+
43+
- runFlow:
44+
file: '../subflows/capture_or_assert_screenshot.yaml'
45+
env:
46+
SCREENSHOT_NAME: 'paragraph_styles_alignment'
32.6 KB
Loading
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
appId: swmansion.enriched.example
2+
---
3+
# Validates that text alignment is displayed correctly
4+
- launchApp
5+
6+
- tapOn:
7+
id: 'toggle-screen-button'
8+
9+
- tapOn:
10+
id: 'toggle-enriched-text-screen-button'
11+
12+
- runFlow:
13+
file: '../subflows/set_enriched_text_value.yaml'
14+
env:
15+
VALUE: >
16+
<html>
17+
<p>Left aligned</p>
18+
<p style="text-align: center">Centre aligned</p>
19+
<h6 style="text-align: center">Heading 6</h6>
20+
<p style="text-align: right">Right aligned</p>
21+
<ol style="text-align: right">
22+
<li>Element 1</li>
23+
<li>Element 2</li>
24+
</ol>
25+
</html>
26+
27+
- runFlow:
28+
file: '../subflows/capture_or_assert_screenshot.yaml'
29+
env:
30+
SCREENSHOT_NAME: 'alignment_visual'
35.5 KB
Loading

android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,13 @@ class EnrichedTextInputViewManager :
448448
view?.requestHTML(requestId)
449449
}
450450

451+
override fun setTextAlignment(
452+
view: EnrichedTextInputView?,
453+
alignment: String,
454+
) {
455+
TODO("Not yet implemented")
456+
}
457+
451458
override fun measure(
452459
context: Context,
453460
localData: ReadableMap?,

apps/example/src/components/Toolbar.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,18 @@ const STYLE_ITEMS = [
8585
name: 'checkbox-list',
8686
icon: 'check-square-o',
8787
},
88+
{
89+
name: 'align-left',
90+
icon: 'align-left',
91+
},
92+
{
93+
name: 'align-center',
94+
icon: 'align-center',
95+
},
96+
{
97+
name: 'align-right',
98+
icon: 'align-right',
99+
},
88100
] as const;
89101

90102
type Item = (typeof STYLE_ITEMS)[number];
@@ -168,6 +180,15 @@ export const Toolbar: FC<ToolbarProps> = ({
168180
case 'mention':
169181
editorRef.current?.startMention('@');
170182
break;
183+
case 'align-left':
184+
editorRef.current?.setTextAlignment('left');
185+
break;
186+
case 'align-center':
187+
editorRef.current?.setTextAlignment('center');
188+
break;
189+
case 'align-right':
190+
editorRef.current?.setTextAlignment('right');
191+
break;
171192
}
172193
};
173194

@@ -256,6 +277,12 @@ export const Toolbar: FC<ToolbarProps> = ({
256277
return stylesState.mention.isActive;
257278
case 'checkbox-list':
258279
return stylesState.checkboxList.isActive;
280+
case 'align-left':
281+
return stylesState.alignment === 'left';
282+
case 'align-center':
283+
return stylesState.alignment === 'center';
284+
case 'align-right':
285+
return stylesState.alignment === 'right';
259286
default:
260287
return false;
261288
}

apps/example/src/constants/editorConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const DEFAULT_STYLES: StylesState = {
3232
image: DEFAULT_STYLE_STATE,
3333
mention: DEFAULT_STYLE_STATE,
3434
checkboxList: DEFAULT_STYLE_STATE,
35+
alignment: 'left',
3536
};
3637

3738
export const DEFAULT_LINK_STATE = {

docs/INPUT_API_REFERENCE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,12 +292,14 @@ interface OnChangeStateEvent {
292292
isConflicting: boolean;
293293
isBlocking: boolean;
294294
};
295+
alignment: string;
295296
}
296297
```
297298

298299
- `isActive` indicates if the style is active within current selection.
299300
- `isBlocking` indicates if the style is blocked by other currently active, meaning it can't be toggled.
300301
- `isConflicting` indicates if the style is in conflict with other currently active styles, meaning toggling it will remove conflicting style.
302+
- `alignment` indicates the current text alignment of the paragraph at the cursor position. Possible values: `'left'`, `'center'`, `'right'`, `'justify'`.
301303

302304
| Type | Platform |
303305
|-------------------------------------------------------------|----------|
@@ -604,6 +606,16 @@ Sets the selection at the given indexes.
604606
- `start: number` - starting index of the selection.
605607
- `end: number` - first index after the selection's ending index. For just a cursor in place (no selection), `start` equals `end`.
606608

609+
### `.setTextAlignment()`
610+
611+
```ts
612+
setTextAlignment: (alignment: 'left' | 'center' | 'right' | 'justify' | 'default') => void;
613+
```
614+
615+
Sets text alignment for the paragraph(s) at the current selection. When inside a list, the alignment is applied to all contiguous list items.
616+
617+
- `alignment` - the desired text alignment. Use `'default'` to reset to the system natural alignment.
618+
607619
### `.startMention()`
608620

609621
```ts

ios/EnrichedTextInputView.mm

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#import "EnrichedTextInputView.h"
2+
#import "AlignmentUtils.h"
23
#import "AttachmentLayoutUtils.h"
34
#import "CoreText/CoreText.h"
45
#import "DotReplacementUtils.h"
@@ -58,6 +59,7 @@ @implementation EnrichedTextInputView {
5859
NSArray<NSDictionary *> *_contextMenuItems;
5960
NSString *_submitBehavior;
6061
NSDictionary<NSAttributedStringKey, id> *_capturedAttributesBeforeChange;
62+
NSString *_recentlyEmittedAlignment;
6163
}
6264

6365
@synthesize blockEmitting = blockEmitting;
@@ -128,6 +130,7 @@ - (void)setDefaults {
128130
_blockedStyles = [[NSMutableSet alloc] init];
129131
_recentlyActiveLinkRange = NSMakeRange(0, 0);
130132
_recentlyActiveMentionRange = NSMakeRange(0, 0);
133+
_recentlyEmittedAlignment = @"left";
131134
_recentInputString = @"";
132135
_recentlyEmittedHtml = @"<html>\n<p></p>\n</html>";
133136
_emitHtml = NO;
@@ -843,6 +846,20 @@ - (void)refreshPlaceholderLabelStyles {
843846
if (_placeholderColor != nullptr) {
844847
newAttrs[NSForegroundColorAttributeName] = _placeholderColor;
845848
}
849+
850+
// Get the current active alignment in input
851+
NSParagraphStyle *currentTypingPara =
852+
textView.typingAttributes[NSParagraphStyleAttributeName];
853+
NSTextAlignment activeAlignment =
854+
currentTypingPara ? currentTypingPara.alignment : NSTextAlignmentNatural;
855+
NSMutableParagraphStyle *placeholderPStyle =
856+
[newAttrs[NSParagraphStyleAttributeName] mutableCopy];
857+
if (!placeholderPStyle) {
858+
placeholderPStyle = [[NSMutableParagraphStyle alloc] init];
859+
}
860+
placeholderPStyle.alignment = activeAlignment;
861+
newAttrs[NSParagraphStyleAttributeName] = placeholderPStyle;
862+
846863
NSAttributedString *newAttrStr =
847864
[[NSAttributedString alloc] initWithString:_placeholderLabel.text
848865
attributes:newAttrs];
@@ -1052,12 +1069,20 @@ - (void)tryUpdatingActiveStyles {
10521069
}
10531070
}
10541071

1072+
// detect alignment change
1073+
AlignmentStyle *alignmentStyle = stylesDict[@([AlignmentStyle getType])];
1074+
NSString *currentAlignment = [alignmentStyle getStyleState];
1075+
if (![currentAlignment isEqualToString:_recentlyEmittedAlignment]) {
1076+
updateNeeded = YES;
1077+
}
1078+
10551079
if (updateNeeded) {
10561080
auto emitter = [self getEventEmitter];
10571081
if (emitter != nullptr) {
10581082
// update activeStyles and blockedStyles only if emitter is available
10591083
_activeStyles = newActiveStyles;
10601084
_blockedStyles = newBlockedStyles;
1085+
_recentlyEmittedAlignment = currentAlignment;
10611086

10621087
emitter->onChangeState(
10631088
{.bold = GET_STYLE_STATE([BoldStyle getType]),
@@ -1078,7 +1103,8 @@ - (void)tryUpdatingActiveStyles {
10781103
.blockQuote = GET_STYLE_STATE([BlockQuoteStyle getType]),
10791104
.codeBlock = GET_STYLE_STATE([CodeBlockStyle getType]),
10801105
.image = GET_STYLE_STATE([ImageStyle getType]),
1081-
.checkboxList = GET_STYLE_STATE([CheckboxListStyle getType])});
1106+
.checkboxList = GET_STYLE_STATE([CheckboxListStyle getType]),
1107+
.alignment = [currentAlignment UTF8String]});
10821108
}
10831109
}
10841110

@@ -1203,6 +1229,20 @@ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args {
12031229
} else if ([commandName isEqualToString:@"requestHTML"]) {
12041230
NSInteger requestId = [((NSNumber *)args[0]) integerValue];
12051231
[self requestHTML:requestId];
1232+
} else if ([commandName isEqualToString:@"setTextAlignment"]) {
1233+
NSString *alignmentString = (NSString *)args[0];
1234+
1235+
AlignmentStyle *alignmentStyle = stylesDict[@([AlignmentStyle getType])];
1236+
[alignmentStyle
1237+
addAlignment:[AlignmentUtils stringToAlignment:alignmentString]
1238+
range:textView.selectedRange
1239+
withTyping:YES
1240+
withDirtyRange:YES];
1241+
1242+
[self anyTextMayHaveBeenModified];
1243+
if (!_placeholderLabel.isHidden) {
1244+
[self refreshPlaceholderLabelStyles];
1245+
}
12061246
}
12071247
}
12081248

@@ -1768,6 +1808,9 @@ - (void)emitOnContextMenuItemPressEvent:(NSString *)itemText {
17681808
[textView.textStorage.string substringWithRange:selectedRange];
17691809
}
17701810

1811+
AlignmentStyle *alignmentStyle = stylesDict[@([AlignmentStyle getType])];
1812+
NSString *currentAlignment = [alignmentStyle getStyleState];
1813+
17711814
emitter->onContextMenuItemPress(
17721815
{.itemText = [itemText toCppString],
17731816
.selectedText = [selectedText toCppString],
@@ -1793,7 +1836,8 @@ - (void)emitOnContextMenuItemPressEvent:(NSString *)itemText {
17931836
.link = GET_STYLE_STATE([LinkStyle getType]),
17941837
.image = GET_STYLE_STATE([ImageStyle getType]),
17951838
.mention = GET_STYLE_STATE([MentionStyle getType]),
1796-
.checkboxList = GET_STYLE_STATE([CheckboxListStyle getType])}});
1839+
.checkboxList = GET_STYLE_STATE([CheckboxListStyle getType]),
1840+
.alignment = [currentAlignment UTF8String]}});
17971841
}
17981842
}
17991843

ios/enrichedInputTextView/EnrichedInputTextView/EnrichedInputTextView.mm

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
#import "EnrichedInputTextView.h"
2+
#import "AlignmentUtils.h"
23
#import "EnrichedTextInputView.h"
34
#import "HtmlParser.h"
45
#import "StringExtension.h"
56
#import "TextInsertionUtils.h"
7+
#import "TextListsUtils.h"
68
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
79

810
@implementation EnrichedInputTextView
@@ -18,6 +20,44 @@ - (void)layoutSubviews {
1820
}
1921
}
2022

23+
// UITextView places the cursor at the leading edge when a paragraph contains
24+
// zero (or invisible) glyphs because the layout engine has nothing to align.
25+
// We fix this by reading the active alignment and repositioning the caret rect
26+
- (CGRect)caretRectForPosition:(UITextPosition *)position {
27+
CGRect rect = [super caretRectForPosition:position];
28+
NSUInteger idx = [self offsetFromPosition:self.beginningOfDocument
29+
toPosition:position];
30+
NSString *text = self.textStorage.string;
31+
NSRange paraRange = [text paragraphRangeForRange:NSMakeRange(idx, 0)];
32+
33+
// Non-empty paragraph gets its caret drawn the usual way.
34+
if (paraRange.length != 0) {
35+
return rect;
36+
}
37+
38+
NSParagraphStyle *pStyle =
39+
self.typingAttributes[NSParagraphStyleAttributeName];
40+
41+
if (pStyle == nil) {
42+
return rect;
43+
}
44+
45+
NSString *marker =
46+
[TextListsUtils firstTextListWithPrefix:@"EnrichedAlignment"
47+
inArray:pStyle.textLists]
48+
.markerFormat;
49+
NSTextAlignment alignment = [AlignmentUtils markerToAlignment:marker];
50+
CGFloat containerWidth = self.textContainer.size.width;
51+
52+
if (alignment == NSTextAlignmentCenter) {
53+
rect.origin.x = (containerWidth - rect.size.width) / 2.0;
54+
} else if (alignment == NSTextAlignmentRight) {
55+
rect.origin.x = containerWidth - rect.size.width;
56+
}
57+
58+
return rect;
59+
}
60+
2161
- (void)copy:(id)sender {
2262
EnrichedTextInputView *typedInput = (EnrichedTextInputView *)_input;
2363
if (typedInput == nullptr) {

0 commit comments

Comments
 (0)