ReadViewSDK/Doc/WXRead/decompiled/WRCoreTextLayoutFrame.m
2026-05-21 19:40:51 +08:00

986 lines
29 KiB
Objective-C

//
// WRCoreTextLayoutFrame.m
// WeRead
//
// Reverse-engineered from binary analysis
// Single page layout frame implementation
//
// This file contains pseudo-code reconstruction of the WRCoreTextLayoutFrame
// class based on ivar analysis, method signatures, and behavioral context.
//
#import "WRCoreTextLayoutFrame.h"
#import <CoreText/CoreText.h>
// Forward declarations for WeRead-specific classes
@class WRBookCoverView;
@class WRDiscover;
@class RACSubject;
#pragma mark - Internal Constants
// Threshold for avoid-page-break-inside calculation
static const CGFloat kPageBreakAvoidanceThreshold = 20.0;
// Maximum lines to remove for page break avoidance
static const NSUInteger kMaxLinesToRemove = 3;
#pragma mark - WRCoreTextLayoutLine Implementation
@implementation WRCoreTextLayoutLine
- (instancetype)initWithCTLine:(CTLineRef)ctLine origin:(CGPoint)origin range:(NSRange)range {
self = [super init];
if (self) {
_ctLine = (CTLineRef)CFRetain(ctLine);
_origin = origin;
_stringRange = range;
// Calculate typographic metrics
CGFloat ascent, descent, leading;
CTLineGetTypographicBounds(_ctLine, &ascent, &descent, &leading);
_ascent = ascent;
_descent = descent;
_leading = leading;
_height = ascent + descent + leading;
_width = CTLineGetTypographicBounds(_ctLine, NULL, NULL, NULL);
}
return self;
}
- (void)dealloc {
if (_ctLine) {
CFRelease(_ctLine);
_ctLine = NULL;
}
}
- (BOOL)isLastLineInParagraph {
// A line is the last in a paragraph if it ends with a newline
// or if the next line starts a new paragraph
CFRange cfRange = CTLineGetStringRange(_ctLine);
if (cfRange.length == 0) return NO;
NSUInteger endIndex = cfRange.location + cfRange.length - 1;
// This would need access to the full attributed string to determine
// For now, we use the stored property
return _isLastLineInParagraph;
}
@end
#pragma mark - Private Interface
@interface WRCoreTextLayoutFrame () {
// The CoreText frame object
CTFrameRef _ctFrame;
// Cached layout lines
NSArray<WRCoreTextLayoutLine *> *_cachedLines;
// Whether lines have been extracted
BOOL _linesExtracted;
// Render height cache
CGFloat _cachedRenderHeight;
BOOL _renderHeightCached;
// Internal lock for thread safety
NSLock *_frameLock;
}
@end
#pragma mark - WRCoreTextLayoutFrame Implementation
@implementation WRCoreTextLayoutFrame
#pragma mark - Lifecycle
- (instancetype)init {
self = [super init];
if (self) {
[self commonInit];
}
return self;
}
- (instancetype)initWithCTFrame:(CTFrameRef)ctFrame range:(NSRange)range {
self = [super init];
if (self) {
[self commonInit];
[self setCTFrame:ctFrame range:range];
}
return self;
}
/**
* Common initialization - sets up internal state.
*/
- (void)commonInit {
_frameLock = [[NSLock alloc] init];
_cachedLines = @[];
_linesExtracted = NO;
_renderHeightCached = NO;
_cachedRenderHeight = 0;
_customAttributes = [NSMutableDictionary dictionary];
_visibleElements = [NSMutableArray array];
_accessibilityElements = [NSMutableArray array];
_computedValueCache = [NSMutableDictionary dictionary];
_selectedLineIndices = [NSMutableSet set];
_avoidPageBreakInside = NO;
_numberOfColumns = 1;
_columnGap = 20.0;
_contentInsets = UIEdgeInsetsZero;
}
- (void)dealloc {
if (_ctFrame) {
CFRelease(_ctFrame);
_ctFrame = NULL;
}
}
#pragma mark - Property Accessors
- (void)setCTFrame:(CTFrameRef)ctFrame range:(NSRange)range {
[_frameLock lock];
if (_ctFrame) {
CFRelease(_ctFrame);
}
_ctFrame = ctFrame ? (CTFrameRef)CFRetain(ctFrame) : NULL;
_stringRange = range;
// Invalidate cached data
_linesExtracted = NO;
_renderHeightCached = NO;
_cachedLines = @[];
[_frameLock unlock];
}
- (CTFrameRef)ctFrame {
return _ctFrame;
}
- (void)setAvoidPageBreakInside:(BOOL)avoidPageBreakInside {
_avoidPageBreakInside = avoidPageBreakInside;
if (avoidPageBreakInside) {
// When enabled, we need to check and potentially remove lines
[self avoidPageBreakInsideByRemovingLastLinesIfNeeded];
}
}
#pragma mark - Line Extraction
/**
* Extracts layout lines from the CTFrame.
*
* This method iterates through the CTFrame's lines and creates
* WRCoreTextLayoutLine wrapper objects with position information.
*
* Algorithm:
* 1. Get array of CTLines from CTFrame
* 2. For each CTLine:
* a. Get its origin from CTFrameGetLineOrigins
* b. Get the string range from CTLineGetStringRange
* c. Create WRCoreTextLayoutLine wrapper
* d. Determine if it's the last line in a paragraph
* 3. Cache the results
*/
- (void)extractLines {
if (_linesExtracted || !_ctFrame) {
return;
}
[_frameLock lock];
// Get the lines from the CTFrame
CFArrayRef ctLines = CTFrameGetLines(_ctFrame);
if (!ctLines) {
[_frameLock unlock];
return;
}
CFIndex lineCount = CFArrayGetCount(ctLines);
if (lineCount == 0) {
_cachedLines = @[];
_linesExtracted = YES;
[_frameLock unlock];
return;
}
// Get line origins
CGPoint *origins = (CGPoint *)malloc(sizeof(CGPoint) * lineCount);
CTFrameGetLineOrigins(_ctFrame, CFRangeMake(0, 0), origins);
NSMutableArray<WRCoreTextLayoutLine *> *lines = [NSMutableArray arrayWithCapacity:lineCount];
for (CFIndex i = 0; i < lineCount; i++) {
CTLineRef ctLine = CFArrayGetValueAtIndex(ctLines, i);
// Get the string range for this line
CFRange cfRange = CTLineGetStringRange(ctLine);
NSRange range = NSMakeRange(cfRange.location, cfRange.length);
// Create layout line wrapper
WRCoreTextLayoutLine *layoutLine = [[WRCoreTextLayoutLine alloc]
initWithCTLine:ctLine
origin:origins[i]
range:range];
// Determine if this is the last line in a paragraph
// Check if the next line starts a new paragraph or if this is the last line
if (i == lineCount - 1) {
layoutLine.isLastLineInParagraph = YES;
} else {
// Check if the character after this line is a newline
NSUInteger endOfLine = range.location + range.length;
if (endOfLine < [_attributedString length]) {
unichar nextChar = [[_attributedString string] characterAtIndex:endOfLine];
layoutLine.isLastLineInParagraph = (nextChar == '\n' || nextChar == '\r');
}
}
[lines addObject:layoutLine];
}
free(origins);
_cachedLines = [lines copy];
_linesExtracted = YES;
[_frameLock unlock];
}
/**
* Returns the array of layout lines.
* Triggers extraction if not already done.
*/
- (NSArray<WRCoreTextLayoutLine *> *)lines {
[self extractLines];
return _cachedLines;
}
- (NSUInteger)lineCount {
return [[self lines] count];
}
- (WRCoreTextLayoutLine *)lineAtIndex:(NSUInteger)index {
NSArray *lines = [self lines];
if (index < [lines count]) {
return lines[index];
}
return nil;
}
#pragma mark - Height Calculation
/**
* Calculates the rendered content height.
*
* This method determines the actual height of the rendered content
* by examining the position of the last line. The rendered height
* may be less than the frame height if there's unused space at the bottom.
*
* Algorithm:
* 1. Get all layout lines
* 2. Find the lowest line (by origin.y - descent)
* 3. Return that position as the rendered height
*
* @return The rendered content height in points
*/
- (CGFloat)getRenderHeight {
[_frameLock lock];
if (_renderHeightCached) {
[_frameLock unlock];
return _cachedRenderHeight;
}
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
if ([lines count] == 0) {
_cachedRenderHeight = 0;
_renderHeightCached = YES;
[_frameLock unlock];
return 0;
}
// Find the lowest point of the last line
// The last line's bottom edge (origin.y - descent) gives us the content height
WRCoreTextLayoutLine *lastLine = [lines lastObject];
// In CoreText, the coordinate system is flipped (origin at bottom-left)
// The line's origin.y is the baseline position
// To get the bottom of the line, we subtract the descent
CGFloat lastLineBottom = lastLine.origin.y - lastLine.descent;
// The rendered height is from the top of the frame to the bottom of the last line
// Since CoreText uses bottom-left origin, we need to convert
CGFloat frameHeight = _frame.size.height;
_cachedRenderHeight = frameHeight - lastLineBottom;
// Add content insets
_cachedRenderHeight += _contentInsets.top + _contentInsets.bottom;
_renderHeightCached = YES;
[_frameLock unlock];
return _cachedRenderHeight;
}
/**
* Returns the descent of the last line.
* Used for precise baseline alignment at the bottom of the page.
*/
- (CGFloat)lastLineDescent {
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
if ([lines count] == 0) {
return 0;
}
WRCoreTextLayoutLine *lastLine = [lines lastObject];
return lastLine.descent;
}
#pragma mark - Drawing
/**
* Draws the layout frame content into a CGContext.
*
* This is the primary rendering method for the reading view.
* It performs the following steps:
*
* 1. Save the graphics state
* 2. Flip the coordinate system (UIKit vs CoreText)
* 3. Apply position offset
* 4. Draw text using CTFrameDraw
* 5. Draw image attachments at their positions
* 6. Draw decorative elements (strikethrough, underline, highlights)
* 7. Restore the graphics state
*
* @param context The CGContext to draw into
* @param image Optional image (used for cover pages)
* @param size The size of the drawing area
* @param rect The rectangle to draw within
* @param position The position offset for multi-page layouts
*/
- (void)drawInContext:(CGContextRef)context
image:(UIImage *)image
size:(CGSize)size
inRect:(CGRect)rect
position:(CGPoint)position {
if (!context || !_ctFrame) {
return;
}
[_frameLock lock];
// Save graphics state before drawing
CGContextSaveGState(context);
// CoreText uses a bottom-left origin, UIKit uses top-left
// We need to flip the coordinate system
CGContextTranslateCTM(context, 0, rect.size.height);
CGContextScaleCTM(context, 1.0, -1.0);
// Apply the position offset (for multi-column or multi-page layouts)
CGContextTranslateCTM(context, position.x, -position.y);
// Apply content insets
CGContextTranslateCTM(context, _contentInsets.left, _contentInsets.bottom);
// Draw the optional image (for cover pages or special layouts)
if (image) {
[self drawCoverImage:image inContext:context rect:rect];
}
// Draw the main text content
CTFrameDraw(_ctFrame, context);
// Draw image attachments
[self drawAttachmentsInContext:context inRect:rect];
// Draw decorative elements
[self drawDecorativeElementsInContext:context inRect:rect];
// Restore graphics state
CGContextRestoreGState(context);
[_frameLock unlock];
}
/**
* Draws only the text content without images.
*/
- (void)drawTextInContext:(CGContextRef)context inRect:(CGRect)rect {
if (!context || !_ctFrame) {
return;
}
[_frameLock lock];
CGContextSaveGState(context);
// Flip coordinate system
CGContextTranslateCTM(context, 0, rect.size.height);
CGContextScaleCTM(context, 1.0, -1.0);
// Apply content insets
CGContextTranslateCTM(context, _contentInsets.left, _contentInsets.bottom);
// Draw just the text
CTFrameDraw(_ctFrame, context);
CGContextRestoreGState(context);
[_frameLock unlock];
}
/**
* Draws image attachments at their calculated positions.
*
* This method iterates through the attachment array and draws each
* image at the position determined by the layout engine.
*
* The attachment positions are calculated during layout and stored
* in the attachments array. Each attachment has:
* - A position (origin point)
* - A size
* - An image reference
*/
- (void)drawAttachmentsInContext:(CGContextRef)context inRect:(CGRect)rect {
if (!_attachments || [_attachments count] == 0) {
return;
}
for (NSDictionary *attachment in _attachments) {
UIImage *image = attachment[@"image"];
NSValue *positionValue = attachment[@"position"];
NSValue *sizeValue = attachment[@"size"];
if (!image || !positionValue || !sizeValue) {
continue;
}
CGPoint imagePosition = [positionValue CGPointValue];
CGSize imageSize = [sizeValue CGSizeValue];
CGRect imageRect = CGRectMake(imagePosition.x,
imagePosition.y,
imageSize.width,
imageSize.height);
// Draw the image
CGContextDrawImage(context, imageRect, image.CGImage);
}
}
/**
* Draws decorative elements like strikethrough and underline.
*/
- (void)drawDecorativeElementsInContext:(CGContextRef)context inRect:(CGRect)rect {
// Draw strikethrough lines
if (_strikethroughRanges) {
[self drawStrikethroughInContext:context];
}
// Draw underlines
if (_underlineRanges) {
[self drawUnderlineInContext:context];
}
// Draw highlights
if (_highlightRanges) {
[self drawHighlightsInContext:context];
}
}
/**
* Draws strikethrough lines for the specified ranges.
*/
- (void)drawStrikethroughInContext:(CGContextRef)context {
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
for (NSValue *rangeValue in _strikethroughRanges) {
NSRange range = [rangeValue rangeValue];
// Find lines that intersect with this range
for (WRCoreTextLayoutLine *line in lines) {
NSRange lineRange = line.stringRange;
NSRange intersection = NSIntersectionRange(range, lineRange);
if (intersection.length > 0) {
// Get the x positions for the strikethrough
CGFloat startX = CTLineGetOffsetForStringIndex(
line.ctLine, intersection.location, NULL);
CGFloat endX = CTLineGetOffsetForStringIndex(
line.ctLine, intersection.location + intersection.length, NULL);
// Strikethrough is at the middle of the line
CGFloat y = line.origin.y + (line.ascent * 0.3);
CGContextSetStrokeColorWithColor(context, [UIColor redColor].CGColor);
CGContextSetLineWidth(context, 1.0);
CGContextMoveToPoint(context, line.origin.x + startX, y);
CGContextAddLineToPoint(context, line.origin.x + endX, y);
CGContextStrokePath(context);
}
}
}
}
/**
* Draws underlines for the specified ranges.
*/
- (void)drawUnderlineInContext:(CGContextRef)context {
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
for (NSDictionary *underlineData in _underlineRanges) {
NSRange range = [underlineData[@"range"] rangeValue];
UIColor *color = underlineData[@"color"] ?: [UIColor blueColor];
for (WRCoreTextLayoutLine *line in lines) {
NSRange lineRange = line.stringRange;
NSRange intersection = NSIntersectionRange(range, lineRange);
if (intersection.length > 0) {
CGFloat startX = CTLineGetOffsetForStringIndex(
line.ctLine, intersection.location, NULL);
CGFloat endX = CTLineGetOffsetForStringIndex(
line.ctLine, intersection.location + intersection.length, NULL);
// Underline is below the baseline
CGFloat y = line.origin.y - line.descent;
CGContextSetStrokeColorWithColor(context, color.CGColor);
CGContextSetLineWidth(context, 1.0);
CGContextMoveToPoint(context, line.origin.x + startX, y);
CGContextAddLineToPoint(context, line.origin.x + endX, y);
CGContextStrokePath(context);
}
}
}
}
/**
* Draws highlight backgrounds for the specified ranges.
*/
- (void)drawHighlightsInContext:(CGContextRef)context {
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
for (NSDictionary *highlightData in _highlightRanges) {
NSRange range = [highlightData[@"range"] rangeValue];
UIColor *color = highlightData[@"color"] ?: [UIColor yellowColor];
for (WRCoreTextLayoutLine *line in lines) {
NSRange lineRange = line.stringRange;
NSRange intersection = NSIntersectionRange(range, lineRange);
if (intersection.length > 0) {
CGFloat startX = CTLineGetOffsetForStringIndex(
line.ctLine, intersection.location, NULL);
CGFloat endX = CTLineGetOffsetForStringIndex(
line.ctLine, intersection.location + intersection.length, NULL);
// Highlight rect from descent to ascent
CGFloat y = line.origin.y - line.descent;
CGFloat height = line.ascent + line.descent;
CGRect highlightRect = CGRectMake(
line.origin.x + startX,
y,
endX - startX,
height
);
CGContextSetFillColorWithColor(context, [color colorWithAlphaComponent:0.3].CGColor);
CGContextFillRect(context, highlightRect);
}
}
}
}
/**
* Draws a cover image for special pages.
*/
- (void)drawCoverImage:(UIImage *)image inContext:(CGContextRef)context rect:(CGRect)rect {
if (!image) return;
// Scale the image to fit the frame while maintaining aspect ratio
CGSize imageSize = image.size;
CGSize frameSize = rect.size;
CGFloat widthRatio = frameSize.width / imageSize.width;
CGFloat heightRatio = frameSize.height / imageSize.height;
CGFloat scale = MIN(widthRatio, heightRatio);
CGSize scaledSize = CGSizeMake(imageSize.width * scale, imageSize.height * scale);
// Center the image in the frame
CGFloat x = (frameSize.width - scaledSize.width) / 2;
CGFloat y = (frameSize.height - scaledSize.height) / 2;
CGRect imageRect = CGRectMake(x, y, scaledSize.width, scaledSize.height);
CGContextDrawImage(context, imageRect, image.CGImage);
}
#pragma mark - Page Break Avoidance
/**
* Implements the CSS 'avoid-page-break-inside' property.
*
* When an element (like a table, code block, or list) has this property,
* we need to ensure it doesn't break across pages. If it would break,
* we remove the last few lines to make room for the element on the next page.
*
* Algorithm:
* 1. Check if avoidPageBreakInside is enabled
* 2. Find elements with this property in the current frame
* 3. For each element, check if it would break
* 4. If it would break, remove lines from the end until the element fits
* 5. Return whether any lines were removed
*
* @return YES if lines were removed to avoid page break
*/
- (BOOL)avoidPageBreakInsideByRemovingLastLinesIfNeeded {
if (!_avoidPageBreakInside) {
return NO;
}
[_frameLock lock];
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
if ([lines count] == 0) {
[_frameLock unlock];
return NO;
}
// Check if any line has the avoid-page-break-inside attribute
// This would be set in the attributed string attributes
BOOL needsRemoval = NO;
NSUInteger linesToRemove = 0;
// Iterate from the end to find lines that shouldn't be broken
for (NSInteger i = [lines count] - 1; i >= 0; i--) {
WRCoreTextLayoutLine *line = lines[i];
NSRange range = line.stringRange;
// Check if this line is part of an element with avoid-page-break-inside
if ([self shouldAvoidPageBreakInRange:range]) {
// Check if the element starts before this line
// If so, we need to remove this line and possibly more
needsRemoval = YES;
linesToRemove++;
// Limit the number of lines we remove
if (linesToRemove >= kMaxLinesToRemove) {
break;
}
} else if (needsRemoval) {
// We've found a line that doesn't need avoidance, stop
break;
}
}
if (needsRemoval && linesToRemove > 0) {
// Remove the last N lines
NSMutableArray *mutableLines = [_cachedLines mutableCopy];
[mutableLines removeObjectsInRange:
NSMakeRange([mutableLines count] - linesToRemove, linesToRemove)];
_cachedLines = [mutableLines copy];
// Update the CTFrame to reflect the removal
// This requires recreating the frame with a shorter range
[self rebuildFrameWithoutLastLines:linesToRemove];
[_frameLock unlock];
return YES;
}
[_frameLock unlock];
return NO;
}
/**
* Checks if a text range has the avoid-page-break-inside property.
*/
- (BOOL)shouldAvoidPageBreakInRange:(NSRange)range {
if (!_attributedString || range.location >= [_attributedString length]) {
return NO;
}
// Check the attributes at the start of the range
NSDictionary *attrs = [_attributedString attributesAtIndex:range.location
effectiveRange:NULL];
// Check for our custom avoid-page-break-inside attribute
NSNumber *avoidBreak = attrs[@"WRAvoidPageBreakInside"];
if ([avoidBreak boolValue]) {
return YES;
}
// Also check for block-level elements that shouldn't break
NSString *blockType = attrs[@"WRBlockType"];
if ([blockType isEqualToString:@"table"] ||
[blockType isEqualToString:@"code"] ||
[blockType isEqualToString:@"list"] ||
[blockType isEqualToString:@"blockquote"]) {
return YES;
}
return NO;
}
/**
* Rebuilds the CTFrame without the last N lines.
* This is used after avoidPageBreakInside removes lines.
*/
- (void)rebuildFrameWithoutLastLines:(NSUInteger)count {
if (!_ctFrame || count == 0) {
return;
}
// Get the current lines
NSArray<WRCoreTextLayoutLine *> *lines = _cachedLines;
if ([lines count] == 0) {
return;
}
// Calculate the new end range
WRCoreTextLayoutLine *lastLine = [lines lastObject];
NSUInteger newEnd = lastLine.stringRange.location + lastLine.stringRange.length;
// Update the string range
_stringRange = NSMakeRange(_stringRange.location, newEnd - _stringRange.location);
// Note: In a real implementation, we would need to recreate the CTFrame
// with the updated range. This requires access to the CTTypesetter.
// For this reconstruction, we just update the range metadata.
}
#pragma mark - Hit Testing
/**
* Returns the character index at a given point.
*
* Uses CTLineGetStringIndexForPosition to find which character
* is at the given point. This is used for text selection and
* tap handling.
*
* @param point The point to test (in the frame's coordinate system)
* @return The character index, or NSNotFound
*/
- (NSUInteger)characterIndexAtPoint:(CGPoint)point {
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
for (WRCoreTextLayoutLine *line in lines) {
// Check if the point is within this line's vertical bounds
CGFloat lineTop = line.origin.y + line.ascent;
CGFloat lineBottom = line.origin.y - line.descent;
if (point.y >= lineBottom && point.y <= lineTop) {
// Use CTLine to find the character index
CFIndex index = CTLineGetStringIndexForPosition(line.ctLine, point);
if (index != kCFNotFound) {
return (NSUInteger)index;
}
}
}
return NSNotFound;
}
/**
* Returns the line index at a given point.
*/
- (NSUInteger)lineIndexAtPoint:(CGPoint)point {
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
for (NSUInteger i = 0; i < [lines count]; i++) {
WRCoreTextLayoutLine *line = lines[i];
CGFloat lineTop = line.origin.y + line.ascent;
CGFloat lineBottom = line.origin.y - line.descent;
if (point.y >= lineBottom && point.y <= lineTop) {
return i;
}
}
return NSNotFound;
}
/**
* Returns the rect for a character at the given index.
*/
- (CGRect)rectForCharacterAtIndex:(NSUInteger)index {
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
for (WRCoreTextLayoutLine *line in lines) {
NSRange range = line.stringRange;
if (index >= range.location && index < range.location + range.length) {
// Get the x position for this character
CGFloat x = CTLineGetOffsetForStringIndex(line.ctLine, index, NULL);
return CGRectMake(line.origin.x + x,
line.origin.y - line.descent,
1, // Width of 1 character
line.height);
}
}
return CGRectNull;
}
#pragma mark - Selection
/**
* Returns the currently selected text.
*/
- (NSString *)selectedText {
NSArray<NSValue *> *ranges = [self selectedRanges];
if ([ranges count] == 0) {
return nil;
}
NSMutableString *selectedText = [NSMutableString string];
for (NSValue *rangeValue in ranges) {
NSRange range = [rangeValue rangeValue];
NSString *substring = [_attributedString.string substringWithRange:range];
[selectedText appendString:substring];
}
return [selectedText copy];
}
/**
* Returns the ranges of selected text.
*/
- (NSArray<NSValue *> *)selectedRanges {
NSMutableArray<NSValue *> *ranges = [NSMutableArray array];
// Build ranges from selected line indices
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
for (NSUInteger i = 0; i < [lines count]; i++) {
if ([_selectedLineIndices containsObject:@(i)]) {
WRCoreTextLayoutLine *line = lines[i];
[ranges addObject:[NSValue valueWithRange:line.stringRange]];
}
}
return [ranges copy];
}
/**
* Selects text in the given range.
*/
- (void)selectTextInRange:(NSRange)range {
NSArray<WRCoreTextLayoutLine *> *lines = [self lines];
for (NSUInteger i = 0; i < [lines count]; i++) {
WRCoreTextLayoutLine *line = lines[i];
NSRange intersection = NSIntersectionRange(range, line.stringRange);
if (intersection.length > 0) {
[_selectedLineIndices addObject:@(i)];
}
}
// Notify via RACSubject
[_layoutChangeSubject sendNext:@{
@"type": @"selection",
@"range": [NSValue valueWithRange:range]
}];
}
/**
* Clears the current selection.
*/
- (void)clearSelection {
[_selectedLineIndices removeAllObjects];
[_layoutChangeSubject sendNext:@{
@"type": @"selectionCleared"
}];
}
#pragma mark - Search
/**
* Highlights search results within this frame.
*
* @param searchString The string to search for
* @return The number of matches found
*/
- (NSUInteger)highlightSearchResults:(NSString *)searchString {
if (!searchString || !_attributedString) {
return 0;
}
NSMutableArray<NSValue *> *results = [NSMutableArray array];
NSString *fullText = _attributedString.string;
// Use NSString's rangeOfString for searching
NSRange searchRange = NSMakeRange(0, [fullText length]);
while (searchRange.location < [fullText length]) {
NSRange foundRange = [fullText rangeOfString:searchString
options:NSCaseInsensitiveSearch
range:searchRange];
if (foundRange.location == NSNotFound) {
break;
}
[results addObject:[NSValue valueWithRange:foundRange]];
// Move search range forward
searchRange.location = foundRange.location + foundRange.length;
searchRange.length = [fullText length] - searchRange.location;
}
// Store results
_searchResultString = searchString;
_searchResultRanges = [results copy];
// Create highlight ranges
NSMutableArray *highlights = [NSMutableArray array];
UIColor *highlightColor = [UIColor yellowColor];
for (NSValue *rangeValue in results) {
[highlights addObject:@{
@"range": rangeValue,
@"color": highlightColor
}];
}
_highlightRanges = [highlights copy];
return [results count];
}
/**
* Clears all search result highlights.
*/
- (void)clearSearchHighlights {
_searchResultString = nil;
_searchResultRanges = nil;
_highlightRanges = nil;
}
@end