Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2383,6 +2383,7 @@ export class PresentationEditor extends EventEmitter {
getEpochMapper: () => this.#epochMapper,
getViewportHost: () => this.#viewportHost,
getVisibleHost: () => this.#visibleHost,
getLayoutMode: () => this.#layoutOptions.layoutMode ?? 'vertical',
getHeaderFooterSession: () => this.#headerFooterSession,
getPageGeometryHelper: () => this.#pageGeometryHelper,
getZoom: () => this.#layoutOptions.zoom ?? 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ import { debugLog } from '../selection/SelectionDebug.js';

const MULTI_CLICK_TIME_THRESHOLD_MS = 400;
const MULTI_CLICK_DISTANCE_THRESHOLD_PX = 5;
const AUTO_SCROLL_EDGE_PX = 32;
const AUTO_SCROLL_MAX_SPEED_PX = 24;
/** Tolerance for detecting scrollability to handle sub-pixel rounding in browsers */
const SCROLL_DETECTION_TOLERANCE_PX = 1;

const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));

// =============================================================================
// Types
Expand Down Expand Up @@ -73,6 +79,8 @@ export type EditorInputDependencies = {
getViewportHost: () => HTMLElement;
/** Get visible host element (for scroll) */
getVisibleHost: () => HTMLElement;
/** Get current layout mode */
getLayoutMode: () => 'vertical' | 'horizontal' | 'book';
/** Get header/footer session manager */
getHeaderFooterSession: () => HeaderFooterSessionManager | null;
/** Get page geometry helper */
Expand Down Expand Up @@ -169,6 +177,10 @@ export class EditorInputManager {
#dragLastPointer: SelectionDebugHudState['lastPointer'] = null;
#dragLastRawHit: PositionHit | null = null;
#dragUsedPageNotMountedFallback = false;
#autoScrollActive = false;
#autoScrollTimer: { id: number; kind: 'raf' | 'timeout' } | null = null;
#autoScrollVelocity: { x: number; y: number } = { x: 0, y: 0 };
#lastPointerClient: { clientX: number; clientY: number } | null = null;

// Click tracking for multi-click detection
#clickCount = 0;
Expand Down Expand Up @@ -429,6 +441,8 @@ export class EditorInputManager {
this.#dragLastPointer = null;
this.#dragLastRawHit = null;
this.#dragUsedPageNotMountedFallback = false;
this.#lastPointerClient = null;
this.#stopAutoScroll();
}

#clearCellAnchor(): void {
Expand Down Expand Up @@ -506,6 +520,235 @@ export class EditorInputManager {
return this.#callbacks.hitTestTable?.(x, y) ?? null;
}

#getAutoScrollWindow(): Window | null {
const host = this.#deps?.getVisibleHost();
return host?.ownerDocument?.defaultView ?? (typeof window !== 'undefined' ? window : null);
}

#getScrollTarget(): {
kind: 'element' | 'window';
rect: { top: number; bottom: number; left: number; right: number };
canScrollX: boolean;
canScrollY: boolean;
win: Window | null;
element?: HTMLElement;
scrollWidth?: number;
scrollHeight?: number;
} | null {
if (!this.#deps) return null;
const visibleHost = this.#deps.getVisibleHost();
const doc = visibleHost.ownerDocument ?? document;
const win = doc.defaultView ?? (typeof window !== 'undefined' ? window : null);
if (!win) return null;

const scrollContainer = this.#findScrollableAncestor(visibleHost);
if (scrollContainer) {
const elementCanScrollX =
scrollContainer.scrollWidth > scrollContainer.clientWidth + SCROLL_DETECTION_TOLERANCE_PX;
const elementCanScrollY =
scrollContainer.scrollHeight > scrollContainer.clientHeight + SCROLL_DETECTION_TOLERANCE_PX;
const rect = scrollContainer.getBoundingClientRect();
return {
kind: 'element',
rect: { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right },
canScrollX: elementCanScrollX,
canScrollY: elementCanScrollY,
win,
element: scrollContainer,
};
}

const docEl = doc.documentElement;
const body = doc.body;
const scrollWidth = Math.max(docEl?.scrollWidth ?? 0, body?.scrollWidth ?? 0);
const scrollHeight = Math.max(docEl?.scrollHeight ?? 0, body?.scrollHeight ?? 0);
const clientWidth = win.innerWidth;
const clientHeight = win.innerHeight;
const canScrollX = scrollWidth > clientWidth + SCROLL_DETECTION_TOLERANCE_PX;
const canScrollY = scrollHeight > clientHeight + SCROLL_DETECTION_TOLERANCE_PX;

return {
kind: 'window',
rect: { top: 0, bottom: clientHeight, left: 0, right: clientWidth },
canScrollX,
canScrollY,
win,
scrollWidth,
scrollHeight,
};
}

#findScrollableAncestor(host: HTMLElement): HTMLElement | null {
const doc = host.ownerDocument ?? document;
const win = doc.defaultView ?? (typeof window !== 'undefined' ? window : null);
let node: HTMLElement | null = host;
while (node && node !== doc.body) {
const style = win?.getComputedStyle ? win.getComputedStyle(node) : null;
const overflowY = style?.overflowY ?? style?.overflow ?? '';
const overflowX = style?.overflowX ?? style?.overflow ?? '';
const canScrollY = node.scrollHeight > node.clientHeight + SCROLL_DETECTION_TOLERANCE_PX;
const canScrollX = node.scrollWidth > node.clientWidth + SCROLL_DETECTION_TOLERANCE_PX;
const allowsY = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay';
const allowsX = overflowX === 'auto' || overflowX === 'scroll' || overflowX === 'overlay';

if ((canScrollY && allowsY) || (canScrollX && allowsX)) {
return node;
}
node = node.parentElement;
}
return null;
}

