diff --git a/bin/esbuild.mjs b/bin/esbuild.mjs index 5e0a370c..b3b22f2e 100644 --- a/bin/esbuild.mjs +++ b/bin/esbuild.mjs @@ -127,6 +127,7 @@ if (config.addon) { } else if (config.isDemoClient) { bundleConfig = { ...bundleConfig, + sourcemap: false, entryPoints: [`demo/client/client.ts`], outfile: 'demo/dist/client-bundle.js', external: ['util', 'os', 'fs', 'path', 'stream', 'Terminal'], diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index b33d198e..5e32893e 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -9,11 +9,11 @@ import { Disposable, toDisposable } from 'common/Lifecycle'; import { IBufferService, ICoreMouseService, IOptionsService } from 'common/services/Services'; import { CoreMouseEventType } from 'common/Types'; import { addDisposableListener, scheduleAtNextAnimationFrame } from 'browser/Dom'; -import { SmoothScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; -import type { ScrollableElementChangeOptions } from 'vs/base/browser/ui/scrollbar/scrollableElementOptions'; +import { SmoothScrollableElement } from 'browser/scrollable/scrollableElement'; +import type { ScrollableElementChangeOptions } from 'browser/scrollable/scrollableElementOptions'; import { Emitter, EventUtils } from 'common/Event'; -import { Scrollable, ScrollbarVisibility, type ScrollEvent } from 'vs/base/common/scrollable'; -import { Gesture, EventType as GestureEventType, type GestureEvent } from 'vs/base/browser/touch'; +import { Scrollable, ScrollbarVisibility, type ScrollEvent } from 'browser/scrollable/scrollable'; +import { Gesture, EventType as GestureEventType, type GestureEvent } from 'browser/scrollable/touch'; export class Viewport extends Disposable { diff --git a/src/browser/scrollable/abstractScrollbar.ts b/src/browser/scrollable/abstractScrollbar.ts new file mode 100644 index 00000000..a87d94b6 --- /dev/null +++ b/src/browser/scrollable/abstractScrollbar.ts @@ -0,0 +1,299 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from './dom'; +import { createFastDomNode, FastDomNode } from './fastDomNode'; +import { GlobalPointerMoveMonitor } from './globalPointerMoveMonitor'; +import { StandardWheelEvent } from './mouseEvent'; +import { ScrollbarArrow, ScrollbarArrowOptions } from './scrollbarArrow'; +import { ScrollbarState } from './scrollbarState'; +import { ScrollbarVisibilityController } from './scrollbarVisibilityController'; +import { Widget } from './widget'; +import * as platform from './platform'; +import { INewScrollPosition, Scrollable, ScrollbarVisibility } from './scrollable'; + +/** + * The orthogonal distance to the slider at which dragging "resets". This implements "snapping" + */ +const POINTER_DRAG_RESET_DISTANCE = 140; + +export interface ISimplifiedPointerEvent { + buttons: number; + pageX: number; + pageY: number; +} + +export interface ScrollbarHost { + onMouseWheel(mouseWheelEvent: StandardWheelEvent): void; + onDragStart(): void; + onDragEnd(): void; +} + +export interface AbstractScrollbarOptions { + lazyRender: boolean; + host: ScrollbarHost; + scrollbarState: ScrollbarState; + visibility: ScrollbarVisibility; + extraScrollbarClassName: string; + scrollable: Scrollable; + scrollByPage: boolean; +} + +export abstract class AbstractScrollbar extends Widget { + + protected _host: ScrollbarHost; + protected _scrollable: Scrollable; + protected _scrollByPage: boolean; + private _lazyRender: boolean; + protected _scrollbarState: ScrollbarState; + protected _visibilityController: ScrollbarVisibilityController; + private _pointerMoveMonitor: GlobalPointerMoveMonitor; + + public domNode: FastDomNode; + public slider!: FastDomNode; + + protected _shouldRender: boolean; + + constructor(opts: AbstractScrollbarOptions) { + super(); + this._lazyRender = opts.lazyRender; + this._host = opts.host; + this._scrollable = opts.scrollable; + this._scrollByPage = opts.scrollByPage; + this._scrollbarState = opts.scrollbarState; + this._visibilityController = this._register(new ScrollbarVisibilityController(opts.visibility, 'visible scrollbar ' + opts.extraScrollbarClassName, 'invisible scrollbar ' + opts.extraScrollbarClassName)); + this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()); + this._pointerMoveMonitor = this._register(new GlobalPointerMoveMonitor()); + this._shouldRender = true; + this.domNode = createFastDomNode(document.createElement('div')); + this.domNode.setAttribute('role', 'presentation'); + this.domNode.setAttribute('aria-hidden', 'true'); + + this._visibilityController.setDomNode(this.domNode); + this.domNode.setPosition('absolute'); + + this._register(dom.addDisposableListener(this.domNode.domNode, dom.EventType.POINTER_DOWN, (e: PointerEvent) => this._domNodePointerDown(e))); + } + + // ----------------- creation + + /** + * Creates the dom node for an arrow & adds it to the container + */ + protected _createArrow(opts: ScrollbarArrowOptions): void { + const arrow = this._register(new ScrollbarArrow(opts)); + this.domNode.domNode.appendChild(arrow.bgDomNode); + this.domNode.domNode.appendChild(arrow.domNode); + } + + /** + * Creates the slider dom node, adds it to the container & hooks up the events + */ + protected _createSlider(top: number, left: number, width: number | undefined, height: number | undefined): void { + this.slider = createFastDomNode(document.createElement('div')); + this.slider.setClassName('slider'); + this.slider.setPosition('absolute'); + this.slider.setTop(top); + this.slider.setLeft(left); + if (typeof width === 'number') { + this.slider.setWidth(width); + } + if (typeof height === 'number') { + this.slider.setHeight(height); + } + this.slider.setLayerHinting(true); + this.slider.setContain('strict'); + + this.domNode.domNode.appendChild(this.slider.domNode); + + this._register(dom.addDisposableListener( + this.slider.domNode, + dom.EventType.POINTER_DOWN, + (e: PointerEvent) => { + if (e.button === 0) { + e.preventDefault(); + this._sliderPointerDown(e); + } + } + )); + + this.onclick(this.slider.domNode, e => { + if (e.leftButton) { + e.stopPropagation(); + } + }); + } + + // ----------------- Update state + + protected _onElementSize(visibleSize: number): boolean { + if (this._scrollbarState.setVisibleSize(visibleSize)) { + this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()); + this._shouldRender = true; + if (!this._lazyRender) { + this.render(); + } + } + return this._shouldRender; + } + + protected _onElementScrollSize(elementScrollSize: number): boolean { + if (this._scrollbarState.setScrollSize(elementScrollSize)) { + this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()); + this._shouldRender = true; + if (!this._lazyRender) { + this.render(); + } + } + return this._shouldRender; + } + + protected _onElementScrollPosition(elementScrollPosition: number): boolean { + if (this._scrollbarState.setScrollPosition(elementScrollPosition)) { + this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()); + this._shouldRender = true; + if (!this._lazyRender) { + this.render(); + } + } + return this._shouldRender; + } + + // ----------------- rendering + + public beginReveal(): void { + this._visibilityController.setShouldBeVisible(true); + } + + public beginHide(): void { + this._visibilityController.setShouldBeVisible(false); + } + + public render(): void { + if (!this._shouldRender) { + return; + } + this._shouldRender = false; + + this._renderDomNode(this._scrollbarState.getRectangleLargeSize(), this._scrollbarState.getRectangleSmallSize()); + this._updateSlider(this._scrollbarState.getSliderSize(), this._scrollbarState.getArrowSize() + this._scrollbarState.getSliderPosition()); + } + // ----------------- DOM events + + private _domNodePointerDown(e: PointerEvent): void { + if (e.target !== this.domNode.domNode) { + return; + } + this._onPointerDown(e); + } + + public delegatePointerDown(e: PointerEvent): void { + const domTop = this.domNode.domNode.getClientRects()[0].top; + const sliderStart = domTop + this._scrollbarState.getSliderPosition(); + const sliderStop = domTop + this._scrollbarState.getSliderPosition() + this._scrollbarState.getSliderSize(); + const pointerPos = this._sliderPointerPosition(e); + if (sliderStart <= pointerPos && pointerPos <= sliderStop) { + if (e.button === 0) { + e.preventDefault(); + this._sliderPointerDown(e); + } + } else { + this._onPointerDown(e); + } + } + + private _onPointerDown(e: PointerEvent): void { + let offsetX: number; + let offsetY: number; + if (e.target === this.domNode.domNode && typeof e.offsetX === 'number' && typeof e.offsetY === 'number') { + offsetX = e.offsetX; + offsetY = e.offsetY; + } else { + const domNodePosition = dom.getDomNodePagePosition(this.domNode.domNode); + offsetX = e.pageX - domNodePosition.left; + offsetY = e.pageY - domNodePosition.top; + } + + const offset = this._pointerDownRelativePosition(offsetX, offsetY); + this._setDesiredScrollPositionNow( + this._scrollByPage + ? this._scrollbarState.getDesiredScrollPositionFromOffsetPaged(offset) + : this._scrollbarState.getDesiredScrollPositionFromOffset(offset) + ); + + if (e.button === 0) { + e.preventDefault(); + this._sliderPointerDown(e); + } + } + + private _sliderPointerDown(e: PointerEvent): void { + if (!e.target || !(e.target instanceof Element)) { + return; + } + const initialPointerPosition = this._sliderPointerPosition(e); + const initialPointerOrthogonalPosition = this._sliderOrthogonalPointerPosition(e); + const initialScrollbarState = this._scrollbarState.clone(); + this.slider.toggleClassName('active', true); + + this._pointerMoveMonitor.startMonitoring( + e.target, + e.pointerId, + e.buttons, + (pointerMoveData: PointerEvent) => { + const pointerOrthogonalPosition = this._sliderOrthogonalPointerPosition(pointerMoveData); + const pointerOrthogonalDelta = Math.abs(pointerOrthogonalPosition - initialPointerOrthogonalPosition); + + if (platform.isWindows && pointerOrthogonalDelta > POINTER_DRAG_RESET_DISTANCE) { + this._setDesiredScrollPositionNow(initialScrollbarState.getScrollPosition()); + return; + } + + const pointerPosition = this._sliderPointerPosition(pointerMoveData); + const pointerDelta = pointerPosition - initialPointerPosition; + this._setDesiredScrollPositionNow(initialScrollbarState.getDesiredScrollPositionFromDelta(pointerDelta)); + }, + () => { + this.slider.toggleClassName('active', false); + this._host.onDragEnd(); + } + ); + + this._host.onDragStart(); + } + + private _setDesiredScrollPositionNow(_desiredScrollPosition: number): void { + + const desiredScrollPosition: INewScrollPosition = {}; + this.writeScrollPosition(desiredScrollPosition, _desiredScrollPosition); + + this._scrollable.setScrollPositionNow(desiredScrollPosition); + } + + public updateScrollbarSize(scrollbarSize: number): void { + this._updateScrollbarSize(scrollbarSize); + this._scrollbarState.setScrollbarSize(scrollbarSize); + this._shouldRender = true; + if (!this._lazyRender) { + this.render(); + } + } + + public isNeeded(): boolean { + return this._scrollbarState.isNeeded(); + } + + // ----------------- Overwrite these + + protected abstract _renderDomNode(largeSize: number, smallSize: number): void; + protected abstract _updateSlider(sliderSize: number, sliderPosition: number): void; + + protected abstract _pointerDownRelativePosition(offsetX: number, offsetY: number): number; + protected abstract _sliderPointerPosition(e: ISimplifiedPointerEvent): number; + protected abstract _sliderOrthogonalPointerPosition(e: ISimplifiedPointerEvent): number; + protected abstract _updateScrollbarSize(size: number): void; + + public abstract writeScrollPosition(target: INewScrollPosition, scrollPosition: number): void; +} diff --git a/src/browser/scrollable/arrays.ts b/src/browser/scrollable/arrays.ts new file mode 100644 index 00000000..4a643fff --- /dev/null +++ b/src/browser/scrollable/arrays.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +export function tail(array: ArrayLike, n: number = 0): T | undefined { + return array[array.length - (1 + n)]; +} + +export function tail2(arr: T[]): [T[], T] { + if (arr.length === 0) { + throw new Error('Invalid tail call'); + } + + return [arr.slice(0, arr.length - 1), arr[arr.length - 1]]; +} diff --git a/src/browser/scrollable/async.ts b/src/browser/scrollable/async.ts new file mode 100644 index 00000000..8fb5f9c7 --- /dev/null +++ b/src/browser/scrollable/async.ts @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IDisposable } from './lifecycle'; + +export class TimeoutTimer implements IDisposable { + private _token: any = -1; + private _isDisposed = false; + + dispose(): void { + this.cancel(); + this._isDisposed = true; + } + + cancel(): void { + if (this._token !== -1) { + clearTimeout(this._token); + this._token = -1; + } + } + + cancelAndSet(runner: () => void, timeout: number): void { + if (this._isDisposed) { + throw new Error('Calling cancelAndSet on a disposed TimeoutTimer'); + } + this.cancel(); + this._token = setTimeout(() => { + this._token = -1; + runner(); + }, timeout); + } + + setIfNotSet(runner: () => void, timeout: number): void { + if (this._isDisposed) { + throw new Error('Calling setIfNotSet on a disposed TimeoutTimer'); + } + if (this._token !== -1) { + return; + } + this._token = setTimeout(() => { + this._token = -1; + runner(); + }, timeout); + } +} + +export class IntervalTimer implements IDisposable { + private _disposable: IDisposable | undefined; + private _isDisposed = false; + + cancel(): void { + this._disposable?.dispose(); + this._disposable = undefined; + } + + cancelAndSet(runner: () => void, interval: number, context: Window | typeof globalThis = globalThis): void { + if (this._isDisposed) { + throw new Error('Calling cancelAndSet on a disposed IntervalTimer'); + } + this.cancel(); + const handle = context.setInterval(() => { + runner(); + }, interval); + this._disposable = { + dispose: () => { + context.clearInterval(handle as any); + this._disposable = undefined; + } + }; + } + + dispose(): void { + this.cancel(); + this._isDisposed = true; + } +} diff --git a/src/browser/scrollable/browser.ts b/src/browser/scrollable/browser.ts new file mode 100644 index 00000000..72dd0bcb --- /dev/null +++ b/src/browser/scrollable/browser.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CodeWindow, mainWindow } from './window'; +import { Emitter } from './event'; + +class WindowManager { + + static readonly INSTANCE = new WindowManager(); + + // --- Zoom Level + + private readonly mapWindowIdToZoomLevel = new Map(); + + private readonly _onDidChangeZoomLevel = new Emitter(); + readonly onDidChangeZoomLevel = this._onDidChangeZoomLevel.event; + + getZoomLevel(targetWindow: Window): number { + return this.mapWindowIdToZoomLevel.get(this.getWindowId(targetWindow)) ?? 0; + } + setZoomLevel(zoomLevel: number, targetWindow: Window): void { + if (this.getZoomLevel(targetWindow) === zoomLevel) { + return; + } + + const targetWindowId = this.getWindowId(targetWindow); + this.mapWindowIdToZoomLevel.set(targetWindowId, zoomLevel); + this._onDidChangeZoomLevel.fire(targetWindowId); + } + + // --- Zoom Factor + + private readonly mapWindowIdToZoomFactor = new Map(); + + getZoomFactor(targetWindow: Window): number { + return this.mapWindowIdToZoomFactor.get(this.getWindowId(targetWindow)) ?? 1; + } + setZoomFactor(zoomFactor: number, targetWindow: Window): void { + this.mapWindowIdToZoomFactor.set(this.getWindowId(targetWindow), zoomFactor); + } + + // --- Fullscreen + + private readonly _onDidChangeFullscreen = new Emitter(); + readonly onDidChangeFullscreen = this._onDidChangeFullscreen.event; + + private readonly mapWindowIdToFullScreen = new Map(); + + setFullscreen(fullscreen: boolean, targetWindow: Window): void { + if (this.isFullscreen(targetWindow) === fullscreen) { + return; + } + + const windowId = this.getWindowId(targetWindow); + this.mapWindowIdToFullScreen.set(windowId, fullscreen); + this._onDidChangeFullscreen.fire(windowId); + } + isFullscreen(targetWindow: Window): boolean { + return !!this.mapWindowIdToFullScreen.get(this.getWindowId(targetWindow)); + } + + private getWindowId(targetWindow: Window): number { + return (targetWindow as CodeWindow).vscodeWindowId; + } +} + +export function addMatchMediaChangeListener(targetWindow: Window, query: string | MediaQueryList, callback: (this: MediaQueryList, ev: MediaQueryListEvent) => any): void { + if (typeof query === 'string') { + query = targetWindow.matchMedia(query); + } + query.addEventListener('change', callback); +} + +/** A zoom index, e.g. 1, 2, 3 */ +export function setZoomLevel(zoomLevel: number, targetWindow: Window): void { + WindowManager.INSTANCE.setZoomLevel(zoomLevel, targetWindow); +} +export function getZoomLevel(targetWindow: Window): number { + return WindowManager.INSTANCE.getZoomLevel(targetWindow); +} +export const onDidChangeZoomLevel = WindowManager.INSTANCE.onDidChangeZoomLevel; + +/** The zoom scale for an index, e.g. 1, 1.2, 1.4 */ +export function getZoomFactor(targetWindow: Window): number { + return WindowManager.INSTANCE.getZoomFactor(targetWindow); +} +export function setZoomFactor(zoomFactor: number, targetWindow: Window): void { + WindowManager.INSTANCE.setZoomFactor(zoomFactor, targetWindow); +} + +export function setFullscreen(fullscreen: boolean, targetWindow: Window): void { + WindowManager.INSTANCE.setFullscreen(fullscreen, targetWindow); +} +export function isFullscreen(targetWindow: Window): boolean { + return WindowManager.INSTANCE.isFullscreen(targetWindow); +} +export const onDidChangeFullscreen = WindowManager.INSTANCE.onDidChangeFullscreen; + +const userAgent = typeof navigator === 'object' ? navigator.userAgent : ''; + +export const isFirefox = (userAgent.indexOf('Firefox') >= 0); +export const isWebKit = (userAgent.indexOf('AppleWebKit') >= 0); +export const isChrome = (userAgent.indexOf('Chrome') >= 0); +export const isSafari = (!isChrome && (userAgent.indexOf('Safari') >= 0)); +export const isWebkitWebView = (!isChrome && !isSafari && isWebKit); +export const isElectron = (userAgent.indexOf('Electron/') >= 0); +export const isAndroid = (userAgent.indexOf('Android') >= 0); + +let standalone = false; +if (typeof mainWindow.matchMedia === 'function') { + const standaloneMatchMedia = mainWindow.matchMedia('(display-mode: standalone) or (display-mode: window-controls-overlay)'); + const fullScreenMatchMedia = mainWindow.matchMedia('(display-mode: fullscreen)'); + standalone = standaloneMatchMedia.matches; + addMatchMediaChangeListener(mainWindow, standaloneMatchMedia, ({ matches }) => { + // entering fullscreen would change standaloneMatchMedia.matches to false + // if standalone is true (running as PWA) and entering fullscreen, skip this change + if (standalone && fullScreenMatchMedia.matches) { + return; + } + // otherwise update standalone (browser to PWA or PWA to browser) + standalone = matches; + }); +} +export function isStandalone(): boolean { + return standalone; +} + +// Visible means that the feature is enabled, not necessarily being rendered +// e.g. visible is true even in fullscreen mode where the controls are hidden +// See docs at https://developer.mozilla.org/en-US/docs/Web/API/WindowControlsOverlay/visible +export function isWCOEnabled(): boolean { + return (navigator as any)?.windowControlsOverlay?.visible; +} + +// Returns the bounding rect of the titlebar area if it is supported and defined +// See docs at https://developer.mozilla.org/en-US/docs/Web/API/WindowControlsOverlay/getTitlebarAreaRect +export function getWCOBoundingRect(): DOMRect | undefined { + return (navigator as any)?.windowControlsOverlay?.getTitlebarAreaRect(); +} diff --git a/src/browser/scrollable/collections.ts b/src/browser/scrollable/collections.ts new file mode 100644 index 00000000..d0df190c --- /dev/null +++ b/src/browser/scrollable/collections.ts @@ -0,0 +1,140 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * An interface for a JavaScript object that + * acts a dictionary. The keys are strings. + */ +export type IStringDictionary = Record; + +/** + * An interface for a JavaScript object that + * acts a dictionary. The keys are numbers. + */ +export type INumberDictionary = Record; + +/** + * Groups the collection into a dictionary based on the provided + * group function. + */ +export function groupBy(data: V[], groupFn: (element: V) => K): Record { + const result: Record = Object.create(null); + for (const element of data) { + const key = groupFn(element); + let target = result[key]; + if (!target) { + target = result[key] = []; + } + target.push(element); + } + return result; +} + +export function diffSets(before: Set, after: Set): { removed: T[]; added: T[] } { + const removed: T[] = []; + const added: T[] = []; + for (const element of before) { + if (!after.has(element)) { + removed.push(element); + } + } + for (const element of after) { + if (!before.has(element)) { + added.push(element); + } + } + return { removed, added }; +} + +export function diffMaps(before: Map, after: Map): { removed: V[]; added: V[] } { + const removed: V[] = []; + const added: V[] = []; + for (const [index, value] of before) { + if (!after.has(index)) { + removed.push(value); + } + } + for (const [index, value] of after) { + if (!before.has(index)) { + added.push(value); + } + } + return { removed, added }; +} + +/** + * Computes the intersection of two sets. + * + * @param setA - The first set. + * @param setB - The second iterable. + * @returns A new set containing the elements that are in both `setA` and `setB`. + */ +export function intersection(setA: Set, setB: Iterable): Set { + const result = new Set(); + for (const elem of setB) { + if (setA.has(elem)) { + result.add(elem); + } + } + return result; +} + +export class SetWithKey implements Set { + private _map = new Map(); + + constructor(values: T[], private toKey: (t: T) => any) { + for (const value of values) { + this.add(value); + } + } + + get size(): number { + return this._map.size; + } + + add(value: T): this { + const key = this.toKey(value); + this._map.set(key, value); + return this; + } + + delete(value: T): boolean { + return this._map.delete(this.toKey(value)); + } + + has(value: T): boolean { + return this._map.has(this.toKey(value)); + } + + *entries(): IterableIterator<[T, T]> { + for (const entry of this._map.values()) { + yield [entry, entry]; + } + } + + keys(): IterableIterator { + return this.values(); + } + + *values(): IterableIterator { + for (const entry of this._map.values()) { + yield entry; + } + } + + clear(): void { + this._map.clear(); + } + + forEach(callbackfn: (value: T, value2: T, set: Set) => void, thisArg?: any): void { + this._map.forEach(entry => callbackfn.call(thisArg, entry, entry, this)); + } + + [Symbol.iterator](): IterableIterator { + return this.values(); + } + + [Symbol.toStringTag]: string = 'SetWithKey'; +} diff --git a/src/browser/scrollable/decorators.ts b/src/browser/scrollable/decorators.ts new file mode 100644 index 00000000..34592a7b --- /dev/null +++ b/src/browser/scrollable/decorators.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +function createDecorator(mapFn: (fn: Function, key: string) => Function): Function { + return (target: any, key: string, descriptor: any) => { + let fnKey: string | null = null; + let fn: Function | null = null; + + if (typeof descriptor.value === 'function') { + fnKey = 'value'; + fn = descriptor.value; + } else if (typeof descriptor.get === 'function') { + fnKey = 'get'; + fn = descriptor.get; + } + + if (!fn) { + throw new Error('not supported'); + } + + descriptor[fnKey!] = mapFn(fn, key); + }; +} + +export function memoize(_target: any, key: string, descriptor: any) { + let fnKey: string | null = null; + let fn: Function | null = null; + + if (typeof descriptor.value === 'function') { + fnKey = 'value'; + fn = descriptor.value; + + if (fn!.length !== 0) { + console.warn('Memoize should only be used in functions with zero parameters'); + } + } else if (typeof descriptor.get === 'function') { + fnKey = 'get'; + fn = descriptor.get; + } + + if (!fn) { + throw new Error('not supported'); + } + + const memoizeKey = `$memoize$${key}`; + descriptor[fnKey!] = function (...args: any[]) { + if (!this.hasOwnProperty(memoizeKey)) { + Object.defineProperty(this, memoizeKey, { + configurable: false, + enumerable: false, + writable: false, + value: fn.apply(this, args) + }); + } + + return this[memoizeKey]; + }; +} + +export interface IDebounceReducer { + (previousValue: T, ...args: any[]): T; +} + +export function debounce(delay: number, reducer?: IDebounceReducer, initialValueProvider?: () => T): Function { + return createDecorator((fn, key) => { + const timerKey = `$debounce$${key}`; + const resultKey = `$debounce$result$${key}`; + + return function (this: any, ...args: any[]) { + if (!this[resultKey]) { + this[resultKey] = initialValueProvider ? initialValueProvider() : undefined; + } + + clearTimeout(this[timerKey]); + + if (reducer) { + this[resultKey] = reducer(this[resultKey], ...args); + args = [this[resultKey]]; + } + + this[timerKey] = setTimeout(() => { + fn.apply(this, args); + this[resultKey] = initialValueProvider ? initialValueProvider() : undefined; + }, delay); + }; + }); +} + +export function throttle(delay: number, reducer?: IDebounceReducer, initialValueProvider?: () => T): Function { + return createDecorator((fn, key) => { + const timerKey = `$throttle$timer$${key}`; + const resultKey = `$throttle$result$${key}`; + const lastRunKey = `$throttle$lastRun$${key}`; + const pendingKey = `$throttle$pending$${key}`; + + return function (this: any, ...args: any[]) { + if (!this[resultKey]) { + this[resultKey] = initialValueProvider ? initialValueProvider() : undefined; + } + if (this[lastRunKey] === null || this[lastRunKey] === undefined) { + this[lastRunKey] = -Number.MAX_VALUE; + } + + if (reducer) { + this[resultKey] = reducer(this[resultKey], ...args); + } + + if (this[pendingKey]) { + return; + } + + const nextTime = this[lastRunKey] + delay; + if (nextTime <= Date.now()) { + this[lastRunKey] = Date.now(); + fn.apply(this, [this[resultKey]]); + this[resultKey] = initialValueProvider ? initialValueProvider() : undefined; + } else { + this[pendingKey] = true; + this[timerKey] = setTimeout(() => { + this[pendingKey] = false; + this[lastRunKey] = Date.now(); + fn.apply(this, [this[resultKey]]); + this[resultKey] = initialValueProvider ? initialValueProvider() : undefined; + }, nextTime - Date.now()); + } + }; + }); +} diff --git a/src/browser/scrollable/dom.ts b/src/browser/scrollable/dom.ts new file mode 100644 index 00000000..257c2e64 --- /dev/null +++ b/src/browser/scrollable/dom.ts @@ -0,0 +1,198 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IntervalTimer } from './async'; +import { Emitter, Event } from './event'; +import { DisposableStore, IDisposable } from './lifecycle'; + +export interface IRegisteredWindow { + readonly window: Window; + readonly disposables: DisposableStore; +} + +const _onDidRegisterWindow = new Emitter(); +export const onDidRegisterWindow: Event = _onDidRegisterWindow.event; + +export function registerWindow(window: Window): IDisposable { + const disposables = new DisposableStore(); + _onDidRegisterWindow.fire({ window, disposables }); + return disposables; +} + +export function getWindow(e: Node | UIEvent | undefined | null): Window { + const candidateNode = e as Node | undefined | null; + if (candidateNode?.ownerDocument?.defaultView) { + return candidateNode.ownerDocument.defaultView; + } + + const candidateEvent = e as UIEvent | undefined | null; + if (candidateEvent?.view) { + return candidateEvent.view; + } + + return window; +} + +class DomListener implements IDisposable { + private _handler: ((e: any) => void) | null; + private _node: EventTarget | null; + private readonly _type: string; + private readonly _options: boolean | AddEventListenerOptions | undefined; + + constructor(node: EventTarget, type: string, handler: (e: any) => void, options?: boolean | AddEventListenerOptions) { + this._node = node; + this._type = type; + this._handler = handler; + this._options = options; + node.addEventListener(type, handler, options); + } + + public dispose(): void { + if (!this._node || !this._handler) { + return; + } + this._node.removeEventListener(this._type, this._handler, this._options); + this._node = null; + this._handler = null; + } +} + +export function addDisposableListener(node: EventTarget, type: K, handler: (event: GlobalEventHandlersEventMap[K]) => void, useCapture?: boolean): IDisposable; +export function addDisposableListener(node: EventTarget, type: string, handler: (event: any) => void, useCapture?: boolean): IDisposable; +export function addDisposableListener(node: EventTarget, type: string, handler: (event: any) => void, options: AddEventListenerOptions): IDisposable; +export function addDisposableListener(node: EventTarget, type: string, handler: (event: any) => void, useCaptureOrOptions?: boolean | AddEventListenerOptions): IDisposable { + return new DomListener(node, type, handler, useCaptureOrOptions); +} + +export function addStandardDisposableListener(node: HTMLElement, type: string, handler: (event: any) => void, useCapture?: boolean): IDisposable { + return addDisposableListener(node, type, handler, useCapture); +} + +export const EventType = { + CLICK: 'click', + MOUSE_DOWN: 'mousedown', + MOUSE_OVER: 'mouseover', + MOUSE_LEAVE: 'mouseleave', + KEY_DOWN: 'keydown', + KEY_UP: 'keyup', + INPUT: 'input', + BLUR: 'blur', + FOCUS: 'focus', + CHANGE: 'change', + POINTER_DOWN: 'pointerdown', + POINTER_MOVE: 'pointermove', + POINTER_UP: 'pointerup', + MOUSE_WHEEL: 'wheel', + WHEEL: 'wheel' +} as const; + +export interface IDomNodePagePosition { + left: number; + top: number; + width: number; + height: number; +} + +export function getDomNodePagePosition(domNode: HTMLElement): IDomNodePagePosition { + const bb = domNode.getBoundingClientRect(); + const win = getWindow(domNode); + return { + left: bb.left + win.scrollX, + top: bb.top + win.scrollY, + width: bb.width, + height: bb.height + }; +} + +class AnimationFrameQueueItem implements IDisposable { + private _canceled = false; + + constructor(private readonly _runner: () => void, public priority: number) { + } + + public dispose(): void { + this._canceled = true; + } + + public execute(): void { + if (this._canceled) { + return; + } + try { + this._runner(); + } catch (e) { + console.error(e); + } + } + + public static sort(a: AnimationFrameQueueItem, b: AnimationFrameQueueItem): number { + return b.priority - a.priority; + } +} + +interface IWindowAnimationFrameState { + next: AnimationFrameQueueItem[]; + current: AnimationFrameQueueItem[]; + animFrameRequested: boolean; + inAnimationFrameRunner: boolean; +} + +const animationFrameState = new Map(); + +function getAnimationFrameState(targetWindow: Window): IWindowAnimationFrameState { + let state = animationFrameState.get(targetWindow); + if (!state) { + state = { + next: [], + current: [], + animFrameRequested: false, + inAnimationFrameRunner: false + }; + animationFrameState.set(targetWindow, state); + } + return state; +} + +function animationFrameRunner(targetWindow: Window): void { + const state = getAnimationFrameState(targetWindow); + state.animFrameRequested = false; + + state.current = state.next; + state.next = []; + + state.inAnimationFrameRunner = true; + while (state.current.length > 0) { + state.current.sort(AnimationFrameQueueItem.sort); + const top = state.current.shift()!; + top.execute(); + } + state.inAnimationFrameRunner = false; +} + +export function scheduleAtNextAnimationFrame(targetWindow: Window, runner: () => void, priority: number = 0): IDisposable { + const state = getAnimationFrameState(targetWindow); + const item = new AnimationFrameQueueItem(runner, priority); + state.next.push(item); + + if (!state.animFrameRequested) { + state.animFrameRequested = true; + targetWindow.requestAnimationFrame(() => animationFrameRunner(targetWindow)); + } + + return item; +} + +export class WindowIntervalTimer extends IntervalTimer { + private readonly _defaultTarget?: Window; + + constructor(node?: Node) { + super(); + this._defaultTarget = node ? getWindow(node) : undefined; + } + + public cancelAndSet(runner: () => void, interval: number, targetWindow?: Window): void { + super.cancelAndSet(runner, interval, targetWindow ?? this._defaultTarget ?? window); + } +} diff --git a/src/browser/scrollable/event.ts b/src/browser/scrollable/event.ts new file mode 100644 index 00000000..97351595 --- /dev/null +++ b/src/browser/scrollable/event.ts @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + * + * Minimal event utilities for scrollable components. + */ + +import { Disposable, DisposableStore, IDisposable, toDisposable } from './lifecycle'; + +export interface Event { + (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore): IDisposable; +} + +export class Emitter { + private _listeners: { fn: (e: T) => any; thisArgs: any }[] = []; + private _disposed = false; + private _event: Event | undefined; + + public get event(): Event { + if (this._event) { + return this._event; + } + this._event = (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[] | DisposableStore) => { + if (this._disposed) { + return Disposable.None; + } + + const entry = { fn: listener, thisArgs }; + this._listeners.push(entry); + + const result = toDisposable(() => { + const idx = this._listeners.indexOf(entry); + if (idx !== -1) { + this._listeners.splice(idx, 1); + } + }); + + if (disposables) { + if (Array.isArray(disposables)) { + disposables.push(result); + } else { + disposables.add(result); + } + } + + return result; + }; + return this._event; + } + + public fire(event: T): void { + if (this._disposed || this._listeners.length === 0) { + return; + } + if (this._listeners.length === 1) { + const { fn, thisArgs } = this._listeners[0]; + fn.call(thisArgs, event); + return; + } + + const listeners = this._listeners.slice(); + for (const { fn, thisArgs } of listeners) { + fn.call(thisArgs, event); + } + } + + public dispose(): void { + if (this._disposed) { + return; + } + this._disposed = true; + this._listeners.length = 0; + } +} + +export namespace Event { + export const None: Event = () => Disposable.None; + + export function runAndSubscribe(event: Event, handler: (e: T) => void, initial: T): IDisposable; + export function runAndSubscribe(event: Event, handler: (e: T | undefined) => void): IDisposable; + export function runAndSubscribe(event: Event, handler: (e: T | undefined) => void, initial?: T): IDisposable { + handler(initial); + return event(e => handler(e)); + } +} diff --git a/src/browser/scrollable/fastDomNode.ts b/src/browser/scrollable/fastDomNode.ts new file mode 100644 index 00000000..5190bae6 --- /dev/null +++ b/src/browser/scrollable/fastDomNode.ts @@ -0,0 +1,320 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export class FastDomNode { + + private _maxWidth: string = ''; + private _width: string = ''; + private _height: string = ''; + private _top: string = ''; + private _left: string = ''; + private _bottom: string = ''; + private _right: string = ''; + private _paddingTop: string = ''; + private _paddingLeft: string = ''; + private _paddingBottom: string = ''; + private _paddingRight: string = ''; + private _fontFamily: string = ''; + private _fontWeight: string = ''; + private _fontSize: string = ''; + private _fontStyle: string = ''; + private _fontFeatureSettings: string = ''; + private _fontVariationSettings: string = ''; + private _textDecoration: string = ''; + private _lineHeight: string = ''; + private _letterSpacing: string = ''; + private _className: string = ''; + private _display: string = ''; + private _position: string = ''; + private _visibility: string = ''; + private _color: string = ''; + private _backgroundColor: string = ''; + private _layerHint: boolean = false; + private _contain: 'none' | 'strict' | 'content' | 'size' | 'layout' | 'style' | 'paint' = 'none'; + private _boxShadow: string = ''; + + constructor( + public readonly domNode: T + ) { } + + public setMaxWidth(_maxWidth: number | string): void { + const maxWidth = numberAsPixels(_maxWidth); + if (this._maxWidth === maxWidth) { + return; + } + this._maxWidth = maxWidth; + this.domNode.style.maxWidth = this._maxWidth; + } + + public setWidth(_width: number | string): void { + const width = numberAsPixels(_width); + if (this._width === width) { + return; + } + this._width = width; + this.domNode.style.width = this._width; + } + + public setHeight(_height: number | string): void { + const height = numberAsPixels(_height); + if (this._height === height) { + return; + } + this._height = height; + this.domNode.style.height = this._height; + } + + public setTop(_top: number | string): void { + const top = numberAsPixels(_top); + if (this._top === top) { + return; + } + this._top = top; + this.domNode.style.top = this._top; + } + + public setLeft(_left: number | string): void { + const left = numberAsPixels(_left); + if (this._left === left) { + return; + } + this._left = left; + this.domNode.style.left = this._left; + } + + public setBottom(_bottom: number | string): void { + const bottom = numberAsPixels(_bottom); + if (this._bottom === bottom) { + return; + } + this._bottom = bottom; + this.domNode.style.bottom = this._bottom; + } + + public setRight(_right: number | string): void { + const right = numberAsPixels(_right); + if (this._right === right) { + return; + } + this._right = right; + this.domNode.style.right = this._right; + } + + public setPaddingTop(_paddingTop: number | string): void { + const paddingTop = numberAsPixels(_paddingTop); + if (this._paddingTop === paddingTop) { + return; + } + this._paddingTop = paddingTop; + this.domNode.style.paddingTop = this._paddingTop; + } + + public setPaddingLeft(_paddingLeft: number | string): void { + const paddingLeft = numberAsPixels(_paddingLeft); + if (this._paddingLeft === paddingLeft) { + return; + } + this._paddingLeft = paddingLeft; + this.domNode.style.paddingLeft = this._paddingLeft; + } + + public setPaddingBottom(_paddingBottom: number | string): void { + const paddingBottom = numberAsPixels(_paddingBottom); + if (this._paddingBottom === paddingBottom) { + return; + } + this._paddingBottom = paddingBottom; + this.domNode.style.paddingBottom = this._paddingBottom; + } + + public setPaddingRight(_paddingRight: number | string): void { + const paddingRight = numberAsPixels(_paddingRight); + if (this._paddingRight === paddingRight) { + return; + } + this._paddingRight = paddingRight; + this.domNode.style.paddingRight = this._paddingRight; + } + + public setFontFamily(fontFamily: string): void { + if (this._fontFamily === fontFamily) { + return; + } + this._fontFamily = fontFamily; + this.domNode.style.fontFamily = this._fontFamily; + } + + public setFontWeight(fontWeight: string): void { + if (this._fontWeight === fontWeight) { + return; + } + this._fontWeight = fontWeight; + this.domNode.style.fontWeight = this._fontWeight; + } + + public setFontSize(_fontSize: number | string): void { + const fontSize = numberAsPixels(_fontSize); + if (this._fontSize === fontSize) { + return; + } + this._fontSize = fontSize; + this.domNode.style.fontSize = this._fontSize; + } + + public setFontStyle(fontStyle: string): void { + if (this._fontStyle === fontStyle) { + return; + } + this._fontStyle = fontStyle; + this.domNode.style.fontStyle = this._fontStyle; + } + + public setFontFeatureSettings(fontFeatureSettings: string): void { + if (this._fontFeatureSettings === fontFeatureSettings) { + return; + } + this._fontFeatureSettings = fontFeatureSettings; + this.domNode.style.fontFeatureSettings = this._fontFeatureSettings; + } + + public setFontVariationSettings(fontVariationSettings: string): void { + if (this._fontVariationSettings === fontVariationSettings) { + return; + } + this._fontVariationSettings = fontVariationSettings; + this.domNode.style.fontVariationSettings = this._fontVariationSettings; + } + + public setTextDecoration(textDecoration: string): void { + if (this._textDecoration === textDecoration) { + return; + } + this._textDecoration = textDecoration; + this.domNode.style.textDecoration = this._textDecoration; + } + + public setLineHeight(_lineHeight: number | string): void { + const lineHeight = numberAsPixels(_lineHeight); + if (this._lineHeight === lineHeight) { + return; + } + this._lineHeight = lineHeight; + this.domNode.style.lineHeight = this._lineHeight; + } + + public setLetterSpacing(_letterSpacing: number | string): void { + const letterSpacing = numberAsPixels(_letterSpacing); + if (this._letterSpacing === letterSpacing) { + return; + } + this._letterSpacing = letterSpacing; + this.domNode.style.letterSpacing = this._letterSpacing; + } + + public setClassName(className: string): void { + if (this._className === className) { + return; + } + this._className = className; + this.domNode.className = this._className; + } + + public toggleClassName(className: string, shouldHaveIt?: boolean): void { + this.domNode.classList.toggle(className, shouldHaveIt); + this._className = this.domNode.className; + } + + public setDisplay(display: string): void { + if (this._display === display) { + return; + } + this._display = display; + this.domNode.style.display = this._display; + } + + public setPosition(position: string): void { + if (this._position === position) { + return; + } + this._position = position; + this.domNode.style.position = this._position; + } + + public setVisibility(visibility: string): void { + if (this._visibility === visibility) { + return; + } + this._visibility = visibility; + this.domNode.style.visibility = this._visibility; + } + + public setColor(color: string): void { + if (this._color === color) { + return; + } + this._color = color; + this.domNode.style.color = this._color; + } + + public setBackgroundColor(backgroundColor: string): void { + if (this._backgroundColor === backgroundColor) { + return; + } + this._backgroundColor = backgroundColor; + this.domNode.style.backgroundColor = this._backgroundColor; + } + + public setLayerHinting(layerHint: boolean): void { + if (this._layerHint === layerHint) { + return; + } + this._layerHint = layerHint; + if (layerHint) { + this.domNode.style.transform = 'translate3d(0px, 0px, 0px)'; + } else { + this.domNode.style.transform = ''; + } + } + + public setContain(contain: 'none' | 'strict' | 'content' | 'size' | 'layout' | 'style' | 'paint'): void { + if (this._contain === contain) { + return; + } + this._contain = contain; + this.domNode.style.contain = this._contain; + } + + public setBoxShadow(boxShadow: string): void { + if (this._boxShadow === boxShadow) { + return; + } + this._boxShadow = boxShadow; + this.domNode.style.boxShadow = this._boxShadow; + } + + public setAttribute(name: string, value: string): void { + this.domNode.setAttribute(name, value); + } + + public removeAttribute(name: string): void { + this.domNode.removeAttribute(name); + } + + public appendChild(child: FastDomNode): void { + this.domNode.appendChild(child.domNode); + } + + public removeChild(child: FastDomNode): void { + this.domNode.removeChild(child.domNode); + } +} + +export function createFastDomNode(domNode: T): FastDomNode { + return new FastDomNode(domNode); +} + +function numberAsPixels(value: number | string): string { + return (typeof value === 'number' ? `${value}px` : value); +} diff --git a/src/browser/scrollable/functional.ts b/src/browser/scrollable/functional.ts new file mode 100644 index 00000000..d580cf37 --- /dev/null +++ b/src/browser/scrollable/functional.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Given a function, returns a function that is only calling that function once. + */ +export function createSingleCallFunction(this: unknown, fn: T, fnDidRunCallback?: () => void): T { + const _this = this; + let didCall = false; + let result: unknown; + + return function () { + if (didCall) { + return result; + } + + didCall = true; + if (fnDidRunCallback) { + try { + result = fn.apply(_this, arguments); + } finally { + fnDidRunCallback(); + } + } else { + result = fn.apply(_this, arguments); + } + + return result; + } as unknown as T; +} diff --git a/src/browser/scrollable/globalPointerMoveMonitor.ts b/src/browser/scrollable/globalPointerMoveMonitor.ts new file mode 100644 index 00000000..e39541cc --- /dev/null +++ b/src/browser/scrollable/globalPointerMoveMonitor.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from './dom'; +import { DisposableStore, IDisposable, toDisposable } from './lifecycle'; + +export interface IPointerMoveCallback { + (event: PointerEvent): void; +} + +export interface IOnStopCallback { + (browserEvent?: PointerEvent | KeyboardEvent): void; +} + +export class GlobalPointerMoveMonitor implements IDisposable { + + private readonly _hooks = new DisposableStore(); + private _pointerMoveCallback: IPointerMoveCallback | null = null; + private _onStopCallback: IOnStopCallback | null = null; + + public dispose(): void { + this.stopMonitoring(false); + this._hooks.dispose(); + } + + public stopMonitoring(invokeStopCallback: boolean, browserEvent?: PointerEvent | KeyboardEvent): void { + if (!this.isMonitoring()) { + return; + } + + this._hooks.clear(); + this._pointerMoveCallback = null; + const onStopCallback = this._onStopCallback; + this._onStopCallback = null; + + if (invokeStopCallback && onStopCallback) { + onStopCallback(browserEvent); + } + } + + public isMonitoring(): boolean { + return !!this._pointerMoveCallback; + } + + public startMonitoring( + initialElement: Element, + pointerId: number, + initialButtons: number, + pointerMoveCallback: IPointerMoveCallback, + onStopCallback: IOnStopCallback + ): void { + if (this.isMonitoring()) { + this.stopMonitoring(false); + } + this._pointerMoveCallback = pointerMoveCallback; + this._onStopCallback = onStopCallback; + + let eventSource: Element | Window = initialElement; + + try { + initialElement.setPointerCapture(pointerId); + this._hooks.add(toDisposable(() => { + try { + initialElement.releasePointerCapture(pointerId); + } catch (err) { + // ignore + } + })); + } catch (err) { + eventSource = dom.getWindow(initialElement); + } + + this._hooks.add(dom.addDisposableListener( + eventSource, + dom.EventType.POINTER_MOVE, + (e) => { + if (e.buttons !== initialButtons) { + this.stopMonitoring(true); + return; + } + + e.preventDefault(); + this._pointerMoveCallback!(e); + } + )); + + this._hooks.add(dom.addDisposableListener( + eventSource, + dom.EventType.POINTER_UP, + (e: PointerEvent) => this.stopMonitoring(true) + )); + } +} diff --git a/src/browser/scrollable/horizontalScrollbar.ts b/src/browser/scrollable/horizontalScrollbar.ts new file mode 100644 index 00000000..13548026 --- /dev/null +++ b/src/browser/scrollable/horizontalScrollbar.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AbstractScrollbar, ISimplifiedPointerEvent, ScrollbarHost } from './abstractScrollbar'; +import { ScrollableElementResolvedOptions } from './scrollableElementOptions'; +import { ScrollbarState } from './scrollbarState'; +import { INewScrollPosition, Scrollable, ScrollbarVisibility, ScrollEvent } from './scrollable'; + +export class HorizontalScrollbar extends AbstractScrollbar { + + constructor(scrollable: Scrollable, options: ScrollableElementResolvedOptions, host: ScrollbarHost) { + const scrollDimensions = scrollable.getScrollDimensions(); + const scrollPosition = scrollable.getCurrentScrollPosition(); + super({ + lazyRender: options.lazyRender, + host: host, + scrollbarState: new ScrollbarState( + (options.horizontalHasArrows ? options.arrowSize : 0), + (options.horizontal === ScrollbarVisibility.Hidden ? 0 : options.horizontalScrollbarSize), + (options.vertical === ScrollbarVisibility.Hidden ? 0 : options.verticalScrollbarSize), + scrollDimensions.width, + scrollDimensions.scrollWidth, + scrollPosition.scrollLeft + ), + visibility: options.horizontal, + extraScrollbarClassName: 'horizontal', + scrollable: scrollable, + scrollByPage: options.scrollByPage + }); + + if (options.horizontalHasArrows) { + throw new Error('horizontalHasArrows is not supported in xterm.js'); + } + + this._createSlider(Math.floor((options.horizontalScrollbarSize - options.horizontalSliderSize) / 2), 0, undefined, options.horizontalSliderSize); + } + + protected _updateSlider(sliderSize: number, sliderPosition: number): void { + this.slider.setWidth(sliderSize); + this.slider.setLeft(sliderPosition); + } + + protected _renderDomNode(largeSize: number, smallSize: number): void { + this.domNode.setWidth(largeSize); + this.domNode.setHeight(smallSize); + this.domNode.setLeft(0); + this.domNode.setBottom(0); + } + + public onDidScroll(e: ScrollEvent): boolean { + this._shouldRender = this._onElementScrollSize(e.scrollWidth) || this._shouldRender; + this._shouldRender = this._onElementScrollPosition(e.scrollLeft) || this._shouldRender; + this._shouldRender = this._onElementSize(e.width) || this._shouldRender; + return this._shouldRender; + } + + protected _pointerDownRelativePosition(offsetX: number, offsetY: number): number { + return offsetX; + } + + protected _sliderPointerPosition(e: ISimplifiedPointerEvent): number { + return e.pageX; + } + + protected _sliderOrthogonalPointerPosition(e: ISimplifiedPointerEvent): number { + return e.pageY; + } + + protected _updateScrollbarSize(size: number): void { + this.slider.setHeight(size); + } + + public writeScrollPosition(target: INewScrollPosition, scrollPosition: number): void { + target.scrollLeft = scrollPosition; + } + + public updateOptions(options: ScrollableElementResolvedOptions): void { + this.updateScrollbarSize(options.horizontal === ScrollbarVisibility.Hidden ? 0 : options.horizontalScrollbarSize); + this._scrollbarState.setOppositeScrollbarSize(options.vertical === ScrollbarVisibility.Hidden ? 0 : options.verticalScrollbarSize); + this._visibilityController.setVisibility(options.horizontal); + this._scrollByPage = options.scrollByPage; + } +} diff --git a/src/browser/scrollable/iframe.ts b/src/browser/scrollable/iframe.ts new file mode 100644 index 00000000..e8522e03 --- /dev/null +++ b/src/browser/scrollable/iframe.ts @@ -0,0 +1,135 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Represents a window in a possible chain of iframes + */ +interface IWindowChainElement { + /** + * The window object for it + */ + readonly window: WeakRef; + /** + * The iframe element inside the window.parent corresponding to window + */ + readonly iframeElement: Element | null; +} + +const sameOriginWindowChainCache = new WeakMap(); + +function getParentWindowIfSameOrigin(w: Window): Window | null { + if (!w.parent || w.parent === w) { + return null; + } + + // Cannot really tell if we have access to the parent window unless we try to access something in it + try { + const location = w.location; + const parentLocation = w.parent.location; + if (location.origin !== 'null' && parentLocation.origin !== 'null' && location.origin !== parentLocation.origin) { + return null; + } + } catch (e) { + return null; + } + + return w.parent; +} + +export class IframeUtils { + + /** + * Returns a chain of embedded windows with the same origin (which can be accessed programmatically). + * Having a chain of length 1 might mean that the current execution environment is running outside of an iframe or inside an iframe embedded in a window with a different origin. + */ + private static getSameOriginWindowChain(targetWindow: Window): IWindowChainElement[] { + let windowChainCache = sameOriginWindowChainCache.get(targetWindow); + if (!windowChainCache) { + windowChainCache = []; + sameOriginWindowChainCache.set(targetWindow, windowChainCache); + let w: Window | null = targetWindow; + let parent: Window | null; + do { + parent = getParentWindowIfSameOrigin(w); + if (parent) { + windowChainCache.push({ + window: new WeakRef(w), + iframeElement: w.frameElement || null + }); + } else { + windowChainCache.push({ + window: new WeakRef(w), + iframeElement: null + }); + } + w = parent; + } while (w); + } + return windowChainCache.slice(0); + } + + /** + * Returns the position of `childWindow` relative to `ancestorWindow` + */ + public static getPositionOfChildWindowRelativeToAncestorWindow(childWindow: Window, ancestorWindow: Window | null) { + + if (!ancestorWindow || childWindow === ancestorWindow) { + return { + top: 0, + left: 0 + }; + } + + let top = 0, left = 0; + + const windowChain = this.getSameOriginWindowChain(childWindow); + + for (const windowChainEl of windowChain) { + const windowInChain = windowChainEl.window.deref(); + top += windowInChain?.scrollY ?? 0; + left += windowInChain?.scrollX ?? 0; + + if (windowInChain === ancestorWindow) { + break; + } + + if (!windowChainEl.iframeElement) { + break; + } + + const boundingRect = windowChainEl.iframeElement.getBoundingClientRect(); + top += boundingRect.top; + left += boundingRect.left; + } + + return { + top: top, + left: left + }; + } +} + +/** + * Returns a sha-256 composed of `parentOrigin` and `salt` converted to base 32 + */ +export async function parentOriginHash(parentOrigin: string, salt: string): Promise { + // This same code is also inlined at `src/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html` + if (!crypto.subtle) { + throw new Error(`'crypto.subtle' is not available so webviews will not work. This is likely because the editor is not running in a secure context (https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts).`); + } + + const strData = JSON.stringify({ parentOrigin, salt }); + const encoder = new TextEncoder(); + const arrData = encoder.encode(strData); + const hash = await crypto.subtle.digest('sha-256', arrData); + return sha256AsBase32(hash); +} + +function sha256AsBase32(bytes: ArrayBuffer): string { + const array = Array.from(new Uint8Array(bytes)); + const hexArray = array.map(b => b.toString(16).padStart(2, '0')).join(''); + // sha256 has 256 bits, so we need at most ceil(lg(2^256-1)/lg(32)) = 52 chars to represent it in base 32 + return BigInt(`0x${hexArray}`).toString(32).padStart(52, '0'); +} diff --git a/src/browser/scrollable/iterator.ts b/src/browser/scrollable/iterator.ts new file mode 100644 index 00000000..c329ed6d --- /dev/null +++ b/src/browser/scrollable/iterator.ts @@ -0,0 +1,159 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export namespace Iterable { + + export function is(thing: any): thing is Iterable { + return thing && typeof thing === 'object' && typeof thing[Symbol.iterator] === 'function'; + } + + const _empty: Iterable = Object.freeze([]); + export function empty(): Iterable { + return _empty; + } + + export function* single(element: T): Iterable { + yield element; + } + + export function wrap(iterableOrElement: Iterable | T): Iterable { + if (is(iterableOrElement)) { + return iterableOrElement; + } else { + return single(iterableOrElement); + } + } + + export function from(iterable: Iterable | undefined | null): Iterable { + return iterable || _empty; + } + + export function* reverse(array: Array): Iterable { + for (let i = array.length - 1; i >= 0; i--) { + yield array[i]; + } + } + + export function isEmpty(iterable: Iterable | undefined | null): boolean { + return !iterable || iterable[Symbol.iterator]().next().done === true; + } + + export function first(iterable: Iterable): T | undefined { + return iterable[Symbol.iterator]().next().value; + } + + export function some(iterable: Iterable, predicate: (t: T, i: number) => unknown): boolean { + let i = 0; + for (const element of iterable) { + if (predicate(element, i++)) { + return true; + } + } + return false; + } + + export function find(iterable: Iterable, predicate: (t: T) => t is R): R | undefined; + export function find(iterable: Iterable, predicate: (t: T) => boolean): T | undefined; + export function find(iterable: Iterable, predicate: (t: T) => boolean): T | undefined { + for (const element of iterable) { + if (predicate(element)) { + return element; + } + } + + return undefined; + } + + export function filter(iterable: Iterable, predicate: (t: T) => t is R): Iterable; + export function filter(iterable: Iterable, predicate: (t: T) => boolean): Iterable; + export function* filter(iterable: Iterable, predicate: (t: T) => boolean): Iterable { + for (const element of iterable) { + if (predicate(element)) { + yield element; + } + } + } + + export function* map(iterable: Iterable, fn: (t: T, index: number) => R): Iterable { + let index = 0; + for (const element of iterable) { + yield fn(element, index++); + } + } + + export function* flatMap(iterable: Iterable, fn: (t: T, index: number) => Iterable): Iterable { + let index = 0; + for (const element of iterable) { + yield* fn(element, index++); + } + } + + export function* concat(...iterables: Iterable[]): Iterable { + for (const iterable of iterables) { + yield* iterable; + } + } + + export function reduce(iterable: Iterable, reducer: (previousValue: R, currentValue: T) => R, initialValue: R): R { + let value = initialValue; + for (const element of iterable) { + value = reducer(value, element); + } + return value; + } + + /** + * Returns an iterable slice of the array, with the same semantics as `array.slice()`. + */ + export function* slice(arr: ReadonlyArray, from: number, to = arr.length): Iterable { + if (from < 0) { + from += arr.length; + } + + if (to < 0) { + to += arr.length; + } else if (to > arr.length) { + to = arr.length; + } + + for (; from < to; from++) { + yield arr[from]; + } + } + + /** + * Consumes `atMost` elements from iterable and returns the consumed elements, + * and an iterable for the rest of the elements. + */ + export function consume(iterable: Iterable, atMost: number = Number.POSITIVE_INFINITY): [T[], Iterable] { + const consumed: T[] = []; + + if (atMost === 0) { + return [consumed, iterable]; + } + + const iterator = iterable[Symbol.iterator](); + + for (let i = 0; i < atMost; i++) { + const next = iterator.next(); + + if (next.done) { + return [consumed, Iterable.empty()]; + } + + consumed.push(next.value); + } + + return [consumed, { [Symbol.iterator]() { return iterator; } }]; + } + + export async function asyncToArray(iterable: AsyncIterable): Promise { + const result: T[] = []; + for await (const item of iterable) { + result.push(item); + } + return Promise.resolve(result); + } +} diff --git a/src/browser/scrollable/keyboardEvent.ts b/src/browser/scrollable/keyboardEvent.ts new file mode 100644 index 00000000..91f55ae3 --- /dev/null +++ b/src/browser/scrollable/keyboardEvent.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +export interface IKeyboardEvent { + readonly browserEvent: KeyboardEvent; + readonly keyCode: number; + readonly ctrlKey: boolean; + readonly shiftKey: boolean; + readonly altKey: boolean; + readonly metaKey: boolean; + readonly key: string; + + preventDefault(): void; + stopPropagation(): void; +} + +export class StandardKeyboardEvent implements IKeyboardEvent { + public readonly browserEvent: KeyboardEvent; + public readonly keyCode: number; + public readonly ctrlKey: boolean; + public readonly shiftKey: boolean; + public readonly altKey: boolean; + public readonly metaKey: boolean; + public readonly key: string; + + constructor(e: KeyboardEvent) { + this.browserEvent = e; + this.keyCode = (e.keyCode || (e as any).which || 0) as number; + this.ctrlKey = e.ctrlKey; + this.shiftKey = e.shiftKey; + this.altKey = e.altKey; + this.metaKey = e.metaKey; + this.key = e.key || ''; + } + + public preventDefault(): void { + this.browserEvent.preventDefault(); + } + + public stopPropagation(): void { + this.browserEvent.stopPropagation(); + } +} diff --git a/src/browser/scrollable/lifecycle.ts b/src/browser/scrollable/lifecycle.ts new file mode 100644 index 00000000..e848334f --- /dev/null +++ b/src/browser/scrollable/lifecycle.ts @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + * + * Minimal lifecycle utilities for scrollable components. + */ + +export interface IDisposable { + dispose(): void; +} + +export function toDisposable(fn: () => void): IDisposable { + return { dispose: fn }; +} + +export function dispose(disposable: T): T; +export function dispose(disposable: T | undefined): T | undefined; +export function dispose(disposables: T[]): T[]; +export function dispose(arg: T | T[] | undefined): T | T[] | undefined { + if (!arg) { + return arg; + } + if (Array.isArray(arg)) { + for (const d of arg) { + d.dispose(); + } + return []; + } + arg.dispose(); + return arg; +} + +export function combinedDisposable(...disposables: IDisposable[]): IDisposable { + return toDisposable(() => dispose(disposables)); +} + +export class DisposableStore implements IDisposable { + private readonly _disposables = new Set(); + private _isDisposed = false; + + public add(o: T): T { + if (this._isDisposed) { + o.dispose(); + } else { + this._disposables.add(o); + } + return o; + } + + public dispose(): void { + if (this._isDisposed) { + return; + } + this._isDisposed = true; + for (const d of this._disposables) { + d.dispose(); + } + this._disposables.clear(); + } + + public clear(): void { + for (const d of this._disposables) { + d.dispose(); + } + this._disposables.clear(); + } +} + +export abstract class Disposable implements IDisposable { + static readonly None: IDisposable = Object.freeze({ dispose() { } }); + + protected readonly _store = new DisposableStore(); + + public dispose(): void { + this._store.dispose(); + } + + protected _register(o: T): T { + return this._store.add(o); + } +} + +export function markAsSingleton(singleton: T): T { + return singleton; +} + +export class MutableDisposable implements IDisposable { + private _value: T | undefined; + private _isDisposed = false; + + public get value(): T | undefined { + return this._isDisposed ? undefined : this._value; + } + + public set value(value: T | undefined) { + if (this._isDisposed || value === this._value) { + return; + } + this._value?.dispose(); + this._value = value; + } + + public clear(): void { + this.value = undefined; + } + + public dispose(): void { + this._isDisposed = true; + this._value?.dispose(); + this._value = undefined; + } +} diff --git a/src/browser/scrollable/linkedList.ts b/src/browser/scrollable/linkedList.ts new file mode 100644 index 00000000..42a1c2aa --- /dev/null +++ b/src/browser/scrollable/linkedList.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +class Node { + + static readonly Undefined = new Node(undefined); + + element: E; + next: Node; + prev: Node; + + constructor(element: E) { + this.element = element; + this.next = Node.Undefined; + this.prev = Node.Undefined; + } +} + +export class LinkedList { + + private _first: Node = Node.Undefined; + private _last: Node = Node.Undefined; + private _size: number = 0; + + get size(): number { + return this._size; + } + + isEmpty(): boolean { + return this._first === Node.Undefined; + } + + clear(): void { + let node = this._first; + while (node !== Node.Undefined) { + const next = node.next; + node.prev = Node.Undefined; + node.next = Node.Undefined; + node = next; + } + + this._first = Node.Undefined; + this._last = Node.Undefined; + this._size = 0; + } + + unshift(element: E): () => void { + return this._insert(element, false); + } + + push(element: E): () => void { + return this._insert(element, true); + } + + private _insert(element: E, atTheEnd: boolean): () => void { + const newNode = new Node(element); + if (this._first === Node.Undefined) { + this._first = newNode; + this._last = newNode; + + } else if (atTheEnd) { + // push + const oldLast = this._last; + this._last = newNode; + newNode.prev = oldLast; + oldLast.next = newNode; + + } else { + // unshift + const oldFirst = this._first; + this._first = newNode; + newNode.next = oldFirst; + oldFirst.prev = newNode; + } + this._size += 1; + + let didRemove = false; + return () => { + if (!didRemove) { + didRemove = true; + this._remove(newNode); + } + }; + } + + shift(): E | undefined { + if (this._first === Node.Undefined) { + return undefined; + } else { + const res = this._first.element; + this._remove(this._first); + return res; + } + } + + pop(): E | undefined { + if (this._last === Node.Undefined) { + return undefined; + } else { + const res = this._last.element; + this._remove(this._last); + return res; + } + } + + private _remove(node: Node): void { + if (node.prev !== Node.Undefined && node.next !== Node.Undefined) { + // middle + const anchor = node.prev; + anchor.next = node.next; + node.next.prev = anchor; + + } else if (node.prev === Node.Undefined && node.next === Node.Undefined) { + // only node + this._first = Node.Undefined; + this._last = Node.Undefined; + + } else if (node.next === Node.Undefined) { + // last + this._last = this._last.prev!; + this._last.next = Node.Undefined; + + } else if (node.prev === Node.Undefined) { + // first + this._first = this._first.next!; + this._first.prev = Node.Undefined; + } + + // done + this._size -= 1; + } + + *[Symbol.iterator](): Iterator { + let node = this._first; + while (node !== Node.Undefined) { + yield node.element; + node = node.next; + } + } +} diff --git a/src/browser/scrollable/map.ts b/src/browser/scrollable/map.ts new file mode 100644 index 00000000..5aa55f48 --- /dev/null +++ b/src/browser/scrollable/map.ts @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export function getOrSet(map: Map, key: K, value: V): V { + let result = map.get(key); + if (result === undefined) { + result = value; + map.set(key, result); + } + + return result; +} + +export function mapToString(map: Map): string { + const entries: string[] = []; + map.forEach((value, key) => { + entries.push(`${key} => ${value}`); + }); + + return `Map(${map.size}) {${entries.join(', ')}}`; +} + +export function setToString(set: Set): string { + const entries: K[] = []; + set.forEach(value => { + entries.push(value); + }); + + return `Set(${set.size}) {${entries.join(', ')}}`; +} + +export const enum Touch { + None = 0, + AsOld = 1, + AsNew = 2 +} + +export class CounterSet { + + private map = new Map(); + + add(value: T): CounterSet { + this.map.set(value, (this.map.get(value) || 0) + 1); + return this; + } + + delete(value: T): boolean { + let counter = this.map.get(value) || 0; + + if (counter === 0) { + return false; + } + + counter--; + + if (counter === 0) { + this.map.delete(value); + } else { + this.map.set(value, counter); + } + + return true; + } + + has(value: T): boolean { + return this.map.has(value); + } +} + +/** + * A map that allows access both by keys and values. + * **NOTE**: values need to be unique. + */ +export class BidirectionalMap { + + private readonly _m1 = new Map(); + private readonly _m2 = new Map(); + + constructor(entries?: readonly (readonly [K, V])[]) { + if (entries) { + for (const [key, value] of entries) { + this.set(key, value); + } + } + } + + clear(): void { + this._m1.clear(); + this._m2.clear(); + } + + set(key: K, value: V): void { + this._m1.set(key, value); + this._m2.set(value, key); + } + + get(key: K): V | undefined { + return this._m1.get(key); + } + + getKey(value: V): K | undefined { + return this._m2.get(value); + } + + delete(key: K): boolean { + const value = this._m1.get(key); + if (value === undefined) { + return false; + } + this._m1.delete(key); + this._m2.delete(value); + return true; + } + + forEach(callbackfn: (value: V, key: K, map: BidirectionalMap) => void, thisArg?: any): void { + this._m1.forEach((value, key) => { + callbackfn.call(thisArg, value, key, this); + }); + } + + keys(): IterableIterator { + return this._m1.keys(); + } + + values(): IterableIterator { + return this._m1.values(); + } +} + +export class SetMap { + + private map = new Map>(); + + add(key: K, value: V): void { + let values = this.map.get(key); + + if (!values) { + values = new Set(); + this.map.set(key, values); + } + + values.add(value); + } + + delete(key: K, value: V): void { + const values = this.map.get(key); + + if (!values) { + return; + } + + values.delete(value); + + if (values.size === 0) { + this.map.delete(key); + } + } + + forEach(key: K, fn: (value: V) => void): void { + const values = this.map.get(key); + + if (!values) { + return; + } + + values.forEach(fn); + } + + get(key: K): ReadonlySet { + const values = this.map.get(key); + if (!values) { + return new Set(); + } + return values; + } +} + +export function mapsStrictEqualIgnoreOrder(a: Map, b: Map): boolean { + if (a === b) { + return true; + } + + if (a.size !== b.size) { + return false; + } + + for (const [key, value] of a) { + if (!b.has(key) || b.get(key) !== value) { + return false; + } + } + + for (const [key] of b) { + if (!a.has(key)) { + return false; + } + } + + return true; +} diff --git a/src/browser/scrollable/mouseEvent.ts b/src/browser/scrollable/mouseEvent.ts new file mode 100644 index 00000000..08b8e453 --- /dev/null +++ b/src/browser/scrollable/mouseEvent.ts @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as browser from './browser'; +import { IframeUtils } from './iframe'; +import * as platform from './platform'; + +export interface IMouseEvent { + readonly browserEvent: MouseEvent; + readonly leftButton: boolean; + readonly middleButton: boolean; + readonly rightButton: boolean; + readonly buttons: number; + readonly target: HTMLElement; + readonly detail: number; + readonly posx: number; + readonly posy: number; + readonly ctrlKey: boolean; + readonly shiftKey: boolean; + readonly altKey: boolean; + readonly metaKey: boolean; + readonly timestamp: number; + + preventDefault(): void; + stopPropagation(): void; +} + +export class StandardMouseEvent implements IMouseEvent { + + public readonly browserEvent: MouseEvent; + + public readonly leftButton: boolean; + public readonly middleButton: boolean; + public readonly rightButton: boolean; + public readonly buttons: number; + public readonly target: HTMLElement; + public detail: number; + public readonly posx: number; + public readonly posy: number; + public readonly ctrlKey: boolean; + public readonly shiftKey: boolean; + public readonly altKey: boolean; + public readonly metaKey: boolean; + public readonly timestamp: number; + + constructor(targetWindow: Window, e: MouseEvent) { + this.timestamp = Date.now(); + this.browserEvent = e; + this.leftButton = e.button === 0; + this.middleButton = e.button === 1; + this.rightButton = e.button === 2; + this.buttons = e.buttons; + + this.target = e.target as HTMLElement; + + this.detail = e.detail || 1; + if (e.type === 'dblclick') { + this.detail = 2; + } + this.ctrlKey = e.ctrlKey; + this.shiftKey = e.shiftKey; + this.altKey = e.altKey; + this.metaKey = e.metaKey; + + if (typeof e.pageX === 'number') { + this.posx = e.pageX; + this.posy = e.pageY; + } else { + this.posx = e.clientX + this.target.ownerDocument.body.scrollLeft + this.target.ownerDocument.documentElement.scrollLeft; + this.posy = e.clientY + this.target.ownerDocument.body.scrollTop + this.target.ownerDocument.documentElement.scrollTop; + } + + const iframeOffsets = IframeUtils.getPositionOfChildWindowRelativeToAncestorWindow(targetWindow, e.view); + this.posx -= iframeOffsets.left; + this.posy -= iframeOffsets.top; + } + + public preventDefault(): void { + this.browserEvent.preventDefault(); + } + + public stopPropagation(): void { + this.browserEvent.stopPropagation(); + } +} + +export interface IMouseWheelEvent extends MouseEvent { + readonly wheelDelta: number; + readonly wheelDeltaX: number; + readonly wheelDeltaY: number; + + readonly deltaX: number; + readonly deltaY: number; + readonly deltaZ: number; + readonly deltaMode: number; +} + +interface IWebKitMouseWheelEvent { + wheelDeltaY: number; + wheelDeltaX: number; +} + +interface IGeckoMouseWheelEvent { + HORIZONTAL_AXIS: number; + VERTICAL_AXIS: number; + axis: number; + detail: number; +} + +export class StandardWheelEvent { + + public readonly browserEvent: IMouseWheelEvent | null; + public readonly deltaY: number; + public readonly deltaX: number; + public readonly target: Node | null; + + constructor(e: IMouseWheelEvent | null, deltaX: number = 0, deltaY: number = 0) { + + this.browserEvent = e || null; + this.target = e ? (e.target || (e as any).targetNode || e.srcElement) : null; + + this.deltaY = deltaY; + this.deltaX = deltaX; + + let shouldFactorDPR: boolean = false; + if (browser.isChrome) { + const chromeVersionMatch = navigator.userAgent.match(/Chrome\/(\d+)/); + const chromeMajorVersion = chromeVersionMatch ? parseInt(chromeVersionMatch[1]) : 123; + shouldFactorDPR = chromeMajorVersion <= 122; + } + + if (e) { + const e1 = e as IWebKitMouseWheelEvent as any; + const e2 = e as unknown as IGeckoMouseWheelEvent; + const devicePixelRatio = e.view?.devicePixelRatio || 1; + + if (typeof e1.wheelDeltaY !== 'undefined') { + if (shouldFactorDPR) { + this.deltaY = e1.wheelDeltaY / (120 * devicePixelRatio); + } else { + this.deltaY = e1.wheelDeltaY / 120; + } + } else if (typeof e2.VERTICAL_AXIS !== 'undefined' && e2.axis === e2.VERTICAL_AXIS) { + this.deltaY = -e2.detail / 3; + } else if (e.type === 'wheel') { + const ev = e as unknown as WheelEvent; + + if (ev.deltaMode === ev.DOM_DELTA_LINE) { + if (browser.isFirefox && !platform.isMacintosh) { + this.deltaY = -e.deltaY / 3; + } else { + this.deltaY = -e.deltaY; + } + } else { + this.deltaY = -e.deltaY / 40; + } + } + + if (typeof e1.wheelDeltaX !== 'undefined') { + if (browser.isSafari && platform.isWindows) { + this.deltaX = -(e1.wheelDeltaX / 120); + } else if (shouldFactorDPR) { + this.deltaX = e1.wheelDeltaX / (120 * devicePixelRatio); + } else { + this.deltaX = e1.wheelDeltaX / 120; + } + } else if (typeof e2.HORIZONTAL_AXIS !== 'undefined' && e2.axis === e2.HORIZONTAL_AXIS) { + this.deltaX = -e.detail / 3; + } else if (e.type === 'wheel') { + const ev = e as unknown as WheelEvent; + + if (ev.deltaMode === ev.DOM_DELTA_LINE) { + if (browser.isFirefox && !platform.isMacintosh) { + this.deltaX = -e.deltaX / 3; + } else { + this.deltaX = -e.deltaX; + } + } else { + this.deltaX = -e.deltaX / 40; + } + } + + if (this.deltaY === 0 && this.deltaX === 0 && e.wheelDelta) { + if (shouldFactorDPR) { + this.deltaY = e.wheelDelta / (120 * devicePixelRatio); + } else { + this.deltaY = e.wheelDelta / 120; + } + } + } + } + + public preventDefault(): void { + this.browserEvent?.preventDefault(); + } + + public stopPropagation(): void { + this.browserEvent?.stopPropagation(); + } +} diff --git a/src/browser/scrollable/nls.ts b/src/browser/scrollable/nls.ts new file mode 100644 index 00000000..1661dfb0 --- /dev/null +++ b/src/browser/scrollable/nls.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface ILocalizeInfo { + key: string; + comment: string[]; +} + +export function localize(info: ILocalizeInfo | string, message: string, ...args: (string | number | boolean | undefined | null)[]): string { + return message; +} + +export interface INLSLanguagePackConfiguration { + + /** + * The path to the translations config file that contains pointers to + * all message bundles for `main` and extensions. + */ + readonly translationsConfigFile: string; + + /** + * The path to the file containing the translations for this language + * pack as flat string array. + */ + readonly messagesFile: string; + + /** + * The path to the file that can be used to signal a corrupt language + * pack, for example when reading the `messagesFile` fails. This will + * instruct the application to re-create the cache on next startup. + */ + readonly corruptMarkerFile: string; +} + +export interface INLSConfiguration { + + /** + * Locale as defined in `argv.json` or `app.getLocale()`. + */ + readonly userLocale: string; + + /** + * Locale as defined by the OS (e.g. `app.getPreferredSystemLanguages()`). + */ + readonly osLocale: string; + + /** + * The actual language of the UI that ends up being used considering `userLocale` + * and `osLocale`. + */ + readonly resolvedLanguage: string; + + /** + * Defined if a language pack is used that is not the + * default english language pack. This requires a language + * pack to be installed as extension. + */ + readonly languagePack?: INLSLanguagePackConfiguration; + + /** + * The path to the file containing the default english messages + * as flat string array. The file is only present in built + * versions of the application. + */ + readonly defaultMessagesFile: string; + + /** + * Below properties are deprecated and only there to continue support + * for `vscode-nls` module that depends on them. + * Refs https://github.com/microsoft/vscode-nls/blob/main/src/node/main.ts#L36-L46 + */ + /** @deprecated */ + readonly locale: string; + /** @deprecated */ + readonly availableLanguages: Record; + /** @deprecated */ + readonly _languagePackSupport?: boolean; + /** @deprecated */ + readonly _languagePackId?: string; + /** @deprecated */ + readonly _translationsConfigFile?: string; + /** @deprecated */ + readonly _cacheRoot?: string; + /** @deprecated */ + readonly _resolvedLanguagePackCoreLocation?: string; + /** @deprecated */ + readonly _corruptedFile?: string; +} diff --git a/src/browser/scrollable/numbers.ts b/src/browser/scrollable/numbers.ts new file mode 100644 index 00000000..ab4c9f92 --- /dev/null +++ b/src/browser/scrollable/numbers.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +export function rot(index: number, modulo: number): number { + return (modulo + (index % modulo)) % modulo; +} + +export class Counter { + private _next = 0; + + getNext(): number { + return this._next++; + } +} + +export class MovingAverage { + + private _n = 1; + private _val = 0; + + update(value: number): number { + this._val = this._val + (value - this._val) / this._n; + this._n += 1; + return this._val; + } + + get value(): number { + return this._val; + } +} + +export class SlidingWindowAverage { + + private _n: number = 0; + private _val = 0; + + private readonly _values: number[] = []; + private _index: number = 0; + private _sum = 0; + + constructor(size: number) { + this._values = new Array(size); + this._values.fill(0, 0, size); + } + + update(value: number): number { + const oldValue = this._values[this._index]; + this._values[this._index] = value; + this._index = (this._index + 1) % this._values.length; + + this._sum -= oldValue; + this._sum += value; + + if (this._n < this._values.length) { + this._n += 1; + } + + this._val = this._sum / this._n; + return this._val; + } + + get value(): number { + return this._val; + } +} + +/** Returns whether the point is within the triangle formed by the following 6 x/y point pairs */ +export function isPointWithinTriangle( + x: number, y: number, + ax: number, ay: number, + bx: number, by: number, + cx: number, cy: number +) { + const v0x = cx - ax; + const v0y = cy - ay; + const v1x = bx - ax; + const v1y = by - ay; + const v2x = x - ax; + const v2y = y - ay; + + const dot00 = v0x * v0x + v0y * v0y; + const dot01 = v0x * v1x + v0y * v1y; + const dot02 = v0x * v2x + v0y * v2y; + const dot11 = v1x * v1x + v1y * v1y; + const dot12 = v1x * v2x + v1y * v2y; + + const invDenom = 1 / (dot00 * dot11 - dot01 * dot01); + const u = (dot11 * dot02 - dot01 * dot12) * invDenom; + const v = (dot00 * dot12 - dot01 * dot02) * invDenom; + + return u >= 0 && v >= 0 && u + v < 1; +} diff --git a/src/browser/scrollable/platform.ts b/src/browser/scrollable/platform.ts new file mode 100644 index 00000000..5af117f8 --- /dev/null +++ b/src/browser/scrollable/platform.ts @@ -0,0 +1,12 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +const userAgent = typeof navigator === 'object' ? navigator.userAgent : ''; +const platform = typeof navigator === 'object' ? navigator.platform : ''; + +export const isMacintosh = platform.indexOf('Mac') >= 0; +export const isWindows = platform.indexOf('Win') >= 0; +export const isLinux = platform.indexOf('Linux') >= 0; +export const isIOS = /iPad|iPhone|iPod/.test(userAgent); diff --git a/src/browser/scrollable/scrollable.ts b/src/browser/scrollable/scrollable.ts new file mode 100644 index 00000000..5935fdcd --- /dev/null +++ b/src/browser/scrollable/scrollable.ts @@ -0,0 +1,490 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from './event'; +import { Disposable, IDisposable } from './lifecycle'; + +export const enum ScrollbarVisibility { + Auto = 1, + Hidden = 2, + Visible = 3 +} + +export interface ScrollEvent { + inSmoothScrolling: boolean; + + oldWidth: number; + oldScrollWidth: number; + oldScrollLeft: number; + + width: number; + scrollWidth: number; + scrollLeft: number; + + oldHeight: number; + oldScrollHeight: number; + oldScrollTop: number; + + height: number; + scrollHeight: number; + scrollTop: number; + + widthChanged: boolean; + scrollWidthChanged: boolean; + scrollLeftChanged: boolean; + + heightChanged: boolean; + scrollHeightChanged: boolean; + scrollTopChanged: boolean; +} + +export class ScrollState implements IScrollDimensions, IScrollPosition { + _scrollStateBrand: void = undefined; + + public readonly rawScrollLeft: number; + public readonly rawScrollTop: number; + + public readonly width: number; + public readonly scrollWidth: number; + public readonly scrollLeft: number; + public readonly height: number; + public readonly scrollHeight: number; + public readonly scrollTop: number; + + constructor( + private readonly _forceIntegerValues: boolean, + width: number, + scrollWidth: number, + scrollLeft: number, + height: number, + scrollHeight: number, + scrollTop: number + ) { + if (this._forceIntegerValues) { + width = width | 0; + scrollWidth = scrollWidth | 0; + scrollLeft = scrollLeft | 0; + height = height | 0; + scrollHeight = scrollHeight | 0; + scrollTop = scrollTop | 0; + } + + this.rawScrollLeft = scrollLeft; + this.rawScrollTop = scrollTop; + + if (width < 0) { + width = 0; + } + if (scrollLeft + width > scrollWidth) { + scrollLeft = scrollWidth - width; + } + if (scrollLeft < 0) { + scrollLeft = 0; + } + + if (height < 0) { + height = 0; + } + if (scrollTop + height > scrollHeight) { + scrollTop = scrollHeight - height; + } + if (scrollTop < 0) { + scrollTop = 0; + } + + this.width = width; + this.scrollWidth = scrollWidth; + this.scrollLeft = scrollLeft; + this.height = height; + this.scrollHeight = scrollHeight; + this.scrollTop = scrollTop; + } + + public equals(other: ScrollState): boolean { + return ( + this.rawScrollLeft === other.rawScrollLeft + && this.rawScrollTop === other.rawScrollTop + && this.width === other.width + && this.scrollWidth === other.scrollWidth + && this.scrollLeft === other.scrollLeft + && this.height === other.height + && this.scrollHeight === other.scrollHeight + && this.scrollTop === other.scrollTop + ); + } + + public withScrollDimensions(update: INewScrollDimensions, useRawScrollPositions: boolean): ScrollState { + return new ScrollState( + this._forceIntegerValues, + (typeof update.width !== 'undefined' ? update.width : this.width), + (typeof update.scrollWidth !== 'undefined' ? update.scrollWidth : this.scrollWidth), + useRawScrollPositions ? this.rawScrollLeft : this.scrollLeft, + (typeof update.height !== 'undefined' ? update.height : this.height), + (typeof update.scrollHeight !== 'undefined' ? update.scrollHeight : this.scrollHeight), + useRawScrollPositions ? this.rawScrollTop : this.scrollTop + ); + } + + public withScrollPosition(update: INewScrollPosition): ScrollState { + return new ScrollState( + this._forceIntegerValues, + this.width, + this.scrollWidth, + (typeof update.scrollLeft !== 'undefined' ? update.scrollLeft : this.rawScrollLeft), + this.height, + this.scrollHeight, + (typeof update.scrollTop !== 'undefined' ? update.scrollTop : this.rawScrollTop) + ); + } + + public createScrollEvent(previous: ScrollState, inSmoothScrolling: boolean): ScrollEvent { + const widthChanged = (this.width !== previous.width); + const scrollWidthChanged = (this.scrollWidth !== previous.scrollWidth); + const scrollLeftChanged = (this.scrollLeft !== previous.scrollLeft); + + const heightChanged = (this.height !== previous.height); + const scrollHeightChanged = (this.scrollHeight !== previous.scrollHeight); + const scrollTopChanged = (this.scrollTop !== previous.scrollTop); + + return { + inSmoothScrolling: inSmoothScrolling, + oldWidth: previous.width, + oldScrollWidth: previous.scrollWidth, + oldScrollLeft: previous.scrollLeft, + + width: this.width, + scrollWidth: this.scrollWidth, + scrollLeft: this.scrollLeft, + + oldHeight: previous.height, + oldScrollHeight: previous.scrollHeight, + oldScrollTop: previous.scrollTop, + + height: this.height, + scrollHeight: this.scrollHeight, + scrollTop: this.scrollTop, + + widthChanged: widthChanged, + scrollWidthChanged: scrollWidthChanged, + scrollLeftChanged: scrollLeftChanged, + + heightChanged: heightChanged, + scrollHeightChanged: scrollHeightChanged, + scrollTopChanged: scrollTopChanged, + }; + } + +} + +export interface IScrollDimensions { + readonly width: number; + readonly scrollWidth: number; + readonly height: number; + readonly scrollHeight: number; +} +export interface INewScrollDimensions { + width?: number; + scrollWidth?: number; + height?: number; + scrollHeight?: number; +} + +export interface IScrollPosition { + readonly scrollLeft: number; + readonly scrollTop: number; +} +export interface ISmoothScrollPosition { + readonly scrollLeft: number; + readonly scrollTop: number; + + readonly width: number; + readonly height: number; +} +export interface INewScrollPosition { + scrollLeft?: number; + scrollTop?: number; +} + +export interface IScrollableOptions { + forceIntegerValues: boolean; + smoothScrollDuration: number; + scheduleAtNextAnimationFrame: (callback: () => void) => IDisposable; +} + +export class Scrollable extends Disposable { + + _scrollableBrand: void = undefined; + + private _smoothScrollDuration: number; + private readonly _scheduleAtNextAnimationFrame: (callback: () => void) => IDisposable; + private _state: ScrollState; + private _smoothScrolling: SmoothScrollingOperation | null; + + private _onScroll = this._register(new Emitter()); + public readonly onScroll: Event = this._onScroll.event; + + constructor(options: IScrollableOptions) { + super(); + + this._smoothScrollDuration = options.smoothScrollDuration; + this._scheduleAtNextAnimationFrame = options.scheduleAtNextAnimationFrame; + this._state = new ScrollState(options.forceIntegerValues, 0, 0, 0, 0, 0, 0); + this._smoothScrolling = null; + } + + public override dispose(): void { + if (this._smoothScrolling) { + this._smoothScrolling.dispose(); + this._smoothScrolling = null; + } + super.dispose(); + } + + public setSmoothScrollDuration(smoothScrollDuration: number): void { + this._smoothScrollDuration = smoothScrollDuration; + } + + public validateScrollPosition(scrollPosition: INewScrollPosition): IScrollPosition { + return this._state.withScrollPosition(scrollPosition); + } + + public getScrollDimensions(): IScrollDimensions { + return this._state; + } + + public setScrollDimensions(dimensions: INewScrollDimensions, useRawScrollPositions: boolean): void { + const newState = this._state.withScrollDimensions(dimensions, useRawScrollPositions); + this._setState(newState, Boolean(this._smoothScrolling)); + + this._smoothScrolling?.acceptScrollDimensions(this._state); + } + + public getFutureScrollPosition(): IScrollPosition { + if (this._smoothScrolling) { + return this._smoothScrolling.to; + } + return this._state; + } + + public getCurrentScrollPosition(): IScrollPosition { + return this._state; + } + + public setScrollPositionNow(update: INewScrollPosition): void { + const newState = this._state.withScrollPosition(update); + + if (this._smoothScrolling) { + this._smoothScrolling.dispose(); + this._smoothScrolling = null; + } + + this._setState(newState, false); + } + + public setScrollPositionSmooth(update: INewScrollPosition, reuseAnimation?: boolean): void { + if (this._smoothScrollDuration === 0) { + return this.setScrollPositionNow(update); + } + + if (this._smoothScrolling) { + update = { + scrollLeft: (typeof update.scrollLeft === 'undefined' ? this._smoothScrolling.to.scrollLeft : update.scrollLeft), + scrollTop: (typeof update.scrollTop === 'undefined' ? this._smoothScrolling.to.scrollTop : update.scrollTop) + }; + + const validTarget = this._state.withScrollPosition(update); + + if (this._smoothScrolling.to.scrollLeft === validTarget.scrollLeft && this._smoothScrolling.to.scrollTop === validTarget.scrollTop) { + return; + } + let newSmoothScrolling: SmoothScrollingOperation; + if (reuseAnimation) { + newSmoothScrolling = new SmoothScrollingOperation(this._smoothScrolling.from, validTarget, this._smoothScrolling.startTime, this._smoothScrolling.duration); + } else { + newSmoothScrolling = this._smoothScrolling.combine(this._state, validTarget, this._smoothScrollDuration); + } + this._smoothScrolling.dispose(); + this._smoothScrolling = newSmoothScrolling; + } else { + const validTarget = this._state.withScrollPosition(update); + + this._smoothScrolling = SmoothScrollingOperation.start(this._state, validTarget, this._smoothScrollDuration); + } + + this._smoothScrolling.animationFrameDisposable = this._scheduleAtNextAnimationFrame(() => { + if (!this._smoothScrolling) { + return; + } + this._smoothScrolling.animationFrameDisposable = null; + this._performSmoothScrolling(); + }); + } + + public hasPendingScrollAnimation(): boolean { + return Boolean(this._smoothScrolling); + } + + private _performSmoothScrolling(): void { + if (!this._smoothScrolling) { + return; + } + const update = this._smoothScrolling.tick(); + const newState = this._state.withScrollPosition(update); + + this._setState(newState, true); + + if (!this._smoothScrolling) { + return; + } + + if (update.isDone) { + this._smoothScrolling.dispose(); + this._smoothScrolling = null; + return; + } + + this._smoothScrolling.animationFrameDisposable = this._scheduleAtNextAnimationFrame(() => { + if (!this._smoothScrolling) { + return; + } + this._smoothScrolling.animationFrameDisposable = null; + this._performSmoothScrolling(); + }); + } + + private _setState(newState: ScrollState, inSmoothScrolling: boolean): void { + const oldState = this._state; + if (oldState.equals(newState)) { + return; + } + this._state = newState; + this._onScroll.fire(this._state.createScrollEvent(oldState, inSmoothScrolling)); + } +} + +export class SmoothScrollingUpdate { + + public readonly scrollLeft: number; + public readonly scrollTop: number; + public readonly isDone: boolean; + + constructor(scrollLeft: number, scrollTop: number, isDone: boolean) { + this.scrollLeft = scrollLeft; + this.scrollTop = scrollTop; + this.isDone = isDone; + } + +} + +interface IAnimation { + (completion: number): number; +} + +function createEaseOutCubic(from: number, to: number): IAnimation { + const delta = to - from; + return function (completion: number): number { + return from + delta * easeOutCubic(completion); + }; +} + +function createComposed(a: IAnimation, b: IAnimation, cut: number): IAnimation { + return function (completion: number): number { + if (completion < cut) { + return a(completion / cut); + } + return b((completion - cut) / (1 - cut)); + }; +} + +export class SmoothScrollingOperation { + + public readonly from: ISmoothScrollPosition; + public to: ISmoothScrollPosition; + public readonly duration: number; + public readonly startTime: number; + public animationFrameDisposable: IDisposable | null; + + private scrollLeft!: IAnimation; + private scrollTop!: IAnimation; + + constructor(from: ISmoothScrollPosition, to: ISmoothScrollPosition, startTime: number, duration: number) { + this.from = from; + this.to = to; + this.duration = duration; + this.startTime = startTime; + + this.animationFrameDisposable = null; + + this._initAnimations(); + } + + private _initAnimations(): void { + this.scrollLeft = this._initAnimation(this.from.scrollLeft, this.to.scrollLeft, this.to.width); + this.scrollTop = this._initAnimation(this.from.scrollTop, this.to.scrollTop, this.to.height); + } + + private _initAnimation(from: number, to: number, viewportSize: number): IAnimation { + const delta = Math.abs(from - to); + if (delta > 2.5 * viewportSize) { + let stop1: number, stop2: number; + if (from < to) { + stop1 = from + 0.75 * viewportSize; + stop2 = to - 0.75 * viewportSize; + } else { + stop1 = from - 0.75 * viewportSize; + stop2 = to + 0.75 * viewportSize; + } + return createComposed(createEaseOutCubic(from, stop1), createEaseOutCubic(stop2, to), 0.33); + } + return createEaseOutCubic(from, to); + } + + public dispose(): void { + if (this.animationFrameDisposable !== null) { + this.animationFrameDisposable.dispose(); + this.animationFrameDisposable = null; + } + } + + public acceptScrollDimensions(state: ScrollState): void { + this.to = state.withScrollPosition(this.to); + this._initAnimations(); + } + + public tick(): SmoothScrollingUpdate { + return this._tick(Date.now()); + } + + protected _tick(now: number): SmoothScrollingUpdate { + const completion = (now - this.startTime) / this.duration; + + if (completion < 1) { + const newScrollLeft = this.scrollLeft(completion); + const newScrollTop = this.scrollTop(completion); + return new SmoothScrollingUpdate(newScrollLeft, newScrollTop, false); + } + + return new SmoothScrollingUpdate(this.to.scrollLeft, this.to.scrollTop, true); + } + + public combine(from: ISmoothScrollPosition, to: ISmoothScrollPosition, duration: number): SmoothScrollingOperation { + return SmoothScrollingOperation.start(from, to, duration); + } + + public static start(from: ISmoothScrollPosition, to: ISmoothScrollPosition, duration: number): SmoothScrollingOperation { + duration = duration + 10; + const startTime = Date.now() - 10; + + return new SmoothScrollingOperation(from, to, startTime, duration); + } +} + +function easeInCubic(t: number) { + return Math.pow(t, 3); +} + +function easeOutCubic(t: number) { + return 1 - easeInCubic(1 - t); +} diff --git a/src/browser/scrollable/scrollableElement.ts b/src/browser/scrollable/scrollableElement.ts new file mode 100644 index 00000000..7dd4e755 --- /dev/null +++ b/src/browser/scrollable/scrollableElement.ts @@ -0,0 +1,665 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getZoomFactor, isChrome } from './browser'; +import * as dom from './dom'; +import { FastDomNode, createFastDomNode } from './fastDomNode'; +import { IMouseEvent, IMouseWheelEvent, StandardWheelEvent } from './mouseEvent'; +import { ScrollbarHost } from './abstractScrollbar'; +import { HorizontalScrollbar } from './horizontalScrollbar'; +import { ScrollableElementChangeOptions, ScrollableElementCreationOptions, ScrollableElementResolvedOptions } from './scrollableElementOptions'; +import { VerticalScrollbar } from './verticalScrollbar'; +import { Widget } from './widget'; +import { TimeoutTimer } from './async'; +import { Emitter, Event } from './event'; +import { IDisposable, dispose } from './lifecycle'; +import * as platform from './platform'; +import { INewScrollDimensions, INewScrollPosition, IScrollDimensions, IScrollPosition, ScrollEvent, Scrollable, ScrollbarVisibility } from './scrollable'; +// import 'vs/css!./media/scrollbars'; + +const HIDE_TIMEOUT = 500; +const SCROLL_WHEEL_SENSITIVITY = 50; +const SCROLL_WHEEL_SMOOTH_SCROLL_ENABLED = true; + +export interface IOverviewRulerLayoutInfo { + parent: HTMLElement; + insertBefore: HTMLElement; +} + +class MouseWheelClassifierItem { + public timestamp: number; + public deltaX: number; + public deltaY: number; + public score: number; + + constructor(timestamp: number, deltaX: number, deltaY: number) { + this.timestamp = timestamp; + this.deltaX = deltaX; + this.deltaY = deltaY; + this.score = 0; + } +} + +export class MouseWheelClassifier { + + public static readonly INSTANCE = new MouseWheelClassifier(); + + private readonly _capacity: number; + private _memory: MouseWheelClassifierItem[]; + private _front: number; + private _rear: number; + + constructor() { + this._capacity = 5; + this._memory = []; + this._front = -1; + this._rear = -1; + } + + public isPhysicalMouseWheel(): boolean { + if (this._front === -1 && this._rear === -1) { + return false; + } + + let remainingInfluence = 1; + let score = 0; + let iteration = 1; + + let index = this._rear; + do { + const influence = (index === this._front ? remainingInfluence : Math.pow(2, -iteration)); + remainingInfluence -= influence; + score += this._memory[index].score * influence; + + if (index === this._front) { + break; + } + + index = (this._capacity + index - 1) % this._capacity; + iteration++; + } while (true); + + return (score <= 0.5); + } + + public acceptStandardWheelEvent(e: StandardWheelEvent): void { + if (isChrome) { + const targetWindow = dom.getWindow(e.browserEvent); + const pageZoomFactor = getZoomFactor(targetWindow); + this.accept(Date.now(), e.deltaX * pageZoomFactor, e.deltaY * pageZoomFactor); + } else { + this.accept(Date.now(), e.deltaX, e.deltaY); + } + } + + public accept(timestamp: number, deltaX: number, deltaY: number): void { + let previousItem = null; + const item = new MouseWheelClassifierItem(timestamp, deltaX, deltaY); + + if (this._front === -1 && this._rear === -1) { + this._memory[0] = item; + this._front = 0; + this._rear = 0; + } else { + previousItem = this._memory[this._rear]; + + this._rear = (this._rear + 1) % this._capacity; + if (this._rear === this._front) { + this._front = (this._front + 1) % this._capacity; + } + this._memory[this._rear] = item; + } + + item.score = this._computeScore(item, previousItem); + } + + private _computeScore(item: MouseWheelClassifierItem, previousItem: MouseWheelClassifierItem | null): number { + + if (Math.abs(item.deltaX) > 0 && Math.abs(item.deltaY) > 0) { + return 1; + } + + let score: number = 0.5; + + if (!this._isAlmostInt(item.deltaX) || !this._isAlmostInt(item.deltaY)) { + score += 0.25; + } + + if (previousItem) { + const absDeltaX = Math.abs(item.deltaX); + const absDeltaY = Math.abs(item.deltaY); + + const absPreviousDeltaX = Math.abs(previousItem.deltaX); + const absPreviousDeltaY = Math.abs(previousItem.deltaY); + + const minDeltaX = Math.max(Math.min(absDeltaX, absPreviousDeltaX), 1); + const minDeltaY = Math.max(Math.min(absDeltaY, absPreviousDeltaY), 1); + + const maxDeltaX = Math.max(absDeltaX, absPreviousDeltaX); + const maxDeltaY = Math.max(absDeltaY, absPreviousDeltaY); + + const isSameModulo = (maxDeltaX % minDeltaX === 0 && maxDeltaY % minDeltaY === 0); + if (isSameModulo) { + score -= 0.5; + } + } + + return Math.min(Math.max(score, 0), 1); + } + + private _isAlmostInt(value: number): boolean { + const delta = Math.abs(Math.round(value) - value); + return (delta < 0.01); + } +} + +export abstract class AbstractScrollableElement extends Widget { + + private readonly _options: ScrollableElementResolvedOptions; + protected readonly _scrollable: Scrollable; + private readonly _verticalScrollbar: VerticalScrollbar; + private readonly _horizontalScrollbar: HorizontalScrollbar; + private readonly _domNode: HTMLElement; + + private readonly _leftShadowDomNode: FastDomNode | null; + private readonly _topShadowDomNode: FastDomNode | null; + private readonly _topLeftShadowDomNode: FastDomNode | null; + + private readonly _listenOnDomNode: HTMLElement; + + private _mouseWheelToDispose: IDisposable[]; + + private _isDragging: boolean; + private _mouseIsOver: boolean; + + private readonly _hideTimeout: TimeoutTimer; + private _shouldRender: boolean; + + private _revealOnScroll: boolean; + + private readonly _onScroll = this._register(new Emitter()); + public readonly onScroll: Event = this._onScroll.event; + + private readonly _onWillScroll = this._register(new Emitter()); + public readonly onWillScroll: Event = this._onWillScroll.event; + + public get options(): Readonly { + return this._options; + } + + protected constructor(element: HTMLElement, options: ScrollableElementCreationOptions, scrollable: Scrollable) { + super(); + this._options = resolveOptions(options); + this._scrollable = scrollable; + + this._register(this._scrollable.onScroll((e) => { + this._onWillScroll.fire(e); + this._onDidScroll(e); + this._onScroll.fire(e); + })); + + const scrollbarHost: ScrollbarHost = { + onMouseWheel: (mouseWheelEvent: StandardWheelEvent) => this._onMouseWheel(mouseWheelEvent), + onDragStart: () => this._onDragStart(), + onDragEnd: () => this._onDragEnd(), + }; + this._verticalScrollbar = this._register(new VerticalScrollbar(this._scrollable, this._options, scrollbarHost)); + this._horizontalScrollbar = this._register(new HorizontalScrollbar(this._scrollable, this._options, scrollbarHost)); + + this._domNode = document.createElement('div'); + this._domNode.className = 'xterm-scrollable-element ' + this._options.className; + this._domNode.setAttribute('role', 'presentation'); + this._domNode.style.position = 'relative'; + this._domNode.appendChild(element); + this._domNode.appendChild(this._horizontalScrollbar.domNode.domNode); + this._domNode.appendChild(this._verticalScrollbar.domNode.domNode); + + if (this._options.useShadows) { + this._leftShadowDomNode = createFastDomNode(document.createElement('div')); + this._leftShadowDomNode.setClassName('shadow'); + this._domNode.appendChild(this._leftShadowDomNode.domNode); + + this._topShadowDomNode = createFastDomNode(document.createElement('div')); + this._topShadowDomNode.setClassName('shadow'); + this._domNode.appendChild(this._topShadowDomNode.domNode); + + this._topLeftShadowDomNode = createFastDomNode(document.createElement('div')); + this._topLeftShadowDomNode.setClassName('shadow'); + this._domNode.appendChild(this._topLeftShadowDomNode.domNode); + } else { + this._leftShadowDomNode = null; + this._topShadowDomNode = null; + this._topLeftShadowDomNode = null; + } + + this._listenOnDomNode = this._options.listenOnDomNode || this._domNode; + + this._mouseWheelToDispose = []; + this._setListeningToMouseWheel(this._options.handleMouseWheel); + + this.onmouseover(this._listenOnDomNode, (e) => this._onMouseOver(e)); + this.onmouseleave(this._listenOnDomNode, (e) => this._onMouseLeave(e)); + + this._hideTimeout = this._register(new TimeoutTimer()); + this._isDragging = false; + this._mouseIsOver = false; + + this._shouldRender = true; + + this._revealOnScroll = true; + } + + public override dispose(): void { + this._mouseWheelToDispose = dispose(this._mouseWheelToDispose); + super.dispose(); + } + + public getDomNode(): HTMLElement { + return this._domNode; + } + + public getOverviewRulerLayoutInfo(): IOverviewRulerLayoutInfo { + return { + parent: this._domNode, + insertBefore: this._verticalScrollbar.domNode.domNode, + }; + } + + public delegateVerticalScrollbarPointerDown(browserEvent: PointerEvent): void { + this._verticalScrollbar.delegatePointerDown(browserEvent); + } + + public getScrollDimensions(): IScrollDimensions { + return this._scrollable.getScrollDimensions(); + } + + public setScrollDimensions(dimensions: INewScrollDimensions): void { + this._scrollable.setScrollDimensions(dimensions, false); + } + + public updateClassName(newClassName: string): void { + this._options.className = newClassName; + if (platform.isMacintosh) { + this._options.className += ' mac'; + } + this._domNode.className = 'xterm-scrollable-element ' + this._options.className; + } + + public updateOptions(newOptions: ScrollableElementChangeOptions): void { + if (typeof newOptions.handleMouseWheel !== 'undefined') { + this._options.handleMouseWheel = newOptions.handleMouseWheel; + this._setListeningToMouseWheel(this._options.handleMouseWheel); + } + if (typeof newOptions.mouseWheelScrollSensitivity !== 'undefined') { + this._options.mouseWheelScrollSensitivity = newOptions.mouseWheelScrollSensitivity; + } + if (typeof newOptions.fastScrollSensitivity !== 'undefined') { + this._options.fastScrollSensitivity = newOptions.fastScrollSensitivity; + } + if (typeof newOptions.scrollPredominantAxis !== 'undefined') { + this._options.scrollPredominantAxis = newOptions.scrollPredominantAxis; + } + if (typeof newOptions.horizontal !== 'undefined') { + this._options.horizontal = newOptions.horizontal; + } + if (typeof newOptions.vertical !== 'undefined') { + this._options.vertical = newOptions.vertical; + } + if (typeof newOptions.horizontalScrollbarSize !== 'undefined') { + this._options.horizontalScrollbarSize = newOptions.horizontalScrollbarSize; + } + if (typeof newOptions.verticalScrollbarSize !== 'undefined') { + this._options.verticalScrollbarSize = newOptions.verticalScrollbarSize; + } + if (typeof newOptions.scrollByPage !== 'undefined') { + this._options.scrollByPage = newOptions.scrollByPage; + } + this._horizontalScrollbar.updateOptions(this._options); + this._verticalScrollbar.updateOptions(this._options); + + if (!this._options.lazyRender) { + this._render(); + } + } + + public setRevealOnScroll(value: boolean) { + this._revealOnScroll = value; + } + + public delegateScrollFromMouseWheelEvent(browserEvent: IMouseWheelEvent) { + this._onMouseWheel(new StandardWheelEvent(browserEvent)); + } + + // -------------------- mouse wheel scrolling -------------------- + + private _setListeningToMouseWheel(shouldListen: boolean): void { + const isListening = (this._mouseWheelToDispose.length > 0); + + if (isListening === shouldListen) { + return; + } + + this._mouseWheelToDispose = dispose(this._mouseWheelToDispose); + + if (shouldListen) { + const onMouseWheel = (browserEvent: IMouseWheelEvent) => { + this._onMouseWheel(new StandardWheelEvent(browserEvent)); + }; + + this._mouseWheelToDispose.push(dom.addDisposableListener(this._listenOnDomNode, dom.EventType.MOUSE_WHEEL, onMouseWheel, { passive: false })); + } + } + + private _onMouseWheel(e: StandardWheelEvent): void { + if (e.browserEvent?.defaultPrevented) { + return; + } + + const classifier = MouseWheelClassifier.INSTANCE; + if (SCROLL_WHEEL_SMOOTH_SCROLL_ENABLED) { + classifier.acceptStandardWheelEvent(e); + } + + let didScroll = false; + + if (e.deltaY || e.deltaX) { + let deltaY = e.deltaY * this._options.mouseWheelScrollSensitivity; + let deltaX = e.deltaX * this._options.mouseWheelScrollSensitivity; + + if (this._options.scrollPredominantAxis) { + if (this._options.scrollYToX && deltaX + deltaY === 0) { + deltaX = deltaY = 0; + } else if (Math.abs(deltaY) >= Math.abs(deltaX)) { + deltaX = 0; + } else { + deltaY = 0; + } + } + + if (this._options.flipAxes) { + [deltaY, deltaX] = [deltaX, deltaY]; + } + + const shiftConvert = !platform.isMacintosh && e.browserEvent && e.browserEvent.shiftKey; + if ((this._options.scrollYToX || shiftConvert) && !deltaX) { + deltaX = deltaY; + deltaY = 0; + } + + if (e.browserEvent && e.browserEvent.altKey) { + deltaX = deltaX * this._options.fastScrollSensitivity; + deltaY = deltaY * this._options.fastScrollSensitivity; + } + + const futureScrollPosition = this._scrollable.getFutureScrollPosition(); + + let desiredScrollPosition: INewScrollPosition = {}; + if (deltaY) { + const deltaScrollTop = SCROLL_WHEEL_SENSITIVITY * deltaY; + const desiredScrollTop = futureScrollPosition.scrollTop - (deltaScrollTop < 0 ? Math.floor(deltaScrollTop) : Math.ceil(deltaScrollTop)); + this._verticalScrollbar.writeScrollPosition(desiredScrollPosition, desiredScrollTop); + } + if (deltaX) { + const deltaScrollLeft = SCROLL_WHEEL_SENSITIVITY * deltaX; + const desiredScrollLeft = futureScrollPosition.scrollLeft - (deltaScrollLeft < 0 ? Math.floor(deltaScrollLeft) : Math.ceil(deltaScrollLeft)); + this._horizontalScrollbar.writeScrollPosition(desiredScrollPosition, desiredScrollLeft); + } + + desiredScrollPosition = this._scrollable.validateScrollPosition(desiredScrollPosition); + + if (futureScrollPosition.scrollLeft !== desiredScrollPosition.scrollLeft || futureScrollPosition.scrollTop !== desiredScrollPosition.scrollTop) { + + const canPerformSmoothScroll = ( + SCROLL_WHEEL_SMOOTH_SCROLL_ENABLED + && this._options.mouseWheelSmoothScroll + && classifier.isPhysicalMouseWheel() + ); + + if (canPerformSmoothScroll) { + this._scrollable.setScrollPositionSmooth(desiredScrollPosition); + } else { + this._scrollable.setScrollPositionNow(desiredScrollPosition); + } + + didScroll = true; + } + } + + let consumeMouseWheel = didScroll; + if (!consumeMouseWheel && this._options.alwaysConsumeMouseWheel) { + consumeMouseWheel = true; + } + if (!consumeMouseWheel && this._options.consumeMouseWheelIfScrollbarIsNeeded && (this._verticalScrollbar.isNeeded() || this._horizontalScrollbar.isNeeded())) { + consumeMouseWheel = true; + } + + if (consumeMouseWheel) { + e.preventDefault(); + e.stopPropagation(); + } + } + + private _onDidScroll(e: ScrollEvent): void { + this._shouldRender = this._horizontalScrollbar.onDidScroll(e) || this._shouldRender; + this._shouldRender = this._verticalScrollbar.onDidScroll(e) || this._shouldRender; + + if (this._options.useShadows) { + this._shouldRender = true; + } + + if (this._revealOnScroll) { + this._reveal(); + } + + if (!this._options.lazyRender) { + this._render(); + } + } + + public renderNow(): void { + if (!this._options.lazyRender) { + throw new Error('Please use `lazyRender` together with `renderNow`!'); + } + + this._render(); + } + + private _render(): void { + if (!this._shouldRender) { + return; + } + + this._shouldRender = false; + + this._horizontalScrollbar.render(); + this._verticalScrollbar.render(); + + if (this._options.useShadows) { + const scrollState = this._scrollable.getCurrentScrollPosition(); + const enableTop = scrollState.scrollTop > 0; + const enableLeft = scrollState.scrollLeft > 0; + + const leftClassName = (enableLeft ? ' left' : ''); + const topClassName = (enableTop ? ' top' : ''); + const topLeftClassName = (enableLeft || enableTop ? ' top-left-corner' : ''); + this._leftShadowDomNode!.setClassName(`shadow${leftClassName}`); + this._topShadowDomNode!.setClassName(`shadow${topClassName}`); + this._topLeftShadowDomNode!.setClassName(`shadow${topLeftClassName}${topClassName}${leftClassName}`); + } + } + + // -------------------- fade in / fade out -------------------- + + private _onDragStart(): void { + this._isDragging = true; + this._reveal(); + } + + private _onDragEnd(): void { + this._isDragging = false; + this._hide(); + } + + private _onMouseLeave(e: IMouseEvent): void { + this._mouseIsOver = false; + this._hide(); + } + + private _onMouseOver(e: IMouseEvent): void { + this._mouseIsOver = true; + this._reveal(); + } + + private _reveal(): void { + this._verticalScrollbar.beginReveal(); + this._horizontalScrollbar.beginReveal(); + this._scheduleHide(); + } + + private _hide(): void { + if (!this._mouseIsOver && !this._isDragging) { + this._verticalScrollbar.beginHide(); + this._horizontalScrollbar.beginHide(); + } + } + + private _scheduleHide(): void { + if (!this._mouseIsOver && !this._isDragging) { + this._hideTimeout.cancelAndSet(() => this._hide(), HIDE_TIMEOUT); + } + } +} + +export class ScrollableElement extends AbstractScrollableElement { + + constructor(element: HTMLElement, options: ScrollableElementCreationOptions) { + options = options || {}; + options.mouseWheelSmoothScroll = false; + const scrollable = new Scrollable({ + forceIntegerValues: true, + smoothScrollDuration: 0, + scheduleAtNextAnimationFrame: (callback) => dom.scheduleAtNextAnimationFrame(dom.getWindow(element), callback) + }); + super(element, options, scrollable); + this._register(scrollable); + } + + public setScrollPosition(update: INewScrollPosition): void { + this._scrollable.setScrollPositionNow(update); + } + + public getScrollPosition(): IScrollPosition { + return this._scrollable.getCurrentScrollPosition(); + } +} + +export class SmoothScrollableElement extends AbstractScrollableElement { + + constructor(element: HTMLElement, options: ScrollableElementCreationOptions, scrollable: Scrollable) { + super(element, options, scrollable); + } + + public setScrollPosition(update: INewScrollPosition & { reuseAnimation?: boolean }): void { + if (update.reuseAnimation) { + this._scrollable.setScrollPositionSmooth(update, update.reuseAnimation); + } else { + this._scrollable.setScrollPositionNow(update); + } + } + + public getScrollPosition(): IScrollPosition { + return this._scrollable.getCurrentScrollPosition(); + } + +} + +export class DomScrollableElement extends AbstractScrollableElement { + + private _element: HTMLElement; + + constructor(element: HTMLElement, options: ScrollableElementCreationOptions) { + options = options || {}; + options.mouseWheelSmoothScroll = false; + const scrollable = new Scrollable({ + forceIntegerValues: false, + smoothScrollDuration: 0, + scheduleAtNextAnimationFrame: (callback) => dom.scheduleAtNextAnimationFrame(dom.getWindow(element), callback) + }); + super(element, options, scrollable); + this._register(scrollable); + this._element = element; + this._register(this.onScroll((e) => { + if (e.scrollTopChanged) { + this._element.scrollTop = e.scrollTop; + } + if (e.scrollLeftChanged) { + this._element.scrollLeft = e.scrollLeft; + } + })); + this.scanDomNode(); + } + + public setScrollPosition(update: INewScrollPosition): void { + this._scrollable.setScrollPositionNow(update); + } + + public getScrollPosition(): IScrollPosition { + return this._scrollable.getCurrentScrollPosition(); + } + + public scanDomNode(): void { + this.setScrollDimensions({ + width: this._element.clientWidth, + scrollWidth: this._element.scrollWidth, + height: this._element.clientHeight, + scrollHeight: this._element.scrollHeight + }); + this.setScrollPosition({ + scrollLeft: this._element.scrollLeft, + scrollTop: this._element.scrollTop, + }); + } +} + +function resolveOptions(opts: ScrollableElementCreationOptions): ScrollableElementResolvedOptions { + const result: ScrollableElementResolvedOptions = { + lazyRender: (typeof opts.lazyRender !== 'undefined' ? opts.lazyRender : false), + className: (typeof opts.className !== 'undefined' ? opts.className : ''), + useShadows: (typeof opts.useShadows !== 'undefined' ? opts.useShadows : true), + handleMouseWheel: (typeof opts.handleMouseWheel !== 'undefined' ? opts.handleMouseWheel : true), + flipAxes: (typeof opts.flipAxes !== 'undefined' ? opts.flipAxes : false), + consumeMouseWheelIfScrollbarIsNeeded: (typeof opts.consumeMouseWheelIfScrollbarIsNeeded !== 'undefined' ? opts.consumeMouseWheelIfScrollbarIsNeeded : false), + alwaysConsumeMouseWheel: (typeof opts.alwaysConsumeMouseWheel !== 'undefined' ? opts.alwaysConsumeMouseWheel : false), + scrollYToX: (typeof opts.scrollYToX !== 'undefined' ? opts.scrollYToX : false), + mouseWheelScrollSensitivity: (typeof opts.mouseWheelScrollSensitivity !== 'undefined' ? opts.mouseWheelScrollSensitivity : 1), + fastScrollSensitivity: (typeof opts.fastScrollSensitivity !== 'undefined' ? opts.fastScrollSensitivity : 5), + scrollPredominantAxis: (typeof opts.scrollPredominantAxis !== 'undefined' ? opts.scrollPredominantAxis : true), + mouseWheelSmoothScroll: (typeof opts.mouseWheelSmoothScroll !== 'undefined' ? opts.mouseWheelSmoothScroll : true), + arrowSize: (typeof opts.arrowSize !== 'undefined' ? opts.arrowSize : 11), + + listenOnDomNode: (typeof opts.listenOnDomNode !== 'undefined' ? opts.listenOnDomNode : null), + + horizontal: (typeof opts.horizontal !== 'undefined' ? opts.horizontal : ScrollbarVisibility.Auto), + horizontalScrollbarSize: (typeof opts.horizontalScrollbarSize !== 'undefined' ? opts.horizontalScrollbarSize : 10), + horizontalSliderSize: (typeof opts.horizontalSliderSize !== 'undefined' ? opts.horizontalSliderSize : 0), + horizontalHasArrows: (typeof opts.horizontalHasArrows !== 'undefined' ? opts.horizontalHasArrows : false), + + vertical: (typeof opts.vertical !== 'undefined' ? opts.vertical : ScrollbarVisibility.Auto), + verticalScrollbarSize: (typeof opts.verticalScrollbarSize !== 'undefined' ? opts.verticalScrollbarSize : 10), + verticalHasArrows: (typeof opts.verticalHasArrows !== 'undefined' ? opts.verticalHasArrows : false), + verticalSliderSize: (typeof opts.verticalSliderSize !== 'undefined' ? opts.verticalSliderSize : 0), + + scrollByPage: (typeof opts.scrollByPage !== 'undefined' ? opts.scrollByPage : false) + }; + + result.horizontalSliderSize = (typeof opts.horizontalSliderSize !== 'undefined' ? opts.horizontalSliderSize : result.horizontalScrollbarSize); + result.verticalSliderSize = (typeof opts.verticalSliderSize !== 'undefined' ? opts.verticalSliderSize : result.verticalScrollbarSize); + + if (platform.isMacintosh) { + result.className += ' mac'; + } + + return result; +} diff --git a/src/browser/scrollable/scrollableElementOptions.ts b/src/browser/scrollable/scrollableElementOptions.ts new file mode 100644 index 00000000..fd363b73 --- /dev/null +++ b/src/browser/scrollable/scrollableElementOptions.ts @@ -0,0 +1,165 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ScrollbarVisibility } from './scrollable'; + +export interface ScrollableElementCreationOptions { + /** + * The scrollable element should not do any DOM mutations until renderNow() is called. + * Defaults to false. + */ + lazyRender?: boolean; + /** + * CSS Class name for the scrollable element. + */ + className?: string; + /** + * Drop subtle horizontal and vertical shadows. + * Defaults to false. + */ + useShadows?: boolean; + /** + * Handle mouse wheel (listen to mouse wheel scrolling). + * Defaults to true + */ + handleMouseWheel?: boolean; + /** + * If mouse wheel is handled, make mouse wheel scrolling smooth. + * Defaults to true. + */ + mouseWheelSmoothScroll?: boolean; + /** + * Flip axes. Treat vertical scrolling like horizontal and vice-versa. + * Defaults to false. + */ + flipAxes?: boolean; + /** + * If enabled, will scroll horizontally when scrolling vertical. + * Defaults to false. + */ + scrollYToX?: boolean; + /** + * Consume all mouse wheel events if a scrollbar is needed (i.e. scrollSize > size). + * Defaults to false. + */ + consumeMouseWheelIfScrollbarIsNeeded?: boolean; + /** + * Always consume mouse wheel events, even when scrolling is no longer possible. + * Defaults to false. + */ + alwaysConsumeMouseWheel?: boolean; + /** + * A multiplier to be used on the `deltaX` and `deltaY` of mouse wheel scroll events. + * Defaults to 1. + */ + mouseWheelScrollSensitivity?: number; + /** + * FastScrolling mulitplier speed when pressing `Alt` + * Defaults to 5. + */ + fastScrollSensitivity?: number; + /** + * Whether the scrollable will only scroll along the predominant axis when scrolling both + * vertically and horizontally at the same time. + * Prevents horizontal drift when scrolling vertically on a trackpad. + * Defaults to true. + */ + scrollPredominantAxis?: boolean; + /** + * Height for vertical arrows (top/bottom) and width for horizontal arrows (left/right). + * Defaults to 11. + */ + arrowSize?: number; + /** + * The dom node events should be bound to. + * If no listenOnDomNode is provided, the dom node passed to the constructor will be used for event listening. + */ + listenOnDomNode?: HTMLElement; + /** + * Control the visibility of the horizontal scrollbar. + * Accepted values: 'auto' (on mouse over), 'visible' (always visible), 'hidden' (never visible) + * Defaults to 'auto'. + */ + horizontal?: ScrollbarVisibility; + /** + * Height (in px) of the horizontal scrollbar. + * Defaults to 10. + */ + horizontalScrollbarSize?: number; + /** + * Height (in px) of the horizontal scrollbar slider. + * Defaults to `horizontalScrollbarSize` + */ + horizontalSliderSize?: number; + /** + * Render arrows (left/right) for the horizontal scrollbar. + * Defaults to false. + */ + horizontalHasArrows?: boolean; + /** + * Control the visibility of the vertical scrollbar. + * Accepted values: 'auto' (on mouse over), 'visible' (always visible), 'hidden' (never visible) + * Defaults to 'auto'. + */ + vertical?: ScrollbarVisibility; + /** + * Width (in px) of the vertical scrollbar. + * Defaults to 10. + */ + verticalScrollbarSize?: number; + /** + * Width (in px) of the vertical scrollbar slider. + * Defaults to `verticalScrollbarSize` + */ + verticalSliderSize?: number; + /** + * Render arrows (top/bottom) for the vertical scrollbar. + * Defaults to false. + */ + verticalHasArrows?: boolean; + /** + * Scroll gutter clicks move by page vs. jump to position. + * Defaults to false. + */ + scrollByPage?: boolean; +} + +export interface ScrollableElementChangeOptions { + handleMouseWheel?: boolean; + mouseWheelScrollSensitivity?: number; + fastScrollSensitivity?: number; + scrollPredominantAxis?: boolean; + horizontal?: ScrollbarVisibility; + horizontalScrollbarSize?: number; + vertical?: ScrollbarVisibility; + verticalScrollbarSize?: number; + scrollByPage?: boolean; +} + +export interface ScrollableElementResolvedOptions { + lazyRender: boolean; + className: string; + useShadows: boolean; + handleMouseWheel: boolean; + flipAxes: boolean; + scrollYToX: boolean; + consumeMouseWheelIfScrollbarIsNeeded: boolean; + alwaysConsumeMouseWheel: boolean; + mouseWheelScrollSensitivity: number; + fastScrollSensitivity: number; + scrollPredominantAxis: boolean; + mouseWheelSmoothScroll: boolean; + arrowSize: number; + listenOnDomNode: HTMLElement | null; + horizontal: ScrollbarVisibility; + horizontalScrollbarSize: number; + horizontalSliderSize: number; + horizontalHasArrows: boolean; + vertical: ScrollbarVisibility; + verticalScrollbarSize: number; + verticalSliderSize: number; + verticalHasArrows: boolean; + scrollByPage: boolean; +} diff --git a/src/browser/scrollable/scrollbarArrow.ts b/src/browser/scrollable/scrollbarArrow.ts new file mode 100644 index 00000000..649ae9f0 --- /dev/null +++ b/src/browser/scrollable/scrollbarArrow.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { GlobalPointerMoveMonitor } from './globalPointerMoveMonitor'; +import { Widget } from './widget'; +import { TimeoutTimer } from './async'; +import * as dom from './dom'; + +/** + * The arrow image size. + */ +export const ARROW_IMG_SIZE = 11; + +export interface ScrollbarArrowOptions { + onActivate: () => void; + className: string; + // icon: ThemeIcon; + + bgWidth: number; + bgHeight: number; + + top?: number; + left?: number; + bottom?: number; + right?: number; +} + +export class ScrollbarArrow extends Widget { + + private _onActivate: () => void; + public bgDomNode: HTMLElement; + public domNode: HTMLElement; + private _pointerdownRepeatTimer: dom.WindowIntervalTimer; + private _pointerdownScheduleRepeatTimer: TimeoutTimer; + private _pointerMoveMonitor: GlobalPointerMoveMonitor; + + constructor(opts: ScrollbarArrowOptions) { + super(); + this._onActivate = opts.onActivate; + + this.bgDomNode = document.createElement('div'); + this.bgDomNode.className = 'arrow-background'; + this.bgDomNode.style.position = 'absolute'; + this.bgDomNode.style.width = opts.bgWidth + 'px'; + this.bgDomNode.style.height = opts.bgHeight + 'px'; + if (typeof opts.top !== 'undefined') { + this.bgDomNode.style.top = '0px'; + } + if (typeof opts.left !== 'undefined') { + this.bgDomNode.style.left = '0px'; + } + if (typeof opts.bottom !== 'undefined') { + this.bgDomNode.style.bottom = '0px'; + } + if (typeof opts.right !== 'undefined') { + this.bgDomNode.style.right = '0px'; + } + + this.domNode = document.createElement('div'); + this.domNode.className = opts.className; + // this.domNode.classList.add(...ThemeIcon.asClassNameArray(opts.icon)); + + this.domNode.style.position = 'absolute'; + this.domNode.style.width = ARROW_IMG_SIZE + 'px'; + this.domNode.style.height = ARROW_IMG_SIZE + 'px'; + if (typeof opts.top !== 'undefined') { + this.domNode.style.top = opts.top + 'px'; + } + if (typeof opts.left !== 'undefined') { + this.domNode.style.left = opts.left + 'px'; + } + if (typeof opts.bottom !== 'undefined') { + this.domNode.style.bottom = opts.bottom + 'px'; + } + if (typeof opts.right !== 'undefined') { + this.domNode.style.right = opts.right + 'px'; + } + + this._pointerMoveMonitor = this._register(new GlobalPointerMoveMonitor()); + this._register(dom.addStandardDisposableListener(this.bgDomNode, dom.EventType.POINTER_DOWN, (e) => this._arrowPointerDown(e))); + this._register(dom.addStandardDisposableListener(this.domNode, dom.EventType.POINTER_DOWN, (e) => this._arrowPointerDown(e))); + + this._pointerdownRepeatTimer = this._register(new dom.WindowIntervalTimer()); + this._pointerdownScheduleRepeatTimer = this._register(new TimeoutTimer()); + } + + private _arrowPointerDown(e: PointerEvent): void { + if (!e.target || !(e.target instanceof Element)) { + return; + } + const scheduleRepeater = () => { + this._pointerdownRepeatTimer.cancelAndSet(() => this._onActivate(), 1000 / 24, dom.getWindow(e)); + }; + + this._onActivate(); + this._pointerdownRepeatTimer.cancel(); + this._pointerdownScheduleRepeatTimer.cancelAndSet(scheduleRepeater, 200); + + this._pointerMoveMonitor.startMonitoring( + e.target, + e.pointerId, + e.buttons, + (pointerMoveData) => { /* Intentional empty */ }, + () => { + this._pointerdownRepeatTimer.cancel(); + this._pointerdownScheduleRepeatTimer.cancel(); + } + ); + + e.preventDefault(); + } +} diff --git a/src/browser/scrollable/scrollbarState.ts b/src/browser/scrollable/scrollbarState.ts new file mode 100644 index 00000000..8cb9c105 --- /dev/null +++ b/src/browser/scrollable/scrollbarState.ts @@ -0,0 +1,223 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * The minimal size of the slider (such that it can still be clickable) -- it is artificially enlarged. + */ +const MINIMUM_SLIDER_SIZE = 20; + +export class ScrollbarState { + + /** + * For the vertical scrollbar: the width. + * For the horizontal scrollbar: the height. + */ + private _scrollbarSize: number; + + /** + * For the vertical scrollbar: the height of the pair horizontal scrollbar. + * For the horizontal scrollbar: the width of the pair vertical scrollbar. + */ + private _oppositeScrollbarSize: number; + + /** + * For the vertical scrollbar: the height of the scrollbar's arrows. + * For the horizontal scrollbar: the width of the scrollbar's arrows. + */ + private readonly _arrowSize: number; + + // --- variables + /** + * For the vertical scrollbar: the viewport height. + * For the horizontal scrollbar: the viewport width. + */ + private _visibleSize: number; + + /** + * For the vertical scrollbar: the scroll height. + * For the horizontal scrollbar: the scroll width. + */ + private _scrollSize: number; + + /** + * For the vertical scrollbar: the scroll top. + * For the horizontal scrollbar: the scroll left. + */ + private _scrollPosition: number; + + // --- computed variables + + /** + * `visibleSize` - `oppositeScrollbarSize` + */ + private _computedAvailableSize: number; + /** + * (`scrollSize` > 0 && `scrollSize` > `visibleSize`) + */ + private _computedIsNeeded: boolean; + + private _computedSliderSize: number; + private _computedSliderRatio: number; + private _computedSliderPosition: number; + + constructor(arrowSize: number, scrollbarSize: number, oppositeScrollbarSize: number, visibleSize: number, scrollSize: number, scrollPosition: number) { + this._scrollbarSize = Math.round(scrollbarSize); + this._oppositeScrollbarSize = Math.round(oppositeScrollbarSize); + this._arrowSize = Math.round(arrowSize); + + this._visibleSize = visibleSize; + this._scrollSize = scrollSize; + this._scrollPosition = scrollPosition; + + this._computedAvailableSize = 0; + this._computedIsNeeded = false; + this._computedSliderSize = 0; + this._computedSliderRatio = 0; + this._computedSliderPosition = 0; + + this._refreshComputedValues(); + } + + public clone(): ScrollbarState { + return new ScrollbarState(this._arrowSize, this._scrollbarSize, this._oppositeScrollbarSize, this._visibleSize, this._scrollSize, this._scrollPosition); + } + + public setVisibleSize(visibleSize: number): boolean { + const iVisibleSize = Math.round(visibleSize); + if (this._visibleSize !== iVisibleSize) { + this._visibleSize = iVisibleSize; + this._refreshComputedValues(); + return true; + } + return false; + } + + public setScrollSize(scrollSize: number): boolean { + const iScrollSize = Math.round(scrollSize); + if (this._scrollSize !== iScrollSize) { + this._scrollSize = iScrollSize; + this._refreshComputedValues(); + return true; + } + return false; + } + + public setScrollPosition(scrollPosition: number): boolean { + const iScrollPosition = Math.round(scrollPosition); + if (this._scrollPosition !== iScrollPosition) { + this._scrollPosition = iScrollPosition; + this._refreshComputedValues(); + return true; + } + return false; + } + + public setScrollbarSize(scrollbarSize: number): void { + this._scrollbarSize = Math.round(scrollbarSize); + } + + public setOppositeScrollbarSize(oppositeScrollbarSize: number): void { + this._oppositeScrollbarSize = Math.round(oppositeScrollbarSize); + } + + private static _computeValues(oppositeScrollbarSize: number, arrowSize: number, visibleSize: number, scrollSize: number, scrollPosition: number) { + const computedAvailableSize = Math.max(0, visibleSize - oppositeScrollbarSize); + const computedRepresentableSize = Math.max(0, computedAvailableSize - 2 * arrowSize); + const computedIsNeeded = (scrollSize > 0 && scrollSize > visibleSize); + + if (!computedIsNeeded) { + return { + computedAvailableSize: Math.round(computedAvailableSize), + computedIsNeeded: computedIsNeeded, + computedSliderSize: Math.round(computedRepresentableSize), + computedSliderRatio: 0, + computedSliderPosition: 0, + }; + } + + const computedSliderSize = Math.round(Math.max(MINIMUM_SLIDER_SIZE, Math.floor(visibleSize * computedRepresentableSize / scrollSize))); + + const computedSliderRatio = (computedRepresentableSize - computedSliderSize) / (scrollSize - visibleSize); + const computedSliderPosition = (scrollPosition * computedSliderRatio); + + return { + computedAvailableSize: Math.round(computedAvailableSize), + computedIsNeeded: computedIsNeeded, + computedSliderSize: Math.round(computedSliderSize), + computedSliderRatio: computedSliderRatio, + computedSliderPosition: Math.round(computedSliderPosition), + }; + } + + private _refreshComputedValues(): void { + const r = ScrollbarState._computeValues(this._oppositeScrollbarSize, this._arrowSize, this._visibleSize, this._scrollSize, this._scrollPosition); + this._computedAvailableSize = r.computedAvailableSize; + this._computedIsNeeded = r.computedIsNeeded; + this._computedSliderSize = r.computedSliderSize; + this._computedSliderRatio = r.computedSliderRatio; + this._computedSliderPosition = r.computedSliderPosition; + } + + public getArrowSize(): number { + return this._arrowSize; + } + + public getScrollPosition(): number { + return this._scrollPosition; + } + + public getRectangleLargeSize(): number { + return this._computedAvailableSize; + } + + public getRectangleSmallSize(): number { + return this._scrollbarSize; + } + + public isNeeded(): boolean { + return this._computedIsNeeded; + } + + public getSliderSize(): number { + return this._computedSliderSize; + } + + public getSliderPosition(): number { + return this._computedSliderPosition; + } + + public getDesiredScrollPositionFromOffset(offset: number): number { + if (!this._computedIsNeeded) { + return 0; + } + + const desiredSliderPosition = offset - this._arrowSize - this._computedSliderSize / 2; + return Math.round(desiredSliderPosition / this._computedSliderRatio); + } + + public getDesiredScrollPositionFromOffsetPaged(offset: number): number { + if (!this._computedIsNeeded) { + return 0; + } + + const correctedOffset = offset - this._arrowSize; + let desiredScrollPosition = this._scrollPosition; + if (correctedOffset < this._computedSliderPosition) { + desiredScrollPosition -= this._visibleSize; + } else { + desiredScrollPosition += this._visibleSize; + } + return desiredScrollPosition; + } + + public getDesiredScrollPositionFromDelta(delta: number): number { + if (!this._computedIsNeeded) { + return 0; + } + + const desiredSliderPosition = this._computedSliderPosition + delta; + return Math.round(desiredSliderPosition / this._computedSliderRatio); + } +} diff --git a/src/browser/scrollable/scrollbarVisibilityController.ts b/src/browser/scrollable/scrollbarVisibilityController.ts new file mode 100644 index 00000000..f8c22460 --- /dev/null +++ b/src/browser/scrollable/scrollbarVisibilityController.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { FastDomNode } from './fastDomNode'; +import { TimeoutTimer } from './async'; +import { Disposable } from './lifecycle'; +import { ScrollbarVisibility } from './scrollable'; + +export class ScrollbarVisibilityController extends Disposable { + private _visibility: ScrollbarVisibility; + private _visibleClassName: string; + private _invisibleClassName: string; + private _domNode: FastDomNode | null; + private _rawShouldBeVisible: boolean; + private _shouldBeVisible: boolean; + private _isNeeded: boolean; + private _isVisible: boolean; + private _revealTimer: TimeoutTimer; + + constructor(visibility: ScrollbarVisibility, visibleClassName: string, invisibleClassName: string) { + super(); + this._visibility = visibility; + this._visibleClassName = visibleClassName; + this._invisibleClassName = invisibleClassName; + this._domNode = null; + this._isVisible = false; + this._isNeeded = false; + this._rawShouldBeVisible = false; + this._shouldBeVisible = false; + this._revealTimer = this._register(new TimeoutTimer()); + } + + public setVisibility(visibility: ScrollbarVisibility): void { + if (this._visibility !== visibility) { + this._visibility = visibility; + this._updateShouldBeVisible(); + } + } + + public setShouldBeVisible(rawShouldBeVisible: boolean): void { + this._rawShouldBeVisible = rawShouldBeVisible; + this._updateShouldBeVisible(); + } + + private _applyVisibilitySetting(): boolean { + if (this._visibility === ScrollbarVisibility.Hidden) { + return false; + } + if (this._visibility === ScrollbarVisibility.Visible) { + return true; + } + return this._rawShouldBeVisible; + } + + private _updateShouldBeVisible(): void { + const shouldBeVisible = this._applyVisibilitySetting(); + + if (this._shouldBeVisible !== shouldBeVisible) { + this._shouldBeVisible = shouldBeVisible; + this.ensureVisibility(); + } + } + + public setIsNeeded(isNeeded: boolean): void { + if (this._isNeeded !== isNeeded) { + this._isNeeded = isNeeded; + this.ensureVisibility(); + } + } + + public setDomNode(domNode: FastDomNode): void { + this._domNode = domNode; + this._domNode.setClassName(this._invisibleClassName); + + this.setShouldBeVisible(false); + } + + public ensureVisibility(): void { + + if (!this._isNeeded) { + this._hide(false); + return; + } + + if (this._shouldBeVisible) { + this._reveal(); + } else { + this._hide(true); + } + } + + private _reveal(): void { + if (this._isVisible) { + return; + } + this._isVisible = true; + + this._revealTimer.setIfNotSet(() => { + this._domNode?.setClassName(this._visibleClassName); + }, 0); + } + + private _hide(withFadeAway: boolean): void { + this._revealTimer.cancel(); + if (!this._isVisible) { + return; + } + this._isVisible = false; + this._domNode?.setClassName(this._invisibleClassName + (withFadeAway ? ' fade' : '')); + } +} diff --git a/src/browser/scrollable/stopwatch.ts b/src/browser/scrollable/stopwatch.ts new file mode 100644 index 00000000..e32c0dd9 --- /dev/null +++ b/src/browser/scrollable/stopwatch.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// fake definition so that the valid layers check won't trip on this +declare const globalThis: { performance?: { now(): number } }; + +const hasPerformanceNow = (globalThis.performance && typeof globalThis.performance.now === 'function'); + +export class StopWatch { + + private _startTime: number; + private _stopTime: number; + + private readonly _now: () => number; + + public static create(highResolution?: boolean): StopWatch { + return new StopWatch(highResolution); + } + + constructor(highResolution?: boolean) { + this._now = hasPerformanceNow && highResolution === false ? Date.now : globalThis.performance!.now.bind(globalThis.performance); + this._startTime = this._now(); + this._stopTime = -1; + } + + public stop(): void { + this._stopTime = this._now(); + } + + public reset(): void { + this._startTime = this._now(); + this._stopTime = -1; + } + + public elapsed(): number { + if (this._stopTime !== -1) { + return this._stopTime - this._startTime; + } + return this._now() - this._startTime; + } +} diff --git a/src/browser/scrollable/symbols.ts b/src/browser/scrollable/symbols.ts new file mode 100644 index 00000000..9aa8e5bb --- /dev/null +++ b/src/browser/scrollable/symbols.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Can be passed into the Delayed to defer using a microtask + * */ +export const MicrotaskDelay = Symbol('MicrotaskDelay'); diff --git a/src/browser/scrollable/touch.ts b/src/browser/scrollable/touch.ts new file mode 100644 index 00000000..e9304cf8 --- /dev/null +++ b/src/browser/scrollable/touch.ts @@ -0,0 +1,364 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DomUtils from './dom'; +import { mainWindow } from './window'; +import * as arrays from './arrays'; +import { memoize } from './decorators'; +import { Event as EventUtils } from './event'; +import { Disposable, IDisposable, markAsSingleton, toDisposable } from './lifecycle'; +import { LinkedList } from './linkedList'; + +export namespace EventType { + export const Tap = '-xterm-gesturetap'; + export const Change = '-xterm-gesturechange'; + export const Start = '-xterm-gesturestart'; + export const End = '-xterm-gesturesend'; + export const Contextmenu = '-xterm-gesturecontextmenu'; +} + +interface TouchData { + id: number; + initialTarget: EventTarget; + initialTimeStamp: number; + initialPageX: number; + initialPageY: number; + rollingTimestamps: number[]; + rollingPageX: number[]; + rollingPageY: number[]; +} + +export interface GestureEvent extends MouseEvent { + initialTarget: EventTarget | undefined; + translationX: number; + translationY: number; + pageX: number; + pageY: number; + tapCount: number; +} + +interface Touch { + identifier: number; + screenX: number; + screenY: number; + clientX: number; + clientY: number; + pageX: number; + pageY: number; + radiusX: number; + radiusY: number; + rotationAngle: number; + force: number; + target: Element; +} + +interface TouchList { + [i: number]: Touch; + length: number; + item(index: number): Touch; + identifiedTouch(id: number): Touch; +} + +interface TouchEvent extends Event { + touches: TouchList; + targetTouches: TouchList; + changedTouches: TouchList; +} + +export class Gesture extends Disposable { + + private static readonly SCROLL_FRICTION = -0.005; + private static INSTANCE: Gesture; + private static readonly HOLD_DELAY = 700; + + private dispatched = false; + private readonly targets = new LinkedList(); + private readonly ignoreTargets = new LinkedList(); + private handle: IDisposable | null; + + private readonly activeTouches: { [id: number]: TouchData }; + + private _lastSetTapCountTime: number; + + private static readonly CLEAR_TAP_COUNT_TIME = 400; // ms + + + private constructor() { + super(); + + this.activeTouches = {}; + this.handle = null; + this._lastSetTapCountTime = 0; + + this._register(EventUtils.runAndSubscribe(DomUtils.onDidRegisterWindow, ({ window, disposables }) => { + disposables.add(DomUtils.addDisposableListener(window.document, 'touchstart', (e: TouchEvent) => this.onTouchStart(e), { passive: false })); + disposables.add(DomUtils.addDisposableListener(window.document, 'touchend', (e: TouchEvent) => this.onTouchEnd(window, e))); + disposables.add(DomUtils.addDisposableListener(window.document, 'touchmove', (e: TouchEvent) => this.onTouchMove(e), { passive: false })); + }, { window: mainWindow, disposables: this._store })); + } + + public static addTarget(element: HTMLElement): IDisposable { + if (!Gesture.isTouchDevice()) { + return Disposable.None; + } + if (!Gesture.INSTANCE) { + Gesture.INSTANCE = markAsSingleton(new Gesture()); + } + + const remove = Gesture.INSTANCE.targets.push(element); + return toDisposable(remove); + } + + public static ignoreTarget(element: HTMLElement): IDisposable { + if (!Gesture.isTouchDevice()) { + return Disposable.None; + } + if (!Gesture.INSTANCE) { + Gesture.INSTANCE = markAsSingleton(new Gesture()); + } + + const remove = Gesture.INSTANCE.ignoreTargets.push(element); + return toDisposable(remove); + } + + @memoize + static isTouchDevice(): boolean { + return 'ontouchstart' in mainWindow || navigator.maxTouchPoints > 0; + } + + public override dispose(): void { + if (this.handle) { + this.handle.dispose(); + this.handle = null; + } + + super.dispose(); + } + + private onTouchStart(e: TouchEvent): void { + const timestamp = Date.now(); + + if (this.handle) { + this.handle.dispose(); + this.handle = null; + } + + for (let i = 0, len = e.targetTouches.length; i < len; i++) { + const touch = e.targetTouches.item(i); + + this.activeTouches[touch.identifier] = { + id: touch.identifier, + initialTarget: touch.target, + initialTimeStamp: timestamp, + initialPageX: touch.pageX, + initialPageY: touch.pageY, + rollingTimestamps: [timestamp], + rollingPageX: [touch.pageX], + rollingPageY: [touch.pageY] + }; + + const evt = this.newGestureEvent(EventType.Start, touch.target); + evt.pageX = touch.pageX; + evt.pageY = touch.pageY; + this.dispatchEvent(evt); + } + + if (this.dispatched) { + e.preventDefault(); + e.stopPropagation(); + this.dispatched = false; + } + } + + private onTouchEnd(targetWindow: Window, e: TouchEvent): void { + const timestamp = Date.now(); + + const activeTouchCount = Object.keys(this.activeTouches).length; + + for (let i = 0, len = e.changedTouches.length; i < len; i++) { + + const touch = e.changedTouches.item(i); + + if (!this.activeTouches.hasOwnProperty(String(touch.identifier))) { + console.warn('move of an UNKNOWN touch', touch); + continue; + } + + const data = this.activeTouches[touch.identifier], + holdTime = Date.now() - data.initialTimeStamp; + + if (holdTime < Gesture.HOLD_DELAY + && Math.abs(data.initialPageX - arrays.tail(data.rollingPageX)!) < 30 + && Math.abs(data.initialPageY - arrays.tail(data.rollingPageY)!) < 30) { + + const evt = this.newGestureEvent(EventType.Tap, data.initialTarget); + evt.pageX = arrays.tail(data.rollingPageX)!; + evt.pageY = arrays.tail(data.rollingPageY)!; + this.dispatchEvent(evt); + + } else if (holdTime >= Gesture.HOLD_DELAY + && Math.abs(data.initialPageX - arrays.tail(data.rollingPageX)!) < 30 + && Math.abs(data.initialPageY - arrays.tail(data.rollingPageY)!) < 30) { + + const evt = this.newGestureEvent(EventType.Contextmenu, data.initialTarget); + evt.pageX = arrays.tail(data.rollingPageX)!; + evt.pageY = arrays.tail(data.rollingPageY)!; + this.dispatchEvent(evt); + + } else if (activeTouchCount === 1) { + const finalX = arrays.tail(data.rollingPageX)!; + const finalY = arrays.tail(data.rollingPageY)!; + + const deltaT = arrays.tail(data.rollingTimestamps)! - data.rollingTimestamps[0]; + const deltaX = finalX - data.rollingPageX[0]; + const deltaY = finalY - data.rollingPageY[0]; + + const dispatchTo = [...this.targets].filter(t => data.initialTarget instanceof Node && t.contains(data.initialTarget)); + this.inertia(targetWindow, dispatchTo, timestamp, + Math.abs(deltaX) / deltaT, + deltaX > 0 ? 1 : -1, + finalX, + Math.abs(deltaY) / deltaT, + deltaY > 0 ? 1 : -1, + finalY + ); + } + + + this.dispatchEvent(this.newGestureEvent(EventType.End, data.initialTarget)); + delete this.activeTouches[touch.identifier]; + } + + if (this.dispatched) { + e.preventDefault(); + e.stopPropagation(); + this.dispatched = false; + } + } + + private newGestureEvent(type: string, initialTarget?: EventTarget): GestureEvent { + const event = document.createEvent('CustomEvent') as unknown as GestureEvent; + event.initEvent(type, false, true); + event.initialTarget = initialTarget; + event.tapCount = 0; + return event; + } + + private dispatchEvent(event: GestureEvent): void { + if (event.type === EventType.Tap) { + const currentTime = (new Date()).getTime(); + let setTapCount = 0; + if (currentTime - this._lastSetTapCountTime > Gesture.CLEAR_TAP_COUNT_TIME) { + setTapCount = 1; + } else { + setTapCount = 2; + } + + this._lastSetTapCountTime = currentTime; + event.tapCount = setTapCount; + } else if (event.type === EventType.Change || event.type === EventType.Contextmenu) { + this._lastSetTapCountTime = 0; + } + + if (event.initialTarget instanceof Node) { + for (const ignoreTarget of this.ignoreTargets) { + if (ignoreTarget.contains(event.initialTarget)) { + return; + } + } + + const targets: [number, HTMLElement][] = []; + for (const target of this.targets) { + if (target.contains(event.initialTarget)) { + let depth = 0; + let now: Node | null = event.initialTarget; + while (now && now !== target) { + depth++; + now = now.parentElement; + } + targets.push([depth, target]); + } + } + + targets.sort((a, b) => a[0] - b[0]); + + for (const [_, target] of targets) { + target.dispatchEvent(event); + this.dispatched = true; + } + } + } + + private inertia(targetWindow: Window, dispatchTo: readonly EventTarget[], t1: number, vX: number, dirX: number, x: number, vY: number, dirY: number, y: number): void { + this.handle = DomUtils.scheduleAtNextAnimationFrame(targetWindow, () => { + const now = Date.now(); + + const deltaT = now - t1; + let delta_pos_x = 0, delta_pos_y = 0; + let stopped = true; + + vX += Gesture.SCROLL_FRICTION * deltaT; + vY += Gesture.SCROLL_FRICTION * deltaT; + + if (vX > 0) { + stopped = false; + delta_pos_x = dirX * vX * deltaT; + } + + if (vY > 0) { + stopped = false; + delta_pos_y = dirY * vY * deltaT; + } + + const evt = this.newGestureEvent(EventType.Change); + evt.translationX = delta_pos_x; + evt.translationY = delta_pos_y; + dispatchTo.forEach(d => d.dispatchEvent(evt)); + + if (!stopped) { + this.inertia(targetWindow, dispatchTo, now, vX, dirX, x + delta_pos_x, vY, dirY, y + delta_pos_y); + } + }); + } + + private onTouchMove(e: TouchEvent): void { + const timestamp = Date.now(); + + for (let i = 0, len = e.changedTouches.length; i < len; i++) { + + const touch = e.changedTouches.item(i); + + if (!this.activeTouches.hasOwnProperty(String(touch.identifier))) { + console.warn('end of an UNKNOWN touch', touch); + continue; + } + + const data = this.activeTouches[touch.identifier]; + + const evt = this.newGestureEvent(EventType.Change, data.initialTarget); + evt.translationX = touch.pageX - arrays.tail(data.rollingPageX)!; + evt.translationY = touch.pageY - arrays.tail(data.rollingPageY)!; + evt.pageX = touch.pageX; + evt.pageY = touch.pageY; + this.dispatchEvent(evt); + + if (data.rollingPageX.length > 3) { + data.rollingPageX.shift(); + data.rollingPageY.shift(); + data.rollingTimestamps.shift(); + } + + data.rollingPageX.push(touch.pageX); + data.rollingPageY.push(touch.pageY); + data.rollingTimestamps.push(timestamp); + } + + if (this.dispatched) { + e.preventDefault(); + e.stopPropagation(); + this.dispatched = false; + } + } +} diff --git a/src/browser/scrollable/verticalScrollbar.ts b/src/browser/scrollable/verticalScrollbar.ts new file mode 100644 index 00000000..d155a0c8 --- /dev/null +++ b/src/browser/scrollable/verticalScrollbar.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AbstractScrollbar, ISimplifiedPointerEvent, ScrollbarHost } from './abstractScrollbar'; +import { ScrollableElementResolvedOptions } from './scrollableElementOptions'; +import { ScrollbarState } from './scrollbarState'; +import { INewScrollPosition, Scrollable, ScrollbarVisibility, ScrollEvent } from './scrollable'; + +export class VerticalScrollbar extends AbstractScrollbar { + + constructor(scrollable: Scrollable, options: ScrollableElementResolvedOptions, host: ScrollbarHost) { + const scrollDimensions = scrollable.getScrollDimensions(); + const scrollPosition = scrollable.getCurrentScrollPosition(); + super({ + lazyRender: options.lazyRender, + host: host, + scrollbarState: new ScrollbarState( + (options.verticalHasArrows ? options.arrowSize : 0), + (options.vertical === ScrollbarVisibility.Hidden ? 0 : options.verticalScrollbarSize), + 0, + scrollDimensions.height, + scrollDimensions.scrollHeight, + scrollPosition.scrollTop + ), + visibility: options.vertical, + extraScrollbarClassName: 'vertical', + scrollable: scrollable, + scrollByPage: options.scrollByPage + }); + + if (options.verticalHasArrows) { + throw new Error('horizontalHasArrows is not supported in xterm.js'); + } + + this._createSlider(0, Math.floor((options.verticalScrollbarSize - options.verticalSliderSize) / 2), options.verticalSliderSize, undefined); + } + + protected _updateSlider(sliderSize: number, sliderPosition: number): void { + this.slider.setHeight(sliderSize); + this.slider.setTop(sliderPosition); + } + + protected _renderDomNode(largeSize: number, smallSize: number): void { + this.domNode.setWidth(smallSize); + this.domNode.setHeight(largeSize); + this.domNode.setRight(0); + this.domNode.setTop(0); + } + + public onDidScroll(e: ScrollEvent): boolean { + this._shouldRender = this._onElementScrollSize(e.scrollHeight) || this._shouldRender; + this._shouldRender = this._onElementScrollPosition(e.scrollTop) || this._shouldRender; + this._shouldRender = this._onElementSize(e.height) || this._shouldRender; + return this._shouldRender; + } + + protected _pointerDownRelativePosition(offsetX: number, offsetY: number): number { + return offsetY; + } + + protected _sliderPointerPosition(e: ISimplifiedPointerEvent): number { + return e.pageY; + } + + protected _sliderOrthogonalPointerPosition(e: ISimplifiedPointerEvent): number { + return e.pageX; + } + + protected _updateScrollbarSize(size: number): void { + this.slider.setWidth(size); + } + + public writeScrollPosition(target: INewScrollPosition, scrollPosition: number): void { + target.scrollTop = scrollPosition; + } + + public updateOptions(options: ScrollableElementResolvedOptions): void { + this.updateScrollbarSize(options.vertical === ScrollbarVisibility.Hidden ? 0 : options.verticalScrollbarSize); + this._scrollbarState.setOppositeScrollbarSize(0); + this._visibilityController.setVisibility(options.vertical); + this._scrollByPage = options.scrollByPage; + } + +} diff --git a/src/browser/scrollable/widget.ts b/src/browser/scrollable/widget.ts new file mode 100644 index 00000000..5102cc8e --- /dev/null +++ b/src/browser/scrollable/widget.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from './dom'; +import { IKeyboardEvent, StandardKeyboardEvent } from './keyboardEvent'; +import { IMouseEvent, StandardMouseEvent } from './mouseEvent'; +import { Gesture } from './touch'; +import { Disposable, IDisposable } from './lifecycle'; + +export abstract class Widget extends Disposable { + + protected onclick(domNode: HTMLElement, listener: (e: IMouseEvent) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.CLICK, (e: MouseEvent) => listener(new StandardMouseEvent(dom.getWindow(domNode), e)))); + } + + protected onmousedown(domNode: HTMLElement, listener: (e: IMouseEvent) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.MOUSE_DOWN, (e: MouseEvent) => listener(new StandardMouseEvent(dom.getWindow(domNode), e)))); + } + + protected onmouseover(domNode: HTMLElement, listener: (e: IMouseEvent) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.MOUSE_OVER, (e: MouseEvent) => listener(new StandardMouseEvent(dom.getWindow(domNode), e)))); + } + + protected onmouseleave(domNode: HTMLElement, listener: (e: IMouseEvent) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.MOUSE_LEAVE, (e: MouseEvent) => listener(new StandardMouseEvent(dom.getWindow(domNode), e)))); + } + + protected onkeydown(domNode: HTMLElement, listener: (e: IKeyboardEvent) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => listener(new StandardKeyboardEvent(e)))); + } + + protected onkeyup(domNode: HTMLElement, listener: (e: IKeyboardEvent) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.KEY_UP, (e: KeyboardEvent) => listener(new StandardKeyboardEvent(e)))); + } + + protected oninput(domNode: HTMLElement, listener: (e: Event) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.INPUT, listener)); + } + + protected onblur(domNode: HTMLElement, listener: (e: Event) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.BLUR, listener)); + } + + protected onfocus(domNode: HTMLElement, listener: (e: Event) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.FOCUS, listener)); + } + + protected onchange(domNode: HTMLElement, listener: (e: Event) => void): void { + this._register(dom.addDisposableListener(domNode, dom.EventType.CHANGE, listener)); + } + + protected ignoreGesture(domNode: HTMLElement): IDisposable { + return Gesture.ignoreTarget(domNode); + } +} diff --git a/src/browser/scrollable/window.ts b/src/browser/scrollable/window.ts new file mode 100644 index 00000000..3a377a85 --- /dev/null +++ b/src/browser/scrollable/window.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type CodeWindow = Window & typeof globalThis & { + readonly vscodeWindowId: number; +}; + +export function ensureCodeWindow(targetWindow: Window, fallbackWindowId: number): asserts targetWindow is CodeWindow { +} + +// eslint-disable-next-line no-restricted-globals +export const mainWindow = (typeof window === 'object' ? window : globalThis) as CodeWindow;