Skip to content

Commit a42bc35

Browse files
authored
fix(ios): keep a sheet's toolbar host off its own controls (#4148)
* fix(ios): keep a sheet's toolbar host off its own controls On iOS 26/27 UIKit hosts a sheet's bars in a Toolbar container at the sheet's frame. An undimmed sheet keeps the presenting screen in the capture, so the host also encloses that screen's bars and scroll view, and the own-presentation exemption failed: every control in the sheet was refused as covered. The host now covers the presentation beneath it but not its own. A host that encloses a presentation stacked over it still covers everything before it. * fix(ios): keep a barless sheet's controls pressable An undimmed sheet with no bar or scroll view still gets a Toolbar host at its frame. That host encloses only the screen beneath, so it was not recognised as the sheet's host and covered the sheet's own controls. Read it as covering the presentation beneath whenever it encloses any region before the previous host. * fix(ios): read a leading tab bar as beneath only over a screen beneath A host whose own presentation lists its tab bar first, with nothing before the previous host enclosed, became over-beneath and covered that tab bar. Skip leading tab bars only when the host also encloses a region of the screen beneath, which also makes the all-tab-bars special case unnecessary. Move the doc comment onto the function it describes, and reword the changeset so it covers sheets without bars of their own. --------- Co-authored-by: Justin Starner <[email protected]>
1 parent 1b5af38 commit a42bc35

3 files changed

Lines changed: 304 additions & 23 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'agent-device': patch
3+
---
4+
5+
Fix iOS 26/27 `press`, `click`, and `fill` refusing every control inside a sheet that keeps the screen beneath it in the capture (such as an undimmed sheet), with or without bars or a scroll view of its own, as covered by the sheet's `Toolbar` host. The host now covers the screen beneath the sheet but not the sheet's own controls. A host that encloses no bar or scroll view, or a presentation stacked over it, still covers.

‎packages/capture-kit/src/snapshot-occlusion-sibling-sized-chrome.test.ts‎

Lines changed: 208 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -617,15 +617,217 @@ test('a toolbar host enclosing the bars of a presentation stacked over it keeps
617617
for (const label of COVER_CONTROLS) assert.ok(!covered.includes(label));
618618
});
619619

