diff --git a/packages/core/src/canvas/config/config.ts b/packages/core/src/canvas/config/config.ts index ddbd57c60a..8bd45e0092 100644 --- a/packages/core/src/canvas/config/config.ts +++ b/packages/core/src/canvas/config/config.ts @@ -89,6 +89,19 @@ export interface CanvasConfig { * Experimental: enable infinite canvas. */ infiniteCanvas?: boolean; + + /** + * Enables the scrollable canvas feature. + * + * When this feature flag is set to `true`, the canvas element + * will have its `overflow` style set to `auto`, allowing users to scroll + * the canvas content if it exceeds the visible area. This is useful for + * handling large diagrams or zoomed-in views where parts of the content + * are initially hidden. If `false`, the canvas will use default overflow (typically hidden). + * + * @default false + */ + scrollableCanvas?: boolean; } const config: () => CanvasConfig = () => ({ diff --git a/packages/core/src/canvas/index.ts b/packages/core/src/canvas/index.ts index 7be4ffb81c..5c9f79f148 100644 --- a/packages/core/src/canvas/index.ts +++ b/packages/core/src/canvas/index.ts @@ -418,25 +418,36 @@ export default class CanvasModule extends Module { const canvasOffset = opts.canvasOff || this.canvasRectOffset(el, elRect); const targetHeight = targetEl.offsetHeight || 0; const targetWidth = targetEl.offsetWidth || 0; - const elRight = elRect.left + elRect.width; + const elementRight = elRect.left + elRect.width; const canvasView = this.getCanvasView(); + const { scrollTop: canvasScrollTop, scrollLeft: canvasScrollLeft } = canvasView.getCanvasScroll(); const canvasRect = canvasView.getPosition(); const frameOffset = canvasView.getFrameOffset(el); const { event } = opts; - let top = -targetHeight; - let left = !isUndefined(opts.left) ? opts.left : elRect.width - targetWidth; - left = elRect.left < -left ? -elRect.left : left; - left = elRight > canvasRect.width ? left - (elRight - canvasRect.width) : left; + const defaultLeftOffset = elRect.width - targetWidth; + const defaultTopOffset = -targetHeight; - // Check when the target top edge reaches the top of the viewable canvas - if (canvasOffset.top < targetHeight) { + let left = !isUndefined(opts.left) ? opts.left : defaultLeftOffset; + const canvasLiftLimit = Math.max(-elRect.left + canvasScrollLeft, 0); + left = Math.max(left, canvasLiftLimit); + + const elementRightLimit = elementRight - targetWidth; + left = Math.min(left, elementRightLimit); + + const canvasRightLimit = canvasRect.width + canvasScrollLeft - targetWidth - elRect.left; + left = Math.min(left, canvasRightLimit); + + const targetReachesCanvasTop = canvasOffset.top < targetHeight + canvasScrollTop; + let top = defaultTopOffset; + + if (targetReachesCanvasTop) { const fullHeight = elRect.height + targetHeight; - const elIsShort = fullHeight < frameOffset.height; + const elementIsShorterThanFrame = fullHeight < frameOffset.height; // Scroll with the window if the top edge is reached and the // element is bigger than the canvas - if (elIsShort) { + if (elementIsShorterThanFrame) { top = top + fullHeight; } else { top = -canvasOffset.top < elRect.height ? -canvasOffset.top : elRect.height; @@ -501,7 +512,13 @@ export default class CanvasModule extends Module { */ getMouseRelativeCanvas(ev: MouseEvent | { clientX: number; clientY: number }, opts: any) { const zoom = this.getZoomDecimal(); - const { top = 0, left = 0 } = this.getCanvasView().getPosition(opts) ?? {}; + const canvasView = this.getCanvasView(); + const canvasPos = canvasView.getPosition(opts) ?? { top: 0, left: 0 }; + const canvasScroll = canvasView.getCanvasScroll(); + const { top, left } = { + top: canvasPos.top + canvasScroll.scrollTop, + left: canvasPos.left + canvasScroll.scrollLeft, + }; return { y: ev.clientY * zoom + top, diff --git a/packages/core/src/canvas/view/CanvasView.ts b/packages/core/src/canvas/view/CanvasView.ts index 62132c86aa..a128025b33 100644 --- a/packages/core/src/canvas/view/CanvasView.ts +++ b/packages/core/src/canvas/view/CanvasView.ts @@ -358,11 +358,12 @@ export default class CanvasView extends ModuleView { } getRectToScreen(boxRect: Partial): BoxRect { + const canvasScroll = this.getCanvasScroll(); const zoom = this.module.getZoomDecimal(); const coords = this.module.getCoords(); const vwDelta = this.getViewportDelta(); - const x = (boxRect.x ?? 0) * zoom + coords.x + vwDelta.x || 0; - const y = (boxRect.y ?? 0) * zoom + coords.y + vwDelta.y || 0; + const x = (boxRect.x ?? 0) * zoom - canvasScroll.scrollLeft + coords.x + vwDelta.x || 0; + const y = (boxRect.y ?? 0) * zoom - canvasScroll.scrollTop + coords.y + vwDelta.y || 0; return { x, @@ -461,6 +462,7 @@ export default class CanvasView extends ModuleView { const frEl = winEl ? (winEl.frameElement as HTMLElement) : frame; this.frmOff = this.offset(frEl || frame); } + return this.frmOff; } @@ -562,6 +564,23 @@ export default class CanvasView extends ModuleView { }; } + /** + * Returns the scroll position of the canvas. + * + * If the canvas is scrollable, returns the current `scrollTop` and `scrollLeft` values. + * Otherwise, returns an object with `scrollTop` and `scrollLeft` both set to 0. + * + * @returns An object containing the vertical and horizontal scroll positions. + */ + getCanvasScroll(): { scrollTop: number; scrollLeft: number } { + return this.config.scrollableCanvas + ? { + scrollTop: this.el.scrollTop, + scrollLeft: this.el.scrollLeft, + } + : { scrollTop: 0, scrollLeft: 0 }; + } + /** * Update javascript of a specific component passed by its View * @param {ModuleView} view Component's View @@ -671,7 +690,10 @@ export default class CanvasView extends ModuleView { this.toolsGlobEl = el.querySelector(`.${ppfx}tools-gl`)!; this.spotsEl = el.querySelector('[data-spots]')!; this.cvStyle = el.querySelector('[data-canvas-style]')!; - this.el.className = getUiClass(em, this.className); + el.className = getUiClass(em, this.className); + if (config.scrollableCanvas === true) { + el.style.overflow = 'auto'; + } this.ready = true; this._renderFrames(); diff --git a/packages/core/src/commands/view/SelectComponent.ts b/packages/core/src/commands/view/SelectComponent.ts index 462b9fca19..cfafd661c9 100644 --- a/packages/core/src/commands/view/SelectComponent.ts +++ b/packages/core/src/commands/view/SelectComponent.ts @@ -40,6 +40,7 @@ export default { 'onHover', 'onOut', 'onClick', + 'onCanvasScroll', 'onFrameScroll', 'onFrameResize', 'onFrameUpdated', @@ -75,14 +76,16 @@ export default { * @private * */ toggleSelectComponent(enable: boolean) { - const { em } = this; + const { em, canvas } = this; + const canvasEl = canvas.getCanvasView().el; const listenToEl = em.getConfig().listenToEl!; const { parentNode } = em.getContainer()!; const method = enable ? 'on' : 'off'; const methods = { on, off }; const eventCmpUpdate = ComponentsEvents.update; !listenToEl.length && parentNode && listenToEl.push(parentNode as HTMLElement); - const trigger = (win: Window, body: HTMLBodyElement) => { + const trigger = (win: Window, body: HTMLBodyElement, canvasEl: HTMLElement) => { + methods[method](canvasEl, 'scroll', this.onCanvasScroll, true); methods[method](body, 'mouseover', this.onHover); methods[method](body, 'mouseleave', this.onOut); methods[method](body, 'click', this.onClick); @@ -101,7 +104,7 @@ export default { em.Canvas.getFrames().forEach((frame) => { const { view } = frame; const win = view?.getWindow(); - win && trigger(win, view?.getBody()!); + win && trigger(win, view?.getBody()!, canvasEl); }); }, @@ -589,6 +592,15 @@ export default { return this.canvas.getBadgeEl(opts.view); }, + /** + * On canvas scroll callback + * @private + */ + onCanvasScroll(e: any) { + this.onFrameScroll(e); + this.onContainerChange(); + }, + /** * On frame scroll callback * @private