diff --git a/docs/api/datasources.md b/docs/api/datasources.md index b044ad288..cfe8080c6 100644 --- a/docs/api/datasources.md +++ b/docs/api/datasources.md @@ -3,40 +3,62 @@ ## DataSources This module manages data sources within the editor. -You can initialize the module with the editor by passing an instance of `EditorModel`. +Once the editor is instantiated, you can use the following API to manage data sources: ```js -const editor = new EditorModel(); -const dsm = new DataSourceManager(editor); +const editor = grapesjs.init({ ... }); +const dsm = editor.DataSources; ``` -Once the editor is instantiated, you can use the following API to manage data sources: +## Available Events +* `data:add` Added new data source. -```js -const dsm = editor.DataSources; +```javascript +editor.on('data:add', (dataSource) => { ... }); ``` -* [add][1] - Add a new data source. -* [get][2] - Retrieve a data source by its ID. -* [getAll][3] - Retrieve all data sources. -* [remove][4] - Remove a data source by its ID. -* [clear][5] - Remove all data sources. +* `data:remove` Data source removed. -Example of adding a data source: +```javascript +editor.on('data:remove', (dataSource) => { ... }); +``` -```js -const ds = dsm.add({ - id: 'my_data_source_id', - records: [ - { id: 'id1', name: 'value1' }, - { id: 'id2', name: 'value2' } - ] +* `data:update` Data source updated. + +```javascript +editor.on('data:update', (dataSource, changes) => { ... }); +``` + +* `data:path` Data record path update. + +```javascript +editor.on('data:path:SOURCE_ID.RECORD_ID.PROP_NAME', ({ dataSource, dataRecord, path }) => { ... }); +editor.on('data:path', ({ dataSource, dataRecord, path }) => { + console.log('Path update in any data source') }); ``` -### Parameters +* `data:pathSource` Data record path update per source. + +```javascript +editor.on('data:pathSource:SOURCE_ID', ({ dataSource, dataRecord, path }) => { ... }); +``` + +* `data` Catch-all event for all the events mentioned above. + +```javascript +editor.on('data', ({ event, model, ... }) => { ... }); +``` + +## Methods + +* [add][1] - Add a new data source. +* [get][2] - Retrieve a data source by its ID. +* [getAll][3] - Retrieve all data sources. +* [remove][4] - Remove a data source by its ID. +* [clear][5] - Remove all data sources. -* `em` **EditorModel** Editor model. +[DataSource]: datasource.html ## add diff --git a/packages/core/package.json b/packages/core/package.json index 75ed9ee4e..b70192e03 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "grapesjs", "description": "Free and Open Source Web Builder Framework", - "version": "0.22.7", + "version": "0.22.8", "author": "Artur Arseniev", "license": "BSD-3-Clause", "homepage": "http://grapesjs.com", diff --git a/packages/core/src/abstract/Module.ts b/packages/core/src/abstract/Module.ts index 6533ebea1..8889ea433 100644 --- a/packages/core/src/abstract/Module.ts +++ b/packages/core/src/abstract/Module.ts @@ -44,7 +44,7 @@ export default abstract class Module impl collections: Collection[] = []; cls: any[] = []; state?: Model; - events: any; + events: object = {}; model?: any; view?: any; @@ -129,18 +129,19 @@ export abstract class ItemManagerModule< cls: any[] = []; all: TCollection; view?: View; + events!: Record; constructor( em: EditorModel, moduleName: string, all: any, - events?: any, + events?: Record, defaults?: TConf, opts: { skipListen?: boolean } = {}, ) { super(em, moduleName, defaults); this.all = all; - this.events = events; + if (events) this.events = events; !opts.skipListen && this.__initListen(); } diff --git a/packages/core/src/block_manager/index.ts b/packages/core/src/block_manager/index.ts index 8815bbbdd..a3f6f9cc3 100644 --- a/packages/core/src/block_manager/index.ts +++ b/packages/core/src/block_manager/index.ts @@ -72,11 +72,11 @@ export default class BlockManager extends ItemManagerModule blocksVisible.add(model)); blocks.on('remove', (model) => blocksVisible.remove(model)); blocks.on('reset', (coll) => blocksVisible.reset(coll.models)); + blocks.add(config.blocks || []); } /** diff --git a/packages/core/src/canvas/index.ts b/packages/core/src/canvas/index.ts index 9169d565c..f1d3f2486 100644 --- a/packages/core/src/canvas/index.ts +++ b/packages/core/src/canvas/index.ts @@ -617,6 +617,7 @@ export default class CanvasModule extends Module { const el = this.getCanvasView().el; this.autoScroller.start(el, el, { zoom: this.em.getZoomDecimal(), + ignoredElement: this.getSpotsEl(), }); } } diff --git a/packages/core/src/canvas/model/CanvasSpots.ts b/packages/core/src/canvas/model/CanvasSpots.ts index 165657988..e175570c0 100644 --- a/packages/core/src/canvas/model/CanvasSpots.ts +++ b/packages/core/src/canvas/model/CanvasSpots.ts @@ -17,7 +17,7 @@ export default class CanvasSpots extends ModuleCollection { const { em } = this; this.refreshDbn = debounce(() => this.refresh(), 0); - const evToRefreshDbn = `${ComponentsEvents.resize} styleable:change component:input ${ComponentsEvents.update} frame:updated undo redo`; + const evToRefreshDbn = `${ComponentsEvents.resize} styleable:change ${ComponentsEvents.input} ${ComponentsEvents.update} frame:updated undo redo`; this.listenTo(em, evToRefreshDbn, () => this.refreshDbn()); } diff --git a/packages/core/src/canvas/view/FrameView.ts b/packages/core/src/canvas/view/FrameView.ts index 123127dcf..d2b16ea4c 100644 --- a/packages/core/src/canvas/view/FrameView.ts +++ b/packages/core/src/canvas/view/FrameView.ts @@ -229,6 +229,7 @@ export default class FrameView extends ModuleView { this.autoScroller.start(this.el, this.getWindow(), { lastMaxHeight: this.getWrapper().offsetHeight - this.el.offsetHeight, zoom: this.em.getZoomDecimal(), + ignoredElement: this.em.Canvas.getSpotsEl(), }); } diff --git a/packages/core/src/code_manager/model/CssGenerator.ts b/packages/core/src/code_manager/model/CssGenerator.ts index 3bcfbc401..ee98afc47 100644 --- a/packages/core/src/code_manager/model/CssGenerator.ts +++ b/packages/core/src/code_manager/model/CssGenerator.ts @@ -172,7 +172,7 @@ export default class CssGenerator extends Model { }); if ((selectorStrNoAdd && found) || selectorsAdd || singleAtRule || !model) { - const block = rule.getDeclaration({ body: 1 }); + const block = rule.getDeclaration(); block && (opts.json ? (result = rule) : (result += block)); } else { dump.push(rule); diff --git a/packages/core/src/commands/view/SelectComponent.ts b/packages/core/src/commands/view/SelectComponent.ts index b97e645ec..8d79ddc7c 100644 --- a/packages/core/src/commands/view/SelectComponent.ts +++ b/packages/core/src/commands/view/SelectComponent.ts @@ -96,7 +96,7 @@ export default { methods[method](listenToEl, 'scroll', this.onContainerChange); em[method](`component:toggled ${eventCmpUpdate} undo redo`, this.onSelect, this); em[method]('change:componentHovered', this.onHovered, this); - em[method](`${ComponentsEvents.resize} styleable:change component:input`, this.updateGlobalPos, this); + em[method](`${ComponentsEvents.resize} styleable:change ${ComponentsEvents.input}`, this.updateGlobalPos, this); em[method](`${eventCmpUpdate}:toolbar`, this._upToolbar, this); em[method]('frame:updated', this.onFrameUpdated, this); em[method]('canvas:updateTools', this.onFrameUpdated, this); diff --git a/packages/core/src/css_composer/index.ts b/packages/core/src/css_composer/index.ts index 7851e7687..32b158179 100644 --- a/packages/core/src/css_composer/index.ts +++ b/packages/core/src/css_composer/index.ts @@ -40,6 +40,8 @@ import EditorModel from '../editor/model/Editor'; import Component from '../dom_components/model/Component'; import { ObjectAny, PrevToNewIdMap } from '../common'; import { UpdateStyleOptions } from '../domain_abstract/model/StyleableModel'; +import { CssEvents } from './types'; +import CssRuleView from './view/CssRuleView'; /** @private */ interface RuleOptions { @@ -72,8 +74,15 @@ export interface GetSetRuleOptions extends UpdateStyleOptions { type CssRuleStyle = Required['style']; export default class CssComposer extends ItemManagerModule { + classes = { + CssRule, + CssRules, + CssRuleView, + CssRulesView, + }; rules: CssRules; rulesView?: CssRulesView; + events = CssEvents; Selectors = Selectors; @@ -85,7 +94,7 @@ export default class CssComposer extends ItemManagerModule { * }); * cssRule.getDeclaration() // ".class1{color:red;}" */ - getDeclaration(opts: ObjectAny = {}) { + getDeclaration(opts: ToCssOptions = {}) { let result = ''; const { important } = this.attributes; const selectors = this.selectorsToString(opts); @@ -285,7 +292,7 @@ export default class CssRule extends StyleableModel { * }); * cssRule.toCSS() // "@media (min-width: 500px){.class1{color:red;}}" */ - toCSS(opts: ObjectAny = {}) { + toCSS(opts: ToCssOptions = {}) { let result = ''; const atRule = this.getAtRule(); const block = this.getDeclaration(opts); diff --git a/packages/core/src/css_composer/types.ts b/packages/core/src/css_composer/types.ts new file mode 100644 index 000000000..708950c53 --- /dev/null +++ b/packages/core/src/css_composer/types.ts @@ -0,0 +1,9 @@ +export enum CssEvents { + /** + * @event `css:mount` CSS rule is mounted in the canvas. + * @example + * editor.on('css:mount', ({ rule }) => { ... }); + */ + mount = 'css:mount', + mountBefore = 'css:mount:before', +} diff --git a/packages/core/src/css_composer/view/CssRuleView.ts b/packages/core/src/css_composer/view/CssRuleView.ts index 16dd8e6c9..903127437 100644 --- a/packages/core/src/css_composer/view/CssRuleView.ts +++ b/packages/core/src/css_composer/view/CssRuleView.ts @@ -1,6 +1,8 @@ import FrameView from '../../canvas/view/FrameView'; import { View } from '../../common'; +import EditorModel from '../../editor/model/Editor'; import CssRule from '../model/CssRule'; +import { CssEvents } from '../types'; export default class CssRuleView extends View { config: any; @@ -19,6 +21,10 @@ export default class CssRuleView extends View { return this.config.frameView; } + get em(): EditorModel { + return this.model.em!; + } + remove() { super.remove(); this.model.removeView(this); @@ -35,9 +41,13 @@ export default class CssRuleView extends View { } render() { - const { model, el } = this; + const { model, el, em } = this; const important = model.get('important'); - el.innerHTML = model.toCSS({ important }); + const css = model.toCSS({ important }); + const mountProps = { rule: model, ruleView: this, css }; + em?.trigger(CssEvents.mountBefore, mountProps); + el.innerHTML = mountProps.css; + em?.trigger(CssEvents.mount, mountProps); return this; } } diff --git a/packages/core/src/data_sources/index.ts b/packages/core/src/data_sources/index.ts index 11b5de277..63837cdff 100644 --- a/packages/core/src/data_sources/index.ts +++ b/packages/core/src/data_sources/index.ts @@ -1,38 +1,24 @@ /** * This module manages data sources within the editor. - * You can initialize the module with the editor by passing an instance of `EditorModel`. - * - * ```js - * const editor = new EditorModel(); - * const dsm = new DataSourceManager(editor); - * ``` - * * Once the editor is instantiated, you can use the following API to manage data sources: * * ```js + * const editor = grapesjs.init({ ... }); * const dsm = editor.DataSources; * ``` * + * {REPLACE_EVENTS} + * + * ## Methods * * [add](#add) - Add a new data source. * * [get](#get) - Retrieve a data source by its ID. * * [getAll](#getall) - Retrieve all data sources. * * [remove](#remove) - Remove a data source by its ID. * * [clear](#clear) - Remove all data sources. * - * Example of adding a data source: - * - * ```js - * const ds = dsm.add({ - * id: 'my_data_source_id', - * records: [ - * { id: 'id1', name: 'value1' }, - * { id: 'id2', name: 'value2' } - * ] - * }); - * ``` + * [DataSource]: datasource.html * * @module DataSources - * @param {EditorModel} em - Editor model. */ import { ItemManagerModule, ModuleConfig } from '../abstract/Module'; diff --git a/packages/core/src/data_sources/model/DataRecord.ts b/packages/core/src/data_sources/model/DataRecord.ts index 3c8c5fe93..d0ce687e3 100644 --- a/packages/core/src/data_sources/model/DataRecord.ts +++ b/packages/core/src/data_sources/model/DataRecord.ts @@ -63,9 +63,9 @@ export default class DataRecord ext * @private * @name handleChange */ - handleChange() { + handleChange(m: DataRecord, opts: SetOptions) { const changed = this.changedAttributes(); - keys(changed).forEach((prop) => this.triggerChange(prop)); + keys(changed).forEach((prop) => this.triggerChange(prop, opts)); } /** @@ -113,10 +113,12 @@ export default class DataRecord ext * @param {String} [prop] - Optional property name to trigger a change event for a specific property. * @name triggerChange */ - triggerChange(prop?: string) { + triggerChange(prop?: string, options: SetOptions = {}) { const { dataSource, em } = this; - const data = { dataSource, dataRecord: this }; const paths = this.getPaths(prop); + const data = { dataSource, dataRecord: this, path: paths[0], options }; + em.trigger(DataSourcesEvents.path, data); + em.trigger(`${DataSourcesEvents.pathSource}:${dataSource.id}`, data); paths.forEach((path) => em.trigger(`${DataSourcesEvents.path}:${path}`, { ...data, path })); } diff --git a/packages/core/src/data_sources/model/conditional_variables/operators/BooleanOperator.ts b/packages/core/src/data_sources/model/conditional_variables/operators/BooleanOperator.ts index 413495694..638fa484f 100644 --- a/packages/core/src/data_sources/model/conditional_variables/operators/BooleanOperator.ts +++ b/packages/core/src/data_sources/model/conditional_variables/operators/BooleanOperator.ts @@ -1,5 +1,3 @@ -import { enumToArray } from '../../../utils'; -import { DataConditionSimpleOperation } from '../types'; import { SimpleOperator } from './BaseOperator'; export enum BooleanOperation { diff --git a/packages/core/src/data_sources/model/conditional_variables/operators/NumberOperator.ts b/packages/core/src/data_sources/model/conditional_variables/operators/NumberOperator.ts index 04f4da498..4aa9838d4 100644 --- a/packages/core/src/data_sources/model/conditional_variables/operators/NumberOperator.ts +++ b/packages/core/src/data_sources/model/conditional_variables/operators/NumberOperator.ts @@ -1,4 +1,3 @@ -import { enumToArray } from '../../../utils'; import { SimpleOperator } from './BaseOperator'; export enum NumberOperation { diff --git a/packages/core/src/data_sources/types.ts b/packages/core/src/data_sources/types.ts index ad88c6409..4e7098d94 100644 --- a/packages/core/src/data_sources/types.ts +++ b/packages/core/src/data_sources/types.ts @@ -58,6 +58,28 @@ export interface DataSourceTransformers { onRecordSetValue?: (args: { id: string | number; key: string; value: any }) => any; } +type DotSeparatedKeys = T extends object + ? { + [K in keyof T]: K extends string + ? T[K] extends object + ? `${K}` | `${K}.${DotSeparatedKeys}` + : `${K}` + : never; + }[keyof T] + : never; + +export type DeepPartialDot = { + [P in DotSeparatedKeys]?: P extends `${infer K}.${infer Rest}` + ? K extends keyof T + ? Rest extends DotSeparatedKeys + ? DeepPartialDot[Rest] + : never + : never + : P extends keyof T + ? T[P] + : never; +}; + /**{START_EVENTS}*/ export enum DataSourcesEvents { /** @@ -86,10 +108,20 @@ export enum DataSourcesEvents { /** * @event `data:path` Data record path update. * @example - * editor.on('data:path:SOURCE_ID:RECORD_ID:PROP_NAME', ({ dataSource, dataRecord, path }) => { ... }); + * editor.on('data:path:SOURCE_ID.RECORD_ID.PROP_NAME', ({ dataSource, dataRecord, path }) => { ... }); + * editor.on('data:path', ({ dataSource, dataRecord, path }) => { + * console.log('Path update in any data source') + * }); */ path = 'data:path', + /** + * @event `data:pathSource` Data record path update per source. + * @example + * editor.on('data:pathSource:SOURCE_ID', ({ dataSource, dataRecord, path }) => { ... }); + */ + pathSource = 'data:pathSource:', + /** * @event `data` Catch-all event for all the events mentioned above. * @example @@ -98,24 +130,6 @@ export enum DataSourcesEvents { all = 'data', } /**{END_EVENTS}*/ -type DotSeparatedKeys = T extends object - ? { - [K in keyof T]: K extends string - ? T[K] extends object - ? `${K}` | `${K}.${DotSeparatedKeys}` - : `${K}` - : never; - }[keyof T] - : never; -export type DeepPartialDot = { - [P in DotSeparatedKeys]?: P extends `${infer K}.${infer Rest}` - ? K extends keyof T - ? Rest extends DotSeparatedKeys - ? DeepPartialDot[Rest] - : never - : never - : P extends keyof T - ? T[P] - : never; -}; +// need this to avoid the TS documentation generator to break +export default DataSourcesEvents; diff --git a/packages/core/src/dom_components/model/ComponentVideo.ts b/packages/core/src/dom_components/model/ComponentVideo.ts index 2100d83a9..1bd420723 100644 --- a/packages/core/src/dom_components/model/ComponentVideo.ts +++ b/packages/core/src/dom_components/model/ComponentVideo.ts @@ -26,7 +26,7 @@ export default class ComponentVideo extends ComponentImage { viUrl: 'https://player.vimeo.com/video/', loop: false, poster: '', - muted: 0, + muted: false, autoplay: false, controls: true, color: '', @@ -50,12 +50,13 @@ export default class ComponentVideo extends ComponentImage { updatePropsFromAttr() { if (this.get('provider') === defProvider) { - const { controls, autoplay, loop } = this.get('attributes')!; + const { controls, autoplay, loop, muted } = this.get('attributes')!; const toUp: ObjectAny = {}; if (isDef(controls)) toUp.controls = !!controls; if (isDef(autoplay)) toUp.autoplay = !!autoplay; if (isDef(loop)) toUp.loop = !!loop; + if (isDef(muted)) toUp.muted = !!muted; // Update for muted if (!isEmptyObj(toUp)) { this.set(toUp); @@ -111,6 +112,7 @@ export default class ComponentVideo extends ComponentImage { hasParam(qr.color) && this.set('color', qr.color); qr.rel === '0' && this.set('rel', 0); qr.modestbranding === '1' && this.set('modestbranding', 1); + qr.muted === '1' && this.set('muted', true); break; default: } @@ -157,6 +159,7 @@ export default class ComponentVideo extends ComponentImage { attr.loop = !!this.get('loop'); attr.autoplay = !!this.get('autoplay'); attr.controls = !!this.get('controls'); + attr.muted = !!this.get('muted'); } return attr; @@ -206,6 +209,7 @@ export default class ComponentVideo extends ComponentImage { this.getAutoplayTrait(), this.getLoopTrait(), this.getControlsTrait(), + this.getMutedTrait(), ]; } /** @@ -237,6 +241,7 @@ export default class ComponentVideo extends ComponentImage { name: 'modestbranding', changeProp: true, }, + this.getMutedTrait(), ]; } @@ -262,6 +267,7 @@ export default class ComponentVideo extends ComponentImage { }, this.getAutoplayTrait(), this.getLoopTrait(), + this.getMutedTrait(), ]; } @@ -307,6 +313,20 @@ export default class ComponentVideo extends ComponentImage { }; } + /** + * Return object trait + * @return {Object} + * @private + */ + getMutedTrait() { + return { + type: 'checkbox', + label: 'Muted', + name: 'muted', + changeProp: true, + }; + } + /** * Returns url to youtube video * @return {string} @@ -318,10 +338,9 @@ export default class ComponentVideo extends ComponentImage { const list = this.get('list'); url += id + (id.indexOf('?') < 0 ? '?' : ''); url += list ? `&list=${list}` : ''; - url += this.get('autoplay') ? '&autoplay=1&mute=1' : ''; + url += this.get('autoplay') ? '&autoplay=1' : ''; + url += this.get('muted') ? '&mute=1' : ''; url += !this.get('controls') ? '&controls=0&showinfo=0' : ''; - // Loop works only with playlist enabled - // https://stackoverflow.com/questions/25779966/youtube-iframe-loop-doesnt-work url += this.get('loop') ? `&loop=1&playlist=${id}` : ''; url += this.get('rel') ? '' : '&rel=0'; url += this.get('modestbranding') ? '&modestbranding=1' : ''; @@ -347,7 +366,8 @@ export default class ComponentVideo extends ComponentImage { getVimeoSrc() { let url = this.get('viUrl') as string; url += this.get('videoId') + '?'; - url += this.get('autoplay') ? '&autoplay=1&muted=1' : ''; + url += this.get('autoplay') ? '&autoplay=1' : ''; + url += this.get('muted') ? '&muted=1' : ''; url += this.get('loop') ? '&loop=1' : ''; url += !this.get('controls') ? '&title=0&portrait=0&badge=0' : ''; url += this.get('color') ? '&color=' + this.get('color') : ''; diff --git a/packages/core/src/dom_components/types.ts b/packages/core/src/dom_components/types.ts index 9e2bc1293..dc367099a 100644 --- a/packages/core/src/dom_components/types.ts +++ b/packages/core/src/dom_components/types.ts @@ -69,6 +69,13 @@ export enum ComponentsEvents { select = 'component:select', selectBefore = 'component:select:before', + /** + * @event `component:mount` Component is mounted in the canvas. + * @example + * editor.on('component:mount', (component) => { ... }); + */ + mount = 'component:mount', + /** * @event `component:script:mount` Component with script is mounted. * @example @@ -91,6 +98,13 @@ export enum ComponentsEvents { */ render = 'component:render', + /** + * @event `component:input` Event triggered on `input` DOM event. This is useful to catch direct input changes in the component (eg. Text component). + * @example + * editor.on('component:input', (component) => { ... }); + */ + input = 'component:input', + /** * @event `component:resize` Component resized. This event is triggered when the component is resized in the canvas. * @example diff --git a/packages/core/src/dom_components/view/ComponentTextView.ts b/packages/core/src/dom_components/view/ComponentTextView.ts index f1f1e5442..a5c301f0d 100644 --- a/packages/core/src/dom_components/view/ComponentTextView.ts +++ b/packages/core/src/dom_components/view/ComponentTextView.ts @@ -9,6 +9,7 @@ import { getComponentIds } from '../model/Components'; import ComponentText from '../model/ComponentText'; import { ComponentDefinition } from '../model/types'; import ComponentView from './ComponentView'; +import { ComponentsEvents } from '../types'; export default class ComponentTextView extends ComponentView { rte?: RichTextEditorModule; @@ -217,11 +218,9 @@ export default class ComponentTextView model.emitWithEditor(ev, model)); } /** diff --git a/packages/core/src/dom_components/view/ComponentVideoView.ts b/packages/core/src/dom_components/view/ComponentVideoView.ts index 9769d74fe..68f0063d2 100644 --- a/packages/core/src/dom_components/view/ComponentVideoView.ts +++ b/packages/core/src/dom_components/view/ComponentVideoView.ts @@ -18,7 +18,7 @@ export default class ComponentVideoView extends ComponentImageView `change:${p}`).join(' '); this.listenTo(model, 'change:provider', this.updateProvider); this.listenTo(model, 'change:src', this.updateSrc); @@ -80,6 +80,7 @@ export default class ComponentVideoView extends ComponentImageView extends Model= 0) { const start = str.indexOf('/*'); - const end = str.indexOf('*/') + 2; - str = str.replace(str.slice(start, end), ''); + const end = str.indexOf('*/'); + const endIndex = end > -1 ? end + 2 : undefined; + str = str.replace(str.slice(start, endIndex), ''); } const decls = str.split(';'); diff --git a/packages/core/src/utils/AutoScroller.ts b/packages/core/src/utils/AutoScroller.ts index 954c21346..455781a06 100644 --- a/packages/core/src/utils/AutoScroller.ts +++ b/packages/core/src/utils/AutoScroller.ts @@ -15,6 +15,7 @@ export default class AutoScroller { * are relative to the iframe's document, not the main window's. */ private rectIsInScrollIframe: boolean = false; + private ignoredElement?: HTMLElement; // If the mouse is over this element, don't autoscroll constructor( autoscrollLimit: number = 50, @@ -31,11 +32,20 @@ export default class AutoScroller { bindAll(this, 'start', 'autoscroll', 'updateClientY', 'stop'); } - start(eventEl: HTMLElement, scrollEl: HTMLElement | Window, opts?: { lastMaxHeight?: number; zoom?: number }) { + start( + eventEl: HTMLElement, + scrollEl: HTMLElement | Window, + opts?: { + lastMaxHeight?: number; + zoom?: number; + ignoredElement?: HTMLElement; + }, + ) { this.eventEl = eventEl; this.scrollEl = scrollEl; this.lastMaxHeight = opts?.lastMaxHeight || Number.POSITIVE_INFINITY; this.zoom = opts?.zoom || 1; + this.ignoredElement = opts?.ignoredElement; // By detaching those from the stack avoid browsers lags // Noticeable with "fast" drag of blocks @@ -47,24 +57,32 @@ export default class AutoScroller { 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?.(); - } + if (!this.dragging || !scrollEl) return; + if (this.lastClientY === undefined) { + setTimeout(() => { + requestAnimationFrame(this.autoscroll); + }, 50); + return; + } - requestAnimationFrame(this.autoscroll); + const clientY = this.lastClientY ?? 0; + const limitTop = this.autoscrollLimit; + const eventElHeight = this.getEventElHeight(); + const limitBottom = eventElHeight - limitTop; + let scrollAmount = 0; + + if (clientY < limitTop) scrollAmount += clientY - limitTop; + if (clientY > limitBottom) scrollAmount += clientY - limitBottom; + + const scrollTop = this.getElScrollTop(scrollEl); + scrollAmount = Math.min(scrollAmount, this.lastMaxHeight - scrollTop); + scrollAmount = Math.max(scrollAmount, -scrollTop); + if (scrollAmount !== 0) { + scrollEl.scrollBy({ top: scrollAmount, behavior: 'auto' }); + this.onScroll?.(); } + + requestAnimationFrame(this.autoscroll); } private getEventElHeight() { @@ -76,6 +94,12 @@ export default class AutoScroller { } private updateClientY(ev: Event) { + const target = ev.target as HTMLElement; + + if (this.ignoredElement && this.ignoredElement.contains(target)) { + return; + } + const scrollEl = this.scrollEl; ev.preventDefault(); @@ -99,5 +123,7 @@ export default class AutoScroller { stop() { this.toggleAutoscrollFx(false); + this.lastClientY = undefined; + this.ignoredElement = undefined; } } diff --git a/packages/core/src/utils/sorter/BaseComponentNode.ts b/packages/core/src/utils/sorter/BaseComponentNode.ts index 75ecad125..be26aed0d 100644 --- a/packages/core/src/utils/sorter/BaseComponentNode.ts +++ b/packages/core/src/utils/sorter/BaseComponentNode.ts @@ -152,6 +152,10 @@ export abstract class BaseComponentNode extends SortableTreeNode { return this.model.em.Components.canMove(this.model, source.model, this.getRealIndex(index)).result; } + equals(node?: BaseComponentNode): node is BaseComponentNode { + return !!node?._model && this._model.getId() === node._model.getId(); + } + /** * Abstract method to get the view associated with this component. * Subclasses must implement this method. diff --git a/packages/core/src/utils/sorter/CanvasNewComponentNode.ts b/packages/core/src/utils/sorter/CanvasNewComponentNode.ts index 68582e0dc..9ba5bcc1e 100644 --- a/packages/core/src/utils/sorter/CanvasNewComponentNode.ts +++ b/packages/core/src/utils/sorter/CanvasNewComponentNode.ts @@ -3,7 +3,6 @@ import CanvasComponentNode from './CanvasComponentNode'; import { getSymbolMain, getSymbolTop, isSymbol, isSymbolMain } from '../../dom_components/model/SymbolUtils'; import Component from '../../dom_components/model/Component'; import { ContentElement, ContentType } from './types'; -import { isComponent } from '../mixins'; type CanMoveSource = Component | ContentType; diff --git a/packages/core/src/utils/sorter/DropLocationDeterminer.ts b/packages/core/src/utils/sorter/DropLocationDeterminer.ts index 736573704..e41e60938 100644 --- a/packages/core/src/utils/sorter/DropLocationDeterminer.ts +++ b/packages/core/src/utils/sorter/DropLocationDeterminer.ts @@ -36,6 +36,8 @@ type lastMoveData = { hoveredNode?: NodeType; /** The index where the placeholder or dragged element should be inserted. */ index?: number; + /** The index under the mouse pointer during this move. */ + hoveredIndex?: number; /** Placement relative to the target ('before' or 'after'). */ placement?: Placement; /** The mouse event, used if we want to move placeholder with scrolling. */ @@ -113,19 +115,28 @@ export class DropLocationDeterminer> ext private handleMove(mouseEvent: MouseEvent): void { this.adjustForScroll(); - const { targetNode: lastTargetNode } = this.lastMoveData; this.eventHandlers.onMouseMove?.(mouseEvent); const { mouseXRelative: mouseX, mouseYRelative: mouseY } = this.getMousePositionRelativeToContainer( mouseEvent.clientX, mouseEvent.clientY, ); - const targetNode = this.getTargetNode(mouseEvent); + + const mouseTargetEl = this.getMouseTargetElement(mouseEvent); + const targetEl = this.getFirstElementWithAModel(mouseTargetEl); + const hoveredModel = targetEl ? $(targetEl)?.data('model') : undefined; + const hoveredNode = hoveredModel ? this.getOrCreateHoveredNode(hoveredModel) : undefined; + const hoveredIndex = hoveredNode + ? this.getIndexInParent(hoveredNode!, hoveredNode!.nodeDimensions!, mouseX, mouseY) + : 0; + const targetNode = hoveredNode ? this.getValidParent(hoveredNode, 0, mouseX, mouseY) : undefined; + const targetChanged = !targetNode?.equals(lastTargetNode); if (targetChanged) { this.eventHandlers.onTargetChange?.(lastTargetNode, targetNode); } - if (!targetNode) { + + if (!targetNode || !hoveredNode) { this.triggerLegacyOnMoveCallback(mouseEvent, 0); this.triggerMoveEvent(mouseX, mouseY); this.restLastMoveData(); @@ -144,10 +155,11 @@ export class DropLocationDeterminer> ext } this.lastMoveData = { - ...this.lastMoveData, targetNode, + hoveredNode, mouseEvent, index, + hoveredIndex, placement, placeholderDimensions, }; @@ -249,39 +261,6 @@ export class DropLocationDeterminer> ext }; } - /** - * Retrieves the target node based on the mouse event. - * Determines the element being hovered, its corresponding model, and - * calculates the valid parent node to use as the target node. - * - * @param mouseEvent - The mouse event containing the cursor position and target element. - * @returns The target node if a valid one is found, otherwise undefined. - */ - private getTargetNode(mouseEvent: MouseEvent): NodeType | undefined { - this.cacheContainerPosition(this.containerContext.container); - const { mouseXRelative, mouseYRelative } = this.getMousePositionRelativeToContainer( - mouseEvent.clientX, - mouseEvent.clientY, - ); - - // Get the element under the mouse - const mouseTargetEl = this.getMouseTargetElement(mouseEvent); - const targetEl = this.getFirstElementWithAModel(mouseTargetEl); - if (!targetEl) return; - const hoveredModel = $(targetEl)?.data('model'); - if (!hoveredModel) return; - - let hoveredNode = this.getOrCreateHoveredNode(hoveredModel); - - // Get the drop position index based on the mouse position - const { index } = this.getDropPosition(hoveredNode, mouseXRelative, mouseYRelative); - - // Determine the valid target node (or its valid parent) - let targetNode = this.getValidParent(hoveredNode, index, mouseXRelative, mouseYRelative); - - return this.getOrReuseTargetNode(targetNode); - } - /** * Creates a new hovered node or reuses the last hovered node if it is the same. * @@ -291,8 +270,11 @@ export class DropLocationDeterminer> ext private getOrCreateHoveredNode(hoveredModel: T): NodeType { const lastHoveredNode = this.lastMoveData.hoveredNode; const hoveredNode = new this.treeClass(hoveredModel); - const newHoveredNode = hoveredNode.equals(lastHoveredNode) ? lastHoveredNode : hoveredNode; - this.lastMoveData.hoveredNode = newHoveredNode; + const sameHoveredNode = hoveredNode.equals(lastHoveredNode); + const newHoveredNode = sameHoveredNode ? lastHoveredNode : hoveredNode; + newHoveredNode.nodeDimensions = sameHoveredNode + ? lastHoveredNode!.nodeDimensions! + : this.getDim(hoveredNode.element!); return newHoveredNode; } @@ -396,16 +378,23 @@ export class DropLocationDeterminer> ext private getValidParent(targetNode: NodeType, index: number, mouseX: number, mouseY: number): NodeType | undefined { if (!targetNode) return; - const lastTargetNode = this.lastMoveData.targetNode; - const targetNotChanged = targetNode.equals(lastTargetNode); - targetNode.nodeDimensions = targetNotChanged ? lastTargetNode.nodeDimensions! : this.getDim(targetNode.element!); + const { + targetNode: lastTargetNode, + hoveredNode: lastHoveredNode, + hoveredIndex: lastHoveredIndex, + } = this.lastMoveData; + + const sameHoveredNode = targetNode.equals(lastHoveredNode); + targetNode.nodeDimensions = sameHoveredNode ? lastHoveredNode!.nodeDimensions! : this.getDim(targetNode.element!); + const hoverIndex = this.getIndexInParent(targetNode, targetNode.nodeDimensions!, mouseX, mouseY); + const sameHoveredIndex = hoverIndex === lastHoveredIndex; + const sameHoverPosition = sameHoveredNode && sameHoveredIndex; + if (sameHoverPosition && lastTargetNode) return lastTargetNode; + if (!targetNode.isWithinDropBounds(mouseX, mouseY)) { return this.handleParentTraversal(targetNode, mouseX, mouseY); } - const positionNotChanged = targetNotChanged && index === this.lastMoveData.index; - if (positionNotChanged) return lastTargetNode; - const canMove = this.sourceNodes.some((node) => targetNode.canMove(node, index)); this.triggerDragValidation(canMove, targetNode); if (canMove) return targetNode; @@ -417,17 +406,16 @@ export class DropLocationDeterminer> ext const parent = targetNode.getParent() as NodeType; if (!parent) return; - const indexInParent = this.getIndexInParent(parent, targetNode, targetNode.nodeDimensions!, mouseX, mouseY); + const indexInParent = this.getIndexInParent(targetNode, targetNode.nodeDimensions!, mouseX, mouseY); + if (indexInParent === undefined) return; + return this.getValidParent(parent, indexInParent, mouseX, mouseY); } - private getIndexInParent( - parent: NodeType, - targetNode: NodeType, - nodeDimensions: Dimension, - mouseX: number, - mouseY: number, - ) { + private getIndexInParent(targetNode: NodeType, nodeDimensions: Dimension, mouseX: number, mouseY: number) { + const parent = targetNode.getParent() as NodeType; + if (!parent) return; + let indexInParent = parent?.indexOfChild(targetNode); nodeDimensions.dir = this.getDirection(targetNode.element!, parent.element!); diff --git a/packages/core/test/specs/css_composer/index.ts b/packages/core/test/specs/css_composer/index.ts index 1714a71d9..2728a5e61 100644 --- a/packages/core/test/specs/css_composer/index.ts +++ b/packages/core/test/specs/css_composer/index.ts @@ -162,7 +162,7 @@ describe('Css Composer', () => { const rule = obj.getIdRule(name)!; expect(rule.selectorsToString()).toEqual(`#${name}`); expect(rule.styleToString()).toEqual('color:red;'); - expect(rule.styleToString({ important: 1 })).toEqual('color:red !important;'); + expect(rule.styleToString({ important: true })).toEqual('color:red !important;'); expect(rule.styleToString({ important: ['color'] })).toEqual('color:red !important;'); }); diff --git a/packages/core/test/specs/parser/model/ParserHtml.ts b/packages/core/test/specs/parser/model/ParserHtml.ts index 0f0000e4e..95ffe32ab 100644 --- a/packages/core/test/specs/parser/model/ParserHtml.ts +++ b/packages/core/test/specs/parser/model/ParserHtml.ts @@ -84,11 +84,17 @@ describe('ParserHtml', () => { }); test('Parse style with comments', () => { - expect(obj.parseStyle('/* color #ffffff; */ width: 100px;')).toEqual({ + expect(obj.parseStyle('/* color #ffffff; */ width: 100px; /* height: 10px; */')).toEqual({ width: '100px', }); }); + test('Parse style with broken comments', () => { + expect(obj.parseStyle('/* color #ffffff; */ height: 50px; /* width: 10px; ')).toEqual({ + height: '50px', + }); + }); + test('Parse class string to array', () => { const str = 'test1 test2 test3 test-4'; const result = ['test1', 'test2', 'test3', 'test-4']; @@ -922,8 +928,8 @@ describe('ParserHtml', () => { test('converts data-gjs-data-resolver to dataResolver', () => { const str = ` -
`;