diff --git a/packages/core/src/canvas/index.ts b/packages/core/src/canvas/index.ts index ac1ed2170..4e1c98f70 100644 --- a/packages/core/src/canvas/index.ts +++ b/packages/core/src/canvas/index.ts @@ -44,10 +44,12 @@ import { CanvasEvents, CanvasRefreshOptions, ToWorldOption } from './types'; import CanvasView, { FitViewportOptions } from './view/CanvasView'; import FrameView from './view/FrameView'; import { DragSource } from '../utils/sorter/types'; +import AutoScroller from '../utils/AutoScroller'; export type CanvasEvent = `${CanvasEvents}`; export default class CanvasModule extends Module { + autoScroller: AutoScroller; /** * Get configuration object * @name getConfig @@ -83,6 +85,8 @@ export default class CanvasModule extends Module { this.model = this.canvas; this.startAutoscroll = this.startAutoscroll.bind(this); this.stopAutoscroll = this.stopAutoscroll.bind(this); + this.autoScroller = new AutoScroller(); + return this; } @@ -606,6 +610,13 @@ export default class CanvasModule extends Module { startAutoscroll(frame?: Frame) { const fr = (frame && frame.view) || this.em.getCurrentFrame(); fr && fr.startAutoscroll(); + + if (this.config.scrollableCanvas) { + const el = this.getCanvasView().el; + this.autoScroller.start(el, el, { + zoom: this.em.getZoomDecimal(), + }); + } } /** @@ -615,6 +626,10 @@ export default class CanvasModule extends Module { stopAutoscroll(frame?: Frame) { const fr = (frame && frame.view) || this.em.getCurrentFrame(); fr && fr.stopAutoscroll(); + + if (this.config.scrollableCanvas) { + this.autoScroller.stop(); + } } /** diff --git a/packages/core/src/canvas/view/FrameView.ts b/packages/core/src/canvas/view/FrameView.ts index 7b37fc7b4..c987aeb7b 100644 --- a/packages/core/src/canvas/view/FrameView.ts +++ b/packages/core/src/canvas/view/FrameView.ts @@ -6,21 +6,13 @@ import ComponentWrapperView from '../../dom_components/view/ComponentWrapperView import ComponentView from '../../dom_components/view/ComponentView'; import { type as typeHead } from '../../dom_components/model/ComponentHead'; import Droppable from '../../utils/Droppable'; -import { - append, - appendVNodes, - createCustomEvent, - createEl, - getPointerEvent, - motionsEv, - off, - on, -} from '../../utils/dom'; +import { append, appendVNodes, createCustomEvent, createEl, motionsEv, off, on } from '../../utils/dom'; import { hasDnd, setViewEl } from '../../utils/mixins'; import Canvas from '../model/Canvas'; import Frame from '../model/Frame'; import FrameWrapView from './FrameWrapView'; import CanvasEvents from '../types'; +import AutoScroller from '../../utils/AutoScroller'; export default class FrameView extends ModuleView { /** @ts-ignore */ @@ -39,6 +31,7 @@ export default class FrameView extends ModuleView { lastClientY?: number; lastMaxHeight = 0; + private autoScroller: AutoScroller; private jsContainer?: HTMLElement; private tools: { [key: string]: HTMLElement } = {}; private wrapper?: ComponentWrapperView; @@ -47,7 +40,7 @@ export default class FrameView extends ModuleView { constructor(model: Frame, view?: FrameWrapView) { super({ model }); - bindAll(this, 'updateClientY', 'stopAutoscroll', 'autoscroll', '_emitUpdate'); + bindAll(this, 'startAutoscroll', 'stopAutoscroll', '_emitUpdate'); const { el } = this; //@ts-ignore this.module._config = { @@ -63,6 +56,17 @@ export default class FrameView extends ModuleView { this.listenTo(cvModel, 'change:styles', this.renderStyles); model.view = this; setViewEl(el, this); + + this.autoScroller = new AutoScroller(this.config.autoscrollLimit, { + rectIsInScrollIframe: true, + onScroll: () => { + const toolsEl = this.getGlobalToolsEl(); + toolsEl.style.opacity = '0'; + this.showGlobalTools(); + + this.em.Canvas.spots.refreshDbn(); + }, + }); } getBoxRect(): BoxRect { @@ -222,74 +226,20 @@ export default class FrameView extends ModuleView { } startAutoscroll() { - this.lastMaxHeight = this.getWrapper().offsetHeight - this.el.offsetHeight; - - // By detaching those from the stack avoid browsers lags - // Noticeable with "fast" drag of blocks - setTimeout(() => { - this._toggleAutoscrollFx(true); - requestAnimationFrame(this.autoscroll); - }, 0); - } - - autoscroll() { - if (this.dragging) { - const { lastClientY } = this; - const canvas = this.em.Canvas; - const win = this.getWindow(); - const actualTop = win.pageYOffset; - const clientY = lastClientY || 0; - const limitTop = canvas.getConfig().autoscrollLimit!; - const limitBottom = this.getRect().height - limitTop; - let nextTop = actualTop; - - if (clientY < limitTop) { - nextTop -= limitTop - clientY; - } - - if (clientY > limitBottom) { - nextTop += clientY - limitBottom; - } - - if ( - !isUndefined(lastClientY) && // Fixes #3134 - nextTop !== actualTop && - nextTop > 0 && - nextTop < this.lastMaxHeight - ) { - const toolsEl = this.getGlobalToolsEl(); - toolsEl.style.opacity = '0'; - this.showGlobalTools(); - win.scrollTo(0, nextTop); - canvas.spots.refreshDbn(); - } - - requestAnimationFrame(this.autoscroll); - } + this.autoScroller.start(this.el, this.getWindow(), { + lastMaxHeight: this.getWrapper().offsetHeight - this.el.offsetHeight, + zoom: this.em.getZoomDecimal(), + }); } - updateClientY(ev: Event) { - ev.preventDefault(); - this.lastClientY = getPointerEvent(ev).clientY * this.em.getZoomDecimal(); + stopAutoscroll() { + this.autoScroller.stop(); } showGlobalTools() { this.getGlobalToolsEl().style.opacity = ''; } - stopAutoscroll() { - this.dragging && this._toggleAutoscrollFx(false); - } - - _toggleAutoscrollFx(enable: boolean) { - this.dragging = enable; - const win = this.getWindow(); - const method = enable ? 'on' : 'off'; - const mt = { on, off }; - mt[method](win, 'mousemove dragover', this.updateClientY); - mt[method](win, 'mouseup', this.stopAutoscroll); - } - render() { const { $el, ppfx, em } = this; $el.attr({ class: `${ppfx}frame` }); diff --git a/packages/core/src/utils/AutoScroller.ts b/packages/core/src/utils/AutoScroller.ts new file mode 100644 index 000000000..954c21346 --- /dev/null +++ b/packages/core/src/utils/AutoScroller.ts @@ -0,0 +1,103 @@ +import { bindAll } from 'underscore'; +import { getPointerEvent, off, on } from './dom'; + +export default class AutoScroller { + private eventEl?: HTMLElement; // Element that handles mouse events + private scrollEl?: HTMLElement | Window; // Element that will be scrolled + private dragging: boolean = false; + private lastClientY?: number; + private lastMaxHeight: number = 0; + private onScroll?: () => void; + private autoscrollLimit: number; + private zoom: number = 1; + /** + * When an element is inside an iframe, its `getBoundingClientRect()` values + * are relative to the iframe's document, not the main window's. + */ + private rectIsInScrollIframe: boolean = false; + + constructor( + autoscrollLimit: number = 50, + opts?: { + lastMaxHeight?: number; + onScroll?: () => void; + rectIsInScrollIframe?: boolean; + }, + ) { + this.autoscrollLimit = autoscrollLimit; + this.lastMaxHeight = opts?.lastMaxHeight ?? 0; + this.onScroll = opts?.onScroll; + this.rectIsInScrollIframe = !!opts?.rectIsInScrollIframe; + bindAll(this, 'start', 'autoscroll', 'updateClientY', 'stop'); + } + + start(eventEl: HTMLElement, scrollEl: HTMLElement | Window, opts?: { lastMaxHeight?: number; zoom?: number }) { + this.eventEl = eventEl; + this.scrollEl = scrollEl; + this.lastMaxHeight = opts?.lastMaxHeight || Number.POSITIVE_INFINITY; + this.zoom = opts?.zoom || 1; + + // By detaching those from the stack avoid browsers lags + // Noticeable with "fast" drag of blocks + setTimeout(() => { + this.toggleAutoscrollFx(true); + requestAnimationFrame(this.autoscroll); + }, 0); + } + + private autoscroll() { + const scrollEl = this.scrollEl; + if (this.dragging && scrollEl) { + const clientY = this.lastClientY ?? 0; + const limitTop = this.autoscrollLimit; + const eventElHeight = this.getEventElHeight(); + const limitBottom = eventElHeight - limitTop; + let nextTop = 0; + + if (clientY < limitTop) nextTop += clientY - limitTop; + if (clientY > limitBottom) nextTop += clientY - limitBottom; + + const scrollTop = this.getElScrollTop(scrollEl); + if (this.lastClientY !== undefined && nextTop !== 0 && this.lastMaxHeight - nextTop > scrollTop) { + scrollEl.scrollBy({ top: nextTop, left: 0, behavior: 'auto' }); + this.onScroll?.(); + } + + requestAnimationFrame(this.autoscroll); + } + } + + private getEventElHeight() { + const eventEl = this.eventEl; + if (!eventEl) return 0; + + const elRect = eventEl.getBoundingClientRect(); + return elRect.height; + } + + private updateClientY(ev: Event) { + const scrollEl = this.scrollEl; + ev.preventDefault(); + + const scrollTop = !this.rectIsInScrollIframe ? this.getElScrollTop(scrollEl) : 0; + this.lastClientY = getPointerEvent(ev).clientY * this.zoom - scrollTop; + } + + private getElScrollTop(scrollEl: HTMLElement | Window | undefined) { + return (scrollEl instanceof HTMLElement ? scrollEl.scrollTop : scrollEl?.scrollY) || 0; + } + + private toggleAutoscrollFx(enable: boolean) { + this.dragging = enable; + const eventEl = this.eventEl; + if (!eventEl) return; + const method = enable ? 'on' : 'off'; + const mt = { on, off }; + mt[method](eventEl, 'mousemove dragover', this.updateClientY); + mt[method](eventEl, 'mouseup', this.stop); + } + + stop() { + this.toggleAutoscrollFx(false); + } +}