diff --git a/packages/core/src/canvas/index.ts b/packages/core/src/canvas/index.ts index 5c9f79f14..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; } @@ -418,11 +422,9 @@ 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 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; const defaultLeftOffset = elRect.width - targetWidth; @@ -432,7 +434,7 @@ export default class CanvasModule extends Module { const canvasLiftLimit = Math.max(-elRect.left + canvasScrollLeft, 0); left = Math.max(left, canvasLiftLimit); - const elementRightLimit = elementRight - targetWidth; + const elementRightLimit = elRect.width - targetWidth; left = Math.min(left, elementRightLimit); const canvasRightLimit = canvasRect.width + canvasScrollLeft - targetWidth - elRect.left; @@ -443,14 +445,15 @@ export default class CanvasModule extends Module { if (targetReachesCanvasTop) { const fullHeight = elRect.height + targetHeight; - const elementIsShorterThanFrame = fullHeight < frameOffset.height; + const elementIsShorterThanFrame = fullHeight < canvasRect.height; // Scroll with the window if the top edge is reached and the // element is bigger than the canvas if (elementIsShorterThanFrame) { top = top + fullHeight; } else { - top = -canvasOffset.top < elRect.height ? -canvasOffset.top : elRect.height; + const canvasRelativeTop = -canvasOffset.top + canvasScrollTop; + top = canvasRelativeTop < elRect.height ? canvasRelativeTop : elRect.height; } } @@ -607,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(), + }); + } } /** @@ -616,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/model/Canvas.ts b/packages/core/src/canvas/model/Canvas.ts index 6e67e8866..7c539f6de 100644 --- a/packages/core/src/canvas/model/Canvas.ts +++ b/packages/core/src/canvas/model/Canvas.ts @@ -4,6 +4,7 @@ import { Coordinates, CoordinatesTypes, DEFAULT_COORDS } from '../../common'; import { evUpdate as evDeviceUpdate } from '../../device_manager'; import Page from '../../pages/model/Page'; import PagesEvents from '../../pages/types'; +import Frame from './Frame'; import Frames from './Frames'; export default class Canvas extends ModuleModel { @@ -56,14 +57,14 @@ export default class Canvas extends ModuleModel { this.updateDevice({ frame: page.getMainFrame() }); } - updateDevice(opts: any = {}) { + updateDevice(opts: { frame?: Frame } = {}) { const { em } = this; const device = em.getDeviceModel(); - const model = opts.frame || em.getCurrentFrameModel(); + const frame = opts.frame || em.getCurrentFrameModel(); - if (model && device) { - const { width, height } = device.attributes; - model.set({ width, height }, { noUndo: 1 }); + if (frame && device) { + const { width, height, minHeight } = device.attributes; + frame.set({ width, height, minHeight }, { noUndo: 1 }); } } diff --git a/packages/core/src/canvas/view/CanvasView.ts b/packages/core/src/canvas/view/CanvasView.ts index a128025b3..dc0e0ce63 100644 --- a/packages/core/src/canvas/view/CanvasView.ts +++ b/packages/core/src/canvas/view/CanvasView.ts @@ -47,6 +47,7 @@ export interface FitViewportOptions { gap?: number | { x: number; y: number }; ignoreHeight?: boolean; el?: HTMLElement; + zoom?: number; } export default class CanvasView extends ModuleView { @@ -83,7 +84,7 @@ export default class CanvasView extends ModuleView { frames!: FramesView; frame?: FrameView; - private timerZoom?: number; + private timerZoom?: NodeJS.Timeout; private frmOff?: { top: number; left: number; width: number; height: number }; private cvsOff?: { top: number; left: number; width: number; height: number }; @@ -134,6 +135,7 @@ export default class CanvasView extends ModuleView { } remove(...args: any) { + clearTimeout(this.timerZoom); this.frames?.remove(); //@ts-ignore this.frames = undefined; @@ -241,12 +243,12 @@ export default class CanvasView extends ModuleView { this.clearOff(); toolsWrpEl.style.display = 'none'; em.trigger('canvas:update', ev); - this.timerZoom && clearTimeout(this.timerZoom); + clearTimeout(this.timerZoom); this.timerZoom = setTimeout(() => { em.stopDefault(defOpts); em.runDefault(defOpts); toolsWrpEl.style.display = ''; - }, 300) as any; + }, 300); } updateFramesArea() { @@ -269,6 +271,7 @@ export default class CanvasView extends ModuleView { fitViewport(opts: FitViewportOptions = {}) { const { em, module, model } = this; + this.clearOff(); const canvasRect = this.getCanvasOffset(); const { el } = opts; const elFrame = el && getComponentView(el)?.frameView; @@ -303,7 +306,7 @@ export default class CanvasView extends ModuleView { const zoomRatio = noHeight ? widthRatio : Math.min(widthRatio, heightRatio); const zoom = zoomRatio * 100; - module.setZoom(zoom); + module.setZoom(opts.zoom ?? zoom); // check for the frame witdh is necessary as we're centering the frame via CSS const coordX = -boxRect.x + (frame.width >= canvasWidth ? canvasWidth / 2 - boxWidth / 2 : -gapX); 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/canvas/view/FrameWrapView.ts b/packages/core/src/canvas/view/FrameWrapView.ts index 670a6b31b..e825e0121 100644 --- a/packages/core/src/canvas/view/FrameWrapView.ts +++ b/packages/core/src/canvas/view/FrameWrapView.ts @@ -76,6 +76,7 @@ export default class FrameWrapView extends ModuleView { } remove(opts?: any) { + this.sizeObserver?.disconnect(); this.__clear(opts); ModuleView.prototype.remove.apply(this, opts); //@ts-ignore @@ -161,26 +162,23 @@ export default class FrameWrapView extends ModuleView { const newWidth = isNumber(newW) ? `${newW}${un}` : newW; const newHeight = isNumber(newH) ? `${newH}${un}` : newH; style.width = newWidth; + this.sizeObserver?.disconnect(); if (model.hasAutoHeight()) { const iframe = this.frame.el; + const { contentDocument } = iframe; - if ( - iframe.contentDocument - // this doesn't work always - // && !this.sizeObserver - ) { - const { contentDocument } = iframe; + if (contentDocument) { const observer = new ResizeObserver(() => { - style.height = `${contentDocument.body.scrollHeight}px`; + const minHeight = parseFloat(model.get('minHeight')) || 0; + const heightResult = Math.max(contentDocument.body.scrollHeight, minHeight); + style.height = `${heightResult}px`; }); observer.observe(contentDocument.body); - this.sizeObserver?.disconnect(); this.sizeObserver = observer; } } else { style.height = newHeight; - this.sizeObserver?.disconnect(); delete this.sizeObserver; } diff --git a/packages/core/src/commands/view/SelectComponent.ts b/packages/core/src/commands/view/SelectComponent.ts index cfafd661c..0c4b29285 100644 --- a/packages/core/src/commands/view/SelectComponent.ts +++ b/packages/core/src/commands/view/SelectComponent.ts @@ -800,6 +800,7 @@ export default { this.stopSelectComponent(); !opts.preserveSelected && em.setSelected(); this.toggleToolsEl(); + this.updateAttached.cancel(); editor && editor.stopCommand('resize'); }, } as CommandObject; diff --git a/packages/core/src/common/index.ts b/packages/core/src/common/index.ts index 90d9ac5d9..3757c8ddb 100644 --- a/packages/core/src/common/index.ts +++ b/packages/core/src/common/index.ts @@ -24,7 +24,7 @@ export type UndoOptions = { fromUndo?: boolean }; export type WithHTMLParserOptions = { parserOptions?: HTMLParserOptions }; -export type RemoveOptions = Backbone.Silenceable & UndoOptions & { dangerously?: boolean }; +export type RemoveOptions = Backbone.Silenceable & UndoOptions & { dangerously?: boolean; temporary?: boolean }; export type EventHandler = Backbone.EventHandler; diff --git a/packages/core/src/device_manager/model/Device.ts b/packages/core/src/device_manager/model/Device.ts index 83fcabe37..81e64adcc 100644 --- a/packages/core/src/device_manager/model/Device.ts +++ b/packages/core/src/device_manager/model/Device.ts @@ -18,6 +18,11 @@ export interface DeviceProperties { * @example '600px' */ height?: string; + /** + * Min height to set for the editor iframe. + * @example '600px' + */ + minHeight?: string; /** * The width which will be used in media queries, if empty the `width` will be used. * @example '900px' diff --git a/packages/core/src/editor/model/Editor.ts b/packages/core/src/editor/model/Editor.ts index 1c9918a60..37e70767a 100644 --- a/packages/core/src/editor/model/Editor.ts +++ b/packages/core/src/editor/model/Editor.ts @@ -110,9 +110,9 @@ export default class EditorModel extends Model { device: '', }; } - Model = Model; Collection = Collection; + events = EditorEvents; __skip = false; defaultRunning = false; destroyed = false; @@ -532,10 +532,14 @@ export default class EditorModel extends Model { } } - // Hanlde multiple selection + // Handle multiple selection if (ctrlKey && mltSel) { return this.toggleSelected(model); } else if (shiftKey && mltSel) { + if (this.isEditing()) { + // Fixes #6345 where a shift click while editing text should not assume a selection of a component + return; + } this.clearSelection(this.Canvas.getWindow()); const coll = model.collection; const index = model.index(); @@ -907,10 +911,10 @@ export default class EditorModel extends Model { * @private */ runDefault(opts = {}) { - const command = this.get('Commands').get(this.config.defaultCommand); + const command = this.Commands.get(this.config.defaultCommand!); if (!command || this.defaultRunning) return; - command.stop(this, this, opts); - command.run(this, this, opts); + command.stop!(this as any, this, opts); + command.run!(this as any, this, opts); this.defaultRunning = true; } @@ -920,11 +924,11 @@ export default class EditorModel extends Model { * @private */ stopDefault(opts = {}) { - const commands = this.get('Commands'); + const commands = this.Commands; if (!commands) return; - const command = commands.get(this.config.defaultCommand); + const command = commands.get(this.config.defaultCommand!); if (!command || !this.defaultRunning) return; - command.stop(this, this, opts); + command.stop!(this as any, this, opts); this.defaultRunning = false; } diff --git a/packages/core/src/pages/index.ts b/packages/core/src/pages/index.ts index 6945ba073..ebb13495e 100644 --- a/packages/core/src/pages/index.ts +++ b/packages/core/src/pages/index.ts @@ -159,6 +159,41 @@ export default class PageManager extends ItemManagerModule= pages.length || sameIndex) return; + + this.remove(pg, { ...resOpts, temporary: true }); + pages.add(pg, { ...resOpts, at }); + + return pg; + } + /** * Get page by id * @param {String} id Page id diff --git a/packages/core/src/pages/model/Pages.ts b/packages/core/src/pages/model/Pages.ts index e58da7b1f..35733fa67 100644 --- a/packages/core/src/pages/model/Pages.ts +++ b/packages/core/src/pages/model/Pages.ts @@ -20,7 +20,7 @@ export default class Pages extends Collection { onRemove(removed?: Page, _p?: this, opts: RemoveOptions = {}) { // Avoid removing frames if triggered from undo #6142 - if (opts.fromUndo) return; + if (opts.fromUndo || opts.temporary) return; removed?.onRemove(); } } 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); + } +} diff --git a/packages/core/test/specs/editor/index.ts b/packages/core/test/specs/editor/index.ts index 6b85abe4b..0d0ae367d 100644 --- a/packages/core/test/specs/editor/index.ts +++ b/packages/core/test/specs/editor/index.ts @@ -107,4 +107,86 @@ describe('Editor', () => { expect(umStack.length).toBe(3); expect(keys(all).length).toBe(DEFAULT_CMPS); }); + + test('One component can be selected at a time without shift', () => { + const all = editor.Components.allById(); + const em = editor.em; + em.getConfig().multipleSelection = true; + const wrapper = editor.getWrapper()!; + const added = wrapper.append(` +
Component 1
+
Component 2
+ `); + em.setSelected(added[0]); + em.setSelected(added[1]); + expect(editor.getSelectedAll().length).toBe(1); + }); + + test('Shift key should allow selecting multiple components', () => { + const all = editor.Components.allById(); + const em = editor.em; + em.getConfig().multipleSelection = true; + const wrapper = editor.getWrapper()!; + const added = wrapper.append(` +
Component 1
+
Component 2
+ `); + + const callSelectedOptions = { + event: { + shiftKey: true, + }, + }; + + em.setSelected(added[0], callSelectedOptions); + em.setSelected(added[1], callSelectedOptions); + expect(editor.getSelectedAll().length).toBe(2); + }); + + test('Shift key selecting a component that is being edited should should be ignored', () => { + const all = editor.Components.allById(); + const em = editor.em; + em.getConfig().multipleSelection = true; + const wrapper = editor.getWrapper()!; + const added = wrapper.append(` +
Component 1
+
Component 2
+ `); + + const callSelectedOptions = { + event: { + shiftKey: true, + }, + }; + + const firstComponent = all[keys(all)[0]]; + firstComponent.em.setEditing(true); + em.setSelected(added[0], callSelectedOptions); + expect(editor.getSelectedAll().length).toBe(0); + }); + + test.skip('Shift key selecting a component that is being edited should not clear any text selections', () => { + const all = editor.Components.allById(); + const em = editor.em; + em.getConfig().multipleSelection = true; + const wrapper = editor.getWrapper()!; + const added = wrapper.append(` +
Component 1
+
Component 2
+ `); + + const callSelectedOptions = { + event: { + shiftKey: true, + }, + }; + + const firstComponent = all[keys(all)[0]]; + firstComponent.em.setEditing(true); + // TODO: highlight the text of the first component + + em.setSelected(added[0], callSelectedOptions); + // TODO: check if the text of the first component is still highlighted + expect(editor.getSelectedAll().length).toBe(0); + }); });