mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-28 20:44:50 +02:00
[eric] canvas: a composer past its cap scrolls; the wheel walk caches only the overflow style and measures capacity live, the cached verdict outlived the box it judged (ENG-485)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
cca034fce3
commit
5f0ea16c68
@@ -0,0 +1,31 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isScrollContainer, OverflowVerdict } from './scrollContainer';
|
||||
|
||||
function box(scrollHeight: number, overflowY = 'auto') {
|
||||
const el = { scrollHeight, clientHeight: 220, scrollWidth: 300, clientWidth: 300 } as unknown as HTMLElement;
|
||||
let styleReads = 0;
|
||||
const computeStyle = () => { styleReads++; return { overflowY, overflowX: 'visible' }; };
|
||||
return { el, computeStyle, reads: () => styleReads };
|
||||
}
|
||||
|
||||
test('a composer that was short at the first wheel scrolls once it has grown past its cap', () => {
|
||||
const cache = new WeakMap<HTMLElement, OverflowVerdict>();
|
||||
const b = box(200);
|
||||
assert.equal(isScrollContainer(b.el, cache, b.computeStyle), false);
|
||||
(b.el as unknown as { scrollHeight: number }).scrollHeight = 900;
|
||||
assert.equal(isScrollContainer(b.el, cache, b.computeStyle), true);
|
||||
});
|
||||
|
||||
test('the overflow style is read once per node, the capacity every time', () => {
|
||||
const cache = new WeakMap<HTMLElement, OverflowVerdict>();
|
||||
const b = box(900);
|
||||
for (let i = 0; i < 5; i++) assert.equal(isScrollContainer(b.el, cache, b.computeStyle), true);
|
||||
assert.equal(b.reads(), 1);
|
||||
});
|
||||
|
||||
test('a clipped box never takes the wheel, however tall its content', () => {
|
||||
const cache = new WeakMap<HTMLElement, OverflowVerdict>();
|
||||
const b = box(5000, 'hidden');
|
||||
assert.equal(isScrollContainer(b.el, cache, b.computeStyle), false);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// Whether a wheel over this element should scroll IT instead of moving the canvas. The overflow
|
||||
// style is cached per node (getComputedStyle walks were the dominant cost of trackpad nav); the
|
||||
// capacity is read live every time, because a composer or transcript that was short when first
|
||||
// wheeled grows later, and a cached "cannot scroll" from that first wheel stuck for the life of the
|
||||
// canvas (Haik, exp.8: a composer past its cap would not scroll, the canvas panned instead).
|
||||
export type OverflowVerdict = 'scrolls' | 'clips';
|
||||
|
||||
export interface ScrollBox {
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
scrollWidth: number;
|
||||
clientWidth: number;
|
||||
}
|
||||
|
||||
type OverflowStyle = Pick<CSSStyleDeclaration, 'overflowY' | 'overflowX'>;
|
||||
|
||||
export function overflowVerdict(style: OverflowStyle): OverflowVerdict {
|
||||
const scrolls = (v: string) => v === 'auto' || v === 'scroll';
|
||||
return scrolls(style.overflowY) || scrolls(style.overflowX) ? 'scrolls' : 'clips';
|
||||
}
|
||||
|
||||
export function hasScrollCapacity(el: ScrollBox): boolean {
|
||||
return el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth;
|
||||
}
|
||||
|
||||
export function isScrollContainer(
|
||||
el: HTMLElement,
|
||||
cache: WeakMap<HTMLElement, OverflowVerdict>,
|
||||
computeStyle: (el: HTMLElement) => OverflowStyle = (e) => getComputedStyle(e),
|
||||
): boolean {
|
||||
if (!hasScrollCapacity(el)) return false;
|
||||
let verdict = cache.get(el);
|
||||
if (verdict === undefined) {
|
||||
verdict = overflowVerdict(computeStyle(el));
|
||||
cache.set(el, verdict);
|
||||
}
|
||||
return verdict === 'scrolls';
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { applyBrowserZoom } from '@/shared/browserZoom';
|
||||
import { syncTiledGeometry } from '../../canvas/tiledGeometry';
|
||||
import { revealZoom, REVEAL_MIN_ZOOM } from '../../canvas/revealZoom';
|
||||
import { classifyWheelDevice } from './classifyWheelDevice';
|
||||
import { isScrollContainer, OverflowVerdict } from './scrollContainer';
|
||||
|
||||
// Surfaces that are WINDOWS, not canvas cards: they behave like an OS window, so a wheel inside one
|
||||
// belongs to it whether or not you clicked in first. Canvas cards (agent, browser, view) keep the
|
||||
@@ -346,8 +347,8 @@ export function useCanvasControls(
|
||||
wheelRafId = requestAnimationFrame(flushWheel);
|
||||
};
|
||||
|
||||
// Cache "is this element a scrollable child" decision per node. The Cache getComputedStyle ancestor walks; uncached was the dominant cost of trackpad two-finger nav. ResizeObserver below invalidates on scroll-capacity change.
|
||||
const scrollableCache: WeakMap<HTMLElement, 'scrollable' | 'not'> = new WeakMap();
|
||||
// The overflow style is cached per node, the capacity is read live; scrollContainer.ts says why.
|
||||
const overflowCache: WeakMap<HTMLElement, OverflowVerdict> = new WeakMap();
|
||||
const gesture: WheelGesture = { owner: null, at: 0 };
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
@@ -399,25 +400,7 @@ export function useCanvasControls(
|
||||
const isTrackpadScroll = classifyTrackpad(e, e.deltaX, e.deltaY);
|
||||
let target = e.target as HTMLElement | null;
|
||||
while (target && target !== el) {
|
||||
let cls = scrollableCache.get(target);
|
||||
if (cls === undefined) {
|
||||
const couldScroll =
|
||||
target.scrollHeight > target.clientHeight ||
|
||||
target.scrollWidth > target.clientWidth;
|
||||
if (couldScroll) {
|
||||
const style = getComputedStyle(target);
|
||||
const oy = style.overflowY;
|
||||
const ox = style.overflowX;
|
||||
const isOverflowScrollable =
|
||||
oy === 'auto' || oy === 'scroll' || ox === 'auto' || ox === 'scroll';
|
||||
cls = isOverflowScrollable ? 'scrollable' : 'not';
|
||||
} else {
|
||||
cls = 'not';
|
||||
}
|
||||
scrollableCache.set(target, cls);
|
||||
}
|
||||
|
||||
if (cls === 'scrollable' && !isModifierWheel && !canvasOwnsGesture) {
|
||||
if (isScrollContainer(target, overflowCache) && !isModifierWheel && !canvasOwnsGesture) {
|
||||
// Google Maps model: plain scroll acts on the CANVAS over a CARD (chat, scheduled task) UNLESS you've clicked INTO it. Only a card that isn't scroll-focused diverts to the canvas gesture; non-card scrollable UI (dropdowns, menus, nested panels) always scrolls natively, and a focused card scrolls its content.
|
||||
const cardEl = target.closest('[data-select-id]');
|
||||
const cardId = cardEl?.getAttribute('data-select-id') ?? null;
|
||||
|
||||
Reference in New Issue
Block a user