Browse Source

Introduce HTML string document import (#5895)

pull/5907/head
Artur Arseniev 2 years ago
committed by GitHub
parent
commit
13aa53c560
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 20
      src/abstract/ModuleCategories.ts
  2. 5
      src/block_manager/index.ts
  3. 7
      src/block_manager/types.ts
  4. 18
      src/canvas/view/FrameView.ts
  5. 11
      src/code_manager/model/HtmlGenerator.ts
  6. 7
      src/common/index.ts
  7. 12
      src/dom_components/index.ts
  8. 9
      src/dom_components/model/Component.ts
  9. 24
      src/dom_components/model/ComponentHead.ts
  10. 47
      src/dom_components/model/ComponentWrapper.ts
  11. 30
      src/dom_components/model/Components.ts
  12. 8
      src/dom_components/model/types.ts
  13. 18
      src/parser/config/config.ts
  14. 2
      src/parser/index.ts
  15. 2
      src/parser/model/BrowserParserHtml.ts
  16. 325
      src/parser/model/ParserHtml.ts
  17. 9
      src/trait_manager/model/Traits.ts
  18. 7
      src/trait_manager/types.ts
  19. 15
      src/utils/dom.ts
  20. 2
      test/common.ts
  21. 97
      test/specs/dom_components/index.ts
  22. 3
      test/specs/dom_components/model/Component.ts
  23. 22
      test/specs/editor/index.ts
  24. 7
      test/specs/pages/index.ts
  25. 68
      test/specs/parser/model/ParserHtml.ts

20
src/abstract/ModuleCategories.ts

@ -1,9 +1,29 @@
import { isArray, isString } from 'underscore'; import { isArray, isString } from 'underscore';
import { AddOptions, Collection } from '../common'; import { AddOptions, Collection } from '../common';
import { normalizeKey } from '../utils/mixins'; import { normalizeKey } from '../utils/mixins';
import EditorModel from '../editor/model/Editor';
import Category, { CategoryProperties } from './ModuleCategory'; import Category, { CategoryProperties } from './ModuleCategory';
type CategoryCollectionParams = ConstructorParameters<typeof Collection<Category>>;
interface CategoryOptions {
events?: { update?: string };
em?: EditorModel;
}
export default class Categories extends Collection<Category> { export default class Categories extends Collection<Category> {
constructor(models?: CategoryCollectionParams[0], opts: CategoryOptions = {}) {
super(models, opts);
const { events, em } = opts;
const evUpdate = events?.update;
if (em) {
evUpdate &&
this.on('change', (category, options) =>
em.trigger(evUpdate, { category, changes: category.changedAttributes(), options })
);
}
}
/** @ts-ignore */ /** @ts-ignore */
add(model: (CategoryProperties | Category)[] | CategoryProperties | Category, opts?: AddOptions) { add(model: (CategoryProperties | Category)[] | CategoryProperties | Category, opts?: AddOptions) {
const models = isArray(model) ? model : [model]; const models = isArray(model) ? model : [model];

5
src/block_manager/index.ts

@ -66,7 +66,10 @@ export default class BlockManager extends ItemManagerModule<BlockManagerConfig,
// Global blocks collection // Global blocks collection
this.blocks = this.all; this.blocks = this.all;
this.blocksVisible = new Blocks(this.blocks.models, { em }); this.blocksVisible = new Blocks(this.blocks.models, { em });
this.categories = new Categories(); this.categories = new Categories([], {
em,
events: { update: BlocksEvents.categoryUpdate },
});
// Setup the sync between the global and public collections // Setup the sync between the global and public collections
this.blocks.on('add', model => this.blocksVisible.add(model)); this.blocks.on('add', model => this.blocksVisible.add(model));

7
src/block_manager/types.ts

@ -54,6 +54,13 @@ export enum BlocksEvents {
*/ */
dragEnd = 'block:drag:stop', dragEnd = 'block:drag:stop',
/**
* @event `block:category:update` Block category updated.
* @example
* editor.on('block:category:update', ({ category, changes }) => { ... });
*/
categoryUpdate = 'block:category:update',
/** /**
* @event `block:custom` Event to use in case of [custom Block Manager UI](https://grapesjs.com/docs/modules/Blocks.html#customization). * @event `block:custom` Event to use in case of [custom Block Manager UI](https://grapesjs.com/docs/modules/Blocks.html#customization).
* @example * @example

18
src/canvas/view/FrameView.ts

@ -3,6 +3,8 @@ import { ModuleView } from '../../abstract';
import { BoxRect, ObjectAny } from '../../common'; import { BoxRect, ObjectAny } from '../../common';
import CssRulesView from '../../css_composer/view/CssRulesView'; import CssRulesView from '../../css_composer/view/CssRulesView';
import ComponentWrapperView from '../../dom_components/view/ComponentWrapperView'; 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 Droppable from '../../utils/Droppable';
import { import {
append, append,
@ -40,6 +42,7 @@ export default class FrameView extends ModuleView<Frame, HTMLIFrameElement> {
private jsContainer?: HTMLElement; private jsContainer?: HTMLElement;
private tools: { [key: string]: HTMLElement } = {}; private tools: { [key: string]: HTMLElement } = {};
private wrapper?: ComponentWrapperView; private wrapper?: ComponentWrapperView;
private headView?: ComponentView;
private frameWrapView?: FrameWrapView; private frameWrapView?: FrameWrapView;
constructor(model: Frame, view?: FrameWrapView) { constructor(model: Frame, view?: FrameWrapView) {
@ -333,6 +336,7 @@ export default class FrameView extends ModuleView<Frame, HTMLIFrameElement> {
evOpts.window = this.getWindow(); evOpts.window = this.getWindow();
em?.trigger(`${evLoad}:before`, evOpts); // deprecated em?.trigger(`${evLoad}:before`, evOpts); // deprecated
em?.trigger(CanvasEvents.frameLoad, evOpts); em?.trigger(CanvasEvents.frameLoad, evOpts);
this.renderHead();
appendScript([...canvas.get('scripts')]); appendScript([...canvas.get('scripts')]);
}; };
} }
@ -368,6 +372,20 @@ export default class FrameView extends ModuleView<Frame, HTMLIFrameElement> {
appendVNodes(head, toAdd); appendVNodes(head, toAdd);
} }
renderHead() {
const { model, em } = this;
const { root } = model;
const HeadView = em.Components.getType(typeHead)!.view;
this.headView = new HeadView({
el: this.getHead(),
model: root.head,
config: {
...root.config,
frameView: this,
},
}).render();
}
renderBody() { renderBody() {
const { config, em, model, ppfx } = this; const { config, em, model, ppfx } = this;
const doc = this.getDoc(); const doc = this.getDoc();

11
src/code_manager/model/HtmlGenerator.ts

@ -1,19 +1,14 @@
import { Model } from '../../common'; import { Model } from '../../common';
import Component from '../../dom_components/model/Component'; import Component from '../../dom_components/model/Component';
import { ToHTMLOptions } from '../../dom_components/model/types';
import EditorModel from '../../editor/model/Editor'; import EditorModel from '../../editor/model/Editor';
export type HTMLGeneratorBuildOptions = { export interface HTMLGeneratorBuildOptions extends ToHTMLOptions {
/** /**
* Remove unnecessary IDs (eg. those created automatically). * Remove unnecessary IDs (eg. those created automatically).
*/ */
cleanId?: boolean; cleanId?: boolean;
}
/**
* You can pass an object of custom attributes to replace with the current ones
* or you can even pass a function to generate attributes dynamically.
*/
attributes?: Record<string, any> | ((component: Component, attr: Record<string, any>) => Record<string, any>);
};
export default class HTMLGenerator extends Model { export default class HTMLGenerator extends Model {
build(model: Component, opts: HTMLGeneratorBuildOptions & { em?: EditorModel } = {}) { build(model: Component, opts: HTMLGeneratorBuildOptions & { em?: EditorModel } = {}) {

7
src/common/index.ts

@ -25,6 +25,13 @@ export type ObjectStrings = Record<string, string>;
export type Nullable = undefined | null | false; export type Nullable = undefined | null | false;
export interface OptionAsDocument {
/**
* Treat the HTML string as document (option valid on the root component, eg. will include doctype, html, head, etc.).
*/
asDocument?: boolean;
}
// https://github.com/microsoft/TypeScript/issues/29729#issuecomment-1483854699 // https://github.com/microsoft/TypeScript/issues/29729#issuecomment-1483854699
export type LiteralUnion<T, U> = T | (U & NOOP); export type LiteralUnion<T, U> = T | (U & NOOP);

12
src/dom_components/index.ts

@ -101,6 +101,7 @@ import ComponentVideoView from './view/ComponentVideoView';
import ComponentView, { IComponentView } from './view/ComponentView'; import ComponentView, { IComponentView } from './view/ComponentView';
import ComponentWrapperView from './view/ComponentWrapperView'; import ComponentWrapperView from './view/ComponentWrapperView';
import ComponentsView from './view/ComponentsView'; import ComponentsView from './view/ComponentsView';
import ComponentHead, { type as typeHead } from './model/ComponentHead';
export type ComponentEvent = export type ComponentEvent =
| 'component:create' | 'component:create'
@ -251,15 +252,20 @@ export default class ComponentManager extends ItemManagerModule<DomComponentsCon
view: ComponentTextNodeView, view: ComponentTextNodeView,
}, },
{ {
id: 'text', id: typeHead,
model: ComponentText, model: ComponentHead,
view: ComponentTextView, view: ComponentView,
}, },
{ {
id: 'wrapper', id: 'wrapper',
model: ComponentWrapper, model: ComponentWrapper,
view: ComponentWrapperView, view: ComponentWrapperView,
}, },
{
id: 'text',
model: ComponentText,
view: ComponentTextView,
},
{ {
id: 'default', id: 'default',
model: Component, model: Component,

9
src/dom_components/model/Component.ts

@ -20,6 +20,7 @@ import Selectors from '../../selector_manager/model/Selectors';
import Traits from '../../trait_manager/model/Traits'; import Traits from '../../trait_manager/model/Traits';
import EditorModel from '../../editor/model/Editor'; import EditorModel from '../../editor/model/Editor';
import { import {
AddComponentsOption,
ComponentAdd, ComponentAdd,
ComponentDefinition, ComponentDefinition,
ComponentDefinitionDefined, ComponentDefinitionDefined,
@ -164,6 +165,10 @@ export default class Component extends StyleableModel<ComponentProperties> {
}; };
} }
get tagName() {
return this.get('tagName')!;
}
get classes() { get classes() {
return this.get('classes')!; return this.get('classes')!;
} }
@ -1145,7 +1150,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
*/ */
components<T extends ComponentAdd | undefined>( components<T extends ComponentAdd | undefined>(
components?: T, components?: T,
opts: any = {} opts: AddComponentsOption = {}
): undefined extends T ? Components : Component[] { ): undefined extends T ? Components : Component[] {
const coll = this.get('components')!; const coll = this.get('components')!;
@ -2035,7 +2040,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
return result(this.prototype, 'defaults'); return result(this.prototype, 'defaults');
} }
static isComponent(el: HTMLElement): ComponentDefinitionDefined | boolean | undefined { static isComponent(el: HTMLElement, opts?: any): ComponentDefinitionDefined | boolean | undefined {
return { tagName: toLowerCase(el.tagName) }; return { tagName: toLowerCase(el.tagName) };
} }

24
src/dom_components/model/ComponentHead.ts

@ -0,0 +1,24 @@
import Component from './Component';
import { toLowerCase } from '../../utils/mixins';
import { DraggableDroppableFn } from './types';
export const type = 'head';
const droppable = ['title', 'style', 'base', 'link', 'meta', 'script', 'noscript'];
export default class ComponentHead extends Component {
get defaults() {
return {
// @ts-ignore
...super.defaults,
type,
tagName: type,
draggable: false,
highlightable: false,
droppable: (({ tagName }) => !tagName || droppable.includes(toLowerCase(tagName))) as DraggableDroppableFn,
};
}
static isComponent(el: HTMLElement) {
return toLowerCase(el.tagName) === type;
}
}

47
src/dom_components/model/ComponentWrapper.ts

@ -1,4 +1,8 @@
import { isUndefined } from 'underscore';
import { attrToString } from '../../utils/dom';
import Component from './Component'; import Component from './Component';
import ComponentHead, { type as typeHead } from './ComponentHead';
import { ToHTMLOptions } from './types';
export default class ComponentWrapper extends Component { export default class ComponentWrapper extends Component {
get defaults() { get defaults() {
@ -11,6 +15,9 @@ export default class ComponentWrapper extends Component {
draggable: false, draggable: false,
components: [], components: [],
traits: [], traits: [],
doctype: '',
head: null,
docEl: null,
stylable: [ stylable: [
'background', 'background',
'background-color', 'background-color',
@ -23,6 +30,46 @@ export default class ComponentWrapper extends Component {
}; };
} }
constructor(...args: ConstructorParameters<typeof Component>) {
super(...args);
const opts = args[1];
const cmp = opts?.em?.Components;
const CmpHead = cmp?.getType(typeHead)?.model;
const CmpDef = cmp?.getType('default').model;
if (CmpHead) {
this.set(
{
head: new CmpHead({}, opts),
docEl: new CmpDef({ tagName: 'html' }, opts),
},
{ silent: true }
);
}
}
get head(): ComponentHead {
return this.get('head');
}
get docEl(): Component {
return this.get('docEl');
}
get doctype(): string {
return this.attributes.doctype || '';
}
toHTML(opts: ToHTMLOptions = {}) {
const { doctype } = this;
const asDoc = !isUndefined(opts.asDocument) ? opts.asDocument : !!doctype;
const { head, docEl } = this;
const body = super.toHTML(opts);
const headStr = (asDoc && head?.toHTML(opts)) || '';
const docElAttr = (asDoc && attrToString(docEl?.getAttrToHTML())) || '';
const docElAttrStr = docElAttr ? ` ${docElAttr}` : '';
return asDoc ? `${doctype}<html${docElAttrStr}>${headStr}${body}</html>` : body;
}
__postAdd() { __postAdd() {
const um = this.em?.UndoManager; const um = this.em?.UndoManager;
!this.__hasUm && um?.add(this); !this.__hasUm && um?.add(this);

30
src/dom_components/model/Components.ts

@ -1,12 +1,13 @@
import { isEmpty, isArray, isString, isFunction, each, includes, extend, flatten, keys } from 'underscore'; import { isEmpty, isArray, isString, isFunction, each, includes, extend, flatten, keys } from 'underscore';
import Component from './Component'; import Component from './Component';
import { AddOptions, Collection, ObjectAny } from '../../common'; import { AddOptions, Collection, ObjectAny, OptionAsDocument } from '../../common';
import { DomComponentsConfig } from '../config/config'; import { DomComponentsConfig } from '../config/config';
import EditorModel from '../../editor/model/Editor'; import EditorModel from '../../editor/model/Editor';
import ComponentManager from '..'; import ComponentManager from '..';
import CssRule from '../../css_composer/model/CssRule'; import CssRule from '../../css_composer/model/CssRule';
import { ComponentAdd, ComponentProperties } from './types'; import { ComponentAdd, ComponentDefinitionDefined, ComponentProperties } from './types';
import ComponentText from './ComponentText'; import ComponentText from './ComponentText';
import ComponentWrapper from './ComponentWrapper';
export const getComponentIds = (cmp?: Component | Component[] | Components, res: string[] = []) => { export const getComponentIds = (cmp?: Component | Component[] | Components, res: string[] = []) => {
if (!cmp) return []; if (!cmp) return [];
@ -228,12 +229,27 @@ Component> {
return new model(attrs, options) as Component; return new model(attrs, options) as Component;
} }
parseString(value: string, opt: AddOptions & { temporary?: boolean; keepIds?: string[] } = {}) { parseString(value: string, opt: AddOptions & OptionAsDocument & { temporary?: boolean; keepIds?: string[] } = {}) {
const { em, domc } = this; const { em, domc, parent } = this;
const asDocument = opt.asDocument && parent?.is('wrapper');
const cssc = em.Css; const cssc = em.Css;
const parsed = em.Parser.parseHtml(value); const parsed = em.Parser.parseHtml(value, { asDocument });
let components = parsed.html;
if (asDocument) {
const root = parent as ComponentWrapper;
const { components: bodyCmps, ...restBody } = (parsed.html as ComponentDefinitionDefined) || {};
const { components: headCmps, ...restHead } = parsed.head || {};
components = bodyCmps!;
root.set(restBody as any, opt);
root.head.set(restHead as any, opt);
root.head.components(headCmps, opt);
root.docEl.set(parsed.root as any, opt);
root.set({ doctype: parsed.doctype });
}
// We need this to avoid duplicate IDs // We need this to avoid duplicate IDs
Component.checkId(parsed.html!, parsed.css, domc!.componentsById, opt); Component.checkId(components, parsed.css, domc!.componentsById, opt);
if (parsed.css && cssc && !opt.temporary) { if (parsed.css && cssc && !opt.temporary) {
const { at, ...optsToPass } = opt; const { at, ...optsToPass } = opt;
@ -243,7 +259,7 @@ Component> {
}); });
} }
return parsed.html; return components;
} }
/** @ts-ignore */ /** @ts-ignore */

8
src/dom_components/model/types.ts

@ -1,5 +1,5 @@
import Frame from '../../canvas/model/Frame'; import Frame from '../../canvas/model/Frame';
import { Nullable } from '../../common'; import { AddOptions, Nullable, OptionAsDocument } from '../../common';
import EditorModel from '../../editor/model/Editor'; import EditorModel from '../../editor/model/Editor';
import Selectors from '../../selector_manager/model/Selectors'; import Selectors from '../../selector_manager/model/Selectors';
import { TraitProperties } from '../../trait_manager/types'; import { TraitProperties } from '../../trait_manager/types';
@ -15,6 +15,8 @@ export type DragMode = 'translate' | 'absolute' | '';
export type DraggableDroppableFn = (source: Component, target: Component, index?: number) => boolean | void; export type DraggableDroppableFn = (source: Component, target: Component, index?: number) => boolean | void;
export interface AddComponentsOption extends AddOptions, OptionAsDocument {}
export interface ComponentStackItem { export interface ComponentStackItem {
id: string; id: string;
model: typeof Component; model: typeof Component;
@ -264,7 +266,7 @@ type ComponentAddType = Component | ComponentDefinition | ComponentDefinitionDef
export type ComponentAdd = ComponentAddType | ComponentAddType[]; export type ComponentAdd = ComponentAddType | ComponentAddType[];
export type ToHTMLOptions = { export interface ToHTMLOptions extends OptionAsDocument {
/** /**
* Custom tagName. * Custom tagName.
*/ */
@ -285,7 +287,7 @@ export type ToHTMLOptions = {
* or you can even pass a function to generate attributes dynamically. * or you can even pass a function to generate attributes dynamically.
*/ */
attributes?: Record<string, any> | ((component: Component, attr: Record<string, any>) => Record<string, any>); attributes?: Record<string, any> | ((component: Component, attr: Record<string, any>) => Record<string, any>);
}; }
export interface ComponentOptions { export interface ComponentOptions {
em?: EditorModel; em?: EditorModel;

18
src/parser/config/config.ts

@ -1,3 +1,6 @@
import { OptionAsDocument } from '../../common';
import { CssRuleJSON } from '../../css_composer/model/CssRule';
import { ComponentDefinitionDefined } from '../../dom_components/model/types';
import Editor from '../../editor'; import Editor from '../../editor';
export interface ParsedCssRule { export interface ParsedCssRule {
@ -11,7 +14,20 @@ export type CustomParserCss = (input: string, editor: Editor) => ParsedCssRule[]
export type CustomParserHtml = (input: string, options: HTMLParserOptions) => HTMLElement; export type CustomParserHtml = (input: string, options: HTMLParserOptions) => HTMLElement;
export interface HTMLParserOptions { export interface HTMLParseResult {
html: ComponentDefinitionDefined | ComponentDefinitionDefined[];
css?: CssRuleJSON[];
doctype?: string;
root?: ComponentDefinitionDefined;
head?: ComponentDefinitionDefined;
}
export interface ParseNodeOptions extends HTMLParserOptions {
inSvg?: boolean;
skipChildren?: boolean;
}
export interface HTMLParserOptions extends OptionAsDocument {
/** /**
* DOMParser mime type. * DOMParser mime type.
* If you use the `text/html` parser, it will fix the invalid syntax automatically. * If you use the `text/html` parser, it will fix the invalid syntax automatically.

2
src/parser/index.ts

@ -69,7 +69,7 @@ export default class ParserModule extends Module<ParserConfig & { name?: string
*/ */
parseHtml(input: string, options: HTMLParserOptions = {}) { parseHtml(input: string, options: HTMLParserOptions = {}) {
const { em, parserHtml } = this; const { em, parserHtml } = this;
parserHtml.compTypes = (em.Components.getTypes() || {}) as any; parserHtml.compTypes = em.Components.getTypes() || [];
return parserHtml.parse(input, this.parserCss, options); return parserHtml.parse(input, this.parserCss, options);
} }

2
src/parser/model/BrowserParserHtml.ts

@ -13,6 +13,8 @@ export default (str: string, config: HTMLParserOptions = {}) => {
let res: HTMLElement; let res: HTMLElement;
if (toHTML) { if (toHTML) {
if (config.asDocument) return doc;
// Replicate the old parser in order to avoid breaking changes // Replicate the old parser in order to avoid breaking changes
const { head, body } = doc; const { head, body } = doc;
// Move all scripts at the bottom of the page // Move all scripts at the bottom of the page

325
src/parser/model/ParserHtml.ts

@ -1,24 +1,17 @@
import { each, isArray, isFunction, isUndefined } from 'underscore'; import { each, isArray, isFunction, isUndefined } from 'underscore';
import { ObjectAny } from '../../common'; import { ObjectAny, ObjectStrings } from '../../common';
import { CssRuleJSON } from '../../css_composer/model/CssRule'; import { ComponentDefinitionDefined, ComponentStackItem } from '../../dom_components/model/types';
import { ComponentDefinitionDefined } from '../../dom_components/model/types';
import EditorModel from '../../editor/model/Editor'; import EditorModel from '../../editor/model/Editor';
import { HTMLParserOptions, ParserConfig } from '../config/config'; import { HTMLParseResult, HTMLParserOptions, ParseNodeOptions, ParserConfig } from '../config/config';
import BrowserParserHtml from './BrowserParserHtml'; import BrowserParserHtml from './BrowserParserHtml';
import { doctypeToString } from '../../utils/dom';
type StringObject = Record<string, string>;
type HTMLParseResult = {
html: ComponentDefinitionDefined | ComponentDefinitionDefined[]; // TODO replace with components
css?: CssRuleJSON[];
};
const modelAttrStart = 'data-gjs-'; const modelAttrStart = 'data-gjs-';
const event = 'parse:html'; const event = 'parse:html';
const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boolean } = {}) => { const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boolean } = {}) => {
return { return {
compTypes: '', compTypes: [] as ComponentStackItem[],
modelAttrStart, modelAttrStart,
@ -50,7 +43,7 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo
*/ */
splitPropsFromAttr(attr: ObjectAny = {}) { splitPropsFromAttr(attr: ObjectAny = {}) {
const props: ObjectAny = {}; const props: ObjectAny = {};
const attrs: StringObject = {}; const attrs: ObjectStrings = {};
each(attr, (value, key) => { each(attr, (value, key) => {
if (key.indexOf(this.modelAttrStart) === 0) { if (key.indexOf(this.modelAttrStart) === 0) {
@ -131,159 +124,174 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo
return result; return result;
}, },
/** parseNodeAttr(node: HTMLElement, result?: ComponentDefinitionDefined) {
* Get data from the node element const model = result || {};
* @param {HTMLElement} el DOM element to traverse const attrs = node.attributes || [];
* @return {Array<Object>} const attrsLen = attrs.length;
*/
parseNode(el: HTMLElement, opts: ObjectAny = {}) {
const result: ComponentDefinitionDefined[] = [];
const nodes = el.childNodes;
for (var i = 0, len = nodes.length; i < len; i++) { for (let i = 0; i < attrsLen; i++) {
const node = nodes[i] as HTMLElement; const nodeName = attrs[i].nodeName;
const attrs = node.attributes || []; let nodeValue: string | boolean = attrs[i].nodeValue!;
const attrsLen = attrs.length;
const nodePrev = result[result.length - 1];
const nodeChild = node.childNodes.length;
const ct = this.compTypes;
let model: ComponentDefinitionDefined = {}; // TODO use component properties
// Start with understanding what kind of component it is
if (ct) {
let obj: any = '';
let type = node.getAttribute && node.getAttribute(`${this.modelAttrStart}type`);
// If the type is already defined, use it
if (type) {
model = { type };
} else {
// Iterate over all available Component Types and
// the first with a valid result will be that component
for (let it = 0; it < ct.length; it++) {
const compType = ct[it];
// @ts-ignore
obj = compType.model.isComponent(node, opts);
if (obj) {
if (typeof obj !== 'object') {
// @ts-ignore
obj = { type: compType.id };
}
break;
}
}
model = obj; if (nodeName == 'style') {
model.style = this.parseStyle(nodeValue);
} else if (nodeName == 'class') {
model.classes = this.parseClass(nodeValue);
} else if (nodeName == 'contenteditable') {
continue;
} else if (nodeName.indexOf(this.modelAttrStart) === 0) {
const propsResult = this.getPropAttribute(nodeName, nodeValue);
model[propsResult.name] = propsResult.value;
} else {
// @ts-ignore Check for attributes from props (eg. required, disabled)
if (nodeValue === '' && node[nodeName] === true) {
nodeValue = true;
} }
}
// Set tag name if not yet done if (!model.attributes) {
if (!model.tagName) { model.attributes = {};
const tag = node.tagName || ''; }
const ns = node.namespaceURI || '';
model.tagName = tag && ns === 'http://www.w3.org/1999/xhtml' ? tag.toLowerCase() : tag;
}
if (attrsLen) { model.attributes[nodeName] = nodeValue;
model.attributes = {};
} }
}
// Parse attributes return model;
for (let j = 0; j < attrsLen; j++) { },
const nodeName = attrs[j].nodeName;
let nodeValue: string | boolean = attrs[j].nodeValue!; detectNode(node: HTMLElement, opts: ParseNodeOptions = {}) {
const { compTypes } = this;
// Isolate attributes let result: ComponentDefinitionDefined = {};
if (nodeName == 'style') {
model.style = this.parseStyle(nodeValue);
} else if (nodeName == 'class') {
model.classes = this.parseClass(nodeValue);
} else if (nodeName == 'contenteditable') {
continue;
} else if (nodeName.indexOf(this.modelAttrStart) === 0) {
const propsResult = this.getPropAttribute(nodeName, nodeValue);
model[propsResult.name] = propsResult.value;
} else {
// @ts-ignore Check for attributes from props (eg. required, disabled)
if (nodeValue === '' && node[nodeName] === true) {
nodeValue = true;
}
model.attributes[nodeName] = nodeValue; if (compTypes) {
let obj;
const type = node.getAttribute?.(`${this.modelAttrStart}type`);
// If the type is already defined, use it
if (type) {
result = { type };
} else {
// Find the component type
for (let i = 0; i < compTypes.length; i++) {
const compType = compTypes[i];
obj = compType.model.isComponent(node, opts);
if (obj) {
if (typeof obj !== 'object') {
obj = { type: compType.id };
}
break;
}
} }
result = obj as ComponentDefinitionDefined;
} }
}
// Check for nested elements but avoid it if already provided return result;
if (nodeChild && !model.components) { },
// Avoid infinite nested text nodes
const firstChild = node.childNodes[0]; parseNode(node: HTMLElement, opts: ParseNodeOptions = {}) {
const nodes = node.childNodes;
// If there is only one child and it's a TEXTNODE const nodesLen = nodes.length;
// just make it content of the current node let model = this.detectNode(node, opts);
if (nodeChild === 1 && firstChild.nodeType === 3) {
!model.type && (model.type = 'text'); if (!model.tagName) {
model.components = { const tag = node.tagName || '';
type: 'textnode', const ns = node.namespaceURI || '';
content: firstChild.nodeValue, model.tagName = tag && ns === 'http://www.w3.org/1999/xhtml' ? tag.toLowerCase() : tag;
}; }
} else {
model.components = this.parseNode(node, { model = this.parseNodeAttr(node, model);
...opts,
inSvg: opts.inSvg || model.type === 'svg', // Check for custom void elements (valid in XML)
}); if (!nodesLen && `${node.outerHTML}`.slice(-2) === '/>') {
} model.void = true;
}
// Check for nested elements but avoid it if already provided
if (nodesLen && !model.components && !opts.skipChildren) {
// Avoid infinite nested text nodes
const firstChild = nodes[0];
// If there is only one child and it's a TEXTNODE
// just make it content of the current node
if (nodesLen === 1 && firstChild.nodeType === 3) {
!model.type && (model.type = 'text');
model.components = {
type: 'textnode',
content: firstChild.nodeValue,
};
} else {
model.components = this.parseNodes(node, {
...opts,
inSvg: opts.inSvg || model.type === 'svg',
});
} }
}
// Check if it's a text node and if could be moved to the prevous model // If all children are texts and there is any textnode inside, the parent should
if (model.type == 'textnode') { // be text too otherwise it won't be possible to edit texnodes.
if (nodePrev && nodePrev.type == 'textnode') { const comps = model.components;
nodePrev.content += model.content; if (!model.type && comps?.length) {
continue; const { textTypes = [], textTags = [] } = config;
let allTxt = true;
let foundTextNode = false;
for (let i = 0; i < comps.length; i++) {
const comp = comps[i];
const cType = comp.type;
if (!textTypes.includes(cType) && !textTags.includes(comp.tagName)) {
allTxt = false;
break;
} }
// Throw away empty nodes (keep spaces) if (cType === 'textnode') {
if (!opts.keepEmptyTextNodes) { foundTextNode = true;
const content = node.nodeValue;
if (content != ' ' && !content!.trim()) {
continue;
}
} }
} }
// Check for custom void elements (valid in XML) if (allTxt && foundTextNode) {
if (!nodeChild && `${node.outerHTML}`.slice(-2) === '/>') { model.type = 'text';
model.void = true;
} }
}
// If all children are texts and there is some textnode the parent should return model;
// be text too otherwise I'm unable to edit texnodes },
const comps = model.components;
if (!model.type && comps) {
const { textTypes = [], textTags = [] } = config;
let allTxt = 1;
let foundTextNode = 0;
for (let ci = 0; ci < comps.length; ci++) { /**
const comp = comps[ci]; * Get data from the node element
const cType = comp.type; * @param {HTMLElement} el DOM element to traverse
* @return {Array<Object>}
*/
parseNodes(el: HTMLElement, opts: ParseNodeOptions = {}) {
const result: ComponentDefinitionDefined[] = [];
const nodes = el.childNodes;
const nodesLen = nodes.length;
if (!textTypes.includes(cType) && !textTags.includes(comp.tagName)) { for (let i = 0; i < nodesLen; i++) {
allTxt = 0; const node = nodes[i] as HTMLElement;
break; const nodePrev = result[result.length - 1];
} const model = this.parseNode(node, opts);
if (cType === 'textnode') { // Check if it's a text node and if it could be moved to the prevous one
foundTextNode = 1; if (model.type === 'textnode') {
} if (nodePrev?.type === 'textnode') {
nodePrev.content += model.content;
continue;
} }
if (allTxt && foundTextNode) { // Throw away empty nodes (keep spaces)
model.type = 'text'; if (!opts.keepEmptyTextNodes) {
const content = node.nodeValue;
if (content != ' ' && !content!.trim()) {
continue;
}
} }
} }
// If tagName is still empty and is not a textnode, do not push it // If the tagName is empty and it's not a textnode, skip it
if (!model.tagName && isUndefined(model.content)) { if (!model.tagName && isUndefined(model.content)) {
continue; continue;
} }
@ -303,17 +311,25 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo
parse(str: string, parserCss?: any, opts: HTMLParserOptions = {}) { parse(str: string, parserCss?: any, opts: HTMLParserOptions = {}) {
const conf = em?.get('Config') || {}; const conf = em?.get('Config') || {};
const res: HTMLParseResult = { html: [] }; const res: HTMLParseResult = { html: [] };
const cf: ObjectAny = { ...config, ...opts }; const cf = { ...config, ...opts };
const options = { const options = {
...config.optionsHtml, ...config.optionsHtml,
// @ts-ignore Support previous `configParser.htmlType` option // @ts-ignore Support previous `configParser.htmlType` option
htmlType: config.optionsHtml?.htmlType || config.htmlType, htmlType: config.optionsHtml?.htmlType || config.htmlType,
...opts, ...opts,
}; };
const { preParser } = options; const { preParser, asDocument } = options;
const input = isFunction(preParser) ? preParser(str, { editor: em?.getEditor()! }) : str; const input = isFunction(preParser) ? preParser(str, { editor: em?.getEditor()! }) : str;
const el = isFunction(cf.parserHtml) ? cf.parserHtml(input, options) : BrowserParserHtml(input, options); const parseRes = isFunction(cf.parserHtml) ? cf.parserHtml(input, options) : BrowserParserHtml(input, options);
const scripts = el.querySelectorAll('script'); let root = parseRes as HTMLElement;
const docEl = parseRes as Document;
if (asDocument) {
root = docEl.documentElement;
res.doctype = doctypeToString(docEl.doctype);
}
const scripts = root.querySelectorAll('script');
let i = scripts.length; let i = scripts.length;
// Support previous `configMain.allowScripts` option // Support previous `configMain.allowScripts` option
@ -321,32 +337,41 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo
// Remove script tags // Remove script tags
if (!allowScripts) { if (!allowScripts) {
while (i--) scripts[i].parentNode.removeChild(scripts[i]); while (i--) scripts[i].parentNode?.removeChild(scripts[i]);
} }
// Remove unsafe attributes // Remove unsafe attributes
if (!options.allowUnsafeAttr || !options.allowUnsafeAttrValue) { if (!options.allowUnsafeAttr || !options.allowUnsafeAttrValue) {
this.__sanitizeNode(el, options); this.__sanitizeNode(root, options);
} }
// Detach style tags and parse them // Detach style tags and parse them
if (parserCss) { if (parserCss) {
const styles = el.querySelectorAll('style'); const styles = root.querySelectorAll('style');
let j = styles.length; let j = styles.length;
let styleStr = ''; let styleStr = '';
while (j--) { while (j--) {
styleStr = styles[j].innerHTML + styleStr; styleStr = styles[j].innerHTML + styleStr;
styles[j].parentNode.removeChild(styles[j]); styles[j].parentNode?.removeChild(styles[j]);
} }
if (styleStr) res.css = parserCss.parse(styleStr); if (styleStr) res.css = parserCss.parse(styleStr);
} }
em?.trigger(`${event}:root`, { input, root: el }); em?.trigger(`${event}:root`, { input, root: root });
const result = this.parseNode(el, cf); let resHtml: HTMLParseResult['html'] = [];
// I have to keep it otherwise it breaks the DomComponents.addComponent (returns always array)
const resHtml = result.length === 1 && !cf.returnArray ? result[0] : result; if (asDocument) {
res.head = this.parseNode(docEl.head, cf);
res.root = this.parseNodeAttr(root);
resHtml = this.parseNode(docEl.body, cf);
} else {
const result = this.parseNodes(root, cf);
// I have to keep it otherwise it breaks the DomComponents.addComponent (returns always array)
resHtml = result.length === 1 && !cf.returnArray ? result[0] : result;
}
res.html = resHtml; res.html = resHtml;
em?.trigger(event, { input, output: res }); em?.trigger(event, { input, output: res });

9
src/trait_manager/model/Traits.ts

@ -5,7 +5,7 @@ import Categories from '../../abstract/ModuleCategories';
import { AddOptions } from '../../common'; import { AddOptions } from '../../common';
import Component from '../../dom_components/model/Component'; import Component from '../../dom_components/model/Component';
import EditorModel from '../../editor/model/Editor'; import EditorModel from '../../editor/model/Editor';
import { TraitProperties } from '../types'; import TraitsEvents, { TraitProperties } from '../types';
import Trait from './Trait'; import Trait from './Trait';
import TraitFactory from './TraitFactory'; import TraitFactory from './TraitFactory';
@ -17,7 +17,12 @@ export default class Traits extends CollectionWithCategories<Trait> {
constructor(coll: TraitProperties[], options: { em: EditorModel }) { constructor(coll: TraitProperties[], options: { em: EditorModel }) {
super(coll); super(coll);
this.em = options.em; const { em } = options;
this.em = em;
this.categories = new Categories([], {
em,
events: { update: TraitsEvents.categoryUpdate },
});
this.on('add', this.handleAdd); this.on('add', this.handleAdd);
this.on('reset', this.handleReset); this.on('reset', this.handleReset);
const tm = this.module; const tm = this.module;

7
src/trait_manager/types.ts

@ -188,6 +188,13 @@ export enum TraitsEvents {
*/ */
value = 'trait:value', value = 'trait:value',
/**
* @event `trait:category:update` Trait category updated.
* @example
* editor.on('trait:category:update', ({ category, changes }) => { ... });
*/
categoryUpdate = 'trait:category:update',
/** /**
* @event `trait:custom` Event to use in case of [custom Trait Manager UI](https://grapesjs.com/docs/modules/Traits.html#custom-trait-manager). * @event `trait:custom` Event to use in case of [custom Trait Manager UI](https://grapesjs.com/docs/modules/Traits.html#custom-trait-manager).
* @example * @example

15
src/utils/dom.ts

@ -205,6 +205,21 @@ export const hasCtrlKey = (ev: WheelEvent) => ev.ctrlKey;
export const hasModifierKey = (ev: WheelEvent) => hasCtrlKey(ev) || ev.metaKey; export const hasModifierKey = (ev: WheelEvent) => hasCtrlKey(ev) || ev.metaKey;
// Ref: https://stackoverflow.com/a/10162353
export const doctypeToString = (dt?: DocumentType | null) => {
if (!dt) return '';
const { name, publicId, systemId } = dt;
const pubId = publicId ? ` PUBLIC "${publicId}"` : '';
const sysId = !publicId && systemId ? ` SYSTEM "${systemId}"` : '';
return `<!DOCTYPE ${name}${pubId}${sysId}>`;
};
export const attrToString = (attrs: ObjectAny = {}) => {
const res: string[] = [];
each(attrs, (value, key) => res.push(`${key}="${value}"`));
return res.join(' ');
};
export const on = <E extends Event = Event>( export const on = <E extends Event = Event>(
el: EventTarget | EventTarget[], el: EventTarget | EventTarget[],
ev: string, ev: string,

2
test/common.ts

@ -0,0 +1,2 @@
// DocEl + Head + Wrapper
export const DEFAULT_CMPS = 3;

97
test/specs/dom_components/index.ts

@ -1,14 +1,14 @@
import DomComponents from '../../../src/dom_components';
import Components from '../../../src/dom_components/model/Components'; import Components from '../../../src/dom_components/model/Components';
import EditorModel from '../../../src/editor/model/Editor'; import EditorModel from '../../../src/editor/model/Editor';
import Editor from '../../../src/editor'; import Editor from '../../../src/editor';
import utils from './../../test_utils.js'; import utils from './../../test_utils.js';
import { Component } from '../../../src'; import { Component } from '../../../src';
import ComponentWrapper from '../../../src/dom_components/model/ComponentWrapper';
describe('DOM Components', () => { describe('DOM Components', () => {
describe('Main', () => { describe('Main', () => {
var em: EditorModel; var em: EditorModel;
var obj: DomComponents; var obj: EditorModel['Components'];
var config: any; var config: any;
var storagMock = utils.storageMock(); var storagMock = utils.storageMock();
var editorModel = { var editorModel = {
@ -63,10 +63,6 @@ describe('DOM Components', () => {
em.destroy(); em.destroy();
}); });
test('Object exists', () => {
expect(DomComponents).toBeTruthy();
});
test.skip('Store and load data', () => { test.skip('Store and load data', () => {
setSmConfig(); setSmConfig();
setEm(); setEm();
@ -147,7 +143,6 @@ describe('DOM Components', () => {
}); });
test('Add new component type with simple model', () => { test('Add new component type with simple model', () => {
obj = em.get('DomComponents');
const id = 'test-type'; const id = 'test-type';
const testProp = 'testValue'; const testProp = 'testValue';
const initialTypes = obj.componentTypes.length; const initialTypes = obj.componentTypes.length;
@ -166,7 +161,6 @@ describe('DOM Components', () => {
}); });
test('Add new component type with custom isComponent', () => { test('Add new component type with custom isComponent', () => {
obj = em.get('DomComponents');
const id = 'test-type'; const id = 'test-type';
const testProp = 'testValue'; const testProp = 'testValue';
obj.addType(id, { obj.addType(id, {
@ -182,7 +176,6 @@ describe('DOM Components', () => {
}); });
test('Extend component type with custom model and view', () => { test('Extend component type with custom model and view', () => {
obj = em.get('DomComponents');
const id = 'image'; const id = 'image';
const testProp = 'testValue'; const testProp = 'testValue';
const initialTypes = obj.getTypes().length; const initialTypes = obj.getTypes().length;
@ -207,7 +200,6 @@ describe('DOM Components', () => {
}); });
test('Add new component type by extending another one, without isComponent', () => { test('Add new component type by extending another one, without isComponent', () => {
obj = em.get('DomComponents');
const id = 'test-type'; const id = 'test-type';
const testProp = 'testValue'; const testProp = 'testValue';
obj.addType(id, { obj.addType(id, {
@ -228,7 +220,6 @@ describe('DOM Components', () => {
}); });
test('Add new component type by extending another one, with custom isComponent', () => { test('Add new component type by extending another one, with custom isComponent', () => {
obj = em.get('DomComponents');
const id = 'test-type'; const id = 'test-type';
const testProp = 'testValue'; const testProp = 'testValue';
obj.addType(id, { obj.addType(id, {
@ -272,7 +263,7 @@ describe('DOM Components', () => {
expect(rule.toCSS()).toEqual(css); expect(rule.toCSS()).toEqual(css);
done(); done();
}, 10); }, 20);
}); });
describe('Custom components with styles', () => { describe('Custom components with styles', () => {
@ -322,4 +313,86 @@ describe('DOM Components', () => {
}); });
}); });
}); });
describe('Rendered components', () => {
let editor: Editor;
let em: EditorModel;
let fxt: HTMLElement;
let root: ComponentWrapper;
beforeEach(done => {
fxt = document.createElement('div');
document.body.appendChild(fxt);
editor = new Editor({
el: fxt,
avoidInlineStyle: true,
storageManager: false,
});
em = editor.getModel();
fxt.appendChild(em.Canvas.render());
em.loadOnStart();
editor.on('change:ready', () => {
root = editor.Components.getWrapper()!;
done();
});
});
afterEach(() => {
editor.destroy();
});
describe('render components with asDocument', () => {
const docHtml = `
<!DOCTYPE html>
<html lang="en" class="cls-html" data-gjs-htmlp="true">
<head class="cls-head" data-gjs-headp="true">
<meta charset="utf-8">
<title>Test</title>
<link rel="stylesheet" href="/noop.css">
<!-- comment -->
</head>
<body class="cls-body" data-gjs-bodyp="true">
<h1>H1</h1>
</body>
</html>
`;
test('initial setup', () => {
expect(root.head.components().length).toBe(0);
expect(root.get('doctype')).toBe('');
});
test('import HTML document without option', () => {
root.components(docHtml);
expect(root.head.components().length).toBe(0);
expect(root.get('doctype')).toBe('');
});
test('import HTML document with asDocument', () => {
root.components(docHtml, { asDocument: true });
const { head, docEl } = root;
expect(head.components().length).toBe(4);
expect(head.get('headp')).toBe(true);
expect(docEl.get('htmlp')).toBe(true);
expect(root.get('bodyp')).toBe(true);
expect(root.doctype).toBe('<!DOCTYPE html>');
const outputHtml = `
<!DOCTYPE html>
<html lang="en" class="cls-html">
<head class="cls-head">
<meta charset="utf-8"/>
<title>Test</title>
<link rel="stylesheet" href="/noop.css"/>
<!-- comment -->
</head>
<body class="cls-body">
<h1>H1</h1>
</body>
</html>
`.replace(/>\s+|\s+</g, m => m.trim());
expect(root.toHTML()).toBe(outputHtml);
});
});
});
}); });

3
test/specs/dom_components/model/Component.ts

@ -703,7 +703,8 @@ describe('Components', () => {
const added = dcomp.addComponent(block) as Component; const added = dcomp.addComponent(block) as Component;
const addComps = added.components(); const addComps = added.components();
// Let's check if everthing is working as expected // Let's check if everthing is working as expected
expect(Object.keys(dcomp.componentsById).length).toBe(3); // + 1 wrapper // 2 test components + 1 wrapper + 1 head + 1 docEl
expect(Object.keys(dcomp.componentsById).length).toBe(5);
expect(added.getId()).toBe(id); expect(added.getId()).toBe(id);
expect(addComps.at(0).getId()).toBe(idB); expect(addComps.at(0).getId()).toBe(idB);
const cc = em.get('CssComposer'); const cc = em.get('CssComposer');

22
test/specs/editor/index.ts

@ -1,7 +1,7 @@
import Editor from '../../../src/editor'; import Editor from '../../../src/editor';
import { DEFAULT_CMPS } from '../../common';
const { keys } = Object; const { keys } = Object;
const initComps = 1;
describe('Editor', () => { describe('Editor', () => {
let editor: Editor; let editor: Editor;
@ -24,7 +24,7 @@ describe('Editor', () => {
const all = editor.Components.allById(); const all = editor.Components.allById();
const allKeys = keys(all); const allKeys = keys(all);
// By default 1 wrapper components is created // By default 1 wrapper components is created
expect(allKeys.length).toBe(initComps); expect(allKeys.length).toBe(DEFAULT_CMPS);
expect(all[allKeys[0]].get('type')).toBe('wrapper'); expect(all[allKeys[0]].get('type')).toBe('wrapper');
}); });
@ -50,7 +50,7 @@ describe('Editor', () => {
const all = editor.Components.allById(); const all = editor.Components.allById();
const wrapper = editor.getWrapper()!; const wrapper = editor.getWrapper()!;
wrapper.append('<div>Component</div>'); // Div component + textnode wrapper.append('<div>Component</div>'); // Div component + textnode
expect(keys(all).length).toBe(2 + initComps); expect(keys(all).length).toBe(2 + DEFAULT_CMPS);
}); });
test('Components are correctly tracked on add and remove', () => { test('Components are correctly tracked on add and remove', () => {
@ -60,16 +60,16 @@ describe('Editor', () => {
<div>Component 1</div> <div>Component 1</div>
<div></div> <div></div>
`); `);
expect(keys(all).length).toBe(3 + initComps); expect(keys(all).length).toBe(3 + DEFAULT_CMPS);
const secComp = added[1]; const secComp = added[1];
secComp.append(` secComp.append(`
<div>Component 2</div> <div>Component 2</div>
<div>Component 3</div> <div>Component 3</div>
`); `);
expect(keys(all).length).toBe(7 + initComps); expect(keys(all).length).toBe(7 + DEFAULT_CMPS);
wrapper.empty(); wrapper.empty();
expect(wrapper.components().length).toBe(0); expect(wrapper.components().length).toBe(0);
expect(keys(all).length).toBe(initComps); expect(keys(all).length).toBe(DEFAULT_CMPS);
}); });
test('Components are correctly tracked with UndoManager', () => { test('Components are correctly tracked with UndoManager', () => {
@ -82,9 +82,9 @@ describe('Editor', () => {
expect(umStack.length).toBe(1); expect(umStack.length).toBe(1);
wrapper.empty(); wrapper.empty();
expect(umStack.length).toBe(2); expect(umStack.length).toBe(2);
expect(keys(all).length).toBe(initComps); expect(keys(all).length).toBe(DEFAULT_CMPS);
um.undo(false); um.undo(false);
expect(keys(all).length).toBe(2 + initComps); expect(keys(all).length).toBe(2 + DEFAULT_CMPS);
}); });
test('Components are correctly tracked with UndoManager and mutiple operations', () => { test('Components are correctly tracked with UndoManager and mutiple operations', () => {
@ -98,13 +98,13 @@ describe('Editor', () => {
<div>Component 2</div> <div>Component 2</div>
</div>`); </div>`);
expect(umStack.length).toBe(1); // UM counts first children expect(umStack.length).toBe(1); // UM counts first children
expect(keys(all).length).toBe(5 + initComps); expect(keys(all).length).toBe(5 + DEFAULT_CMPS);
wrapper.components().at(0).components().at(0).remove(); // Remove 1 component wrapper.components().at(0).components().at(0).remove(); // Remove 1 component
expect(umStack.length).toBe(2); expect(umStack.length).toBe(2);
expect(keys(all).length).toBe(3 + initComps); expect(keys(all).length).toBe(3 + DEFAULT_CMPS);
wrapper.empty(); wrapper.empty();
expect(umStack.length).toBe(3); expect(umStack.length).toBe(3);
expect(keys(all).length).toBe(initComps); expect(keys(all).length).toBe(DEFAULT_CMPS);
}); });
}); });

7
test/specs/pages/index.ts

@ -2,6 +2,7 @@ import { ComponentDefinition } from '../../../src/dom_components/model/types';
import Editor from '../../../src/editor'; import Editor from '../../../src/editor';
import EditorModel from '../../../src/editor/model/Editor'; import EditorModel from '../../../src/editor/model/Editor';
import { PageProperties } from '../../../src/pages/model/Page'; import { PageProperties } from '../../../src/pages/model/Page';
import { DEFAULT_CMPS } from '../../common';
describe('Pages', () => { describe('Pages', () => {
let editor: Editor; let editor: Editor;
@ -50,7 +51,7 @@ describe('Pages', () => {
const frameCmp = frame.getComponent(); const frameCmp = frame.getComponent();
expect(frameCmp.components().length).toBe(0); expect(frameCmp.components().length).toBe(0);
expect(frame.getStyles().length).toBe(0); expect(frame.getStyles().length).toBe(0);
expect(initCmpLen).toBe(1); expect(initCmpLen).toBe(DEFAULT_CMPS);
}); });
test('Adding new page with selection', () => { test('Adding new page with selection', () => {
@ -143,8 +144,8 @@ describe('Pages', () => {
.filter(i => i.is('wrapper')); .filter(i => i.is('wrapper'));
expect(wrappers.length).toBe(initPages.length); expect(wrappers.length).toBe(initPages.length);
// Components container should contain the right amount of components // Components container should contain the right amount of components
// Number of wrappers (eg. 3) where each one containes 1 component and 1 textnode (3 * 3) // Number of wrappers (eg. 3) where each one containes 1 component and 1 textnode (5 * 3)
expect(initCmpLen).toBe(initPages.length * 3); expect(initCmpLen).toBe((2 + DEFAULT_CMPS) * 3);
// Each page contains 1 rule per component // Each page contains 1 rule per component
expect(em.Css.getAll().length).toBe(initPages.length); expect(em.Css.getAll().length).toBe(initPages.length);
}); });

68
test/specs/parser/model/ParserHtml.ts

@ -15,7 +15,7 @@ describe('ParserHtml', () => {
textTypes: ['text', 'textnode', 'comment'], textTypes: ['text', 'textnode', 'comment'],
returnArray: true, returnArray: true,
}); });
obj.compTypes = dom.componentTypes as any; obj.compTypes = dom.componentTypes;
}); });
test('Simple div node', () => { test('Simple div node', () => {
@ -535,7 +535,6 @@ describe('ParserHtml', () => {
const result = [ const result = [
{ {
tagName: 'div', tagName: 'div',
attributes: {},
type: 'text', type: 'text',
test: { test: {
prop1: 'value1', prop1: 'value1',
@ -553,7 +552,6 @@ describe('ParserHtml', () => {
const result = [ const result = [
{ {
tagName: 'div', tagName: 'div',
attributes: {},
type: 'text', type: 'text',
test: ['value1', 'value2'], test: ['value1', 'value2'],
components: { type: 'textnode', content: 'test2 ' }, components: { type: 'textnode', content: 'test2 ' },
@ -654,5 +652,69 @@ describe('ParserHtml', () => {
const preParser = (str: string) => str.replace('javascript:', 'test:'); const preParser = (str: string) => str.replace('javascript:', 'test:');
expect(obj.parse(str, null, { preParser }).html).toEqual([result]); expect(obj.parse(str, null, { preParser }).html).toEqual([result]);
}); });
test('parsing as document', () => {
const str = `
<!DOCTYPE html>
<html class="cls-html" lang="en" data-gjs-htmlp="true">
<head class="cls-head" data-gjs-headp="true">
<meta charset="utf-8">
<title>Test</title>
<link rel="stylesheet" href="/noop.css">
<!-- comment -->
<script src="/noop.js"></script>
<style>.test { color: red }</style>
</head>
<body class="cls-body" data-gjs-bodyp="true">
<h1>H1</h1>
</body>
</html>
`;
expect(obj.parse(str, null, { asDocument: true })).toEqual({
doctype: '<!DOCTYPE html>',
root: { classes: ['cls-html'], attributes: { lang: 'en' }, htmlp: true },
head: {
type: 'head',
tagName: 'head',
headp: true,
classes: ['cls-head'],
components: [
{ tagName: 'meta', attributes: { charset: 'utf-8' } },
{
tagName: 'title',
type: 'text',
components: { type: 'textnode', content: 'Test' },
},
{
tagName: 'link',
attributes: { rel: 'stylesheet', href: '/noop.css' },
},
{
type: 'comment',
tagName: '',
content: ' comment ',
},
{
tagName: 'style',
type: 'text',
components: { type: 'textnode', content: '.test { color: red }' },
},
],
},
html: {
tagName: 'body',
bodyp: true,
classes: ['cls-body'],
components: [
{
tagName: 'h1',
type: 'text',
components: { type: 'textnode', content: 'H1' },
},
],
},
});
});
}); });
}); });

Loading…
Cancel
Save