Files
ghostgram/submodules/TelegramUI/Components/RichTextEditor/Sources/RichTextInputDecUpstream/TextView/IDBlockStackCanvasView.m
T
ichmagmaus111andClaude Opus 5.5 534cabe35b bug fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:55:08 +01:00

1073 lines
53 KiB
Objective-C

#import <TargetConditionals.h>
#if TARGET_OS_IOS
#import "IDBlockStackCanvasView.h"
#import "IDBlockStackCanvasViewInternal.h"
#import "IDBlockDocument.h"
#import "IDBlockLayout.h"
#import "IDTextLayoutCanvasView.h"
#import "IDTextKit2BlockLayoutProvider.h"
#import <UIKit/UIGestureRecognizerSubclass.h>
#include <math.h>
@interface IDBlockLayout (IDBlockStackCanvasHost)
- (void)id_setCanvasHost:(nullable id)host;
- (CGPoint)id_canvasBoundsOrigin;
- (BOOL)updateContentFromBlock:(IDBlock *)block;
- (BOOL)updateLayoutFromBlock:(IDBlock *)block width:(CGFloat)width;
- (void)id_advanceRevisionsForContentChange:(BOOL)contentChanged
layoutChange:(BOOL)layoutChanged;
@end
/// A placed run's own horizontal scroller. Horizontal only — its content height equals its bounds
/// height, so a vertical pan passes through to whatever scrolls the document. Its content view is
/// what a host parents chrome into; the run's block canvases sit above that chrome.
/// One frame of slack either side of the transient in which UIKit clears `isDecelerating` to take a
/// touch. 0.05s is three frames at 60Hz: long enough to cover a dropped frame, short enough that a
/// run which has genuinely settled is not still "in flight" when the next touch lands.
static const CFTimeInterval id_animationGraceInterval = 0.05;
@interface IDPlacedRunScrollView : UIScrollView
@property(nonatomic, strong, readonly) UIView *runContentView;
@property(nonatomic, copy) NSArray<NSUUID *> *memberIdentifiers;
/// The touch that CAUGHT this run mid-flight, while it is still down. A scroll view caught under a
/// finger is TRACKING until that finger lifts, and this run cannot be: it never receives the touch,
/// so UIKit never puts it in that state. Weak, and reassigned for every touch the pan is offered, so
/// a recycled `UITouch` cannot be mistaken for a catch that is still held.
@property(nonatomic, weak, nullable) UITouch *caughtTouch;
/// Dragging, coasting, or held by the finger that caught it.
@property(nonatomic, readonly) BOOL id_isInFlight;
/// Still under the finger that caught this run mid-flight.
@property(nonatomic, readonly) BOOL id_isHeldByCatchingTouch;
/// A touch ended: settle back into range, but ONLY if it is the touch that caught this run.
- (void)id_settleIfCaughtByEndedTouches:(nullable NSSet<UITouch *> *)touches;
/// When this run last MOVED under its own animation, in `CACurrentMediaTime()`.
///
/// `isDecelerating` cannot answer "is this run in flight" at the moment a touch arrives: UIKit
/// clears it as the pan receives the touch and sets it again on the next frame (measured below), so
/// a gate that reads it there is false exactly when it matters. Motion is the durable fact.
@property(nonatomic) CFTimeInterval id_lastAnimatedMotionTime;
/// Where a catch pinned this run, held while the catching finger is down.
@property(nonatomic) CGFloat id_heldOffsetX;
/// Moving under its own animation right now — `isDecelerating`, or motion within the grace window.
@property(nonatomic, readonly) BOOL id_isAnimatingNow;
@end
/// Watches for the END of a touch, and nothing else.
///
/// A run catches itself through its pan's per-touch delegate callback, which is delivered only when
/// a touch BEGINS — so a run caught mid-rubber-band, held where the finger stopped it, has no way to
/// learn that the finger has gone and to spring back. This recogniser is that signal: it recognises
/// nothing, stays out of every arbitration, and exists to report the end of a touch to the runs.
@interface IDRunCatchReleaseObserver : UIGestureRecognizer
@property(nonatomic, copy, nullable) void (^onTouchesEnded)(NSSet<UITouch *> *touches);
@end
@implementation IDRunCatchReleaseObserver
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[super touchesEnded:touches withEvent:event];
if (self.onTouchesEnded != nil) {
self.onTouchesEnded(touches);
}
}
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[super touchesCancelled:touches withEvent:event];
if (self.onTouchesEnded != nil) {
self.onTouchesEnded(touches);
}
}
@end
@implementation IDPlacedRunScrollView
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self != nil) {
_runContentView = [[UIView alloc] initWithFrame:CGRectZero];
[self addSubview:_runContentView];
self.clipsToBounds = YES;
self.delaysContentTouches = NO;
// Invisible infrastructure: a host draws whatever it wants around a run (a table's grips
// and outline), so the scroller shows no indicator of its own.
self.showsVerticalScrollIndicator = NO;
self.showsHorizontalScrollIndicator = NO;
self.alwaysBounceVertical = NO;
self.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
}
return self;
}
/// Touches fall THROUGH the run, as every block canvas already lets them, so the text interaction
/// sees a cell exactly as it sees a stacked block: the touch's view is the stack canvas. Measured
/// on device before this: a tap inside a run went to the scroll view and never placed the caret.
/// Scrolling survives because the stack canvas hosts this run's pan recognizer on its behalf.
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *hitView = [super hitTest:point withEvent:event];
return (hitView == self || hitView == self.runContentView) ? nil : hitView;
}
/// A FINGER DOWN ON A DECELERATING RUN STOPS IT, which UIScrollView does for itself and cannot do
/// here: every touch in this tree lands on the stack canvas — the block canvases and this view let
/// them fall through so the text interaction sees a cell — so the run is not in the touch's
/// responder chain and `-[UIScrollView touchesBegan:withEvent:]` is never called on it. Measured
/// from a point over a cell: canvas → text view → host, with the run nowhere in it.
///
/// This callback is UIKit's own, delivered as the PAN receives each touch — the same moment a scroll
/// view would normally catch itself — so it does not depend on where the pan is hosted, on the
/// delivery delay the enclosing scroll view imposes on content touches, or on a recogniser of our
/// own competing for the touch. The answer is always YES: this decides nothing about the gesture.
///
/// Settling at the CLAMPED offset matters for a touch during the rubber band: left where it is, the
/// run would rest outside its own range with nothing to bring it back, since the bounce it
/// interrupted is what would have.
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldReceiveTouch:(UITouch *)touch {
if (gestureRecognizer == self.panGestureRecognizer) {
BOOL catching = self.id_isAnimatingNow &&
CGRectContainsPoint(self.bounds, [touch locationInView:self]);
// Assigned either way: every touch the pan is offered decides afresh whether this run is
// being held, so a touch that catches nothing clears a stale one.
self.caughtTouch = catching ? touch : nil;
if (catching) {
// WHERE IT IS, not where it was heading: `-setContentOffset:` below pins it here for as
// long as the finger is down, and the release springs it back into range.
self.id_heldOffsetX = self.contentOffset.x;
[self setContentOffset:self.contentOffset animated:NO];
}
}
return YES;
}
/// Is this run moving under its own animation RIGHT NOW — asked at the one moment `isDecelerating`
/// cannot answer it.
///
/// Traced on device (K3, iOS 27.0, a table 25pt into its rubber band): the frame before the touch
/// reads `decel=1`, the `shouldReceiveTouch:` callback reads `decel=0 drag=0 track=1`, and the frame
/// 16ms LATER reads `decel=1` again, after which the run springs home under the finger. So UIKit
/// clears the flag as its pan takes the touch and restores it when nothing stops the animation —
/// and the gate that read `isDecelerating` there declined every catch that has ever been attempted.
/// One frame of slack either side of that transient is what `id_animationGraceInterval` is.
- (BOOL)id_isAnimatingNow {
if (self.isDecelerating) {
return YES;
}
return (CACurrentMediaTime() - self.id_lastAnimatedMotionTime) < id_animationGraceInterval;
}
/// A caught run does not move until the finger lets go.
///
/// The single assignment in the catch cannot do this: at that instant the scroll view does not
/// consider itself to be decelerating, so there is no animation for it to cancel, and UIKit resumes
/// the bounce on the next frame regardless. Pinning every frame is what "held where the finger
/// stopped it" means, and it ends by itself — a catch the user then DRAGS sets `isDragging`, which
/// releases the pin and hands the run back to its own pan.
- (void)setContentOffset:(CGPoint)contentOffset {
if (self.id_isHeldByCatchingTouch && !self.isDragging) {
contentOffset.x = self.id_heldOffsetX;
} else if (self.isDecelerating || self.isDragging) {
// Only motion the run drives itself counts. A host assigning an offset during a layout pass
// is not this run in flight, and counting it would let a touch right after one read as a
// catch.
if (fabs(contentOffset.x - self.contentOffset.x) > 0.01) {
self.id_lastAnimatedMotionTime = CACurrentMediaTime();
}
}
[super setContentOffset:contentOffset];
}
/// In flight: being dragged, coasting from a drag, or STILL HELD by the finger that caught it.
///
/// The last of those is what a caught scroll view would report as `isTracking`, and this one cannot:
/// it never receives the touch. Without it a catch is in flight for one instant and then not, and an
/// outer gesture watching for its moment takes the very next movement — which is the sheet-dismiss
/// defect this exists for.
- (BOOL)id_isInFlight {
return self.isDragging || self.isDecelerating || self.isTracking ||
self.id_isHeldByCatchingTouch || self.id_isAnimatingNow;
}
- (BOOL)id_isHeldByCatchingTouch {
UITouch *held = self.caughtTouch;
if (held == nil) {
return NO;
}
UITouchPhase phase = held.phase;
return phase == UITouchPhaseBegan || phase == UITouchPhaseMoved ||
phase == UITouchPhaseStationary;
}
/// The finger that caught this run has lifted: if the catch left it outside its own range — caught
/// mid-rubber-band — bring it back, which is what the bounce it interrupted would have done.
///
/// **Only for the touch that CAUGHT it.** Applied to every touch that ends, this ate the ordinary
/// rubber band: the finger that pulled a run past its edge lifts, UIKit has not started its own
/// bounce yet, and the run would be clamped home in one frame — leaving no bounce to catch at all,
/// which is what "the bounce is still not catching" turned out to be (K3, 2026-09-11).
///
/// Nothing to do if the run is moving under its own gesture again: a catch the user then DRAGS ends
/// as any drag does, with UIKit's own bounce.
- (void)id_settleIfCaughtByEndedTouches:(NSSet<UITouch *> *)touches {
UITouch *held = self.caughtTouch;
if (held == nil || (touches != nil && ![touches containsObject:held])) {
return;
}
self.caughtTouch = nil;
if (self.isTracking || self.isDragging || self.isDecelerating) {
return;
}
CGFloat settled = [IDBlockStackCanvasView
id_caughtRunOffsetForCurrent:self.contentOffset.x
maximum:self.contentSize.width - CGRectGetWidth(self.bounds)];
if (fabs(settled - self.contentOffset.x) > 0.5) {
[self setContentOffset:CGPointMake(settled, self.contentOffset.y) animated:YES];
}
}
/// The pan lives on the stack canvas, so it must decline every touch that is not a horizontal drag
/// inside this run — a vertical drag belongs to whatever scrolls the document, a drag elsewhere to
/// another run or to nothing.
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer {
if (gestureRecognizer == self.panGestureRecognizer) {
UIPanGestureRecognizer *pan = (UIPanGestureRecognizer *)gestureRecognizer;
if (!CGRectContainsPoint(self.bounds, [pan locationInView:self])) {
return NO;
}
// A run CAUGHT under this finger keeps the gesture, whichever way it then goes — which is
// what catching a scroll view means everywhere else in iOS: the finger that stops a coast
// owns the drag that follows it. Without this the direction test sends that drag to whatever
// encloses the editor, so a catch reads as "the table cannot be grabbed".
CGPoint velocity = [pan velocityInView:self];
if (!self.id_isHeldByCatchingTouch && fabs(velocity.x) <= fabs(velocity.y)) {
return NO;
}
}
return [super gestureRecognizerShouldBegin:gestureRecognizer];
}
@end
@interface IDBlockStackCanvasView () <UIScrollViewDelegate>
@property(nonatomic) BOOL displayDecorationNeedsUpdate;
@property(nonatomic, strong) IDBlockDocument *document;
@property(nonatomic, copy, readwrite) NSArray<IDBlockLayout *> *blockLayouts;
@property(nonatomic, readwrite) CGSize documentContentSize;
@property(nonatomic, strong) NSDictionary<NSUUID *, IDBlockLayout *> *layoutsByIdentifier;
@property(nonatomic, strong)
NSDictionary<NSUUID *, IDTextKit2BlockLayoutProvider *> *providersByIdentifier;
@property(nonatomic, strong) NSDictionary<NSUUID *, NSValue *> *originsByIdentifier;
@property(nonatomic, copy) NSDictionary<NSUUID *, NSValue *> *hitRectsByIdentifier;
@property(nonatomic, copy) NSDictionary<NSUUID *, NSValue *> *selectionSlotsByIdentifier;
@property(nonatomic, strong) NSMutableDictionary<NSUUID *, IDPlacedRunScrollView *> *runScrollViewsByFirstIdentifier;
@property(nonatomic, weak, nullable) id canvasHost;
@property(nonatomic, weak, nullable) NSTextContainer *textContainer;
@property(nonatomic) NSUInteger geometryGeneration;
@property(nonatomic) CGFloat lastLayoutWidth;
@property(nonatomic) BOOL hasLayoutWidth;
@end
/// Whether a hit rect's vertical extent holds the probe line `y`.
static BOOL IDCanvasRectSpansY(CGRect rect, CGFloat y) {
return y >= CGRectGetMinY(rect) && y < CGRectGetMaxY(rect);
}
// `IDBlockTextView+PrivateCallbacks.m`: adds `textContainerOrigin`, which UIKit sends under a private name.
FOUNDATION_EXTERN void IDBlockStackCanvasViewInstallPrivateCallbacks(Class cls);
@implementation IDBlockStackCanvasView
+ (void)initialize {
IDBlockStackCanvasViewInstallPrivateCallbacks(self);
}
- (IDBlockDocument *)id_document {
return self.document;
}
- (instancetype)initWithDocument:(IDBlockDocument *)document {
if (document == nil) {
[NSException raise:NSInvalidArgumentException
format:@"IDBlockStackCanvasView requires a document."];
}
self = [super initWithFrame:CGRectZero];
if (self == nil) {
return nil;
}
_document = document;
_lineFragmentPadding = 5.0; // NSTextContainer's own default
_blockLayouts = @[];
_layoutsByIdentifier = @{};
_providersByIdentifier = @{};
_originsByIdentifier = @{};
_hitRectsByIdentifier = @{};
_selectionSlotsByIdentifier = @{};
_runScrollViewsByFirstIdentifier = [NSMutableDictionary dictionary];
_geometryGeneration = 0;
_hasLayoutWidth = NO;
self.opaque = NO;
__weak __typeof(self) weakSelf = self;
IDRunCatchReleaseObserver *release = [[IDRunCatchReleaseObserver alloc] initWithTarget:nil action:NULL];
// Part of nothing: it never recognises, so it cancels no touch and blocks no gesture, and it
// must not hold the view's own touch callbacks back while it sits in Possible.
release.cancelsTouchesInView = NO;
release.delaysTouchesBegan = NO;
release.delaysTouchesEnded = NO;
release.onTouchesEnded = ^(NSSet<UITouch *> *touches) {
for (IDPlacedRunScrollView *run in weakSelf.runScrollViewsByFirstIdentifier.allValues) {
[run id_settleIfCaughtByEndedTouches:touches];
}
};
[self addGestureRecognizer:release];
[self updateForDocumentChange:nil width:0];
return self;
}
- (void)setDisplayDecorator:(IDBlockTextDisplayDecorator)displayDecorator {
_displayDecorator = [displayDecorator copy];
self.displayDecorationNeedsUpdate = YES;
}
- (void)id_setDisplayDecorationNeedsUpdate {
self.displayDecorationNeedsUpdate = YES;
}
- (void)setEmptyLineAttributesByBlock:
(NSDictionary<NSUUID *, NSDictionary<NSAttributedStringKey, id> *> *)attributes {
if (_emptyLineAttributesByBlock == attributes ||
[_emptyLineAttributesByBlock isEqualToDictionary:attributes ?: @{}]) {
return;
}
_emptyLineAttributesByBlock = [attributes copy];
// No invalidation here: these are read by the per-block pass in `-updateForDocumentChange:`,
// which `setNeedsLayout` does not run. The text view reconciles after setting this, the way it
// does for `lineFragmentPadding` — the same reason, and the same remedy.
}
- (void)setLineFragmentPadding:(CGFloat)lineFragmentPadding {
if (_lineFragmentPadding == lineFragmentPadding) {
return;
}
_lineFragmentPadding = lineFragmentPadding;
// Applied on CHANGE and at layout CREATION, never on every reconcile: a caller may set a single
// layout's container padding directly (the layout tests drive a negative value through one block
// to produce a negative usage origin), and re-asserting this on every pass would silently undo it.
for (IDBlockLayout *layout in self.blockLayouts) {
layout.textContainer.lineFragmentPadding = lineFragmentPadding;
}
}
- (NSDictionary<NSAttributedStringKey, id> *)id_emptyLineAttributesForIdentifier:(NSUUID *)identifier {
NSDictionary<NSAttributedStringKey, id> *provided =
self.emptyLineAttributesProvider != nil ? self.emptyLineAttributesProvider(identifier) : nil;
return provided != nil ? provided : self.emptyLineAttributesByBlock[identifier];
}
- (CGFloat)id_emptyLineCaretOffsetForIdentifier:(NSUUID *)identifier {
NSNumber *provided =
self.emptyLineCaretOffsetProvider != nil ? self.emptyLineCaretOffsetProvider(identifier) : nil;
return provided != nil ? provided.doubleValue : self.emptyLineCaretOffsetsByBlock[identifier].doubleValue;
}
- (void)updateForDocumentChange:(IDBlockDocumentChange *)change width:(CGFloat)width {
CGFloat layoutWidth = isfinite(width) ? MAX(width, 0) : 0;
CGFloat canvasWidth = ceil(layoutWidth);
BOOL decorationWasDirty = self.displayDecorationNeedsUpdate;
NSArray<NSUUID *> *oldOrderedIdentifiers = self.orderedBlockIdentifiers;
NSDictionary<NSUUID *, NSValue *> *oldOrigins = self.originsByIdentifier;
CGSize oldContentSize = self.documentContentSize;
NSMutableDictionary<NSUUID *, NSArray<NSNumber *> *> *oldRevisions =
[NSMutableDictionary dictionary];
for (IDBlockLayout *layout in self.blockLayouts) {
oldRevisions[layout.blockIdentifier] = @[
@(layout.contentRevision), @(layout.layoutGeneration),
];
}
NSDictionary<NSUUID *, IDBlockLayout *> *oldLayouts = self.layoutsByIdentifier;
NSDictionary<NSUUID *, IDTextKit2BlockLayoutProvider *> *oldProviders =
self.providersByIdentifier;
NSMutableDictionary<NSUUID *, IDBlockLayout *> *newLayouts = [NSMutableDictionary dictionary];
NSMutableDictionary<NSUUID *, IDTextKit2BlockLayoutProvider *> *newProviders =
[NSMutableDictionary dictionary];
NSMutableArray<IDBlockLayout *> *orderedLayouts = [NSMutableArray array];
NSSet<NSUUID *> *affectedIdentifiers = change == nil
? [NSSet set]
: [NSSet setWithArray:change.affectedBlockIdentifiers];
for (IDBlock *block in self.document.blocks) {
UIEdgeInsets blockInset = UIEdgeInsetsZero;
NSValue *blockInsetValue = self.blockInsets[block.identifier];
if (blockInsetValue != nil) {
blockInset = blockInsetValue.UIEdgeInsetsValue;
}
// A block's horizontal insets narrow its text MEASURE, stacked or placed. The placed branch
// always did; a stacked block laid out at the full column and then had its canvas moved and
// sized by the same insets, so text wrapped at the column and was drawn starting one `left`
// in — running `left` past the column's trailing edge. Reserving space horizontally and not
// taking it out of the measure is half an inset.
CGFloat blockLayoutWidth = MAX(layoutWidth - blockInset.left - blockInset.right, 0);
NSValue *placedFrame = self.blockFrames[block.identifier];
if (placedFrame != nil) {
blockLayoutWidth = MAX(placedFrame.CGRectValue.size.width - blockInset.left - blockInset.right, 0);
}
IDBlockLayout *layout = oldLayouts[block.identifier];
if (layout == nil) {
layout = [[IDBlockLayout alloc] initWithBlock:block width:blockLayoutWidth];
layout.displayDecorator = self.displayDecorator;
[layout updateContentFromBlock:block];
// AFTER the initializer, which has already laid out at the container's default padding,
// so the layout has to be redone at the host's value.
layout.textContainer.lineFragmentPadding = self.lineFragmentPadding;
layout.id_hostEmptyLineAttributes = [self id_emptyLineAttributesForIdentifier:block.identifier];
layout.id_hostEmptyLineCaretOffset = [self id_emptyLineCaretOffsetForIdentifier:block.identifier];
[layout updateLayoutFromBlock:block width:blockLayoutWidth];
} else {
layout.displayDecorator = self.displayDecorator;
layout.id_hostEmptyLineAttributes = [self id_emptyLineAttributesForIdentifier:block.identifier];
layout.id_hostEmptyLineCaretOffset = [self id_emptyLineCaretOffsetForIdentifier:block.identifier];
BOOL textActuallyChanged =
![layout.contentStorage.textStorage
isEqualToAttributedString:block.attributedText];
BOOL contentChanged = NO;
// `|| displayDecorationNeedsUpdate` is the decoration-only path: a spoiler reveals
// because the SELECTION moved, which produces no document change at all, so without it
// the rendered text would never be recomputed. `updateContentFromBlock:` compares the
// decorated text itself and no-ops when nothing actually changed.
if ((change != nil &&
([affectedIdentifiers containsObject:block.identifier] ||
textActuallyChanged)) ||
self.displayDecorationNeedsUpdate) {
contentChanged = [layout updateContentFromBlock:block];
}
BOOL layoutChanged =
[layout updateLayoutFromBlock:block width:blockLayoutWidth];
[layout id_advanceRevisionsForContentChange:contentChanged
layoutChange:layoutChanged];
}
IDTextKit2BlockLayoutProvider *provider = oldProviders[block.identifier];
if (provider == nil || oldLayouts[block.identifier] != layout) {
provider = [[IDTextKit2BlockLayoutProvider alloc]
initWithBlockLayout:layout];
}
newProviders[block.identifier] = provider;
[layout id_setCanvasHost:self.canvasHost];
if (layout.canvasView.superview != self) {
[self addSubview:layout.canvasView];
}
newLayouts[block.identifier] = layout;
[orderedLayouts addObject:layout];
}
for (NSUUID *identifier in oldLayouts) {
if (newLayouts[identifier] != nil) {
continue;
}
IDBlockLayout *deleted = oldLayouts[identifier];
[deleted.canvasView removeFromSuperview];
[deleted id_setCanvasHost:nil];
}
NSMutableDictionary<NSUUID *, NSValue *> *origins = [NSMutableDictionary dictionary];
NSMutableDictionary<NSUUID *, NSValue *> *hitRects = [NSMutableDictionary dictionary];
NSMutableDictionary<NSUUID *, NSValue *> *slots = [NSMutableDictionary dictionary];
NSMutableSet<NSUUID *> *liveRunStarts = [NSMutableSet set];
__block CGFloat y = self.contentInsets.top;
__block CGFloat documentWidth = canvasWidth;
__block NSUInteger subviewIndex = 0;
// `self.subviews.count`, kept by hand through the pass below. `-subviews` builds a fresh array on
// every call once an insert has invalidated UIKit's cache, and the pass inserted and then asked
// for it once per block: O(n²) in the block count, ~30% of a 200-paragraph paste (composer
// harness, 2026-09-23, `HarnessLargeRichPasteScalingTests`). Read once here, then adjusted at
// each of the pass's three hierarchy changes.
//
// `hierarchy` is `subviews` as it stands before the pass reorders anything, and holds while
// `hierarchyUnchanged` does. A view already at its index is then not re-inserted:
// `insertSubview:atIndex:` looks the view up in `subviews` (O(n)) even when nothing moves, so
// re-inserting every canvas made each pass O(n²) (composer harness, 2026-09-25: Select All ▸
// Bold over media blocks runs ~20 passes; `HarnessAtomFormatScalingTests`). After the first real
// change the pass inserts as before.
NSArray<UIView *> *hierarchy = self.subviews;
__block BOOL hierarchyUnchanged = YES;
__block NSUInteger subviewCount = hierarchy.count;
const CGFloat blockOriginX = self.contentInsets.left;
// A run of consecutive placed blocks shares an anchor: the cursor where the run began. The
// cursor does not move inside the run; it advances past the run's extent once, after it. The
// run's canvases live inside its own horizontal scroll view; the origin map and hit rects
// carry VISUAL positions, so every geometry consumer sees the scroll without knowing of it.
__block BOOL inPlacedRun = NO;
__block CGPoint runAnchor = CGPointZero;
__block CGFloat runExtent = 0;
__block CGFloat runWidth = 0;
__block IDPlacedRunScrollView *runScroll = nil;
__block NSMutableArray<NSUUID *> *runMembers = nil;
void (^closeRun)(void) = ^{
if (!inPlacedRun) {
return;
}
inPlacedRun = NO;
// The run's LEADING RHYTHM belongs to its first row. A host reserves the gap above a table
// by starting the run's placed frames below its anchor, so that space is inside the run and
// inside no block's slot — the one place a document of slots would still have a hole. Giving
// it to the blocks that sit at the top of the run is the same rule every stacked block
// follows in reaching its neighbour.
CGFloat runSlotTop = CGFLOAT_MAX;
for (NSUUID *member in runMembers) {
NSValue *slot = slots[member];
if (slot != nil) {
runSlotTop = MIN(runSlotTop, CGRectGetMinY(slot.CGRectValue));
}
}
if (runSlotTop > runAnchor.y) {
for (NSUUID *member in runMembers) {
NSValue *slot = slots[member];
if (slot == nil) continue;
CGRect box = slot.CGRectValue;
// The first row only: a lower row's gap to the row above it is the grid's border,
// which is the table's to draw and not a hole in the rhythm.
if (CGRectGetMinY(box) > runSlotTop + 0.5) continue;
box.size.height += CGRectGetMinY(box) - runAnchor.y;
box.origin.y = runAnchor.y;
slots[member] = [NSValue valueWithCGRect:box];
}
}
CGFloat contentWidth = MAX(runWidth, canvasWidth);
// Where this run's clip ends. The scroll view grows by the outsets and its content view
// shifts right by the leading one, so the run's content stays exactly where it was laid out
// and only the window onto it changes. The scrollable RANGE is unchanged too: the content
// size grows by both outsets, which is what the frame grew by.
UIEdgeInsets clipOutset = UIEdgeInsetsZero;
NSValue *clipValue = runMembers.firstObject == nil
? nil : self.placedRunClipOutsets[runMembers.firstObject];
if (clipValue != nil) {
clipOutset = clipValue.UIEdgeInsetsValue;
}
// WHERE THE RUN MAY REST is its COLUMN, and that is a different question from where it is
// clipped: the clip is what is visible, the column is what the run belongs to. They are the
// same rect for a run the host draws nothing around, and they are not for one inside a
// container — a host may show content through a margin it does not let the run rest in.
//
// Defaults to the text column, which is what every run had before this existed.
UIEdgeInsets columnOutset = UIEdgeInsetsZero;
NSValue *columnValue = runMembers.firstObject == nil
? nil : self.placedRunColumnOutsets[runMembers.firstObject];
if (columnValue != nil) {
columnOutset = columnValue.UIEdgeInsetsValue;
}
// The travel: enough for the run's right edge to reach its COLUMN's right edge, and no more.
// A narrower column adds travel (the part beyond it must still be reachable); a wider one
// takes it away. The CLIP does not enter into it — a window wider than the column is what a
// scrolling run passes under, never somewhere it comes to rest.
CGFloat travel = MAX(contentWidth - canvasWidth - columnOutset.right, 0);
CGRect runFrame = CGRectMake(runAnchor.x - clipOutset.left, runAnchor.y,
canvasWidth + clipOutset.left + clipOutset.right, runExtent);
CGSize contentSize = CGSizeMake(CGRectGetWidth(runFrame) + travel, runExtent);
// The pass owns the offset only when it CHANGES the run's geometry. UIScrollView holds an
// out-of-range offset on purpose while it rubber-bands, and every scroll tick re-runs this
// pass (`scrollViewDidScroll:` marks the text view for layout) — so a clamp on every run
// cancelled the band one frame at a time, and a run wider than the canvas never
// overscrolled. A run that narrows under an offset still clamps: that offset is out of range
// for good, not mid-band.
BOOL geometryChanged = !CGRectEqualToRect(runScroll.frame, runFrame)
|| !CGSizeEqualToSize(runScroll.contentSize, contentSize);
// Assigned only on a change: a scroll view re-adjusts its offset into range on a frame or
// content-size assignment, the same value or not, which is the clamp by another route.
if (geometryChanged) {
runScroll.frame = runFrame;
runScroll.contentSize = contentSize;
}
CGRect contentFrame = CGRectMake(clipOutset.left, 0, contentWidth, runExtent);
if (!CGRectEqualToRect(runScroll.runContentView.frame, contentFrame)) {
runScroll.runContentView.frame = contentFrame;
}
// Against the COLUMN, not the clip: a run that fits its column does not scroll because its
// clip happens to be narrower, and one that does not fit scrolls the same distance whatever
// the clip is.
runScroll.scrollEnabled = runWidth > canvasWidth + 0.5;
runScroll.memberIdentifiers = runMembers;
if (geometryChanged) {
CGFloat maxOffset = travel;
CGFloat clamped = MIN(MAX(runScroll.contentOffset.x, 0), maxOffset);
if (clamped != runScroll.contentOffset.x) {
runScroll.contentOffset = CGPointMake(clamped, 0);
}
}
y = runAnchor.y + runExtent;
runScroll = nil;
runMembers = nil;
};
[orderedLayouts enumerateObjectsUsingBlock:^(IDBlockLayout *layout,
NSUInteger index,
BOOL *stop) {
UIEdgeInsets blockInset = UIEdgeInsetsZero;
NSValue *blockInsetValue = self.blockInsets[layout.blockIdentifier];
if (blockInsetValue != nil) {
blockInset = blockInsetValue.UIEdgeInsetsValue;
}
NSValue *frameValue = self.blockFrames[layout.blockIdentifier];
if (frameValue != nil) {
CGRect frame = frameValue.CGRectValue;
// A host-declared boundary ends the run in progress, so the next group anchors itself,
// scrolls by itself and is clipped by its own entry. Without one, adjacency alone
// decides — and adjacency cannot see where one table stops and the next begins.
if (inPlacedRun && [self.placedRunStarts containsObject:layout.blockIdentifier]) {
closeRun();
}
if (!inPlacedRun) {
inPlacedRun = YES;
runAnchor = CGPointMake(blockOriginX, y);
runExtent = 0;
runWidth = 0;
runMembers = [NSMutableArray array];
runScroll = self.runScrollViewsByFirstIdentifier[layout.blockIdentifier];
if (runScroll == nil) {
runScroll = [[IDPlacedRunScrollView alloc] initWithFrame:CGRectZero];
runScroll.delegate = self;
self.runScrollViewsByFirstIdentifier[layout.blockIdentifier] = runScroll;
[self addGestureRecognizer:runScroll.panGestureRecognizer];
}
BOOL runScrollWasChild = runScroll.superview == self;
if (!(hierarchyUnchanged && subviewIndex < hierarchy.count
&& hierarchy[subviewIndex] == runScroll)) {
[self insertSubview:runScroll atIndex:MIN(subviewIndex, subviewCount)];
hierarchyUnchanged = NO;
}
if (!runScrollWasChild) {
subviewCount += 1;
}
subviewIndex += 1;
[liveRunStarts addObject:layout.blockIdentifier];
}
CGFloat dx = runScroll.contentOffset.x;
CGFloat textWidth = MAX(frame.size.width - blockInset.left - blockInset.right, 0);
CGFloat height = MAX(layout.usedSize.height, frame.size.height - blockInset.top);
// Inside the run's content view: no anchor, no offset — the scroll view carries both.
layout.canvasView.frame = CGRectMake(frame.origin.x + blockInset.left,
frame.origin.y + blockInset.top,
MAX(textWidth, layout.usedSize.width), height);
layout.canvasView.bounds = (CGRect){[layout id_canvasBoundsOrigin],
layout.canvasView.frame.size};
if (layout.canvasView.superview != runScroll.runContentView) {
if (layout.canvasView.superview == self) {
subviewCount -= 1;
hierarchyUnchanged = NO;
}
[runScroll.runContentView addSubview:layout.canvasView];
}
[runMembers addObject:layout.blockIdentifier];
CGPoint origin = CGPointMake(runAnchor.x + frame.origin.x + blockInset.left - dx,
runAnchor.y + frame.origin.y + blockInset.top);
origins[layout.blockIdentifier] = [NSValue valueWithCGPoint:origin];
CGFloat cellHeight = MAX(frame.size.height, height + blockInset.top);
CGRect hitRect = CGRectMake(runAnchor.x + frame.origin.x - dx, runAnchor.y + frame.origin.y,
frame.size.width, cellHeight);
hitRects[layout.blockIdentifier] = [NSValue valueWithCGRect:hitRect];
// The cell IS the slot: the host's frame already holds the padding (`blockInsets`
// apply inside it) and `hitRect` already carries the run's horizontal offset, so a
// cell's wash scrolls with its table for free.
slots[layout.blockIdentifier] = [NSValue valueWithCGRect:hitRect];
runExtent = MAX(runExtent, frame.origin.y + cellHeight + blockInset.bottom);
runWidth = MAX(runWidth, CGRectGetMaxX(frame));
return;
}
closeRun();
if (layout.canvasView.superview != self) {
[self addSubview:layout.canvasView];
subviewCount += 1;
hierarchyUnchanged = NO;
}
// Where this block's reserved space BEGINS — before its top inset, not after it. The
// pass advances `y` by top inset, canvas, bottom inset, so capturing here and again
// below makes consecutive slots meet exactly, with nothing between them for a
// selection to miss.
CGFloat slotTop = y;
y += blockInset.top;
CGPoint origin = CGPointMake(blockOriginX + blockInset.left, y);
origins[layout.blockIdentifier] = [NSValue valueWithCGPoint:origin];
CGFloat blockCanvasWidth = MAX(canvasWidth - blockInset.left - blockInset.right,
layout.usedSize.width);
layout.canvasView.frame = CGRectMake(blockOriginX + blockInset.left,
y,
blockCanvasWidth,
layout.usedSize.height);
layout.canvasView.bounds = (CGRect){[layout id_canvasBoundsOrigin],
layout.canvasView.frame.size};
if (!(hierarchyUnchanged && subviewIndex < hierarchy.count
&& hierarchy[subviewIndex] == layout.canvasView)) {
[self insertSubview:layout.canvasView atIndex:MIN(subviewIndex, subviewCount)];
hierarchyUnchanged = NO;
}
subviewIndex += 1;
documentWidth = MAX(documentWidth, blockCanvasWidth + blockInset.left + blockInset.right);
y += layout.usedSize.height + blockInset.bottom;
slots[layout.blockIdentifier] = [NSValue valueWithCGRect:
CGRectMake(blockOriginX, slotTop, canvasWidth, y - slotTop)];
}];
closeRun();
for (NSUUID *key in self.runScrollViewsByFirstIdentifier.allKeys) {
if (![liveRunStarts containsObject:key]) {
[self removeGestureRecognizer:self.runScrollViewsByFirstIdentifier[key].panGestureRecognizer];
[self.runScrollViewsByFirstIdentifier[key] removeFromSuperview];
[self.runScrollViewsByFirstIdentifier removeObjectForKey:key];
}
}
self.hitRectsByIdentifier = hitRects.copy;
self.selectionSlotsByIdentifier = slots.copy;
if (decorationWasDirty) {
self.displayDecorationNeedsUpdate = NO;
}
self.layoutsByIdentifier = newLayouts.copy;
self.providersByIdentifier = newProviders.copy;
self.originsByIdentifier = origins.copy;
self.blockLayouts = orderedLayouts.copy;
// CONTENT only, excluding this view's own `contentInsets`: `y` began at the top inset, and the
// host adds the vertical inset itself when sizing its scroll content.
self.documentContentSize =
CGSizeMake(ceil(documentWidth), ceil(MAX(y - self.contentInsets.top, 0.0)));
BOOL geometryChanged = !self.hasLayoutWidth ||
self.lastLayoutWidth != layoutWidth ||
![oldOrderedIdentifiers isEqual:self.orderedBlockIdentifiers] ||
![oldOrigins isEqual:self.originsByIdentifier] ||
!CGSizeEqualToSize(oldContentSize, self.documentContentSize);
if (!geometryChanged) {
for (IDBlockLayout *layout in self.blockLayouts) {
NSArray<NSNumber *> *revisions = oldRevisions[layout.blockIdentifier];
if (revisions == nil ||
revisions[0].unsignedIntegerValue != layout.contentRevision ||
revisions[1].unsignedIntegerValue != layout.layoutGeneration) {
geometryChanged = YES;
break;
}
}
}
self.lastLayoutWidth = layoutWidth;
self.hasLayoutWidth = YES;
if (geometryChanged) {
self.geometryGeneration += 1;
if (self.geometryGeneration == 0) {
self.geometryGeneration = 1;
}
}
}
- (IDBlockLayout *)layoutForBlockIdentifier:(NSUUID *)identifier {
return self.layoutsByIdentifier[identifier];
}
- (NSArray<NSUUID *> *)orderedBlockIdentifiers {
return [self.blockLayouts valueForKey:@"blockIdentifier"];
}
- (IDDocumentGeometryEligibilitySnapshot *)
selectionGeometryEligibilityForDocumentRevision:
(NSUInteger)documentRevision {
(void)documentRevision;
return [[IDDocumentGeometryEligibilitySnapshot alloc]
initWithGeometryGeneration:self.geometryGeneration
orderedBlockIdentifiers:self.orderedBlockIdentifiers];
}
- (id<IDBlockLayoutSnapshot>)snapshotForBlockIdentifier:(NSUUID *)identifier
documentRevision:(NSUInteger)documentRevision {
return [self.providersByIdentifier[identifier]
snapshotForDocumentRevision:documentRevision];
}
- (CGPoint)documentOriginForBlockIdentifier:(NSUUID *)identifier {
NSValue *origin = self.originsByIdentifier[identifier];
return origin == nil ? CGPointZero : origin.CGPointValue;
}
- (CGRect)selectionSlotForBlockIdentifier:(NSUUID *)identifier {
NSValue *slot = self.selectionSlotsByIdentifier[identifier];
if (slot == nil) {
return CGRectNull;
}
CGRect box = slot.CGRectValue;
NSValue *declaredValue = self.selectionBoxes[identifier];
if (declaredValue != nil) {
CGRect declared = declaredValue.CGRectValue;
box.origin.x = CGRectGetMinX(declared);
box.size.width = CGRectGetWidth(declared);
}
return box;
}
- (CGRect)selectionBoxForBlockIdentifier:(NSUUID *)identifier {
NSValue *declared = self.selectionBoxes[identifier];
if (declared == nil) {
return CGRectNull;
}
CGRect slot = [self selectionSlotForBlockIdentifier:identifier];
if (CGRectIsNull(slot)) {
return CGRectNull;
}
return CGRectIntersection(declared.CGRectValue, slot);
}
- (IDBlockLayout *)nearestBlockLayoutForDocumentPoint:(CGPoint)point {
NSArray<IDBlockLayout *> *layouts = self.blockLayouts;
if (layouts.count == 0) {
return nil;
}
// The y-scan the stack always had, with one change: a run of placed blocks is ONE unit of it,
// entered at the run's topmost edge. Inside a run the answer is by rect.
IDBlockLayout *nearest = layouts.firstObject;
NSArray<IDBlockLayout *> *nearestRun = nil;
NSUInteger index = 0;
while (index < layouts.count) {
IDBlockLayout *layout = layouts[index];
if (self.hitRectsByIdentifier[layout.blockIdentifier] == nil) {
if (point.y < [self documentOriginForBlockIdentifier:layout.blockIdentifier].y) {
break;
}
nearest = layout;
nearestRun = nil;
index += 1;
continue;
}
NSUInteger end = index;
CGFloat top = CGFLOAT_MAX;
while (end < layouts.count &&
self.hitRectsByIdentifier[layouts[end].blockIdentifier] != nil) {
top = MIN(top, CGRectGetMinY(self.hitRectsByIdentifier[layouts[end].blockIdentifier].CGRectValue));
end += 1;
}
if (point.y < top) {
break;
}
nearest = layout;
nearestRun = [layouts subarrayWithRange:NSMakeRange(index, end - index)];
index = end;
}
if (nearestRun == nil) {
return nearest;
}
IDBlockLayout *best = nearestRun.firstObject;
CGFloat bestDistance = CGFLOAT_MAX;
for (IDBlockLayout *member in nearestRun) {
CGRect rect = self.hitRectsByIdentifier[member.blockIdentifier].CGRectValue;
if (CGRectContainsPoint(rect, point)) {
return member;
}
CGFloat dx = MAX(MAX(CGRectGetMinX(rect) - point.x, point.x - CGRectGetMaxX(rect)), 0);
CGFloat dy = MAX(MAX(CGRectGetMinY(rect) - point.y, point.y - CGRectGetMaxY(rect)), 0);
CGFloat distance = hypot(dx, dy);
if (distance < bestDistance) {
bestDistance = distance;
best = member;
}
}
return best;
}
- (NSUUID *)nearestBlockIdentifierForDocumentPoint:(CGPoint)point {
return [self nearestBlockLayoutForDocumentPoint:point].blockIdentifier;
}
- (IDPlacedRunScrollView *)id_runScrollViewContaining:(NSUUID *)identifier {
for (IDPlacedRunScrollView *scroll in self.runScrollViewsByFirstIdentifier.allValues) {
if ([scroll.memberIdentifiers containsObject:identifier]) {
return scroll;
}
}
return nil;
}
- (UIView *)placedRunContentViewForBlockIdentifier:(NSUUID *)identifier {
return [self id_runScrollViewContaining:identifier].runContentView;
}
- (CGFloat)placedRunContentOffsetForBlockIdentifier:(NSUUID *)identifier {
return [self id_runScrollViewContaining:identifier].contentOffset.x;
}
- (void)setPlacedRunContentOffset:(CGFloat)offset forBlockIdentifier:(NSUUID *)identifier {
IDPlacedRunScrollView *scroll = [self id_runScrollViewContaining:identifier];
if (scroll == nil) {
return;
}
CGFloat maxX = MAX(scroll.contentSize.width - CGRectGetWidth(scroll.bounds), 0);
scroll.contentOffset = CGPointMake(MIN(MAX(offset, 0), maxX), 0);
}
/// Whether any placed run is IN FLIGHT — being dragged, or coasting from a drag.
///
/// A host that puts this view inside something with a drag of its own — a sheet that dismisses on a
/// downward pan — needs this: that outer gesture recognises simultaneously with everything, and a
/// finger landing on a coasting run would otherwise start the dismiss instead of catching the run
/// (K3, 2026-09-11). A run in flight is the editor's, and the host says so on its behalf.
- (BOOL)id_hasPlacedRunInFlight {
for (IDPlacedRunScrollView *run in self.runScrollViewsByFirstIdentifier.allValues) {
if (run.id_isInFlight) {
return YES;
}
}
return NO;
}
/// Is the placed run that OWNS this view in flight? NO for a view that is inside no run.
///
/// The per-view question, where `id_hasPlacedRunInFlight` is the per-editor one. A host draws its
/// own interactive chrome inside a run — a button pill, a formula's tap target — and a tap that
/// CATCHES a moving run must not also press what is under the finger, which is what every scroll
/// view in iOS does for its own content and what a run cannot do for itself: it never receives the
/// touch, so `touchesShouldBegin:withEvent:inContentView:` is never called on it either.
///
/// The walk stops at this canvas, so a view somewhere else in the host's tree answers NO rather
/// than walking out into the window.
- (BOOL)id_isPlacedRunInFlightForView:(UIView *)view {
UIView *candidate = view;
while (candidate != nil && candidate != self) {
if ([candidate isKindOfClass:[IDPlacedRunScrollView class]]) {
return ((IDPlacedRunScrollView *)candidate).id_isInFlight;
}
candidate = candidate.superview;
}
return NO;
}
/// Where a run caught under a finger settles — see `-[IDPlacedRunScrollView
/// gestureRecognizer:shouldReceiveTouch:]`, which is what catches it.
+ (CGFloat)id_caughtRunOffsetForCurrent:(CGFloat)current maximum:(CGFloat)maximum {
return MIN(MAX(current, 0), MAX(maximum, 0));
}
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
// The pass derives every visual origin from the run's offset, and the text view's own layout
// re-runs the pass and refreshes the interaction geometry — so a scroll only asks for one.
[self.superview setNeedsLayout];
}
- (NSUUID *)verticallyAdjacentBlockIdentifierFromBlockIdentifier:(NSUUID *)identifier
downward:(BOOL)downward
anchorPositionOffset:(CGFloat)anchorX {
NSArray<NSUUID *> *ordered = self.orderedBlockIdentifiers;
NSUInteger sourceIndex = [ordered indexOfObject:identifier];
if (sourceIndex == NSNotFound) {
return nil;
}
NSInteger step = downward ? 1 : -1;
NSValue *sourceRect = self.hitRectsByIdentifier[identifier];
if (sourceRect != nil) {
// Leaving a placed block: the ROW of placed blocks just past its edge, and within it the
// member NEAREST the caret's x. Candidates are the placed blocks adjacent to the source in
// document order (its run, and a run placed straight after it); the row is the nearest
// beyond the edge, whatever border separates it. A point probe under the goal x found
// nothing whenever the x lay outside every cell — left of the first column, or in the
// border between two — and a probe one point past the TOP edge landed in the border above
// it, so the walk stepped out of the run and skipped every row between (composer harness
// p17, `HarnessVerticalMoveAtDocumentEdgeTests`).
CGRect rect = sourceRect.CGRectValue;
NSInteger first = (NSInteger)sourceIndex;
while (first > 0 && self.hitRectsByIdentifier[ordered[first - 1]] != nil) {
first -= 1;
}
NSInteger last = (NSInteger)sourceIndex;
while (last + 1 < (NSInteger)ordered.count && self.hitRectsByIdentifier[ordered[last + 1]] != nil) {
last += 1;
}
const CGFloat tolerance = 0.5;
CGFloat nearestEdge = downward ? CGFLOAT_MAX : -CGFLOAT_MAX;
for (NSInteger index = first; index <= last; index += 1) {
CGRect candidate = self.hitRectsByIdentifier[ordered[index]].CGRectValue;
if (downward && CGRectGetMinY(candidate) >= CGRectGetMaxY(rect) - tolerance) {
nearestEdge = MIN(nearestEdge, CGRectGetMinY(candidate));
} else if (!downward && CGRectGetMaxY(candidate) <= CGRectGetMinY(rect) + tolerance) {
nearestEdge = MAX(nearestEdge, CGRectGetMaxY(candidate));
}
}
NSMutableArray<NSUUID *> *row = [NSMutableArray array];
for (NSInteger index = first; index <= last; index += 1) {
CGRect candidate = self.hitRectsByIdentifier[ordered[index]].CGRectValue;
CGFloat candidateEdge = downward ? CGRectGetMinY(candidate) : CGRectGetMaxY(candidate);
BOOL beyond = downward
? CGRectGetMinY(candidate) >= CGRectGetMaxY(rect) - tolerance
: CGRectGetMaxY(candidate) <= CGRectGetMinY(rect) + tolerance;
if (beyond && fabs(candidateEdge - nearestEdge) <= tolerance) {
[row addObject:ordered[index]];
}
}
NSUUID *nearest = [self id_memberOf:row nearestToX:anchorX];
if (nearest != nil) {
return nearest;
}
// Nothing placed there: step out of the run in document order.
NSInteger index = (NSInteger)sourceIndex;
while (index >= 0 && index < (NSInteger)ordered.count &&
self.hitRectsByIdentifier[ordered[index]] != nil) {
index += step;
}
return (index >= 0 && index < (NSInteger)ordered.count) ? ordered[index] : nil;
}
NSInteger adjacent = (NSInteger)sourceIndex + step;
if (adjacent < 0 || adjacent >= (NSInteger)ordered.count) {
return nil;
}
NSUUID *neighbour = ordered[adjacent];
if (self.hitRectsByIdentifier[neighbour] == nil) {
return neighbour;
}
// Entering a run: the member on the run's near edge under the caret's x, else the edge member.
NSMutableArray<NSUUID *> *run = [NSMutableArray array];
CGFloat runTop = CGFLOAT_MAX;
CGFloat runBottom = -CGFLOAT_MAX;
NSInteger index = adjacent;
while (index >= 0 && index < (NSInteger)ordered.count &&
self.hitRectsByIdentifier[ordered[index]] != nil) {
CGRect rect = self.hitRectsByIdentifier[ordered[index]].CGRectValue;
[run addObject:ordered[index]];
runTop = MIN(runTop, CGRectGetMinY(rect));
runBottom = MAX(runBottom, CGRectGetMaxY(rect));
index += step;
}
// Entering a run: the member on its NEAR edge nearest the caret's x. `run` is collected walking
// in the direction of travel, so its first object is the near edge's FIRST member only going
// down; going up it is the run's LAST cell, and a goal x left of the first column (the start
// of the line under a table) landed in the last column.
CGFloat probeY = downward ? runTop + 1 : runBottom - 1;
NSMutableArray<NSUUID *> *edge = [NSMutableArray array];
for (NSUUID *member in run) {
if (IDCanvasRectSpansY(self.hitRectsByIdentifier[member].CGRectValue, probeY)) {
[edge addObject:member];
}
}
NSUUID *nearest = [self id_memberOf:edge nearestToX:anchorX];
return nearest ?: run.firstObject;
}
/// The member whose hit rect is horizontally nearest `x` (zero inside it), the earliest in document
/// order on a tie; nil for no members.
- (nullable NSUUID *)id_memberOf:(NSArray<NSUUID *> *)members nearestToX:(CGFloat)x {
NSUUID *best = nil;
CGFloat bestDistance = CGFLOAT_MAX;
NSArray<NSUUID *> *ordered = self.orderedBlockIdentifiers;
NSUInteger bestIndex = NSNotFound;
for (NSUUID *member in members) {
CGRect rect = self.hitRectsByIdentifier[member].CGRectValue;
CGFloat distance = x < CGRectGetMinX(rect) ? CGRectGetMinX(rect) - x
: (x > CGRectGetMaxX(rect) ? x - CGRectGetMaxX(rect) : 0.0);
NSUInteger index = [ordered indexOfObject:member];
if (distance < bestDistance || (distance == bestDistance && index < bestIndex)) {
best = member;
bestDistance = distance;
bestIndex = index;
}
}
return best;
}
- (void)id_setCanvasHost:(id)host {
self.canvasHost = host;
for (IDBlockLayout *layout in self.blockLayouts) {
[layout id_setCanvasHost:host];
}
}
@end
#endif