#scheduleAutoScrollTick(): void {
if (this.#autoScrollTimer) return;
const win = this.#getAutoScrollWindow();
if (win?.requestAnimationFrame) {
const id = win.requestAnimationFrame(() => this.#tickAutoScroll());
this.#autoScrollTimer = { id, kind: 'raf' };
return;
}

const timeoutId = (
win?.setTimeout ? win.setTimeout(() => this.#tickAutoScroll(), 16) : setTimeout(() => this.#tickAutoScroll(), 16)
) as number;
this.#autoScrollTimer = { id: timeoutId, kind: 'timeout' };
}

#startAutoScroll(): void {
if (this.#autoScrollActive) return;
this.#autoScrollActive = true;
this.#scheduleAutoScrollTick();
}

#stopAutoScroll(): void {
this.#autoScrollActive = false;
this.#autoScrollVelocity = { x: 0, y: 0 };
if (!this.#autoScrollTimer) return;
const win = this.#getAutoScrollWindow();
if (this.#autoScrollTimer.kind === 'raf') {
const cancel =
win?.cancelAnimationFrame ?? (typeof cancelAnimationFrame !== 'undefined' ? cancelAnimationFrame : undefined);
cancel?.(this.#autoScrollTimer.id);
} else {
clearTimeout(this.#autoScrollTimer.id);
}
this.#autoScrollTimer = null;
}

#updateAutoScrollFromPointer(clientX: number, clientY: number): void {
if (!this.#deps || !this.#isDragging) {
this.#stopAutoScroll();
return;
}

const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body';
if (sessionMode !== 'body' || this.#deps.isViewLocked()) {
this.#stopAutoScroll();
return;
}

const target = this.#getScrollTarget();
if (!target || (!target.canScrollX && !target.canScrollY)) {
this.#stopAutoScroll();
return;
}

const { rect } = target;
const topDist = clientY - rect.top;
const bottomDist = rect.bottom - clientY;
const leftDist = clientX - rect.left;
const rightDist = rect.right - clientX;

let vx = 0;
let vy = 0;

if (target.canScrollY) {
const topFactor = clamp((AUTO_SCROLL_EDGE_PX - topDist) / AUTO_SCROLL_EDGE_PX, 0, 1);
const bottomFactor = clamp((AUTO_SCROLL_EDGE_PX - bottomDist) / AUTO_SCROLL_EDGE_PX, 0, 1);
if (topFactor > 0) {
vy = -AUTO_SCROLL_MAX_SPEED_PX * topFactor;
} else if (bottomFactor > 0) {
vy = AUTO_SCROLL_MAX_SPEED_PX * bottomFactor;
}
}

const layoutMode = this.#deps.getLayoutMode();
if (layoutMode !== 'vertical' && target.canScrollX) {
const leftFactor = clamp((AUTO_SCROLL_EDGE_PX - leftDist) / AUTO_SCROLL_EDGE_PX, 0, 1);
const rightFactor = clamp((AUTO_SCROLL_EDGE_PX - rightDist) / AUTO_SCROLL_EDGE_PX, 0, 1);
if (leftFactor > 0) {
vx = -AUTO_SCROLL_MAX_SPEED_PX * leftFactor;
} else if (rightFactor > 0) {
vx = AUTO_SCROLL_MAX_SPEED_PX * rightFactor;
}
}

if (vx === 0 && vy === 0) {
this.#stopAutoScroll();
return;
}

this.#autoScrollVelocity = { x: vx, y: vy };
this.#startAutoScroll();
}

#tickAutoScroll(): void {
this.#autoScrollTimer = null;
if (!this.#autoScrollActive || !this.#deps || !this.#isDragging) {
this.#stopAutoScroll();
return;
}

const target = this.#getScrollTarget();
if (!target || (!target.canScrollX && !target.canScrollY)) {
this.#stopAutoScroll();
return;
}

const { x, y } = this.#autoScrollVelocity;
if (x === 0 && y === 0) {
this.#stopAutoScroll();
return;
}

let didScroll = false;
if (target.kind === 'element' && target.element) {
const maxScrollTop = Math.max(0, target.element.scrollHeight - target.element.clientHeight);
const maxScrollLeft = Math.max(0, target.element.scrollWidth - target.element.clientWidth);
const nextTop = clamp(target.element.scrollTop + y, 0, maxScrollTop);
const nextLeft = clamp(target.element.scrollLeft + x, 0, maxScrollLeft);
didScroll = nextTop !== target.element.scrollTop || nextLeft !== target.element.scrollLeft;
if (didScroll) {
target.element.scrollTop = nextTop;
target.element.scrollLeft = nextLeft;
}
} else if (target.kind === 'window' && target.win) {
const scrollWidth = target.scrollWidth ?? 0;
const scrollHeight = target.scrollHeight ?? 0;
const maxScrollTop = Math.max(0, scrollHeight - target.win.innerHeight);
const maxScrollLeft = Math.max(0, scrollWidth - target.win.innerWidth);
const currentTop = target.win.scrollY ?? 0;
const currentLeft = target.win.scrollX ?? 0;
const nextTop = clamp(currentTop + y, 0, maxScrollTop);
const nextLeft = clamp(currentLeft + x, 0, maxScrollLeft);
didScroll = nextTop !== currentTop || nextLeft !== currentLeft;
if (didScroll) {
target.win.scrollTo(nextLeft, nextTop);
}
}

if (didScroll) {
const lastPointer = this.#lastPointerClient;
if (lastPointer) {
this.#handleDragSelectionAt(lastPointer.clientX, lastPointer.clientY);
}
this.#scheduleAutoScrollTick();
return;
}

this.#stopAutoScroll();
}

// ==========================================================================
// Event Handlers
// ==========================================================================
Expand Down Expand Up @@ -692,6 +935,7 @@ export class EditorInputManager {
this.#dragLastPointer = { clientX: event.clientX, clientY: event.clientY, x, y };
this.#dragLastRawHit = hit;
this.#dragUsedPageNotMountedFallback = false;
this.#lastPointerClient = { clientX: event.clientX, clientY: event.clientY };

this.#isDragging = true;
if (clickDepth >= 3) {
Expand Down Expand Up @@ -752,16 +996,17 @@ export class EditorInputManager {
const layoutState = this.#deps.getLayoutState();
if (!layoutState.layout) return;

const normalized = this.#callbacks.normalizeClientPoint?.(event.clientX, event.clientY);
if (!normalized) return;

// Handle drag selection
if (this.#isDragging && this.#dragAnchor !== null && event.buttons & 1) {
this.#handleDragSelection(event, normalized);
this.#lastPointerClient = { clientX: event.clientX, clientY: event.clientY };
this.#handleDragSelectionAt(event.clientX, event.clientY);
this.#updateAutoScrollFromPointer(event.clientX, event.clientY);
return;
}

// Handle header/footer hover
const normalized = this.#callbacks.normalizeClientPoint?.(event.clientX, event.clientY);
if (!normalized) return;
this.#handleHover(normalized);
}

Expand All @@ -770,7 +1015,10 @@ export class EditorInputManager {

this.#suppressFocusInFromDraggable = false;

if (!this.#isDragging) return;
if (!this.#isDragging) {
this.#stopAutoScroll();
return;
}

// Release pointer capture
const viewportHost = this.#deps.getViewportHost();
Expand All @@ -791,6 +1039,7 @@ export class EditorInputManager {
const dragPointer = this.#dragLastPointer;

this.#isDragging = false;
this.#stopAutoScroll();

// Reset cell drag mode
if (this.#cellDragMode !== 'none') {
Expand All @@ -811,6 +1060,7 @@ export class EditorInputManager {
this.#dragLastPointer = null;
this.#dragLastRawHit = null;
this.#dragUsedPageNotMountedFallback = false;
this.#lastPointerClient = null;
return;
}

Expand All @@ -819,6 +1069,9 @@ export class EditorInputManager {
}

#handlePointerLeave(): void {
if (!this.#isDragging) {
this.#stopAutoScroll();
}
this.#callbacks.clearHoverRegion?.();
}

Expand Down Expand Up @@ -1148,25 +1401,29 @@ export class EditorInputManager {
this.#focusEditor();
}

#handleDragSelection(event: PointerEvent, normalized: { x: number; y: number }): void {
#handleDragSelectionAt(clientX: number, clientY: number): void {
if (!this.#deps) return;

const layoutState = this.#deps.getLayoutState();
if (!layoutState.layout) return;

const normalized = this.#callbacks.normalizeClientPoint?.(clientX, clientY);
if (!normalized) return;

this.#pendingMarginClick = null;
const prevPointer = this.#dragLastPointer;
this.#dragLastPointer = { clientX: event.clientX, clientY: event.clientY, x: normalized.x, y: normalized.y };
this.#dragLastPointer = { clientX, clientY, x: normalized.x, y: normalized.y };

const layoutState = this.#deps.getLayoutState();
const viewportHost = this.#deps.getViewportHost();
const pageGeometryHelper = this.#deps.getPageGeometryHelper();

const rawHit = clickToPosition(
layoutState.layout!,
layoutState.layout,
layoutState.blocks,
layoutState.measures,
{ x: normalized.x, y: normalized.y },
viewportHost,
event.clientX,
event.clientY,
clientX,
clientY,
pageGeometryHelper ?? undefined,
);

Expand Down Expand Up @@ -1401,6 +1658,8 @@ export class EditorInputManager {
this.#dragLastPointer = null;
this.#dragLastRawHit = null;
this.#dragUsedPageNotMountedFallback = false;
this.#lastPointerClient = null;
this.#stopAutoScroll();
}

#focusHeaderFooterShortcut(kind: 'header' | 'footer'): void {
Expand Down
Loading