620-
test('a toolbar host enclosing the bars of a presentation beneath it keeps covering', () => {
620+
test('a toolbar host enclosing the bars of a presentation beneath it covers that presentation, not its own', () => {
621621
// The capture without the cover: a sheet host enclosing Home's bar and list, which sit before Home's
622-
// own host, covers Home. It covers the sheet too, failing closed; iOS publishes no presentation
623-
// beneath a sheet, so this shape is not captured live.
622+
// own host, covers Home, including the tab bar listed right after Home's host, but not the sheet.
624623
const rows = COVER_OVER_SHEET_ROWS.filter(([index]) => !COVER_ROW_INDEXES.has(index));
625624
const covered = coveredLabels(capturedTree(rows));
626625

627-
assert.ok(covered.includes('Edit'));
628-
assert.ok(covered.includes('Open medium sheet'));
626+
for (const label of ['Edit', 'Open medium sheet', 'Tab Bar', 'Home']) {
627+
assert.ok(covered.includes(label), `${label} sits beneath the sheet`);
628+
}
629+
for (const label of ['Close', 'Primary action', 'Row 1', 'Bottom']) {
630+
assert.ok(!covered.includes(label), `${label} is on the sheet`);
631+
}
632+
});
633+
634+
// `snapshot --json` of an undimmed `formSheet` over a tab screen on an iOS 27 simulator, trimmed. It
635+
// keeps the screen and tab bar beneath it in the capture, and no sibling matches the host's frame.
636+
const UNDIMMED_SHEET_ROWS: CapturedNode[] = [
637+
[0, undefined, 'Application', 'UIApplication', 'Agent Device Tester', [0, 0, 402, 874]],
638+
[1, 0, 'ScrollView', 'RCTEnhancedScrollView', undefined, [18, 62, 366, 729]],
639+
[18, 1, 'Button', 'RCTViewComponentView', 'Open undimmed sheet', [34.33, 515, 333.33, 44.67]],
640+
[26, 0, 'TabBar', 'UITabBar', 'Tab Bar', [0, 791, 402, 83]],
641+
[35, 26, 'Button', '_UITabButton', 'Settings', [286.81, 795, 90.19, 54]],
642+
[37, 0, 'Toolbar', '_UIFloatingBarContainerView', 'Toolbar', [0, 0, 402, 874]],
643+
[38, 0, 'NavigationBar', 'UINavigationBar', 'Undimmed sheet', [0, 78, 402, 54]],
644+
[40, 0, 'Other', 'RCTScrollViewComponentView', undefined, [0, 62, 402, 723.33]],
645+
[41, 40, 'ScrollView', 'RCTEnhancedScrollView', undefined, [0, 62, 402, 723.33]],
646+
[44, 41, 'TextField', 'RCTUITextField', 'Sheet name', [20.33, 107.33, 361.33, 51.33]],
647+
[47, 41, 'Button', 'RCTViewComponentView', 'Close undimmed sheet', [20, 209, 362, 44.67]],
648+
[52, 0, 'Button', 'RCTViewComponentView', 'Continue', [20, 797.33, 362, 44.67]],
649+
[53, 0, 'Toolbar', '_UIFloatingBarContainerView', 'Toolbar', [0, 62, 402, 812]],
650+
];
651+
652+
const UNDIMMED_SHEET_CONTROLS = [
653+
'Undimmed sheet',
654+
'Sheet name',
655+
'Close undimmed sheet',
656+
'Continue',
657+
];
658+
659+
test('the captured iOS 27 undimmed sheet keeps its controls pressable and covers the screen beneath', () => {
660+
const covered = coveredLabels(capturedTree(UNDIMMED_SHEET_ROWS));
661+
662+
for (const label of UNDIMMED_SHEET_CONTROLS) {
663+
assert.ok(!covered.includes(label), `${label} is on the sheet`);
664+
}
665+
for (const label of ['Open undimmed sheet', 'Tab Bar', 'Settings']) {
666+
assert.ok(covered.includes(label), `${label} sits beneath the sheet`);
667+
}
668+
});
669+
670+
test('a tab bar listed right after the presenting host stays beneath the undimmed sheet', () => {
671+
// The SwiftUI capture order: the presenting screen's tab bar follows that screen's host instead of
672+
// preceding it. It still belongs to the screen beneath, so the sheet's host keeps covering it.
673+
const tabBarRows = UNDIMMED_SHEET_ROWS.filter(([index]) => index === 26 || index === 35);
674+
const rows = UNDIMMED_SHEET_ROWS.filter(([index]) => index !== 26 && index !== 35);
675+
rows.splice(rows.findIndex(([index]) => index === 37) + 1, 0, ...tabBarRows);
676+
const covered = coveredLabels(capturedTree(rows));
677+
678+
for (const label of ['Tab Bar', 'Settings']) {
679+
assert.ok(covered.includes(label), `${label} sits beneath the sheet`);
680+
}
681+
for (const label of UNDIMMED_SHEET_CONTROLS) {
682+
assert.ok(!covered.includes(label), `${label} is on the sheet`);
683+
}
684+
});
685+
686+
test("a sheet control listed before the sheet's first bar stays pressable", () => {
687+
// A grabber and a leading button sit between the presenting host and the sheet's navigation bar.
688+
// They belong to the sheet, so only a tab bar there is read as the screen beneath.
689+
const rows = [...UNDIMMED_SHEET_ROWS];
690+
rows.splice(
691+
rows.findIndex(([index]) => index === 37) + 1,
692+
0,
693+
[60, 0, 'Button', '_UIGrabber', 'Sheet Grabber', [153, 64, 96, 10]],
694+
[61, 0, 'Button', 'RCTViewComponentView', 'Leading action', [300, 140, 80, 36]],
695+
);
696+
const covered = coveredLabels(capturedTree(rows));
697+
698+
for (const label of ['Sheet Grabber', 'Leading action', ...UNDIMMED_SHEET_CONTROLS]) {
699+
assert.ok(!covered.includes(label), `${label} is on the sheet`);
700+
}
701+
});
702+
703+
test('a lone screen keeps its tab bar uncovered by a host with no presentation before it', () => {
704+
// With no previous toolbar host there is no screen beneath, so the tab bar is the screen's own.
705+
const nodes = capturedTree([
706+
[0, undefined, 'Application', 'UIApplication', 'App', [0, 0, 402, 874]],
707+
[1, 0, 'TabBar', 'UITabBar', 'Tab Bar', [0, 791, 402, 83]],
708+
[2, 1, 'Button', '_UITabButton', 'Home', [25, 795, 98, 54]],
709+
[3, 0, 'NavigationBar', 'UINavigationBar', 'Title', [0, 62, 402, 54]],
710+
[4, 0, 'CollectionView', 'UICollectionView', undefined, [0, 116, 402, 675]],
711+
[5, 4, 'Button', 'UIButton', 'Row', [16, 132, 370, 52]],
712+
[6, 0, 'Toolbar', '_UIFloatingBarContainerView', 'Toolbar', [0, 62, 402, 812]],
713+
]);
714+
715+
assert.deepEqual(coveredLabels(nodes), []);
716+
});
717+
718+
test('a host listing its own tab bar first keeps it pressable when it encloses nothing beneath', () => {
719+
// After a previous host, a tab bar listed first counts as the screen beneath only when the host also
720+
// encloses a region before that previous host.
721+
const nodes = capturedTree([
722+
[0, undefined, 'Application', 'UIApplication', 'App', [0, 0, 402, 874]],
723+
[1, 0, 'Toolbar', '_UIFloatingBarContainerView', 'Toolbar', [0, 0, 402, 874]],
724+
[2, 0, 'TabBar', 'UITabBar', 'Tab Bar', [0, 791, 402, 83]],
725+
[3, 2, 'Button', '_UITabButton', 'Home', [25, 795, 98, 54]],
726+
[4, 0, 'NavigationBar', 'UINavigationBar', 'Title', [0, 62, 402, 54]],
727+
[5, 0, 'CollectionView', 'UICollectionView', undefined, [0, 116, 402, 675]],
728+
[6, 5, 'Button', 'UIButton', 'Row', [16, 132, 370, 52]],
729+
[7, 0, 'Toolbar', '_UIFloatingBarContainerView', 'Toolbar', [0, 62, 402, 812]],
730+
]);
731+
732+
assert.deepEqual(coveredLabels(nodes), []);
733+
});
734+
735+
test('a bottom host enclosing nothing but a tab bar keeps that tab bar pressable', () => {
736+
// A host after a viewport-sized one that hosts only the tab bar: with no other region of its own,
737+
// the tab bar is its own presentation's, as before.
738+
const nodes = capturedTree([
739+
[0, undefined, 'Application', 'UIApplication', 'App', [0, 0, 402, 874]],
740+
[1, 0, 'NavigationBar', 'UINavigationBar', 'Title', [0, 62, 402, 54]],
741+
[2, 0, 'CollectionView', 'UICollectionView', undefined, [0, 116, 402, 675]],
742+
[3, 2, 'Button', 'UIButton', 'Row', [16, 132, 370, 52]],
743+
[4, 0, 'Toolbar', '_UIFloatingBarContainerView', 'Toolbar', [0, 0, 402, 874]],
744+
[5, 0, 'TabBar', 'UITabBar', 'Tab Bar', [0, 791, 402, 83]],
745+
[6, 5, 'Button', '_UITabButton', 'Home', [25, 795, 98, 54]],
746+
[7, 0, 'Toolbar', '_UIFloatingBarContainerView', 'Toolbar', [0, 700, 402, 174]],
747+
]);
748+
749+
assert.deepEqual(coveredLabels(nodes), []);
750+
});
751+
752+
test('a barless undimmed sheet still covers the tab bar listed right after the presenting host', () => {
753+
// The sheet keeps only its footer button: no bar or scroll view of its own. The presenting screen's
754+
// scroll view is still enclosed, so the tab bar after the presenting host stays beneath the sheet.
755+
const tabBarRows = UNDIMMED_SHEET_ROWS.filter(([index]) => index === 26 || index === 35);
756+
const rows = UNDIMMED_SHEET_ROWS.filter(
757+
([index]) => ![26, 35, 38, 40, 41, 44, 47].includes(index),
758+
);
759+
rows.splice(rows.findIndex(([index]) => index === 37) + 1, 0, ...tabBarRows);
760+
const covered = coveredLabels(capturedTree(rows));
761+
762+
for (const label of ['Tab Bar', 'Settings', 'Open undimmed sheet']) {
763+
assert.ok(covered.includes(label), `${label} sits beneath the sheet`);
764+
}
765+
assert.ok(!covered.includes('Continue'), 'Continue is on the sheet');
766+
});
767+
768+
test('the captured iOS 27 undimmed sheet with no bar or scroll view keeps its controls pressable', () => {
769+
// `snapshot --json` of the `UNDIMMED_SHEET_ROWS` sheet with its header hidden and a plain view for
770+
// content. Its host encloses only the screen beneath, so nothing of the sheet's own marks it.
771+
const covered = coveredLabels(
772+
capturedTree([
773+
[0, undefined, 'Application', 'UIApplication', 'Agent Device Tester', [0, 0, 402, 874]],
774+
[1, 0, 'ScrollView', 'RCTEnhancedScrollView', undefined, [18, 62, 366, 729]],
775+
[
776+
27,
777+
1,
778+
'Button',
779+
'RCTViewComponentView',
780+
'Open undimmed sheet',
781+
[34.33, 655.33, 333.33, 44.67],
782+
],
783+
[34, 0, 'TabBar', 'UITabBar', 'Tab Bar', [0, 791, 402, 83]],
784+
[43, 34, 'Button', '_UITabButton', 'Settings', [286.81, 795, 90.19, 54]],
785+
[45, 0, 'Toolbar', '_UIFloatingBarContainerView', 'Toolbar', [0, 0, 402, 874]],
786+
[46, 0, 'Other', 'RCTViewComponentView', undefined, [0, 62, 402, 211.67]],
787+
[47, 0, 'StaticText', 'RCTParagraphComponentView', 'Sheet name', [20, 82, 362, 17]],
788+
[49, 0, 'TextField', 'RCTUITextField', 'Sheet name', [20.33, 107.33, 361.33, 51.33]],
789+
[50, 0, 'StaticText', 'RCTParagraphComponentView', 'Not continued', [20, 175, 362, 18]],
790+
[52, 0, 'Button', 'RCTViewComponentView', 'Close undimmed sheet', [20, 209, 362, 44.67]],
791+
[53, 0, 'Button', 'RCTViewComponentView', 'Continue', [20, 285.67, 362, 44.67]],
792+
[54, 0, 'Toolbar', '_UIFloatingBarContainerView', 'Toolbar', [0, 62, 402, 812]],
793+
]),
794+
);
795+
796+
for (const label of ['Sheet name', 'Not continued', 'Close undimmed sheet', 'Continue']) {
797+
assert.ok(!covered.includes(label), `${label} is on the sheet`);
798+
}
799+
for (const label of ['Open undimmed sheet', 'Tab Bar', 'Settings']) {
800+
assert.ok(covered.includes(label), `${label} sits beneath the sheet`);
801+
}
802+
});
803+
804+
test('a bar-height toolbar after a host that encloses nothing keeps covering the field under it', () => {
805+
// A toolbar enclosing no bar or scroll view gives no evidence that it is a host, so it is read as an
806+
// opaque bar even with a previous host before it.
807+
const nodes = capturedTree([
808+
[0, undefined, 'Application', 'UIApplication', 'App', [0, 0, 402, 874]],
809+
[1, 0, 'ScrollView', 'UIScrollView', undefined, [0, 62, 402, 812]],
810+
[2, 1, 'Button', 'UIButton', 'Row', [16, 132, 370, 52]],
811+
[3, 0, 'Toolbar', '_UIFloatingBarContainerView', 'Toolbar', [0, 0, 402, 874]],
812+
[4, 0, 'TextField', 'UITextField', 'Field under accessory', [16, 505, 370, 34]],
813+
[5, 0, 'Toolbar', 'UIToolbar', 'Accessory', [0, 500, 402, 44]],
814+
]);
815+
816+
assert.deepEqual(coveredLabels(nodes), ['Field under accessory']);
817+
});
818+
819+
test('an undimmed sheet host stops exempting its own controls once a presentation is stacked over it', () => {
820+
// Non-vacuity for the stacked direction: a bar listed after the host, inside its frame, belongs to a
821+
// presentation drawn over the sheet, so the host keeps covering the sheet as before.
822+
const rows: CapturedNode[] = [
823+
...UNDIMMED_SHEET_ROWS,
824+
[54, 0, 'NavigationBar', 'UINavigationBar', 'Upper sheet', [0, 88, 402, 54]],
825+
];
826+
const covered = coveredLabels(capturedTree(rows));
827+
828+
for (const label of ['Sheet name', 'Close undimmed sheet', 'Continue']) {
829+
assert.ok(covered.includes(label), `${label} sits beneath the upper presentation`);
830+
}
629831
});
630832

631833
test.for([
@@ -674,7 +876,7 @@ test.for([
674876
] satisfies { name: string; rows: CapturedNode[] }[])(
675877
'the captured iOS 27 $name publishes only the top sheet, and none of it is covered',
676878
({ rows }) => {
677-
// iOS drops the presentation beneath a sheet from the capture, so no lower control gets a ref.
879+
// iOS drops the presentation beneath a SwiftUI sheet from the capture, so no lower control gets a ref.
678880
assert.deepEqual(coveredLabels(capturedTree(rows)), []);
679881
},
680882
);

‎packages/capture-kit/src/snapshot-occlusion.ts‎

Lines changed: 91 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const VIEWPORT_CHROME_KIND_FRAGMENTS = ['tabbar', 'toolbar', 'navigationbar'];
3434
*/
3535
const VIEWPORT_CHROME_TOP_INSET_PT = 4;
3636
const TOOLBAR_HOST_KIND_FRAGMENTS = ['toolbar'];
37+
const TAB_BAR_KIND_FRAGMENTS = ['tabbar'];
3738
const SCROLL_INDICATOR_KIND_FRAGMENTS = ['scrollbar', 'scrollindicator'];
3839
const SEMANTIC_TOUCH_KIND_FRAGMENTS = [
3940
'button',
@@ -77,6 +78,7 @@ type OcclusionScan = {
7778
type NeighbourhoodIndex = {
7879
byIndex: Map<number, RawSnapshotNode>;
7980
childrenByParent: Map<number, RawSnapshotNode[]>;
81+
toolbarHostReach: Map<number, ToolbarHostReach | null>;
8082
};
8183

8284
function collectNeighbourhoodIndex(nodes: readonly RawSnapshotNode[]): NeighbourhoodIndex {
@@ -89,7 +91,7 @@ function collectNeighbourhoodIndex(nodes: readonly RawSnapshotNode[]): Neighbour
8991
if (siblings) siblings.push(node);
9092
else childrenByParent.set(node.parentIndex, [node]);
9193
}
92-
return { byIndex, childrenByParent };
94+
return { byIndex, childrenByParent, toolbarHostReach: new Map() };
9395
}
9496

9597
export type SnapshotOcclusionOptions = {
@@ -295,6 +297,7 @@ function visibleCoverRect(
295297
if (areRelatedSnapshotNodes(target, candidate, scan.byIndex)) return null;
296298
const candidateRect = positiveRect(candidate.rect);
297299
if (!candidateRect || areRectsApproximatelyEqual(targetRect, candidateRect)) return null;
300+
if (isInToolbarHostOwnPresentation(candidate, target, scan)) return null;
298301
if (findCoveringNode(scan, candidatePosition, candidate, options)) return null;
299302
return candidateRect;
300303
}
@@ -349,7 +352,7 @@ function isOverlayLikeNode(
349352
* gate keeps a `dialog`/`sheet`/`alert` sized to the content around it covering.
350353
*
351354
* Accepted limit for stacked presentations: the interactive projection lists them as siblings of one
352-
* root. iOS drops what sits beneath a sheet from the capture, but keeps what sits beneath a
355+
* root. iOS drops what sits beneath a SwiftUI sheet from the capture, but keeps what sits beneath a
353356
* full-screen cover, and the cover's toolbar host is viewport-sized, so the full-viewport exemption
354357
* lets it through. Beneath a cover, only the cover's own bars and a lower presentation's
355358
* non-exempt toolbar host still cover.
@@ -373,30 +376,101 @@ function isSiblingSizedChromeContainer(
373376
);
374377
}
375378

379+
type ToolbarHostReach =
380+
| { kind: 'own-presentation' }
381+
| { kind: 'over-beneath'; parentIndex: number; ownBranchIndexes: ReadonlySet<number> };
382+
383+
function toolbarHostReach(
384+
node: RawSnapshotNode,
385+
neighbourhood: NeighbourhoodIndex,
386+
): ToolbarHostReach | null {
387+
const cached = neighbourhood.toolbarHostReach.get(node.index);
388+
if (cached !== undefined) return cached;
389+
const reach = classifyToolbarHostReach(node, neighbourhood);
390+
neighbourhood.toolbarHostReach.set(node.index, reach);
391+
return reach;
392+
}
393+
376394
/**
377-
* SwiftUI toolbar hosts draw their descendants, not their enclosing frame. Exempt a host only
378-
* when it encloses bars or scroll containers from its own presentation: the siblings after the
379-
* previous toolbar and before this host. Enclosing another presentation's regions keeps it covering.
395+
* Toolbar hosts draw their descendants, not their enclosing frame. A host that encloses bars or
396+
* scroll containers of its own presentation, the siblings after the previous toolbar and before
397+
* this host, covers nothing of it. A host that encloses the presentation beneath it, which an
398+
* undimmed sheet keeps in the capture, covers only that presentation, including enclosed tab bars
399+
* listed first after the previous toolbar. A host that encloses nothing, or a presentation stacked
400+
* over it, keeps covering. Like the sibling-sized exemption, this cannot tell an opaque toolbar
401+
* from a host.
380402
*/
381-
function isOwnPresentationToolbarHost(
403+
function classifyToolbarHostReach(
382404
node: RawSnapshotNode,
383405
neighbourhood: NeighbourhoodIndex,
384-
): boolean {
385-
if (!nodeKindIncludesAny(node, TOOLBAR_HOST_KIND_FRAGMENTS)) return false;
386-
if (typeof node.parentIndex !== 'number') return false;
406+
): ToolbarHostReach | null {
407+
if (!nodeKindIncludesAny(node, TOOLBAR_HOST_KIND_FRAGMENTS)) return null;
408+
if (typeof node.parentIndex !== 'number') return null;
387409
const rect = positiveRect(node.rect);
388-
if (!rect) return false;
410+
if (!rect) return null;
389411
const siblings = neighbourhood.childrenByParent.get(node.parentIndex)!;
390412
const position = siblings.findIndex((sibling) => sibling.index === node.index);
391-
const ownStart = ownPresentationStart(siblings, position);
413+
const enclosedPositions = siblings.flatMap((sibling, siblingPosition) =>
414+
siblingPosition !== position && isEnclosedHostedRegion(rect, sibling) ? [siblingPosition] : [],
415+
);
416+
if (enclosedPositions.length === 0) return null;
417+
if (enclosedPositions.some((siblingPosition) => siblingPosition > position)) return null;
418+
419+
const previousHostEnd = ownPresentationStart(siblings, position);
420+
const ownEnclosed = enclosedPositions.filter(
421+
(siblingPosition) => siblingPosition >= previousHostEnd,
422+
);
423+
if (ownEnclosed.length === enclosedPositions.length) return { kind: 'own-presentation' };
424+
const beneathTabBars = new Set<number>();
425+
for (const siblingPosition of ownEnclosed) {
426+
if (!nodeKindIncludesAny(siblings[siblingPosition]!, TAB_BAR_KIND_FRAGMENTS)) break;
427+
beneathTabBars.add(siblingPosition);
428+
}
429+
return {
430+
kind: 'over-beneath',
431+
parentIndex: node.parentIndex,
432+
ownBranchIndexes: new Set(
433+
siblings
434+
.slice(previousHostEnd, position)
435+
.filter((_, offset) => !beneathTabBars.has(previousHostEnd + offset))
436+
.map((sibling) => sibling.index),
437+
),
438+
};
439+
}
392440

393-
let hasHostedRegion = false;
394-
for (const [siblingPosition, sibling] of siblings.entries()) {
395-
if (siblingPosition === position || !isEnclosedHostedRegion(rect, sibling)) continue;
396-
if (siblingPosition < ownStart || siblingPosition > position) return false;
397-
hasHostedRegion = true;
441+
function isOwnPresentationToolbarHost(
442+
node: RawSnapshotNode,
443+
neighbourhood: NeighbourhoodIndex,
444+
): boolean {
445+
return toolbarHostReach(node, neighbourhood)?.kind === 'own-presentation';
446+
}
447+
448+
function isInToolbarHostOwnPresentation(
449+
host: RawSnapshotNode,
450+
target: RawSnapshotNode,
451+
scan: OcclusionScan,
452+
): boolean {
453+
const reach = toolbarHostReach(host, scan.neighbourhood);
454+
if (reach?.kind !== 'over-beneath') return false;
455+
const branch = branchUnderParent(target, reach.parentIndex, scan.byIndex);
456+
return branch !== undefined && reach.ownBranchIndexes.has(branch.index);
457+
}
458+
459+
/** The target itself or its ancestor that is a direct child of `parentIndex`. */
460+
function branchUnderParent(
461+
node: RawSnapshotNode,
462+
parentIndex: number,
463+
byIndex: Map<number, RawSnapshotNode>,
464+
): RawSnapshotNode | undefined {
465+
let current: RawSnapshotNode | undefined = node;
466+
const visited = new Set<number>();
467+
while (current && !visited.has(current.index)) {
468+
if (current.parentIndex === parentIndex) return current;
469+
visited.add(current.index);
470+
current =
471+
typeof current.parentIndex === 'number' ? byIndex.get(current.parentIndex) : undefined;
398472
}
399-
return hasHostedRegion;
473+
return undefined;
400474
}
401475

402476
function ownPresentationStart(siblings: readonly RawSnapshotNode[], position: number): number {

0 commit comments

Comments
 (0)