From 13aa53c56030b5e7e6250420e7e1c375b3661b56 Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Tue, 21 May 2024 14:13:38 +0400 Subject: [PATCH] Introduce HTML string document import (#5895) --- src/abstract/ModuleCategories.ts | 20 ++ src/block_manager/index.ts | 5 +- src/block_manager/types.ts | 7 + src/canvas/view/FrameView.ts | 18 + src/code_manager/model/HtmlGenerator.ts | 11 +- src/common/index.ts | 7 + src/dom_components/index.ts | 12 +- src/dom_components/model/Component.ts | 9 +- src/dom_components/model/ComponentHead.ts | 24 ++ src/dom_components/model/ComponentWrapper.ts | 47 +++ src/dom_components/model/Components.ts | 30 +- src/dom_components/model/types.ts | 8 +- src/parser/config/config.ts | 18 +- src/parser/index.ts | 2 +- src/parser/model/BrowserParserHtml.ts | 2 + src/parser/model/ParserHtml.ts | 325 ++++++++++--------- src/trait_manager/model/Traits.ts | 9 +- src/trait_manager/types.ts | 7 + src/utils/dom.ts | 15 + test/common.ts | 2 + test/specs/dom_components/index.ts | 97 +++++- test/specs/dom_components/model/Component.ts | 3 +- test/specs/editor/index.ts | 22 +- test/specs/pages/index.ts | 7 +- test/specs/parser/model/ParserHtml.ts | 68 +++- 25 files changed, 567 insertions(+), 208 deletions(-) create mode 100644 src/dom_components/model/ComponentHead.ts create mode 100644 test/common.ts diff --git a/src/abstract/ModuleCategories.ts b/src/abstract/ModuleCategories.ts index ce9e269f0..4aace304f 100644 --- a/src/abstract/ModuleCategories.ts +++ b/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>; + +interface CategoryOptions { + events?: { update?: string }; + em?: EditorModel; +} + export default class Categories extends Collection { + 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]; diff --git a/src/block_manager/index.ts b/src/block_manager/index.ts index 6f2ba10bd..48a8f82ac 100644 --- a/src/block_manager/index.ts +++ b/src/block_manager/index.ts @@ -66,7 +66,10 @@ export default class BlockManager extends ItemManagerModule this.blocksVisible.add(model)); diff --git a/src/block_manager/types.ts b/src/block_manager/types.ts index 74d73f495..13f74cdef 100644 --- a/src/block_manager/types.ts +++ b/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 diff --git a/src/canvas/view/FrameView.ts b/src/canvas/view/FrameView.ts index 30d7005db..9a1722d8a 100644 --- a/src/canvas/view/FrameView.ts +++ b/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 { 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 { 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 { 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(); diff --git a/src/code_manager/model/HtmlGenerator.ts b/src/code_manager/model/HtmlGenerator.ts index 8a7ad32c5..5b966c5ee 100644 --- a/src/code_manager/model/HtmlGenerator.ts +++ b/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 | ((component: Component, attr: Record) => Record); -}; +} export default class HTMLGenerator extends Model { build(model: Component, opts: HTMLGeneratorBuildOptions & { em?: EditorModel } = {}) { diff --git a/src/common/index.ts b/src/common/index.ts index 63ccacb2a..bd983b2c3 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -25,6 +25,13 @@ export type ObjectStrings = Record; 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 & NOOP); diff --git a/src/dom_components/index.ts b/src/dom_components/index.ts index 10ad17217..faf587d5d 100644 --- a/src/dom_components/index.ts +++ b/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 { }; } + get tagName() { + return this.get('tagName')!; + } + get classes() { return this.get('classes')!; } @@ -1145,7 +1150,7 @@ export default class Component extends StyleableModel { */ components( 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 { 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) }; } diff --git a/src/dom_components/model/ComponentHead.ts b/src/dom_components/model/ComponentHead.ts new file mode 100644 index 000000000..43c4efef4 --- /dev/null +++ b/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; + } +} diff --git a/src/dom_components/model/ComponentWrapper.ts b/src/dom_components/model/ComponentWrapper.ts index 92b793986..d8988823b 100644 --- a/src/dom_components/model/ComponentWrapper.ts +++ b/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) { + 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}${headStr}${body}` : body; + } + __postAdd() { const um = this.em?.UndoManager; !this.__hasUm && um?.add(this); diff --git a/src/dom_components/model/Components.ts b/src/dom_components/model/Components.ts index 780b49f10..9da8866d1 100644 --- a/src/dom_components/model/Components.ts +++ b/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 */ diff --git a/src/dom_components/model/types.ts b/src/dom_components/model/types.ts index e782f76e5..17b9a54d6 100644 --- a/src/dom_components/model/types.ts +++ b/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 | ((component: Component, attr: Record) => Record); -}; +} export interface ComponentOptions { em?: EditorModel; diff --git a/src/parser/config/config.ts b/src/parser/config/config.ts index ceaa7b437..058045c70 100644 --- a/src/parser/config/config.ts +++ b/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. diff --git a/src/parser/index.ts b/src/parser/index.ts index e33775a42..c7c8b6048 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -69,7 +69,7 @@ export default class ParserModule extends Module { 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 diff --git a/src/parser/model/ParserHtml.ts b/src/parser/model/ParserHtml.ts index bacb66e6c..b30525a32 100644 --- a/src/parser/model/ParserHtml.ts +++ b/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; - -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} - */ - 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} + */ + 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 }); diff --git a/src/trait_manager/model/Traits.ts b/src/trait_manager/model/Traits.ts index 51414f6ad..75ee480c4 100644 --- a/src/trait_manager/model/Traits.ts +++ b/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 { 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; diff --git a/src/trait_manager/types.ts b/src/trait_manager/types.ts index ef5f58d7b..ff4378e5c 100644 --- a/src/trait_manager/types.ts +++ b/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 diff --git a/src/utils/dom.ts b/src/utils/dom.ts index ca4d1c7cb..4eaa540e0 100644 --- a/src/utils/dom.ts +++ b/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 ``; +}; + +export const attrToString = (attrs: ObjectAny = {}) => { + const res: string[] = []; + each(attrs, (value, key) => res.push(`${key}="${value}"`)); + return res.join(' '); +}; + export const on = ( el: EventTarget | EventTarget[], ev: string, diff --git a/test/common.ts b/test/common.ts new file mode 100644 index 000000000..52c45ac13 --- /dev/null +++ b/test/common.ts @@ -0,0 +1,2 @@ +// DocEl + Head + Wrapper +export const DEFAULT_CMPS = 3; diff --git a/test/specs/dom_components/index.ts b/test/specs/dom_components/index.ts index a7108d0fe..600ad7738 100644 --- a/test/specs/dom_components/index.ts +++ b/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 = ` + + + + + Test + + + + +

H1

+ + + `; + + 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(''); + + const outputHtml = ` + + + + + Test + + + + +

H1

+ + + `.replace(/>\s+|\s+ m.trim()); + expect(root.toHTML()).toBe(outputHtml); + }); + }); + }); }); diff --git a/test/specs/dom_components/model/Component.ts b/test/specs/dom_components/model/Component.ts index 51b4b19e8..3ec3415ef 100644 --- a/test/specs/dom_components/model/Component.ts +++ b/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'); diff --git a/test/specs/editor/index.ts b/test/specs/editor/index.ts index 13a7947a0..1d97478d8 100644 --- a/test/specs/editor/index.ts +++ b/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('
Component
'); // 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', () => {
Component 1
`); - expect(keys(all).length).toBe(3 + initComps); + expect(keys(all).length).toBe(3 + DEFAULT_CMPS); const secComp = added[1]; secComp.append(`
Component 2
Component 3
`); - 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', () => {
Component 2
`); 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); }); }); diff --git a/test/specs/pages/index.ts b/test/specs/pages/index.ts index ff78e6eba..3a6e556aa 100644 --- a/test/specs/pages/index.ts +++ b/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); }); diff --git a/test/specs/parser/model/ParserHtml.ts b/test/specs/parser/model/ParserHtml.ts index 393ddc038..4b917ea98 100644 --- a/test/specs/parser/model/ParserHtml.ts +++ b/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 = ` + + + + + Test + + + + + + +

H1

+ + + `; + + expect(obj.parse(str, null, { asDocument: true })).toEqual({ + doctype: '', + 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' }, + }, + ], + }, + }); + }); }); });