diff --git a/packages/core/src/canvas/model/Frame.ts b/packages/core/src/canvas/model/Frame.ts index bccbdf630..125039c67 100644 --- a/packages/core/src/canvas/model/Frame.ts +++ b/packages/core/src/canvas/model/Frame.ts @@ -2,7 +2,10 @@ import { forEach, isEmpty, isNumber, isString, keys, result } from 'underscore'; import CanvasModule from '..'; import { ModuleModel } from '../../abstract'; import { BoxRect, PrevToNewIdMap } from '../../common'; +import type Component from '../../dom_components/model/Component'; import ComponentWrapper from '../../dom_components/model/ComponentWrapper'; +import { ComponentDefinition } from '../../dom_components/model/types'; +import { ComponentsEvents } from '../../dom_components/types'; import Page from '../../pages/model/Page'; import { createId, isComponent, isObject } from '../../utils/mixins'; import FrameView from '../view/FrameView'; @@ -13,6 +16,23 @@ import CanvasEvents from '../types'; const keyAutoW = '__aw'; const keyAutoH = '__ah'; +export interface FrameProperties { + id?: string; + page?: Page; + component?: string | ComponentDefinition | ComponentDefinition[] | Component; + width?: string | number | null; + height?: string | number | null; + x?: number; + y?: number; + attributes?: Record; + head?: { tag: string; attributes: any }[]; + styles?: string | CssRuleJSON[]; + refFrame?: string | Frame | null; + refComponent?: string | Component | null; + skipFromStorage?: boolean; + [key: string]: unknown; +} + const getDimension = (frame: Frame, type: 'width' | 'height') => { const dim = frame.get(type); const viewDim = frame.view?.el[type === 'width' ? 'offsetWidth' : 'offsetHeight']; @@ -37,6 +57,7 @@ const getDimension = (frame: Frame, type: 'width' | 'height') => { * */ export default class Frame extends ModuleModel { + page?: Page; defaults() { return { x: 0, @@ -58,8 +79,11 @@ export default class Frame extends ModuleModel { /** * @hideconstructor */ - constructor(module: CanvasModule, attr: any) { + constructor(module: CanvasModule, attr: FrameProperties) { + const page = attr.page; + delete attr.page; super(module, attr); + this.page = page; const { em } = this; const { styles, component } = this.attributes; const domc = em.Components; @@ -71,8 +95,11 @@ export default class Frame extends ModuleModel { if (!isComponent(component)) { const wrp = isObject(component) ? component : { components: component }; !wrp.type && (wrp.type = 'wrapper'); - const Wrapper = domc.getType('wrapper')!.model; + const Wrapper = (domc.getType(wrp.type as string) || domc.getType('wrapper')!).model; this.set('component', new Wrapper(wrp, modOpts)); + } else { + this.updateComponentFrame(component); + this.emitComponentAdd(component); } if (!styles) { @@ -112,17 +139,38 @@ export default class Frame extends ModuleModel { return this.get('refFrame'); } + get refComponent(): Component | undefined { + return this.get('refComponent'); + } + + updateComponentFrame(component: Component) { + if (component.frame !== this) { + component.opt.frame = this; + } + + component.components().forEach((child) => this.updateComponentFrame(child)); + } + + emitComponentAdd(component: Component, opts: Record = {}) { + this.em.trigger(ComponentsEvents.add, component, opts); + component.components().forEach((child) => this.emitComponentAdd(child, opts)); + } + get root() { const { refFrame } = this; return refFrame?.getComponent() || this.getComponent(); } initRefs() { - const { refFrame } = this; + const { refFrame, refComponent, em } = this; if (isString(refFrame)) { const frame = this.module.framesById[refFrame]; frame && this.set({ refFrame: frame }, { silent: true }); } + if (isString(refComponent)) { + const component = em.Components.getById(refComponent); + component && this.set({ refComponent: component }, { silent: true }); + } } getBoxRect(): BoxRect { @@ -138,7 +186,7 @@ export default class Frame extends ModuleModel { } onRemove() { - !this.refFrame && this.getComponent().remove({ root: 1 }); + !this.refFrame && !this.refComponent && this.getComponent().remove({ root: 1 }); } changesUp(opt: any = {}) { @@ -224,7 +272,7 @@ export default class Frame extends ModuleModel { } getPage(): Page | undefined { - return (this.collection as unknown as Frames)?.page; + return this.page || (this.collection as unknown as Frames)?.page; } _emitUpdated(data = {}) { @@ -254,6 +302,8 @@ export default class Frame extends ModuleModel { const defaults = result(this, 'defaults'); if (opts.fromUndo) delete obj.component; + delete obj.skipFromStorage; + delete obj.refComponent; delete obj.styles; delete obj.changesCount; obj[keyAutoW] && delete obj.width; diff --git a/packages/core/src/canvas/model/Frames.ts b/packages/core/src/canvas/model/Frames.ts index c0ea2d99e..acd609f8d 100644 --- a/packages/core/src/canvas/model/Frames.ts +++ b/packages/core/src/canvas/model/Frames.ts @@ -36,6 +36,10 @@ export default class Frames extends ModuleCollection { this.forEach((frame) => frame.initRefs()); } + toJSON(opts?: Parameters[0]) { + return this.filter((frame) => !frame.get('skipFromStorage')).map((frame) => frame.toJSON(opts)); + } + itemLoaded() { this.loadedItems++; diff --git a/packages/core/src/canvas/view/FrameView.ts b/packages/core/src/canvas/view/FrameView.ts index 9542b347e..927ea56bb 100644 --- a/packages/core/src/canvas/view/FrameView.ts +++ b/packages/core/src/canvas/view/FrameView.ts @@ -1,6 +1,6 @@ import { bindAll, debounce, isFunction, isString } from 'underscore'; import { ModuleView } from '../../abstract'; -import { BoxRect, ObjectAny } from '../../common'; +import { BoxRect } from '../../common'; import CssRulesView from '../../css_composer/view/CssRulesView'; import { type as typeHead } from '../../dom_components/model/ComponentHead'; import ComponentView from '../../dom_components/view/ComponentView'; @@ -36,6 +36,7 @@ export default class FrameView extends ModuleView { private tools: { [key: string]: HTMLElement } = {}; private wrapper?: ComponentWrapperView; private headView?: ComponentView; + private refComponentView?: ComponentView; private frameWrapView?: FrameWrapView; constructor(model: Frame, view?: FrameWrapView) { @@ -145,8 +146,12 @@ export default class FrameView extends ModuleView { return this.getDoc().querySelector('body') as HTMLBodyElement; } + getRootType() { + return this.model.root.get('type') || 'wrapper'; + } + getWrapper() { - return this.getBody().querySelector('[data-gjs-type=wrapper]') as HTMLElement; + return (this.wrapper?.el || this.getBody().querySelector(`[data-gjs-type="${this.getRootType()}"]`)) as HTMLElement; } getJsContainer() { @@ -177,6 +182,19 @@ export default class FrameView extends ModuleView { return this._getTool('[data-offset]'); } + getComponentView(component: any) { + const { Components } = this.em; + const type = component.get('type') || 'default'; + const dt = Components.getTypes(); + const { view = ComponentView } = Components.getType(type) || Components.getType('default') || {}; + + if (!view.getEvents) { + view.getEvents = ComponentView.getEvents; + } + + return { dt, view }; + } + getRect() { if (!this.rect) { this.rect = this.el.getBoundingClientRect(); @@ -220,6 +238,8 @@ export default class FrameView extends ModuleView { remove(...args: any) { this._toggleEffects(false); this.tools = {}; + this.refComponentView?.remove(); + this.refComponentView = undefined; this.wrapper?.remove(); ModuleView.prototype.remove.apply(this, args); return this; @@ -350,6 +370,7 @@ export default class FrameView extends ModuleView { this.renderStyles({ prev: [] }); const colorWarn = '#ffca6f'; + const rootType = this.getRootType(); append( body, @@ -358,7 +379,7 @@ export default class FrameView extends ModuleView { ${hasAutoHeight ? 'body { overflow: hidden }' : ''} - [data-gjs-type="wrapper"] { + [data-gjs-type="${rootType}"] { ${!hasAutoHeight ? 'min-height: 100vh;' : ''} padding-top: 0.001em; } @@ -426,7 +447,7 @@ export default class FrameView extends ModuleView { `, ); const { root } = model; - const { view } = em?.Components?.getType('wrapper') || {}; + const { view } = this.getComponentView(root); if (!view) return; if (isFunction(config.customRenderer)) { @@ -497,10 +518,34 @@ export default class FrameView extends ModuleView { this.droppable = new Droppable(em, this.wrapper?.el); } + this.renderRefComponent(rootView); this.loaded = true; model.trigger('loaded'); } + renderRefComponent(rootView: ComponentView) { + const frame = this.model; + const refComponent = frame.refComponent; + if (!refComponent) return; + + const { dt, view: viewClass } = this.getComponentView(refComponent); + + const view = + refComponent.getView(frame) || + new viewClass({ + model: refComponent, + config: { + ...refComponent.config, + em: this.em, + frameView: this, + }, + componentTypes: dt, + }); + + this.refComponentView = view.render(); + rootView.getChildrenContainer().appendChild(view.el); + } + _toggleEffects(enable: boolean) { const method = enable ? on : off; const win = this.getWindow(); diff --git a/packages/core/src/commands/view/SelectComponent.ts b/packages/core/src/commands/view/SelectComponent.ts index 1eac58715..4894d9498 100644 --- a/packages/core/src/commands/view/SelectComponent.ts +++ b/packages/core/src/commands/view/SelectComponent.ts @@ -12,6 +12,14 @@ import CommandAbstract from './CommandAbstract'; let showOffsets: boolean; +export interface SelectComponentCommandOptions { + onClick?: (ev: Event) => boolean | void; +} + +export interface SelectComponentCommandStopOptions { + preserveSelected?: boolean; +} + export interface SelectComponentCommandRegistryRun { 'core:component-select': CommandPublicFnFromHandler; 'select-comp': CommandPublicFnFromHandler; @@ -41,9 +49,13 @@ export interface SelectComponentCommandRegistryStop { * so those elements are inside the Local Tools box * */ -export default class CommandSelectComponent extends CommandAbstract { +export default class CommandSelectComponent extends CommandAbstract< + SelectComponentCommandOptions, + SelectComponentCommandStopOptions +> { [key: string]: any; activeResizer = false; + opts: SelectComponentCommandOptions = {}; init() { this._upToolbar = debounce(() => { @@ -281,8 +293,16 @@ export default class CommandSelectComponent extends CommandAbstract { } onClick(ev: Event): void { - ev.stopPropagation(); - ev.preventDefault(); + const customHandler = this.opts.onClick; + + if (customHandler) { + const handlerResult = customHandler(ev); + if (handlerResult !== true) return; + } else { + ev.stopPropagation(); + ev.preventDefault(); + } + const { em } = this; if (em.get('_cmpDrag')) { @@ -597,15 +617,17 @@ export default class CommandSelectComponent extends CommandAbstract { return this.canvas.getWindow(); } - run(editor: any) { + run(ed: any, s?: any, opts: SelectComponentCommandOptions = {}) { if (!hasWin()) return; - this.editor = editor && editor.get('Editor'); + this.opts = opts; + this.editor = this.em.Editor; this.enable(); } - stop(ed?: any, sender?: any, opts: any = {}) { + stop(ed?: any, s?: any, opts: SelectComponentCommandStopOptions = {}) { if (!hasWin()) return; const { em, editor } = this; + this.opts = {}; this.onHovered(); this.stopSelectComponent(); !opts.preserveSelected && em.setSelected(); diff --git a/packages/core/src/dom_components/index.ts b/packages/core/src/dom_components/index.ts index 66127ce73..17495a090 100644 --- a/packages/core/src/dom_components/index.ts +++ b/packages/core/src/dom_components/index.ts @@ -610,6 +610,7 @@ export default class ComponentManager extends ItemManagerModule { * Returns component's classes as an array of strings * @return {Array} */ - getClasses() { + getClasses(): string[] { const attr = this.getAttributes(); const classStr = attr.class; return classStr ? classStr.split(' ') : []; @@ -1426,17 +1426,14 @@ export default class Component extends StyleableModel { * Override original clone method * @private * @ts-ignore */ - clone(opt: { symbol?: boolean; symbolInv?: boolean } = {}): this { + clone(opt: { symbol?: boolean; symbolInv?: boolean; frame?: Frame } = {}): this { const em = this.em; const attr = this.dataResolverWatchers.getProps(this.attributes); - const opts = { ...this.opt }; + const opts = { ...this.opt, frame: opt.frame ?? undefined }; const id = this.getId(); const cssc = em?.Css; - // @ts-ignore attr.components = []; - // @ts-ignore attr.classes = []; - // @ts-ignore attr.traits = []; if (isSymbolRoot(this)) { @@ -1448,11 +1445,9 @@ export default class Component extends StyleableModel { attr.components[i] = md.clone({ ...opt, _inner: 1 }); }); this.get('traits')!.each((md, i) => { - // @ts-ignore attr.traits[i] = md.clone(); }); this.get('classes')!.each((md, i) => { - // @ts-ignore attr.classes[i] = md.get('name'); }); @@ -1520,7 +1515,7 @@ export default class Component extends StyleableModel { * @param {Boolean} [opts.noCustom] Avoid custom name assigned to the component. * @returns {String} * */ - getName(opts: { noCustom?: boolean } = {}) { + getName(opts: { noCustom?: boolean } = {}): string { const { em } = this; const { type, tagName, name } = this.attributes; const defName = type || tagName; diff --git a/packages/core/src/dom_components/model/Components.ts b/packages/core/src/dom_components/model/Components.ts index ce45676f5..190d3c2bb 100644 --- a/packages/core/src/dom_components/model/Components.ts +++ b/packages/core/src/dom_components/model/Components.ts @@ -5,12 +5,14 @@ import { DomComponentsConfig } from '../config/config'; import EditorModel from '../../editor/model/Editor'; import ComponentManager from '..'; import CssRule from '../../css_composer/model/CssRule'; +import type Frame from '../../canvas/model/Frame'; import { ComponentAdd, ComponentAddType, ComponentDefinition, ComponentDefinitionDefined, + ComponentOptions, ComponentProperties, } from './types'; import ComponentText from './ComponentText'; @@ -113,10 +115,9 @@ const getComponentsFromDefs = ( }); }; -export interface ComponentsOptions { - em: EditorModel; - config?: DomComponentsConfig; +export interface ComponentsOptions extends Omit { domc?: ComponentManager; + config?: DomComponentsConfig; } interface AddComponentOptions extends AddOptions { @@ -402,10 +403,20 @@ Component> { return model; } + updateFrameRefs(model: Component, frame?: Frame) { + if (!frame || model.frame === frame) return; + model.opt.frame = frame; + model.components().forEach((child) => this.updateFrameRefs(child, frame)); + } + onAdd(model: Component, c?: any, opts: { temporary?: boolean } = {}) { const { domc, em } = this; const avoidInline = em.config.avoidInlineStyle; const allById = domc?.allById(); + const frame = this.parent?.frame || this.opt.frame; + const skipAddEvent = !!this.parent && !frame; + + this.updateFrameRefs(model, frame); allById?.[model.getId()] !== model && domc?.Component.ensureInList(model); if (!avoidInline && em.config.forceClass && !opts.temporary) { @@ -421,7 +432,7 @@ Component> { model.__postAdd({ recursive: true }); - if (em && !opts.temporary) { + if (em && !opts.temporary && !skipAddEvent) { const triggerAdd = (model: Component) => { em.trigger(ComponentsEvents.add, model, opts); model.components().forEach((comp) => triggerAdd(comp)); diff --git a/packages/core/src/editor/model/Editor.ts b/packages/core/src/editor/model/Editor.ts index 52226f9a5..2c6474e1a 100644 --- a/packages/core/src/editor/model/Editor.ts +++ b/packages/core/src/editor/model/Editor.ts @@ -977,10 +977,14 @@ export default class EditorModel extends Model { * @private */ runDefault(opts = {}) { - const command = this.Commands.get(this.config.defaultCommand!); + const { Commands, config } = this; + const defCmd = config.defaultCommand!; + const command = Commands.get(defCmd); + if (!command || this.defaultRunning) return; - command.stop!(this as any, this, opts); - command.run!(this as any, this, opts); + + Commands.stop(defCmd, opts); + Commands.run(defCmd, opts); this.defaultRunning = true; } @@ -990,11 +994,13 @@ export default class EditorModel extends Model { * @private */ stopDefault(opts = {}) { - const commands = this.Commands; - if (!commands) return; - const command = commands.get(this.config.defaultCommand!); + const { Commands, config } = this; + const defCmd = config.defaultCommand!; + const command = Commands?.get(defCmd); + if (!command || !this.defaultRunning) return; - command.stop!(this as any, this, opts); + + Commands.stop(defCmd, opts); this.defaultRunning = false; } diff --git a/packages/core/src/pages/index.ts b/packages/core/src/pages/index.ts index 0005063c6..9b7ef1e97 100644 --- a/packages/core/src/pages/index.ts +++ b/packages/core/src/pages/index.ts @@ -276,7 +276,7 @@ export default class PageManager extends ItemManagerModule !page.get('skipFromStorage'))); } load(data: any) { diff --git a/packages/core/src/pages/model/Page.ts b/packages/core/src/pages/model/Page.ts index 03d8f3d39..435026e51 100644 --- a/packages/core/src/pages/model/Page.ts +++ b/packages/core/src/pages/model/Page.ts @@ -1,11 +1,13 @@ import { forEach, result } from 'underscore'; import { PageManagerConfig } from '../types'; +import type { FrameProperties } from '../../canvas/model/Frame'; import Frames from '../../canvas/model/Frames'; import { Model } from '../../common'; import ComponentWrapper from '../../dom_components/model/ComponentWrapper'; import EditorModel from '../../editor/model/Editor'; import { CssRuleJSON } from '../../css_composer/model/CssRule'; import { ComponentDefinition } from '../../dom_components/model/types'; +import Frame from '../../canvas/model/Frame'; /** @private */ export interface PageProperties { @@ -29,10 +31,20 @@ export interface PageProperties { */ styles?: string | CssRuleJSON[]; + /** + * Frames to load with the page. + */ + frames?: FrameProperties[]; + + /** + * Skip page from project storage. + */ + skipFromStorage?: boolean; + [key: string]: unknown; } -export interface PagePropertiesDefined extends Pick { +export interface PagePropertiesDefined extends Pick { frames: Frames; [key: string]: unknown; } @@ -47,17 +59,20 @@ export default class Page extends Model { } em: EditorModel; - constructor(props: any, opts: { em?: EditorModel; config?: PageManagerConfig } = {}) { - super(props, opts); + constructor(props: PageProperties, opts: { em?: EditorModel; config?: PageManagerConfig } = {}) { + super(props as any, opts); const { em } = opts; - const defFrame: any = {}; + const defFrame: FrameProperties = {}; this.em = em!; if (!props.frames) { defFrame.component = props.component; defFrame.styles = props.styles; ['component', 'styles'].map((i) => this.unset(i)); } - const frms: any[] = props.frames || [defFrame]; + const frms: FrameProperties[] = props.frames || [defFrame]; + frms.forEach((frame) => { + frame.page = this; + }); const frames = new Frames(em!.Canvas, frms); frames.page = this; this.set('frames', frames); @@ -105,7 +120,7 @@ export default class Page extends Model { * @example * const arrayOfFrames = page.getAllFrames(); */ - getAllFrames() { + getAllFrames(): Frame[] { return this.getFrames().models || []; } @@ -115,7 +130,7 @@ export default class Page extends Model { * @example * const mainFrame = page.getMainFrame(); */ - getMainFrame() { + getMainFrame(): Frame { return this.getFrames().at(0); } @@ -135,6 +150,8 @@ export default class Page extends Model { const obj = Model.prototype.toJSON.call(this, opts); const defaults = result(this, 'defaults'); + delete obj.skipFromStorage; + // Remove private keys forEach(obj, (value, key) => { key.indexOf('_') === 0 && delete obj[key]; diff --git a/packages/core/test/specs/dom_components/model/Component.ts b/packages/core/test/specs/dom_components/model/Component.ts index 5f984efac..c09f2a62f 100644 --- a/packages/core/test/specs/dom_components/model/Component.ts +++ b/packages/core/test/specs/dom_components/model/Component.ts @@ -471,6 +471,19 @@ describe('Component', () => { expect(result[0].em).toEqual(em); }); + test('append() assigns the destination frame to added component trees', () => { + const wrapper = em.getWrapper()!; + const frame = em.Pages.getMain().getMainFrame(); + const added = wrapper.append({ + tagName: 'section', + components: [{ tagName: 'span', content: 'Child' }], + })[0]; + const child = added.components().at(0); + + expect(added.frame).toBe(frame); + expect(child?.frame).toBe(frame); + }); + test('components() set new collection', () => { obj.append([{}, {}]); obj.components('test'); diff --git a/packages/core/test/specs/pages/index.ts b/packages/core/test/specs/pages/index.ts index b2abb9a9a..d8ec48eb8 100644 --- a/packages/core/test/specs/pages/index.ts +++ b/packages/core/test/specs/pages/index.ts @@ -281,6 +281,141 @@ describe('Managing pages', () => { expect(rule2.getSelectorsString()).toBe(idSel2); expect(rule2.getStyle()).toEqual({ color: 'blue' }); }); + + test('Skip pages from project storage while keeping runtime models', () => { + const storedPage = pm.add({ + id: 'stored-page', + component: '
Stored page
', + })!; + const skippedPage = pm.add({ + id: 'skipped-page', + skipFromStorage: true, + frames: [ + { + id: 'skipped-page-frame', + component: '
Skipped page frame
', + }, + ], + })!; + const storedPage2 = pm.add({ + id: 'stored-page-2', + component: '
Stored page 2
', + })!; + + expect(pm.getAll().map((page) => page.getId())).toEqual([ + pm.getMain().getId(), + storedPage.getId(), + skippedPage.getId(), + storedPage2.getId(), + ]); + expect(skippedPage.getFrames().length).toBe(1); + + const storedPages = editor.getProjectData().pages; + expect(storedPages.map((page: any) => page.id)).toEqual([ + pm.getMain().getId(), + storedPage.getId(), + storedPage2.getId(), + ]); + expect(storedPages.find((page: any) => page.id === skippedPage.getId())).toBeUndefined(); + expect(storedPages.every((page: any) => !('skipFromStorage' in page))).toBe(true); + }); + + test('Skip frames from project storage while keeping runtime models', () => { + const page = pm.add({ + id: 'frames-page', + frames: [ + { + id: 'frame-1', + component: '
Frame 1
', + }, + { + id: 'frame-2', + component: '
Frame 2
', + skipFromStorage: true, + }, + { + id: 'frame-3', + component: '
Frame 3
', + }, + ], + })!; + + expect(page.getFrames().map((frame) => frame.id)).toEqual(['frame-1', 'frame-2', 'frame-3']); + + const storedPage = editor.getProjectData().pages.find((item: any) => item.id === page.getId()); + expect(storedPage.frames.map((frame: any) => frame.id)).toEqual(['frame-1', 'frame-3']); + expect(storedPage.frames.every((frame: any) => !('skipFromStorage' in frame))).toBe(true); + expect(storedPage.frames[0].component).toBeTruthy(); + expect(storedPage.frames[1].component).toBeTruthy(); + }); + + test('Load project data with skipped pages and frames but omit them on the next store', () => { + editor.loadProjectData({ + assets: [], + pages: [ + { + id: 'page-stored', + frames: [ + { + id: 'frame-stored', + component: '
Stored frame
', + }, + { + id: 'frame-skipped', + component: '
Skipped frame
', + skipFromStorage: true, + }, + ], + }, + { + id: 'page-skipped', + skipFromStorage: true, + frames: [ + { + id: 'frame-on-skipped-page', + component: '
Skipped page frame
', + }, + ], + }, + ], + styles: [], + } as any); + + expect(pm.getAll().map((page) => page.getId())).toEqual(['page-stored', 'page-skipped']); + expect( + pm + .get('page-stored') + ?.getFrames() + .map((frame) => frame.id), + ).toEqual(['frame-stored', 'frame-skipped']); + expect( + pm + .get('page-skipped') + ?.getFrames() + .map((frame) => frame.id), + ).toEqual(['frame-on-skipped-page']); + + const storedPages = editor.getProjectData().pages; + expect(storedPages.map((page: any) => page.id)).toEqual(['page-stored']); + expect(storedPages[0].frames.map((frame: any) => frame.id)).toEqual(['frame-stored']); + }); + + test('Do not leak skipFromStorage in direct page/frame serialization', () => { + const page = pm.add({ + id: 'serialized-page', + skipFromStorage: false, + frames: [ + { + id: 'serialized-frame', + component: '
Serialized frame
', + skipFromStorage: false, + }, + ], + })!; + + expect(page.toJSON()).not.toHaveProperty('skipFromStorage'); + expect(page.getMainFrame().toJSON()).not.toHaveProperty('skipFromStorage'); + }); }); describe('Pages in canvas', () => { @@ -349,4 +484,107 @@ describe('Pages in canvas', () => { await waitEditorEvent(em, CanvasEvents.frameLoadBody); expect(getPageContent()).toEqual('Page 2'); }); + + test('Page with refComponent renders the same model and keeps original ownership', async () => { + const mainPage = pm.getMain(); + const mainWrapper = mainPage.getMainComponent(); + const target = mainWrapper.append({ + attributes: { id: 'isolated-component' }, + content: 'Original content', + })[0]; + + const tempPage = pm.add( + { + id: 'temp-page', + frames: [{ refComponent: target }], + skipFromStorage: true, + }, + { select: true }, + )!; + + await waitEditorEvent(em, CanvasEvents.frameLoadBody); + expect(canvas.getBody().querySelector('#isolated-component')?.textContent).toBe('Original content'); + expect(target.parent()).toBe(mainWrapper); + + target.set('content', 'Updated content'); + expect(canvas.getBody().querySelector('#isolated-component')?.textContent).toBe('Updated content'); + + pm.select(mainPage); + await waitEditorEvent(em, CanvasEvents.frameLoadBody); + expect(canvas.getBody().querySelector('#isolated-component')?.textContent).toBe('Updated content'); + + pm.remove(tempPage); + expect(mainWrapper.components().models).toContain(target); + expect(target.parent()).toBe(mainWrapper); + }); + + test('Moving a component tree across page frames updates its frame reference', () => { + const mainFrame = pm.getMain().getMainFrame(); + const mainWrapper = mainFrame.getComponent(); + const page = pm.add({ + id: 'frame-target-page', + component: [], + })!; + const targetFrame = page.getMainFrame(); + const targetWrapper = targetFrame.getComponent(); + const target = mainWrapper.append({ + tagName: 'section', + components: [{ tagName: 'span', content: 'Inner child' }], + })[0]; + const child = target.components().at(0); + + expect(target.frame).toBe(mainFrame); + expect(child?.frame).toBe(mainFrame); + + targetWrapper.append(target); + + expect(target.frame).toBe(targetFrame); + expect(child?.frame).toBe(targetFrame); + expect(target.parent()).toBe(targetWrapper); + }); + + test('Page supports a custom wrapper type for frames', async () => { + editor.Components.addType('wrapper-component', { + extend: 'wrapper', + model: { + defaults: { customWrapperFlag: true }, + getCustomWrapperFlag() { + return this.get('customWrapperFlag'); + }, + }, + view: { + onRender() { + this.el.setAttribute('data-custom-wrapper', 'true'); + }, + }, + }); + + const mainWrapper = pm.getMain().getMainComponent(); + const target = mainWrapper.append({ + attributes: { id: 'custom-wrapper-target' }, + content: 'Custom wrapper target', + })[0]; + + const tempPage = pm.add( + { + id: 'temp-page-custom-wrapper', + skipFromStorage: true, + frames: [ + { + component: { type: 'wrapper-component' }, + refComponent: target, + }, + ], + }, + { select: true }, + )!; + + await waitEditorEvent(em, CanvasEvents.frameLoadBody); + + const tempWrapper = tempPage.getMainComponent() as any; + expect(tempWrapper.is('wrapper-component')).toBe(true); + expect(tempWrapper.getCustomWrapperFlag()).toBe(true); + expect(canvas.getBody().querySelector('[data-custom-wrapper="true"]')).toBeTruthy(); + expect(canvas.getBody().querySelector('#custom-wrapper-target')?.textContent).toBe('Custom wrapper target'); + }); });