diff --git a/src/canvas/types.ts b/src/canvas/types.ts index d1798e1481..7f9d316234 100644 --- a/src/canvas/types.ts +++ b/src/canvas/types.ts @@ -100,6 +100,36 @@ export enum CanvasEvents { * }); */ pointer = 'canvas:pointer', + + /** + * @event `canvas:frame:load` Frame loaded in canvas. + * The event is triggered right after iframe's `onload`. + * @example + * editor.on('canvas:frame:load', ({ window }) => { + * console.log('Frame loaded', window); + * }); + */ + frameLoad = 'canvas:frame:load', + + /** + * @event `canvas:frame:load:head` Frame head loaded in canvas. + * The event is triggered right after iframe's finished to load the head elemenets (eg. scripts) + * @example + * editor.on('canvas:frame:load:head', ({ window }) => { + * console.log('Frame head loaded', window); + * }); + */ + frameLoadHead = 'canvas:frame:load:head', + + /** + * @event `canvas:frame:load:body` Frame body loaded in canvas. + * The event is triggered when the body is rendered with components. + * @example + * editor.on('canvas:frame:load:body', ({ window }) => { + * console.log('Frame completed the body render', window); + * }); + */ + frameLoadBody = 'canvas:frame:load:body', } /**{END_EVENTS}*/ diff --git a/src/canvas/view/FrameView.ts b/src/canvas/view/FrameView.ts index 6c93319331..56a15f7245 100644 --- a/src/canvas/view/FrameView.ts +++ b/src/canvas/view/FrameView.ts @@ -1,6 +1,6 @@ import { bindAll, debounce, isString, isUndefined } from 'underscore'; import { ModuleView } from '../../abstract'; -import { BoxRect } from '../../common'; +import { BoxRect, ObjectAny } from '../../common'; import CssRulesView from '../../css_composer/view/CssRulesView'; import ComponentWrapperView from '../../dom_components/view/ComponentWrapperView'; import Droppable from '../../utils/Droppable'; @@ -18,6 +18,7 @@ import { hasDnd, setViewEl } from '../../utils/mixins'; import Canvas from '../model/Canvas'; import Frame from '../model/Frame'; import FrameWrapView from './FrameWrapView'; +import CanvasEvents from '../types'; export default class FrameView extends ModuleView { /** @ts-ignore */ @@ -290,14 +291,14 @@ export default class FrameView extends ModuleView { const { $el, ppfx, em } = this; $el.attr({ class: `${ppfx}frame` }); this.renderScripts(); - em.trigger('frame:render', this); + em.trigger('frame:render', this); // deprecated return this; } renderScripts() { const { el, model, em } = this; const evLoad = 'frame:load'; - const evOpts = { el, model, view: this }; + const evOpts: ObjectAny = { el, model, view: this }; const canvas = this.getCanvasModel(); const appendScript = (scripts: any[]) => { if (scripts.length > 0) { @@ -306,11 +307,18 @@ export default class FrameView extends ModuleView { type: 'text/javascript', ...(isString(src) ? { src } : src), }); - scriptEl.onerror = scriptEl.onload = appendScript.bind(null, scripts); el.contentDocument?.head.appendChild(scriptEl); + + if (scriptEl.hasAttribute('nomodule') && 'noModule' in HTMLScriptElement.prototype) { + appendScript(scripts); + } else { + scriptEl.onerror = scriptEl.onload = appendScript.bind(null, scripts); + } } else { + em?.trigger(CanvasEvents.frameLoadHead, evOpts); this.renderBody(); - em && em.trigger(evLoad, evOpts); + em?.trigger(CanvasEvents.frameLoadBody, evOpts); + em?.trigger(evLoad, evOpts); // deprecated } }; @@ -322,7 +330,9 @@ export default class FrameView extends ModuleView { doc.write(frameContent); doc.close(); } - em && em.trigger(`${evLoad}:before`, evOpts); + evOpts.window = this.getWindow(); + em?.trigger(`${evLoad}:before`, evOpts); // deprecated + em?.trigger(CanvasEvents.frameLoad, evOpts); appendScript([...canvas.get('scripts')]); }; } diff --git a/src/commands/index.ts b/src/commands/index.ts index 7db7adfdcc..58fbbda5c4 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -46,7 +46,7 @@ import { isFunction, includes } from 'underscore'; import CommandAbstract, { Command, CommandOptions, CommandObject, CommandFunction } from './view/CommandAbstract'; import defaults, { CommandsConfig } from './config/config'; import { Module } from '../abstract'; -import { eventDrag } from '../dom_components/model/Component'; +import Component, { eventDrag } from '../dom_components/model/Component'; import Editor from '../editor/model/Editor'; import { ObjectAny } from '../common'; @@ -79,6 +79,27 @@ const commandsDef = [ ['component-drag', 'ComponentDrag'], ]; +const defComOptions = { preserveSelected: 1 }; + +export const getOnComponentDragStart = (em: Editor) => (data: any) => em.trigger(`${eventDrag}:start`, data); + +export const getOnComponentDrag = (em: Editor) => (data: any) => em.trigger(eventDrag, data); + +export const getOnComponentDragEnd = + (em: Editor, targets: Component[], opts: { altMode?: boolean } = {}) => + (a: any, b: any, data: any) => { + targets.forEach(trg => trg.set('status', trg.get('selectable') ? 'selected' : '')); + em.setSelected(targets); + targets[0].emitUpdate(); + em.trigger(`${eventDrag}:end`, data); + + // Defer selectComponent in order to prevent canvas "freeze" #2692 + setTimeout(() => em.runDefault(defComOptions)); + + // Dirty patch to prevent parent selection on drop + (opts.altMode || data.cancelled) && em.set('_cmpDrag', 1); + }; + export default class CommandsModule extends Module { CommandAbstract = CommandAbstract; defaultCommands: Record = {}; @@ -118,54 +139,39 @@ export default class CommandsModule extends Module trg.delegate?.move?.(trg) || trg).filter(Boolean); + const target = targets[0] as Component | undefined; + const nativeDrag = event?.type === 'dragstart'; const modes = ['absolute', 'translate']; - if (!sel || !sel.get('draggable')) { + if (!target?.get('draggable')) { return em.logWarning('The element is not draggable'); } - const mode = sel.get('dmode') || em.get('dmode'); + const mode = target.get('dmode') || em.get('dmode'); const hideTlb = () => em.stopDefault(defComOptions); const altMode = includes(modes, mode); - selAll.forEach(sel => sel.trigger('disable')); + targets.forEach(trg => trg.trigger('disable')); // Without setTimeout the ghost image disappears nativeDrag ? setTimeout(hideTlb, 0) : hideTlb(); - const onStart = (data: any) => { - em.trigger(`${eventDrag}:start`, data); - }; - const onDrag = (data: any) => { - em.trigger(eventDrag, data); - }; - const onEnd = (e: any, opts: any, data: any) => { - selAll.forEach(sel => sel.set('status', 'selected')); - ed.select(selAll); - sel.emitUpdate(); - em.trigger(`${eventDrag}:end`, data); - - // Defer selectComponent in order to prevent canvas "freeze" #2692 - setTimeout(() => em.runDefault(defComOptions)); - - // Dirty patch to prevent parent selection on drop - (altMode || data.cancelled) && em.set('_cmpDrag', 1); - }; + const onStart = getOnComponentDragStart(em); + const onDrag = getOnComponentDrag(em); + const onEnd = getOnComponentDragEnd(em, targets, { altMode }); if (altMode) { // TODO move grabbing func in editor/canvas from the Sorter dragger = ed.runCommand('core:component-drag', { guidesInfo: 1, mode, - target: sel, + target, onStart, onDrag, onEnd, @@ -173,7 +179,7 @@ export default class CommandsModule extends Module sel.set('status', 'freezed-selected')); + targets.filter(sel => sel.get('selectable')).forEach(sel => sel.set('status', 'freezed-selected')); }, }; diff --git a/src/commands/view/ComponentDelete.ts b/src/commands/view/ComponentDelete.ts index 46233f73b0..a8d9cff6c5 100644 --- a/src/commands/view/ComponentDelete.ts +++ b/src/commands/view/ComponentDelete.ts @@ -15,7 +15,8 @@ const command: CommandObject<{ component?: Component }> = { component, }); } - component.remove(); + const cmp = component.delegate?.remove?.(component) || component; + cmp.remove(); }); ed.select(toSelect); diff --git a/src/commands/view/CopyComponent.ts b/src/commands/view/CopyComponent.ts index cfbf961f59..4cba918390 100644 --- a/src/commands/view/CopyComponent.ts +++ b/src/commands/view/CopyComponent.ts @@ -3,7 +3,7 @@ import { CommandObject } from './CommandAbstract'; export default { run(ed) { const em = ed.getModel(); - const models = [...ed.getSelectedAll()]; + const models = [...ed.getSelectedAll()].map(md => md.delegate?.copy?.(md) || md).filter(Boolean); models.length && em.set('clipboard', models); }, } as CommandObject; diff --git a/src/commands/view/DeleteComponent.ts b/src/commands/view/DeleteComponent.ts deleted file mode 100644 index 3497fa92b3..0000000000 --- a/src/commands/view/DeleteComponent.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { bindAll, extend } from 'underscore'; -import { $ } from '../../common'; -import Component from '../../dom_components/model/Component'; -import { CommandObject } from './CommandAbstract'; -import SelectComponent from './SelectComponent'; - -export default extend({}, SelectComponent, { - init() { - bindAll(this, 'startDelete', 'stopDelete', 'onDelete'); - this.hoverClass = this.pfx + 'hover-delete'; - this.badgeClass = this.pfx + 'badge-red'; - }, - - enable() { - var that = this; - this.$el.find('*').mouseover(this.startDelete).mouseout(this.stopDelete).click(this.onDelete); - }, - - /** - * Start command - * @param {Object} e - * @private - */ - startDelete(e: any) { - e.stopPropagation(); - var $this = $(e.target); - - // Show badge if possible - if ($this.data('model').get('removable')) { - $this.addClass(this.hoverClass); - this.attachBadge($this.get(0)); - } - }, - - /** - * Stop command - * @param {Object} e - * @private - */ - stopDelete(e: any) { - e.stopPropagation(); - var $this = $(e.target); - $this.removeClass(this.hoverClass); - - // Hide badge if possible - if (this.badge) this.badge.css({ left: -1000, top: -1000 }); - }, - - /** - * Delete command - * @param {Object} e - * @private - */ - onDelete(e: any) { - e.stopPropagation(); - var $this = $(e.target); - - // Do nothing in case can't remove - if (!$this.data('model').get('removable')) return; - - $this.data('model').destroy(); - this.removeBadge(); - this.clean(); - }, - - /** - * Updates badge label - * @param {Object} model - * @private - * */ - updateBadgeLabel(model: Component) { - this.badge.html('Remove ' + model.getName()); - }, -} as CommandObject<{}, { [k: string]: any }>); diff --git a/src/commands/view/PasteComponent.ts b/src/commands/view/PasteComponent.ts index 3f500d1dec..bb88e98c52 100644 --- a/src/commands/view/PasteComponent.ts +++ b/src/commands/view/PasteComponent.ts @@ -6,11 +6,12 @@ import Editor from '../../editor'; export default { run(ed, s, opts = {}) { const em = ed.getModel(); - const clp: Component[] = em.get('clipboard'); + const clp: Component[] | null = em.get('clipboard'); const lastSelected = ed.getSelected(); - if (clp && lastSelected) { - ed.getSelectedAll().forEach(selected => { + if (clp?.length && lastSelected) { + ed.getSelectedAll().forEach(sel => { + const selected = sel.delegate?.copy?.(sel) || sel; const { collection } = selected; let added; if (collection) { diff --git a/src/common/index.ts b/src/common/index.ts index a8780b8224..d44186854b 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -19,6 +19,8 @@ export type ObjectAny = Record; export type ObjectStrings = Record; +export type Nullable = undefined | null | false; + // https://github.com/microsoft/TypeScript/issues/29729#issuecomment-1483854699 export type LiteralUnion = T | (U & NOOP); diff --git a/src/dom_components/config/config.ts b/src/dom_components/config/config.ts index 56da8668ea..e184c03a29 100644 --- a/src/dom_components/config/config.ts +++ b/src/dom_components/config/config.ts @@ -53,6 +53,13 @@ export interface DomComponentsConfig { * https://www.w3.org/TR/2011/WD-html-markup-20110113/syntax.html#void-elements */ voidElements?: string[]; + + /** + * Experimental: Use the frame document for DOM element creation. + * This option might be useful when elements require the local document context to + * work properly (eg. Web Components). + */ + useFrameDoc?: boolean; } export default { @@ -61,6 +68,7 @@ export default { draggableComponents: true, disableTextInnerChilds: false, processor: undefined, + useFrameDoc: false, voidElements: [ 'area', 'base', diff --git a/src/dom_components/model/Component.ts b/src/dom_components/model/Component.ts index c2a43c9152..d5a482c158 100644 --- a/src/dom_components/model/Component.ts +++ b/src/dom_components/model/Component.ts @@ -110,6 +110,7 @@ export const keyUpdateInside = `${keyUpdate}-inside`; * Eg. `toolbar: [ { attributes: {class: 'fa fa-arrows'}, command: 'tlb-move' }, ... ]`. * By default, when `toolbar` property is falsy the editor will add automatically commands `core:component-exit` (select parent component, added if there is one), `tlb-move` (added if `draggable`) , `tlb-clone` (added if `copyable`), `tlb-delete` (added if `removable`). * @property {Collection} [components=null] Children components. Default: `null` + * @property {Object} [delegate=null] Delegate commands to other components. Available commands `remove` | `move` | `copy` | `select`. eg. `{ remove: (cmp) => cmp.closestType('other-type') }` * * @module docsjs.Component */ @@ -154,6 +155,7 @@ export default class Component extends StyleableModel { propagate: '', dmode: '', toolbar: null, + delegate: null, [keySymbol]: 0, [keySymbols]: 0, [keySymbolOvrd]: 0, @@ -182,6 +184,10 @@ export default class Component extends StyleableModel { return this.get('resizable')!; } + get delegate() { + return this.get('delegate'); + } + /** * Hook method, called once the model is created */ @@ -578,7 +584,7 @@ export default class Component extends StyleableModel { * component.removeAttributes('some-attr'); * component.removeAttributes(['some-attr1', 'some-attr2']); */ - removeAttributes(attrs: string[] = [], opts: SetOptions = {}) { + removeAttributes(attrs: string | string[] = [], opts: SetOptions = {}) { const attrArr = Array.isArray(attrs) ? attrs : [attrs]; const compAttr = this.getAttributes(); attrArr.map(i => delete compAttr[i]); @@ -1657,6 +1663,7 @@ export default class Component extends StyleableModel { delete obj.status; delete obj.open; // used in Layers delete obj._undoexc; + delete obj.delegate; if (!opts.fromUndo) { const symbol = obj[keySymbol]; diff --git a/src/dom_components/model/types.ts b/src/dom_components/model/types.ts index cb986ae534..26a9822c94 100644 --- a/src/dom_components/model/types.ts +++ b/src/dom_components/model/types.ts @@ -1,4 +1,5 @@ import Frame from '../../canvas/model/Frame'; +import { Nullable } from '../../common'; import EditorModel from '../../editor/model/Editor'; import Selectors from '../../selector_manager/model/Selectors'; import { TraitProperties } from '../../trait_manager/model/Trait'; @@ -13,6 +14,44 @@ export type DragMode = 'translate' | 'absolute' | ''; export type DraggableDroppableFn = (source: Component, target: Component, index?: number) => boolean | void; +/** + * Delegate commands to other components. + */ +export interface ComponentDelegateProps { + /** + * Delegate remove command to another component. + * @example + * delegate: { + * remove: (cmp) => cmp.closestType('other-type'), + * } + */ + remove?: (cmp: Component) => Component | Nullable; + /** + * Delegate move command to another component. + * @example + * delegate: { + * move: (cmp) => cmp.closestType('other-type'), + * } + */ + move?: (cmp: Component) => Component | Nullable; + /** + * Delegate copy command to another component. + * @example + * delegate: { + * copy: (cmp) => cmp.closestType('other-type'), + * } + */ + copy?: (cmp: Component) => Component | Nullable; + /** + * Delegate select command to another component. + * @example + * delegate: { + * select: (cmp) => cmp.findType('other-type')[0], + * } + */ + select?: (cmp: Component) => Component | Nullable; +} + export interface ComponentProperties { /** * Component type, eg. `text`, `image`, `video`, etc. @@ -167,6 +206,12 @@ export interface ComponentProperties { * By default, when `toolbar` property is falsy the editor will add automatically commands `core:component-exit` (select parent component, added if there is one), `tlb-move` (added if `draggable`) , `tlb-clone` (added if `copyable`), `tlb-delete` (added if `removable`). */ toolbar?: ToolbarButtonProps[]; + + /** + * Delegate commands to other components. + */ + delegate?: ComponentDelegateProps; + ///** // * Children components. Default: `null` // */ diff --git a/src/dom_components/view/ComponentView.ts b/src/dom_components/view/ComponentView.ts index e33ca31eae..f2b2cecb18 100644 --- a/src/dom_components/view/ComponentView.ts +++ b/src/dom_components/view/ComponentView.ts @@ -48,6 +48,10 @@ Component> { getTemplate?: Function; scriptContainer?: HTMLElement; + preinitialize(opt: any = {}) { + this.opts = opt; + } + initialize(opt: any = {}) { const model = this.model; const config = opt.config || {}; @@ -93,6 +97,11 @@ Component> { return this.opts.config.frameView; } + get createDoc() { + const doc = this.frameView?.getDoc() || document; + return this.opts.config?.useFrameDoc ? doc : document; + } + __isDraggable() { const { model, config } = this; const { draggable } = model.attributes; @@ -512,6 +521,10 @@ Component> { this.$el.data({ model, collection, view }); } + _createElement(tagName: string): Node { + return this.createDoc.createElement(tagName); + } + /** * Render children components * @private diff --git a/src/editor/model/Editor.ts b/src/editor/model/Editor.ts index fc56eae1dc..1955bc5db1 100644 --- a/src/editor/model/Editor.ts +++ b/src/editor/model/Editor.ts @@ -480,7 +480,7 @@ export default class EditorModel extends Model { const { event } = opts; const ctrlKey = event && (event.ctrlKey || event.metaKey); const { shiftKey } = event || {}; - const els = (isArray(el) ? el : [el]).map(el => getModel(el, $)); + const els = (isArray(el) ? el : [el]).map(el => getModel(el, $)).map(cmp => cmp?.delegate?.select?.(cmp) || cmp); const selected = this.getSelectedAll(); const mltSel = this.getConfig().multipleSelection; let added; @@ -501,7 +501,7 @@ export default class EditorModel extends Model { if (opts.useValid) { let parent = model.parent(); while (parent && !parent.get('selectable')) parent = parent.parent(); - model = parent; + model = parent!; } else { return; } diff --git a/src/navigator/view/ItemView.ts b/src/navigator/view/ItemView.ts index 9e6240e17c..625b58718b 100644 --- a/src/navigator/view/ItemView.ts +++ b/src/navigator/view/ItemView.ts @@ -1,12 +1,13 @@ import { bindAll, isString } from 'underscore'; import { View, ViewOptions } from '../../common'; -import Component, { eventDrag } from '../../dom_components/model/Component'; +import Component from '../../dom_components/model/Component'; import ComponentView from '../../dom_components/view/ComponentView'; import EditorModel from '../../editor/model/Editor'; import { isEnterKey, isEscKey } from '../../utils/dom'; import { getModel } from '../../utils/mixins'; import LayerManager from '../index'; import ItemsView from './ItemsView'; +import { getOnComponentDrag, getOnComponentDragEnd, getOnComponentDragStart } from '../../commands'; export type ItemViewProps = ViewOptions & { ItemView: ItemView; @@ -317,14 +318,17 @@ export default class ItemView extends View { * */ startSort(ev: MouseEvent) { ev.stopPropagation(); - const { em, sorter } = this; + const { em, sorter, model } = this; // Right or middel click if (ev.button && ev.button !== 0) return; if (sorter) { - sorter.onStart = (data: any) => em.trigger(`${eventDrag}:start`, data); - sorter.onMoveClb = (data: any) => em.trigger(eventDrag, data); - sorter.startSort(ev.target); + const toMove = model.delegate?.move?.(model) || model; + sorter.onStart = getOnComponentDragStart(em); + sorter.onMoveClb = getOnComponentDrag(em); + sorter.onEndMove = getOnComponentDragEnd(em, [toMove]); + const itemEl = (toMove as any).viewLayer?.el || ev.target; + sorter.startSort(itemEl); } } diff --git a/src/navigator/view/ItemsView.ts b/src/navigator/view/ItemsView.ts index fdca2cf4a5..a2ca13a413 100644 --- a/src/navigator/view/ItemsView.ts +++ b/src/navigator/view/ItemsView.ts @@ -1,5 +1,5 @@ import { View } from '../../common'; -import Component, { eventDrag } from '../../dom_components/model/Component'; +import Component from '../../dom_components/model/Component'; import EditorModel from '../../editor/model/Editor'; import ItemView from './ItemView'; @@ -33,11 +33,6 @@ export default class ItemsView extends View { containerSel: `.${this.className}`, itemSel: `.${pfx}layer`, ignoreViewChildren: 1, - onEndMove(created: any, sorter: any, data: any) { - const srcModel = sorter.getSourceModel(); - em.setSelected(srcModel, { forceChange: 1 }); - em.trigger(`${eventDrag}:end`, data); - }, avoidSelectOnEnd: 1, nested: 1, ppfx, diff --git a/src/selector_manager/model/Selector.ts b/src/selector_manager/model/Selector.ts index 4ce46c8786..a74c0e4e65 100644 --- a/src/selector_manager/model/Selector.ts +++ b/src/selector_manager/model/Selector.ts @@ -106,7 +106,7 @@ export default class Selector extends Model `my-selector` */ getName() { diff --git a/src/utils/Sorter.ts b/src/utils/Sorter.ts index 9b7833c878..8939f0994f 100644 --- a/src/utils/Sorter.ts +++ b/src/utils/Sorter.ts @@ -1160,10 +1160,6 @@ export default class Sorter extends View { if (src) { srcModel = this.getSourceModel(); - if (this.selectOnEnd && srcModel && srcModel.set) { - srcModel.set('status', ''); - srcModel.set('status', 'selected'); - } } if (this.moved && target) { diff --git a/src/utils/mixins.ts b/src/utils/mixins.ts index 5adf2e1fff..1f1e648201 100644 --- a/src/utils/mixins.ts +++ b/src/utils/mixins.ts @@ -187,7 +187,7 @@ export const deepMerge = (...args: ObjectAny[]) => { * @param {HTMLElement|Component} el Component or HTML element * @return {Component} */ -const getModel = (el: any, $?: any) => { +const getModel = (el: any, $?: any): Component => { let model = el; if (!$ && el && el.__cashData) { model = el.__cashData.model;