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 { AddOptions, Collection } from '../common';
import { normalizeKey } from '../utils/mixins';
import EditorModel from '../editor/model/Editor';
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> {
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 */
add(model: (CategoryProperties | Category)[] | CategoryProperties | Category, opts?: AddOptions) {
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
this.blocks = this.all;
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
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',
/**
* @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).
* @example

18
src/canvas/view/FrameView.ts

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

11
src/code_manager/model/HtmlGenerator.ts

@ -1,19 +1,14 @@
import { Model } from '../../common';
import Component from '../../dom_components/model/Component';
import { ToHTMLOptions } from '../../dom_components/model/types';
import EditorModel from '../../editor/model/Editor';
export type HTMLGeneratorBuildOptions = {
export interface HTMLGeneratorBuildOptions extends ToHTMLOptions {
/**
* Remove unnecessary IDs (eg. those created automatically).
*/
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 {
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 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
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 ComponentWrapperView from './view/ComponentWrapperView';
import ComponentsView from './view/ComponentsView';
import ComponentHead, { type as typeHead } from './model/ComponentHead';
export type ComponentEvent =
| 'component:create'
@ -251,15 +252,20 @@ export default class ComponentManager extends ItemManagerModule<DomComponentsCon
view: ComponentTextNodeView,
},
{
id: 'text',
model: ComponentText,
view: ComponentTextView,
id: typeHead,
model: ComponentHead,
view: ComponentView,
},
{
id: 'wrapper',
model: ComponentWrapper,
view: ComponentWrapperView,
},
{
id: 'text',
model: ComponentText,
view: ComponentTextView,
},
{
id: 'default',
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 EditorModel from '../../editor/model/Editor';
import {
AddComponentsOption,
ComponentAdd,
ComponentDefinition,
ComponentDefinitionDefined,
@ -164,6 +165,10 @@ export default class Component extends StyleableModel<ComponentProperties> {
};
}
get tagName() {
return this.get('tagName')!;
}
get classes() {
return this.get('classes')!;
}
@ -1145,7 +1150,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
*/
components<T extends ComponentAdd | undefined>(
components?: T,
opts: any = {}
opts: AddComponentsOption = {}
): undefined extends T ? Components : Component[] {
const coll = this.get('components')!;
@ -2035,7 +2040,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
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) };
}

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 ComponentHead, { type as typeHead } from './ComponentHead';
import { ToHTMLOptions } from './types';
export default class ComponentWrapper extends Component {
get defaults() {
@ -11,6 +15,9 @@ export default class ComponentWrapper extends Component {
draggable: false,
components: [],
traits: [],
doctype: '',
head: null,
docEl: null,
stylable: [
'background',
'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() {
const um = this.em?.UndoManager;
!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 Component from './Component';
import { AddOptions, Collection, ObjectAny } from '../../common';
import { AddOptions, Collection, ObjectAny, OptionAsDocument } from '../../common';
import { DomComponentsConfig } from '../config/config';
import EditorModel from '../../editor/model/Editor';
import ComponentManager from '..';
import CssRule from '../../css_composer/model/CssRule';
import { ComponentAdd, ComponentProperties } from './types';
import { ComponentAdd, ComponentDefinitionDefined, ComponentProperties } from './types';
import ComponentText from './ComponentText';
import ComponentWrapper from './ComponentWrapper';
export const getComponentIds = (cmp?: Component | Component[] | Components, res: string[] = []) => {
if (!cmp) return [];
@ -228,12 +229,27 @@ Component> {
return new model(attrs, options) as Component;
}
parseString(value: string, opt: AddOptions & { temporary?: boolean; keepIds?: string[] } = {}) {
const { em, domc } = this;
parseString(value: string, opt: AddOptions & OptionAsDocument & { temporary?: boolean; keepIds?: string[] } = {}) {
const { em, domc, parent } = this;
const asDocument = opt.asDocument && parent?.is('wrapper');
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
Component.checkId(parsed.html!, parsed.css, domc!.componentsById, opt);
Component.checkId(components, parsed.css, domc!.componentsById, opt);
if (parsed.css && cssc && !opt.temporary) {
const { at, ...optsToPass } = opt;
@ -243,7 +259,7 @@ Component> {
});
}
return parsed.html;
return components;
}
/** @ts-ignore */

8
src/dom_components/model/types.ts

@ -1,5 +1,5 @@
import Frame from '../../canvas/model/Frame';
import { Nullable } from '../../common';
import { AddOptions, Nullable, OptionAsDocument } from '../../common';
import EditorModel from '../../editor/model/Editor';
import Selectors from '../../selector_manager/model/Selectors';
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 interface AddComponentsOption extends AddOptions, OptionAsDocument {}
export interface ComponentStackItem {
id: string;
model: typeof Component;
@ -264,7 +266,7 @@ type ComponentAddType = Component | ComponentDefinition | ComponentDefinitionDef
export type ComponentAdd = ComponentAddType | ComponentAddType[];
export type ToHTMLOptions = {
export interface ToHTMLOptions extends OptionAsDocument {
/**
* Custom tagName.
*/
@ -285,7 +287,7 @@ export type ToHTMLOptions = {
* 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 interface ComponentOptions {
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';
export interface ParsedCssRule {
@ -11,7 +14,20 @@ export type CustomParserCss = (input: string, editor: Editor) => ParsedCssRule[]
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.
* 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 = {}) {
const { em, parserHtml } = this;
parserHtml.compTypes = (em.Components.getTypes() || {}) as any;
parserHtml.compTypes = em.Components.getTypes() || [];
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;
if (toHTML) {
if (config.asDocument) return doc;
// Replicate the old parser in order to avoid breaking changes
const { head, body } = doc;
// 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 { ObjectAny } from '../../common';
import { CssRuleJSON } from '../../css_composer/model/CssRule';
import { ComponentDefinitionDefined } from '../../dom_components/model/types';
import { ObjectAny, ObjectStrings } from '../../common';
import { ComponentDefinitionDefined, ComponentStackItem } from '../../dom_components/model/types';
import EditorModel from '../../editor/model/Editor';
import { HTMLParserOptions, ParserConfig } from '../config/config';
import { HTMLParseResult, HTMLParserOptions, ParseNodeOptions, ParserConfig } from '../config/config';
import BrowserParserHtml from './BrowserParserHtml';
type StringObject = Record<string, string>;
type HTMLParseResult = {
html: ComponentDefinitionDefined | ComponentDefinitionDefined[]; // TODO replace with components
css?: CssRuleJSON[];
};
import { doctypeToString } from '../../utils/dom';
const modelAttrStart = 'data-gjs-';
const event = 'parse:html';
const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boolean } = {}) => {
return {
compTypes: '',
compTypes: [] as ComponentStackItem[],
modelAttrStart,
@ -50,7 +43,7 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo
*/
splitPropsFromAttr(attr: ObjectAny = {}) {
const props: ObjectAny = {};
const attrs: StringObject = {};
const attrs: ObjectStrings = {};
each(attr, (value, key) => {
if (key.indexOf(this.modelAttrStart) === 0) {
@ -131,159 +124,174 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo
return result;
},
/**
* Get data from the node element
* @param {HTMLElement} el DOM element to traverse
* @return {Array<Object>}
*/
parseNode(el: HTMLElement, opts: ObjectAny = {}) {
const result: ComponentDefinitionDefined[] = [];
const nodes = el.childNodes;
parseNodeAttr(node: HTMLElement, result?: ComponentDefinitionDefined) {
const model = result || {};
const attrs = node.attributes || [];
const attrsLen = attrs.length;
for (var i = 0, len = nodes.length; i < len; i++) {
const node = nodes[i] as HTMLElement;
const attrs = node.attributes || [];
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;
}
}
for (let i = 0; i < attrsLen; i++) {
const nodeName = attrs[i].nodeName;
let nodeValue: string | boolean = attrs[i].nodeValue!;
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.tagName) {
const tag = node.tagName || '';
const ns = node.namespaceURI || '';
model.tagName = tag && ns === 'http://www.w3.org/1999/xhtml' ? tag.toLowerCase() : tag;
}
if (!model.attributes) {
model.attributes = {};
}
if (attrsLen) {
model.attributes = {};
model.attributes[nodeName] = nodeValue;
}
}
// Parse attributes
for (let j = 0; j < attrsLen; j++) {
const nodeName = attrs[j].nodeName;
let nodeValue: string | boolean = attrs[j].nodeValue!;
// Isolate attributes
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;
}
return model;
},
detectNode(node: HTMLElement, opts: ParseNodeOptions = {}) {
const { compTypes } = this;
let result: ComponentDefinitionDefined = {};
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
if (nodeChild && !model.components) {
// Avoid infinite nested text nodes
const firstChild = node.childNodes[0];
// If there is only one child and it's a TEXTNODE
// just make it content of the current node
if (nodeChild === 1 && firstChild.nodeType === 3) {
!model.type && (model.type = 'text');
model.components = {
type: 'textnode',
content: firstChild.nodeValue,
};
} else {
model.components = this.parseNode(node, {
...opts,
inSvg: opts.inSvg || model.type === 'svg',
});
}
return result;
},
parseNode(node: HTMLElement, opts: ParseNodeOptions = {}) {
const nodes = node.childNodes;
const nodesLen = nodes.length;
let model = this.detectNode(node, opts);
if (!model.tagName) {
const tag = node.tagName || '';
const ns = node.namespaceURI || '';
model.tagName = tag && ns === 'http://www.w3.org/1999/xhtml' ? tag.toLowerCase() : tag;
}
model = this.parseNodeAttr(node, model);
// 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 (model.type == 'textnode') {
if (nodePrev && nodePrev.type == 'textnode') {
nodePrev.content += model.content;
continue;
// If all children are texts and there is any textnode inside, the parent should
// be text too otherwise it won't be possible to edit texnodes.
const comps = model.components;
if (!model.type && comps?.length) {
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 (!opts.keepEmptyTextNodes) {
const content = node.nodeValue;
if (content != ' ' && !content!.trim()) {
continue;
}
if (cType === 'textnode') {
foundTextNode = true;
}
}
// Check for custom void elements (valid in XML)
if (!nodeChild && `${node.outerHTML}`.slice(-2) === '/>') {
model.void = true;
if (allTxt && foundTextNode) {
model.type = 'text';
}
}
// If all children are texts and there is some textnode the parent should
// 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;
return model;
},
for (let ci = 0; ci < comps.length; ci++) {
const comp = comps[ci];
const cType = comp.type;
/**
* Get data from the node element
* @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)) {
allTxt = 0;
break;
}
for (let i = 0; i < nodesLen; i++) {
const node = nodes[i] as HTMLElement;
const nodePrev = result[result.length - 1];
const model = this.parseNode(node, opts);
if (cType === 'textnode') {
foundTextNode = 1;
}
// Check if it's a text node and if it could be moved to the prevous one
if (model.type === 'textnode') {
if (nodePrev?.type === 'textnode') {
nodePrev.content += model.content;
continue;
}
if (allTxt && foundTextNode) {
model.type = 'text';
// Throw away empty nodes (keep spaces)
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)) {
continue;
}
@ -303,17 +311,25 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo
parse(str: string, parserCss?: any, opts: HTMLParserOptions = {}) {
const conf = em?.get('Config') || {};
const res: HTMLParseResult = { html: [] };
const cf: ObjectAny = { ...config, ...opts };
const cf = { ...config, ...opts };
const options = {
...config.optionsHtml,
// @ts-ignore Support previous `configParser.htmlType` option
htmlType: config.optionsHtml?.htmlType || config.htmlType,
...opts,
};
const { preParser } = options;
const { preParser, asDocument } = options;
const input = isFunction(preParser) ? preParser(str, { editor: em?.getEditor()! }) : str;
const el = isFunction(cf.parserHtml) ? cf.parserHtml(input, options) : BrowserParserHtml(input, options);
const scripts = el.querySelectorAll('script');
const parseRes = isFunction(cf.parserHtml) ? cf.parserHtml(input, options) : BrowserParserHtml(input, options);
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;
// Support previous `configMain.allowScripts` option
@ -321,32 +337,41 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo
// Remove script tags
if (!allowScripts) {
while (i--) scripts[i].parentNode.removeChild(scripts[i]);
while (i--) scripts[i].parentNode?.removeChild(scripts[i]);
}
// Remove unsafe attributes
if (!options.allowUnsafeAttr || !options.allowUnsafeAttrValue) {
this.__sanitizeNode(el, options);
this.__sanitizeNode(root, options);
}
// Detach style tags and parse them
if (parserCss) {
const styles = el.querySelectorAll('style');
const styles = root.querySelectorAll('style');
let j = styles.length;
let styleStr = '';
while (j--) {
styleStr = styles[j].innerHTML + styleStr;
styles[j].parentNode.removeChild(styles[j]);
styles[j].parentNode?.removeChild(styles[j]);
}
if (styleStr) res.css = parserCss.parse(styleStr);
}
em?.trigger(`${event}:root`, { input, root: el });
const result = this.parseNode(el, cf);
// I have to keep it otherwise it breaks the DomComponents.addComponent (returns always array)
const resHtml = result.length === 1 && !cf.returnArray ? result[0] : result;
em?.trigger(`${event}:root`, { input, root: root });
let resHtml: HTMLParseResult['html'] = [];
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;
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 Component from '../../dom_components/model/Component';
import EditorModel from '../../editor/model/Editor';
import { TraitProperties } from '../types';
import TraitsEvents, { TraitProperties } from '../types';
import Trait from './Trait';
import TraitFactory from './TraitFactory';
@ -17,7 +17,12 @@ export default class Traits extends CollectionWithCategories<Trait> {
constructor(coll: TraitProperties[], options: { em: EditorModel }) {
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('reset', this.handleReset);
const tm = this.module;

7
src/trait_manager/types.ts

@ -188,6 +188,13 @@ export enum TraitsEvents {
*/
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).
* @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;
// 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>(
el: EventTarget | EventTarget[],
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 EditorModel from '../../../src/editor/model/Editor';
import Editor from '../../../src/editor';
import utils from './../../test_utils.js';
import { Component } from '../../../src';
import ComponentWrapper from '../../../src/dom_components/model/ComponentWrapper';
describe('DOM Components', () => {
describe('Main', () => {
var em: EditorModel;
var obj: DomComponents;
var obj: EditorModel['Components'];
var config: any;
var storagMock = utils.storageMock();
var editorModel = {
@ -63,10 +63,6 @@ describe('DOM Components', () => {
em.destroy();
});
test('Object exists', () => {
expect(DomComponents).toBeTruthy();
});
test.skip('Store and load data', () => {
setSmConfig();
setEm();
@ -147,7 +143,6 @@ describe('DOM Components', () => {
});
test('Add new component type with simple model', () => {
obj = em.get('DomComponents');
const id = 'test-type';
const testProp = 'testValue';
const initialTypes = obj.componentTypes.length;
@ -166,7 +161,6 @@ describe('DOM Components', () => {
});
test('Add new component type with custom isComponent', () => {
obj = em.get('DomComponents');
const id = 'test-type';
const testProp = 'testValue';
obj.addType(id, {
@ -182,7 +176,6 @@ describe('DOM Components', () => {
});
test('Extend component type with custom model and view', () => {
obj = em.get('DomComponents');
const id = 'image';
const testProp = 'testValue';
const initialTypes = obj.getTypes().length;
@ -207,7 +200,6 @@ describe('DOM Components', () => {
});
test('Add new component type by extending another one, without isComponent', () => {
obj = em.get('DomComponents');
const id = 'test-type';
const testProp = 'testValue';
obj.addType(id, {
@ -228,7 +220,6 @@ describe('DOM Components', () => {
});
test('Add new component type by extending another one, with custom isComponent', () => {
obj = em.get('DomComponents');
const id = 'test-type';
const testProp = 'testValue';
obj.addType(id, {
@ -272,7 +263,7 @@ describe('DOM Components', () => {
expect(rule.toCSS()).toEqual(css);
done();
}, 10);
}, 20);
});
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 addComps = added.components();
// 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(addComps.at(0).getId()).toBe(idB);
const cc = em.get('CssComposer');

22
test/specs/editor/index.ts

@ -1,7 +1,7 @@
import Editor from '../../../src/editor';
import { DEFAULT_CMPS } from '../../common';
const { keys } = Object;
const initComps = 1;
describe('Editor', () => {
let editor: Editor;
@ -24,7 +24,7 @@ describe('Editor', () => {
const all = editor.Components.allById();
const allKeys = keys(all);
// 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');
});
@ -50,7 +50,7 @@ describe('Editor', () => {
const all = editor.Components.allById();
const wrapper = editor.getWrapper()!;
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', () => {
@ -60,16 +60,16 @@ describe('Editor', () => {
<div>Component 1</div>
<div></div>
`);
expect(keys(all).length).toBe(3 + initComps);
expect(keys(all).length).toBe(3 + DEFAULT_CMPS);
const secComp = added[1];
secComp.append(`
<div>Component 2</div>
<div>Component 3</div>
`);
expect(keys(all).length).toBe(7 + initComps);
expect(keys(all).length).toBe(7 + DEFAULT_CMPS);
wrapper.empty();
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', () => {
@ -82,9 +82,9 @@ describe('Editor', () => {
expect(umStack.length).toBe(1);
wrapper.empty();
expect(umStack.length).toBe(2);
expect(keys(all).length).toBe(initComps);
expect(keys(all).length).toBe(DEFAULT_CMPS);
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', () => {
@ -98,13 +98,13 @@ describe('Editor', () => {
<div>Component 2</div>
</div>`);
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
expect(umStack.length).toBe(2);
expect(keys(all).length).toBe(3 + initComps);
expect(keys(all).length).toBe(3 + DEFAULT_CMPS);
wrapper.empty();
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 EditorModel from '../../../src/editor/model/Editor';
import { PageProperties } from '../../../src/pages/model/Page';
import { DEFAULT_CMPS } from '../../common';
describe('Pages', () => {
let editor: Editor;
@ -50,7 +51,7 @@ describe('Pages', () => {
const frameCmp = frame.getComponent();
expect(frameCmp.components().length).toBe(0);
expect(frame.getStyles().length).toBe(0);
expect(initCmpLen).toBe(1);
expect(initCmpLen).toBe(DEFAULT_CMPS);
});
test('Adding new page with selection', () => {
@ -143,8 +144,8 @@ describe('Pages', () => {
.filter(i => i.is('wrapper'));
expect(wrappers.length).toBe(initPages.length);
// 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)
expect(initCmpLen).toBe(initPages.length * 3);
// Number of wrappers (eg. 3) where each one containes 1 component and 1 textnode (5 * 3)
expect(initCmpLen).toBe((2 + DEFAULT_CMPS) * 3);
// Each page contains 1 rule per component
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'],
returnArray: true,
});
obj.compTypes = dom.componentTypes as any;
obj.compTypes = dom.componentTypes;
});
test('Simple div node', () => {
@ -535,7 +535,6 @@ describe('ParserHtml', () => {
const result = [
{
tagName: 'div',
attributes: {},
type: 'text',
test: {
prop1: 'value1',
@ -553,7 +552,6 @@ describe('ParserHtml', () => {
const result = [
{
tagName: 'div',
attributes: {},
type: 'text',
test: ['value1', 'value2'],
components: { type: 'textnode', content: 'test2 ' },
@ -654,5 +652,69 @@ describe('ParserHtml', () => {
const preParser = (str: string) => str.replace('javascript:', 'test:');
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