Browse Source

Merge branch 'dev' into feature/data-sources

ci/dependabot
danstarns 2 years ago
parent
commit
5f8c188367
  1. 30
      CONTRIBUTING.md
  2. 10
      README.md
  3. 2
      dist/css/grapes.min.css
  4. 4
      dist/grapes.min.js
  5. 2
      dist/grapes.min.js.map
  6. 16407
      dist/grapes.mjs
  7. 2
      dist/grapes.mjs.map
  8. 4940
      dist/index.d.ts
  9. 9
      docs/.vuepress/components/DemoViewer.vue
  10. 5
      docs/.vuepress/config.js
  11. 11
      docs/.vuepress/public/symbols-model.svg
  12. 77
      docs/api/assets.md
  13. 6
      docs/api/block_manager.md
  14. 18
      docs/api/canvas.md
  15. 61
      docs/api/commands.md
  16. 30
      docs/api/component.md
  17. 71
      docs/api/components.md
  18. 21
      docs/api/i18n.md
  19. 6
      docs/api/trait_manager.md
  20. 18
      docs/api/undo_manager.md
  21. 12
      docs/deploy.sh
  22. 306
      docs/guides/Symbols.md
  23. 2
      docs/modules/Assets.md
  24. 16
      docs/modules/Commands.md
  25. 4
      docs/modules/I18n.md
  26. 2
      docs/modules/Plugins.md
  27. 2
      package.json
  28. 20
      src/abstract/ModuleCategories.ts
  29. 153
      src/asset_manager/index.ts
  30. 95
      src/asset_manager/types.ts
  31. 11
      src/asset_manager/view/FileUploader.ts
  32. 5
      src/block_manager/index.ts
  33. 2
      src/block_manager/model/Block.ts
  34. 7
      src/block_manager/types.ts
  35. 20
      src/canvas/index.ts
  36. 2
      src/canvas/model/Canvas.ts
  37. 4
      src/canvas/model/CanvasSpots.ts
  38. 18
      src/canvas/types.ts
  39. 2
      src/canvas/view/CanvasView.ts
  40. 18
      src/canvas/view/FrameView.ts
  41. 11
      src/code_manager/model/HtmlGenerator.ts
  42. 23
      src/commands/index.ts
  43. 71
      src/commands/types.ts
  44. 33
      src/commands/view/CommandAbstract.ts
  45. 2
      src/commands/view/Fullscreen.ts
  46. 49
      src/commands/view/SelectComponent.ts
  47. 3
      src/commands/view/ShowOffset.ts
  48. 9
      src/common/index.ts
  49. 5
      src/css_composer/index.ts
  50. 18
      src/css_composer/model/CssRule.ts
  51. 151
      src/dom_components/index.ts
  52. 426
      src/dom_components/model/Component.ts
  53. 24
      src/dom_components/model/ComponentHead.ts
  54. 5
      src/dom_components/model/ComponentImage.ts
  55. 6
      src/dom_components/model/ComponentMap.ts
  56. 5
      src/dom_components/model/ComponentTable.ts
  57. 5
      src/dom_components/model/ComponentTableBody.ts
  58. 5
      src/dom_components/model/ComponentText.ts
  59. 6
      src/dom_components/model/ComponentVideo.ts
  60. 47
      src/dom_components/model/ComponentWrapper.ts
  61. 145
      src/dom_components/model/Components.ts
  62. 282
      src/dom_components/model/SymbolUtils.ts
  63. 56
      src/dom_components/model/Symbols.ts
  64. 33
      src/dom_components/model/types.ts
  65. 106
      src/dom_components/types.ts
  66. 2
      src/dom_components/view/ComponentImageView.ts
  67. 4
      src/dom_components/view/ComponentLabelView.ts
  68. 4
      src/dom_components/view/ComponentLinkView.ts
  69. 2
      src/dom_components/view/ComponentTextView.ts
  70. 3
      src/dom_components/view/ComponentView.ts
  71. 18
      src/dom_components/view/ComponentsView.ts
  72. 6
      src/domain_abstract/model/StyleableModel.ts
  73. 10
      src/domain_abstract/ui/InputColor.ts
  74. 4
      src/domain_abstract/ui/InputNumber.ts
  75. 10
      src/editor/config/config.ts
  76. 3
      src/editor/index.ts
  77. 4
      src/editor/model/Editor.ts
  78. 22
      src/editor/view/EditorView.ts
  79. 29
      src/i18n/index.ts
  80. 169
      src/i18n/locale/id.js
  81. 31
      src/i18n/types.ts
  82. 8
      src/navigator/index.ts
  83. 39
      src/navigator/view/ItemView.ts
  84. 59
      src/navigator/view/ItemsView.ts
  85. 8
      src/pages/index.ts
  86. 11
      src/pages/types.ts
  87. 2
      src/panels/view/PanelView.ts
  88. 33
      src/parser/config/config.ts
  89. 2
      src/parser/index.ts
  90. 2
      src/parser/model/BrowserParserHtml.ts
  91. 338
      src/parser/model/ParserHtml.ts
  92. 4
      src/rich_text_editor/index.ts
  93. 7
      src/selector_manager/index.ts
  94. 20
      src/selector_manager/model/Selector.ts
  95. 7
      src/selector_manager/view/ClassTagsView.ts
  96. 38
      src/style_manager/index.ts
  97. 4
      src/styles/scss/_gjs_canvas.scss
  98. 32
      src/styles/scss/main.scss
  99. 9
      src/trait_manager/model/Traits.ts
  100. 7
      src/trait_manager/types.ts

30
CONTRIBUTING.md

@ -11,6 +11,36 @@ We welcome any type of contribution, not only code. Like for example:
- **Money**: We welcome financial contributions in full transparency on our [Open Collective]. - **Money**: We welcome financial contributions in full transparency on our [Open Collective].
## Setting up the repository
This is a Node.js project and you need to have Node.js installed on your machine. You can download it from [here](https://nodejs.org/). We test versions 14 and 16 of Node in the CI, so it's recommended to use one of these versions, or the latest of: 16.20.2
```bash
nvm use 16.20.2
```
You will then use `yarn` to manage the dependencies and run the scripts. You can install it by running:
```bash
npm install -g yarn
```
Then you can clone the repository and install the dependencies:
```bash
git clone __YOUR_FORK__
cd grapesjs
yarn
```
Finally, you can run the development server:
```bash
yarn start
```
Navigate to `http://localhost:8080/` to see the editor in action. The development server will watch for changes in the code and automatically reload the page.
## Your First Contribution ## Your First Contribution
Working on your first Pull Request? You can learn how from this **free** series, [How to Contribute to an Open Source Project on GitHub](https://app.egghead.io/playlists/how-to-contribute-to-an-open-source-project-on-github). Working on your first Pull Request? You can learn how from this **free** series, [How to Contribute to an Open Source Project on GitHub](https://app.egghead.io/playlists/how-to-contribute-to-an-open-source-project-on-github).

10
README.md

@ -6,13 +6,13 @@
[![npm](https://img.shields.io/npm/v/grapesjs.svg)](https://www.npmjs.com/package/grapesjs) [![npm](https://img.shields.io/npm/v/grapesjs.svg)](https://www.npmjs.com/package/grapesjs)
<p align="center"><img src="http://grapesjs.com/img/grapesjs-front-page-m.jpg" alt="GrapesJS" width="500" align="center"/></p> <p align="center"><img src="http://grapesjs.com/assets/images/grapesjs-front-page-m.jpg" alt="GrapesJS" width="500" align="center"/></p>
GrapesJS is a free and open source Web Builder Framework which helps building HTML templates, faster and easily, to be delivered in sites, newsletters or mobile apps. Mainly, GrapesJS was designed to be used inside a [CMS] to speed up the creation of dynamic templates. To better understand this concept check the image below GrapesJS is a free and open source Web Builder Framework which helps building HTML templates, faster and easily, to be delivered in sites, newsletters or mobile apps. Mainly, GrapesJS was designed to be used inside a [CMS] to speed up the creation of dynamic templates. To better understand this concept check the image below
<br/> <br/>
<p align="center"><img src="http://grapesjs.com/img/gjs-concept.png" alt="GrapesJS - Style Manager" height="400" align="center"/></p> <p align="center"><img src="http://grapesjs.com/assets/images/gjs-concept.png" alt="GrapesJS - Style Manager" height="400" align="center"/></p>
<br/> <br/>
Generally any 'template system', that you'd find in various applications like CMS, is composed by the **structure** (HTML), **style** (CSS) and **variables**, which are then replaced with other templates and contents on server-side and rendered on client. Generally any 'template system', that you'd find in various applications like CMS, is composed by the **structure** (HTML), **style** (CSS) and **variables**, which are then replaced with other templates and contents on server-side and rendered on client.
@ -47,11 +47,11 @@ Newsletter Demo - http://grapesjs.com/demo-newsletter-editor.html<br/>
| Blocks | Style Manager | Layer Manager | | Blocks | Style Manager | Layer Manager |
|--|--|--| |--|--|--|
|<img src="http://grapesjs.com/img/sc-grapesjs-blocks-prp.jpg" alt="GrapesJS - Block Manager" height="400" align="center"/>|<img src="http://grapesjs.com/img/sc-grapesjs-style-2.jpg" alt="GrapesJS - Style Manager" height="400" align="center"/>|<img src="http://grapesjs.com/img/sc-grapesjs-layers-2.jpg" alt="GrapesJS - Layer Manager" height="400" align="center"/>| |<img src="http://grapesjs.com/assets/images/sc-grapesjs-blocks-prp.jpg" alt="GrapesJS - Block Manager" height="400" align="center"/>|<img src="http://grapesjs.com/assets/images/sc-grapesjs-style-2.jpg" alt="GrapesJS - Style Manager" height="400" align="center"/>|<img src="http://grapesjs.com/assets/images/sc-grapesjs-layers-2.jpg" alt="GrapesJS - Layer Manager" height="400" align="center"/>|
| Code Viewer | Asset Manager | | Code Viewer | Asset Manager |
|--|--| |--|--|
|<img src="http://grapesjs.com/img/sc-grapesjs-code.jpg" alt="GrapesJS - Code Viewer" height="300" align="center"/>|<img src="http://grapesjs.com/img/sc-grapesjs-assets-1.jpg" alt="GrapesJS - Asset Manager" height="250" align="center"/>| |<img src="http://grapesjs.com/assets/images/sc-grapesjs-code.jpg" alt="GrapesJS - Code Viewer" height="300" align="center"/>|<img src="http://grapesjs.com/assets/images/sc-grapesjs-assets-1.jpg" alt="GrapesJS - Asset Manager" height="250" align="center"/>|
* Local and remote storage * Local and remote storage
@ -203,7 +203,7 @@ Find out more about plugins here: [Creating plugins](https://grapesjs.com/docs/m
If you like the project and you wish to see it grow, please consider supporting us with a donation of your choice or become a backer/sponsor via [Open Collective](https://opencollective.com/grapesjs) If you like the project and you wish to see it grow, please consider supporting us with a donation of your choice or become a backer/sponsor via [Open Collective](https://opencollective.com/grapesjs)
[![PayPalMe](http://grapesjs.com/img/ppme.png)](https://paypal.me/grapesjs) [![PayPalMe](http://grapesjs.com/assets/images/ppme.png)](https://paypal.me/grapesjs)
[![Bitcoin](https://user-images.githubusercontent.com/11614725/52977952-87235f80-33cf-11e9-9607-7a9a354e1155.png)](https://commerce.coinbase.com/checkout/fc90b940-558d-408b-a166-28a823c98173) [![Bitcoin](https://user-images.githubusercontent.com/11614725/52977952-87235f80-33cf-11e9-9607-7a9a354e1155.png)](https://commerce.coinbase.com/checkout/fc90b940-558d-408b-a166-28a823c98173)
<a href="https://opencollective.com/grapesjs"><img src="https://opencollective.com/grapesjs/tiers/sponsors.svg?avatarHeight=64"></a> <a href="https://opencollective.com/grapesjs"><img src="https://opencollective.com/grapesjs/tiers/sponsors.svg?avatarHeight=64"></a>

2
dist/css/grapes.min.css

File diff suppressed because one or more lines are too long

4
dist/grapes.min.js

File diff suppressed because one or more lines are too long

2
dist/grapes.min.js.map

File diff suppressed because one or more lines are too long

16407
dist/grapes.mjs

File diff suppressed because it is too large

2
dist/grapes.mjs.map

File diff suppressed because one or more lines are too long

4940
dist/index.d.ts

File diff suppressed because it is too large

9
docs/.vuepress/components/DemoViewer.vue

@ -26,12 +26,17 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
show: {
type: Boolean,
default: false,
},
}, },
computed: { computed: {
src() { src() {
const { value, user, darkcode } = this; const { value, user, darkcode, show } = this;
const tabs = show ? 'result,js,html,css' : 'js,html,css,result';
const dcStr = darkcode ? '/dark/?menuColor=fff&fontColor=333&accentColor=e67891' : ''; const dcStr = darkcode ? '/dark/?menuColor=fff&fontColor=333&accentColor=e67891' : '';
return `//jsfiddle.net/${user}/${value}/embedded/js,html,css,result${dcStr}`; return `//jsfiddle.net/${user}/${value}/embedded/${tabs}${dcStr}`;
} }
} }
} }

5
docs/.vuepress/config.js

@ -11,8 +11,8 @@ module.exports = {
serviceWorker: false, // Enable Service Worker for offline usage serviceWorker: false, // Enable Service Worker for offline usage
head: [ head: [
['link', { rel: 'icon', href: '/logo-icon.png' }], ['link', { rel: 'icon', href: '/logo-icon.png' }],
['link', { rel: 'stylesheet', href: isDev ? `${devPath}/dist/css/grapes.min.css` : `${baseUrl}/stylesheets/grapes.min.css?v${version}` }], ['link', { rel: 'stylesheet', href: isDev ? `${devPath}/dist/css/grapes.min.css` : `${baseUrl}/assets/styles/grapesjs/grapes.min.css?${version}` }],
['script', { src: isDev ? `${devPath}/grapes.min.js` : `${baseUrl}/js/grapes.min.js?v${version}` }], ['script', { src: isDev ? `${devPath}/grapes.min.js` : `${baseUrl}/assets/scripts/grapesjs/grapes.min.js?${version}` }],
], ],
localesSKIP: { localesSKIP: {
'/': { '/': {
@ -125,6 +125,7 @@ module.exports = {
title: 'Guides', title: 'Guides',
collapsable: false, collapsable: false,
children: [ children: [
['/guides/Symbols', 'Symbols'],
['/guides/Replace-Rich-Text-Editor', 'Replace Rich Text Editor'], ['/guides/Replace-Rich-Text-Editor', 'Replace Rich Text Editor'],
['/guides/Custom-CSS-parser', 'Use Custom CSS Parser'], ['/guides/Custom-CSS-parser', 'Use Custom CSS Parser'],
] ]

11
docs/.vuepress/public/symbols-model.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

77
docs/api/assets.md

@ -19,18 +19,71 @@ const assetManager = editor.AssetManager;
``` ```
## Available Events ## Available Events
* `asset:add` New asset added to the collection. The [Asset] is passed as an argument to the callback.
* `asset:open` - Asset Manager opened. ```javascript
* `asset:close` - Asset Manager closed. editor.on('asset:add', (asset) => { ... });
* `asset:add` - Asset added. The [Asset] is passed as an argument to the callback. ```
* `asset:remove` - Asset removed. The [Asset] is passed as an argument to the callback.
* `asset:update` - Asset updated. The updated [Asset] and the object containing changes are passed as arguments to the callback. * `asset:remove` Asset removed from the collection. The [Asset] is passed as an argument to the callback.
* `asset:upload:start` - Before the upload is started.
* `asset:upload:end` - After the upload is ended. ```javascript
* `asset:upload:error` - On any error in upload, passes the error as an argument. editor.on('asset:remove', (asset) => { ... });
* `asset:upload:response` - On upload response, passes the result as an argument. ```
* `asset` - Catch-all event for all the events mentioned above. An object containing all the available data about the triggered event is passed as an argument to the callback.
* `asset:custom` - Event for handling custom Asset Manager UI. * `asset:update` Asset updated. The [Asset] and the object containing changes are passed as arguments to the callback.
```javascript
editor.on('asset:update', (asset, updatedProps) => { ... });
```
* `asset:open` Asset Manager opened.
```javascript
editor.on('asset:open', () => { ... });
```
* `asset:close` Asset Manager closed.
```javascript
editor.on('asset:close', () => { ... });
```
* `asset:upload:start` Asset upload start.
```javascript
editor.on('asset:upload:start', () => { ... });
```
* `asset:upload:end` Asset upload end.
```javascript
editor.on('asset:upload:end', (result) => { ... });
```
* `asset:upload:error` Asset upload error.
```javascript
editor.on('asset:upload:error', (error) => { ... });
```
* `asset:upload:response` Asset upload response.
```javascript
editor.on('asset:upload:response', (res) => { ... });
```
* `asset:custom` Event to use in case of [custom Asset Manager UI](https://grapesjs.com/docs/modules/Assets.html#customization).
```javascript
editor.on('asset:custom', ({ container, assets, ... }) => { ... });
```
* `asset` Catch-all event for all the events mentioned above. An object containing all the available data about the triggered event is passed as an argument to the callback.
```javascript
editor.on('asset', ({ event, model, ... }) => { ... });
```
## Methods ## Methods
@ -160,7 +213,7 @@ Remove asset
### Parameters ### Parameters
* `asset` **([String][13] | [Asset])** Asset or asset URL * `asset` **([String][13] | [Asset])** Asset or asset URL
* `opts` **Record<[string][13], any>?** * `opts` **RemoveOptions?**
### Examples ### Examples

6
docs/api/block_manager.md

@ -66,6 +66,12 @@ editor.on('block:drag', (block) => { ... });
editor.on('block:drag:stop', (component, block) => { ... }); editor.on('block:drag:stop', (component, block) => { ... });
``` ```
* `block:category:update` Block category updated.
```javascript
editor.on('block:category:update', ({ category, changes }) => { ... });
```
* `block:custom` Event to use in case of [custom Block Manager UI](https://grapesjs.com/docs/modules/Blocks.html#customization). * `block:custom` Event to use in case of [custom Block Manager UI](https://grapesjs.com/docs/modules/Blocks.html#customization).
```javascript ```javascript

18
docs/api/canvas.md

@ -90,6 +90,14 @@ editor.on('canvas:pointer', () => {
}); });
``` ```
* `canvas:refresh` Canvas was refreshed to update elements on top,&#xA;like spots/tools (eg. via `editor.Canvas.refresh()` or on frame resize).
```javascript
editor.on('canvas:refresh', (canvasRefreshOptions) => {
console.log('Canvas refreshed with options:', canvasRefreshOptions);
});
```
* `canvas:frame:load` Frame loaded in canvas.&#xA;The event is triggered right after iframe's `onload`. * `canvas:frame:load` Frame loaded in canvas.&#xA;The event is triggered right after iframe's `onload`.
```javascript ```javascript
@ -396,6 +404,16 @@ Transform a box rect from the world coordinate system to the screen one.
Returns **[Object][2]** Returns **[Object][2]**
## refresh
Update canvas for spots/tools positioning.
### Parameters
* `opts` **[Object][2]?** Options. (optional, default `{}`)
* `opts.spots` **[Object][2]** Update the position of spots. (optional, default `false`)
[1]: https://github.com/GrapesJS/grapesjs/blob/master/src/canvas/config/config.ts [1]: https://github.com/GrapesJS/grapesjs/blob/master/src/canvas/config/config.ts
[2]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object [2]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object

61
docs/api/commands.md

@ -23,14 +23,59 @@ const commands = editor.Commands;
commands.add(...); commands.add(...);
``` ```
* ## Available Events ## Available Events
* `run:{commandName}` - Triggered when some command is called to run (eg. editor.runCommand('preview')) * `command:run` Triggered on run of any command.
* `stop:{commandName}` - Triggered when some command is called to stop (eg. editor.stopCommand('preview'))
* `run:{commandName}:before` - Triggered before the command is called ```javascript
* `stop:{commandName}:before` - Triggered before the command is called to stop editor.on('command:run', ({ id, result, options }) => {
* `abort:{commandName}` - Triggered when the command execution is aborted (`editor.on(`run:preview:before`, opts => opts.abort = 1);`) console.log('Command id', id, 'command result', result);
* `run` - Triggered on run of any command. The id and the result are passed as arguments to the callback });
* `stop` - Triggered on stop of any command. The id and the result are passed as arguments to the callback ```
* `command:run:COMMAND_ID` Triggered on run of a specific command.
```javascript
editor.on('command:run:my-command', ({ result, options }) => { ... });
```
* `command:run:before:COMMAND_ID` Triggered before the command is called.
```javascript
editor.on('command:run:before:my-command', ({ options }) => { ... });
```
* `command:abort:COMMAND_ID` Triggered when the command execution is aborted.
```javascript
editor.on('command:abort:my-command', ({ options }) => { ... });
// The command could be aborted during the before event
editor.on('command:run:before:my-command', ({ options }) => {
if (someCondition) {
options.abort = true;
}
});
```
* `command:stop` Triggered on stop of any command.
```javascript
editor.on('command:stop', ({ id, result, options }) => {
console.log('Command id', id, 'command result', result);
});
```
* `command:stop:COMMAND_ID` Triggered on stop of a specific command.
```javascript
editor.on('command:run:my-command', ({ result, options }) => { ... });
```
* `command:stop:before:COMMAND_ID` Triggered before the command is called to stop.
```javascript
editor.on('command:stop:before:my-command', ({ options }) => { ... });
```
## Methods ## Methods

30
docs/api/component.md

@ -128,6 +128,28 @@ Get the drag mode of the component.
Returns **[String][1]** Drag mode value, options: `'absolute'` | `'translate'` | `''` Returns **[String][1]** Drag mode value, options: `'absolute'` | `'translate'` | `''`
## setSymbolOverride
Set symbol override.
By setting override to `true`, none of its property changes will be propagated to relative symbols.
By setting override to specific properties, changes of those properties will be skipped from propagation.
### Parameters
* `value` **([Boolean][3] | [String][1] | [Array][5]<[String][1]>)**
### Examples
```javascript
component.setSymbolOverride(['children', 'classes']);
```
## getSymbolOverride
Get symbol override value.
Returns **([Boolean][3] | [Array][5]<[String][1]>)**
## find ## find
Find inner components by query string. Find inner components by query string.
@ -238,7 +260,7 @@ Update attributes of the component
### Parameters ### Parameters
* `attrs` **[Object][2]** Key value attributes * `attrs` **[Object][2]** Key value attributes
* `opts` **SetOptions** (optional, default `{}`) * `opts` **SetAttrOptions** (optional, default `{}`)
* `options` **[Object][2]** Options for the model update * `options` **[Object][2]** Options for the model update
### Examples ### Examples
@ -256,7 +278,7 @@ Add attributes to the component
### Parameters ### Parameters
* `attrs` **[Object][2]** Key value attributes * `attrs` **[Object][2]** Key value attributes
* `opts` **SetOptions** (optional, default `{}`) * `opts` **SetAttrOptions** (optional, default `{}`)
* `options` **[Object][2]** Options for the model update * `options` **[Object][2]** Options for the model update
### Examples ### Examples
@ -304,7 +326,7 @@ Set the style on the component
### Parameters ### Parameters
* `prop` **[Object][2]** Key value style object (optional, default `{}`) * `prop` **[Object][2]** Key value style object (optional, default `{}`)
* `opts` **any** (optional, default `{}`) * `opts` **UpdateStyleOptions** (optional, default `{}`)
### Examples ### Examples
@ -859,7 +881,7 @@ Returns **[Boolean][3]**
[5]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array [5]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array
[6]: https://github.com/GrapesJS/grapesjs/blob/master/src/utils/Resizer.js [6]: https://github.com/GrapesJS/grapesjs/blob/master/src/utils/Resizer.ts
[7]: /modules/Components-js.html [7]: /modules/Components-js.html

71
docs/api/components.md

@ -218,6 +218,77 @@ cmp.isComponent({}); // false
Returns **[Boolean][13]** Returns **[Boolean][13]**
## addSymbol
Add a new symbol from a component.
If the passed component is not a symbol, it will be converted to an instance and will return the main symbol.
If the passed component is already an instance, a new instance will be created and returned.
If the passed component is the main symbol, a new instance will be created and returned.
### Parameters
* `component` **[Component]** Component from which create a symbol.
### Examples
```javascript
const symbol = cmp.addSymbol(editor.getSelected());
// cmp.getSymbolInfo(symbol).isSymbol === true;
```
Returns **[Component]**
## getSymbols
Get the array of main symbols.
### Examples
```javascript
const symbols = cmp.getSymbols();
// [Component, Component, ...]
// Removing the main symbol will detach all the relative instances.
symbols[0].remove();
```
Returns **[Array][11]<[Component]>**
## detachSymbol
Detach symbol instance from the main one.
The passed symbol instance will become a regular component.
### Parameters
* `component` **[Component]** The component symbol to detach.
### Examples
```javascript
const cmpInstance = editor.getSelected();
// cmp.getSymbolInfo(cmpInstance).isInstance === true;
cmp.detachSymbol(cmpInstance);
// cmp.getSymbolInfo(cmpInstance).isInstance === false;
```
## getSymbolInfo
Get info about the symbol.
### Parameters
* `component` **[Component]** Component symbol from which to get the info.
* `opts` **{withChanges: [string][12]?}** (optional, default `{}`)
### Examples
```javascript
cmp.getSymbolInfo(editor.getSelected());
// > { isSymbol: true, isMain: false, isInstance: true, ... }
```
Returns **[Object][10]** Object containing symbol info.
## canMove ## canMove
Check if a component can be moved inside another one. Check if a component can be moved inside another one.

21
docs/api/i18n.md

@ -23,11 +23,24 @@ Once the editor is instantiated you can use its API. Before using these methods
const i18n = editor.I18n; const i18n = editor.I18n;
``` ```
### Events ## Available Events
* `i18n:add` New set of messages is added.
* `i18n:add` - New set of messages is added ```javascript
* `i18n:update` - The set of messages is updated editor.on('i18n:add', (messages) => { ... });
* `i18n:locale` - Locale changed ```
* `i18n:update` The set of messages is updated.
```javascript
editor.on('i18n:update', (messages) => { ... });
```
* `i18n:locale` Locale changed.
```javascript
editor.on('i18n:locale', ({ value, valuePrev }) => { ... });
```
## setLocale ## setLocale

6
docs/api/trait_manager.md

@ -36,6 +36,12 @@ editor.on('trait:select', ({ traits, component }) => { ... });
editor.on('trait:value', ({ trait, component, value }) => { ... }); editor.on('trait:value', ({ trait, component, value }) => { ... });
``` ```
* `trait:category:update` Trait category updated.
```javascript
editor.on('trait:category:update', ({ category, changes }) => { ... });
```
* `trait:custom` Event to use in case of [custom Trait Manager UI](https://grapesjs.com/docs/modules/Traits.html#custom-trait-manager). * `trait:custom` Event to use in case of [custom Trait Manager UI](https://grapesjs.com/docs/modules/Traits.html#custom-trait-manager).
```javascript ```javascript

18
docs/api/undo_manager.md

@ -204,6 +204,22 @@ stack.each(item => ...);
Returns **Collection** Returns **Collection**
## skip
Execute the provided callback temporarily stopping tracking changes
### Parameters
* `clb` **[Function][17]** The callback to execute with changes tracking stopped
### Examples
```javascript
um.skip(() => {
// Do stuff without tracking
});
```
## clear ## clear
Clear the stack Clear the stack
@ -247,3 +263,5 @@ Returns **this**
[15]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object [15]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object
[16]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean [16]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean
[17]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function

12
docs/deploy.sh

@ -9,20 +9,20 @@ set -e
# navigate into the build output directory # navigate into the build output directory
cd docs/.vuepress/dist cd docs/.vuepress/dist
# I need to deploy all the documentation inside docs folder # Need to deploy all the documentation inside docs folder
mkdir docs-new mkdir docs-new
# move all the files from the current directory in docs # move all the files from the current directory in docs
mv `\ls -1 ./ | grep -v docs-new` ./docs-new mv `\ls -1 ./ | grep -v docs-new` ./docs-new
# fetch the current site, remove the old docs dir and make current the new one # fetch the current site, remove the old docs dir and make current the new one
git clone -b gh-pages https://github.com/GrapesJS/grapesjs.git tmp && mv tmp/* tmp/.* . && rm -rf tmp git clone -b main https://github.com/GrapesJS/website.git tmp && mv tmp/* tmp/.* . && rm -rf tmp
rm -fR docs rm -fR public/docs
mv ./docs-new ./docs mv ./docs-new ./public/docs
# stage all and commit # stage all and commit
git add -A git add -A
git commit -m 'deploy docs' git commit -m 'deploy docs'
git push https://artf@github.com/GrapesJS/grapesjs.git gh-pages git push https://artf@github.com/GrapesJS/website.git main
# surge --domain grapesjs.surge.sh
cd - cd -

306
docs/guides/Symbols.md

@ -0,0 +1,306 @@
---
title: Symbols
---
# Symbols
::: warning
This feature is released as a beta from GrapesJS v0.21.11
To get a better understanding of the content in this guide we recommend reading [Components] first
:::
Symbols are a special type of [Component] that allows you to easily reuse common elements across your project. They are particularly useful for components that appear multiple times in your project and need to remain consistent. By using Symbols, you can easily update these components in one place and have the changes reflected everywhere they are used.
[[toc]]
## Concept
A Symbol created from a component retains the same shape and uses the same [Components API], but it includes a reference to other related Symbols. When you create a new Symbol from a Component, it creates a Main Symbol, and the original component becomes an Instance Symbol.
When you reuse the Symbol elsewhere, it creates new Instance Symbols. Any updates made to the Main Symbol are automatically replicated in all Instance Symbols, ensuring consistency throughout your project.
Below is a simple representation of the connection between Main and Instance Symbols.
<img :src="$withBase('/symbols-model.svg')">
::: warning Note
This feature operates at a low level, meaning there is no built-in UI for creating and managing symbols. Developers need to implement their own UI to interact with this feature. Below you'll find an example of implementation.
:::
## Programmatic usage
Let's see how to work with and manage Symbols in your project.
### Create symbol
Create a new Symbol from any component in your project:
```js
const anyComponent = editor.getSelected();
const symbolMain = editor.Components.addSymbol(anyComponent);
```
This will transform `anyComponent` to an Instance and the returned `symbolMain` will be the Main Symbol. GrapesJS keeps track of Main Symbols separately in your project JSON, and they will be automatically reconnected when you reload the project.
The `addSymbol` method also handles the creation of Instances. If you call it again by passing `symbolMain` or `anyComponent`, it will create a new Instance of `symbolMain`.
```js
const secondInstance = editor.Components.addSymbol(symbolMain);
```
Now, `symbolMain` references two instances of its shape.
To get all the available Symbols in your project, use `getSymbols`:
```js
const symbols = editor.Components.getSymbols();
const symbolMain = symbols[0];
```
### Symbol details
Once you have Symbols in your project, you might need to know when a Component is a Symbol and get details about it. Use the `getSymbolInfo` method for this:
```js
// Details from the Main Symbol
const symbolMainInfo = editor.Components.getSymbolInfo(symbolMain);
symbolMainInfo.isSymbol; // true; It's a Symbol
symbolMainInfo.isRoot; // true; It's the root of the Symbol
symbolMainInfo.isMain; // true; It's the Main Symbol
symbolMainInfo.isInstance; // false; It's not the Instance Symbol
symbolMainInfo.main; // symbolMainInfo; Reference to the Main Symbol
symbolMainInfo.instances; // [anyComponent, secondInstance]; Reference to Instance Symbols
symbolMainInfo.relatives; // [anyComponent, secondInstance]; Relative Symbols
// Details from the Instance Symbol
const secondInstanceInfo = editor.Components.getSymbolInfo(secondInstance);
symbolMainInfo.isSymbol; // true; It's a Symbol
symbolMainInfo.isRoot; // true; It's the root of the Symbol
symbolMainInfo.isMain; // false; It's not the Main Symbol
symbolMainInfo.isInstance; // true; It's the Instance Symbol
symbolMainInfo.main; // symbolMainInfo; Reference to the Main Symbol
symbolMainInfo.instances; // [anyComponent, secondInstance]; Reference to Instance Symbols
symbolMainInfo.relatives; // [anyComponent, symbolMain]; Relative Symbols
```
### Overrides
When you update a Symbol's properties, changes are propagated to all related Symbols. To avoid propagating specific properties, you can specify at the component level which properties to skip:
```js
anyComponent.set('my-property', true);
secondInstance.get('my-property'); // true; change propagated
anyComponent.setSymbolOverride(['my-property']);
// Get current override value: anyComponent.getSymbolOverride();
anyComponent.set('my-property', false);
secondInstance.get('my-property'); // true; change didn't propagate
```
### Detach symbol
Once you have Symbol instances you might need to disconnect one to create a new custom shape with other components inside, in that case you can use `detachSymbol`.
```js
editor.Components.detachSymbol(anyComponent);
const info = editor.Components.getSymbolInfo(anyComponent);
info.isSymbol; // false; Not a Symbol anymore
const infoMain = editor.Components.getSymbolInfo(symbolMain);
infoMain.instances; // [secondInstance]; Removed the reference
```
### Remove symbol
To remove a Main Symbol and detach all related instances:
```js
const symbolMain = editor.Components.getSymbols()[0];
symbolMain.remove();
```
## Events
The editor triggers several symbol-related events that you can leverage for your integration:
* `symbol:main:add` Added new root main symbol.
```js
editor.on('symbol:main:add', ({ component }) => { ... });
```
* `symbol:main:update` Root main symbol updated.
```js
editor.on('symbol:main:update', ({ component }) => { ... });
```
* `symbol:main:remove` Root main symbol removed.
```js
editor.on('symbol:main:remove', ({ component }) => { ... });
```
* `symbol:main` Catch-all event related to root main symbol updates.
```js
editor.on('symbol:main', ({ event, component }) => { ... });
```
* `symbol:instance:add` Added new root instance symbol.
```js
editor.on('symbol:instance:add', ({ component }) => { ... });
```
* `symbol:instance:remove` Root instance symbol removed.
```js
editor.on('symbol:instance:remove', ({ component }) => { ... });
```
* `symbol:instance` Catch-all event related to root instance symbol updates.
```js
editor.on('symbol:instance', ({ event, component }) => { ... });
```
* `symbol` Catch-all event for any symbol update (main or instance).
```js
editor.on('symbol', () => { ... });
```
## Example
Below is a basic UI implementation leveraging the Symbols API:
<demo-viewer value="ta19s6go" height="500" darkcode show/>
<!-- Demo template, here for reference
<style>
.app-wrapper {
height: 100vh;
display: flex;
flex-direction: column;
}
.vue-app {
padding: 10px;
display: flex;
gap: 10px;
}
.symbols-wrp {
display: flex;
gap: 10px;
width: 100%;
padding: 10px;
flex-direction: column;
border-radius: 3px;
}
.symbols {
display: flex;
gap: 10px;
width: 100%;
}
.symbol {
cursor: pointer;
flex-basis: 100px;
text-align: left;
margin: 0;
}
</style>
<div class="app-wrapper">
<div class="vue-app">
<button @click="createSymbol">Create Symbol</button>
<div class="symbols-wrp gjs-one-bg gjs-two-color">
<div v-if="symbols.length">Click on symbol to append</div>
<div class="symbols">
<div
v-for="symbol in symbols"
class="gjs-block symbol"
@click="createInstance(symbol)"
:key="symbol.getId()"
>
Name: {{ symbol.getName() }}
Instances: {{ getInstancesLength(symbol) }}
</div>
</div>
</div>
</div>
<div id="gjs"></div>
</div>
<script>
const editor = grapesjs.init({
container: '#gjs',
height: '100%',
storageManager: false,
components: `<div style="display: flex">
<article class="card" style="max-width: 300px; padding: 20px">
<img src="https://placehold.co/600x400/000000/FFF" style="max-width: 100%"/>
<h1>Title</h1>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua</p>
</article>
</div>`,
plugins: ['gjs-blocks-basic'],
selectorManager: { componentFirst: true },
});
const { Components } = editor;
const app = new Vue({
el: '.vue-app',
data: { symbols: [] },
mounted() {
editor.on('symbol', this.updateMainSymbolsList);
},
destroyed() {
editor.off('symbol', this.updateMainSymbolsList);
},
methods: {
updateMainSymbolsList() {
this.symbols = Components.getSymbols();
},
createSymbol() {
const selected = editor.getSelected();
if (!selected) return alert('Select a component first!');
const info = Components.getSymbolInfo(selected);
if (info.isSymbol) return alert('Selected component is already a symbol!');
Components.addSymbol(selected);
},
getInstancesLength(symbolMain) {
return Components.getSymbolInfo(symbolMain).instances.length;
},
createInstance(symbolMain) {
const instance = Components.addSymbol(symbolMain);
editor.getWrapper().append(instance, { at: 0 });
}
}
});
</script>
-->
[Component]: </modules/Components.html>
[Components]: </modules/Components.html>
[Components API]: </api/component.html>

2
docs/modules/Assets.md

@ -69,7 +69,7 @@ const editor = grapesjs.init({
``` ```
If you want a complete list of available properties check out the source [AssetImage Model](https://github.com/GrapesJS/grapesjs/blob/dev/src/asset_manager/model/AssetImage.js) If you want a complete list of available properties check out the source [AssetImage Model](https://github.com/GrapesJS/grapesjs/blob/dev/src/asset_manager/model/AssetImage.ts)
The built-in Asset Manager modal is implemented and is showing up when requested. By default, you can make it appear by dragging Image Components in canvas, double clicking on images and all other stuff related to images (eg. CSS styling) The built-in Asset Manager modal is implemented and is showing up when requested. By default, you can make it appear by dragging Image Components in canvas, double clicking on images and all other stuff related to images (eg. CSS styling)

16
docs/modules/Commands.md

@ -267,20 +267,20 @@ The Commands module offers also a set of events that you can use to intercept th
By using our previously created `my-command-modal` command let's see which events we can listen to By using our previously created `my-command-modal` command let's see which events we can listen to
```js ```js
editor.on('run:my-command-modal', () => { editor.on('command:run:my-command-modal', () => {
console.log('After `my-command-modal` execution'); console.log('After `my-command-modal` execution');
// For example, you can add extra content to the modal // For example, you can add extra content to the modal
const modalContent = editor.Modal.getContentEl(); const modalContent = editor.Modal.getContentEl();
modalContent.insertAdjacentHTML('beforeEnd', '<div>Some content</div>'); modalContent.insertAdjacentHTML('beforeEnd', '<div>Some content</div>');
}); });
editor.on('run:my-command-modal:before', () => { editor.on('command:run:before:my-command-modal', () => {
console.log('Before `my-command-modal` execution'); console.log('Before `my-command-modal` execution');
}); });
// for stateful commands // for stateful commands
editor.on('stop:my-command-modal', () => { editor.on('command:stop:my-command-modal', () => {
console.log('After `my-command-modal` is stopped'); console.log('After `my-command-modal` is stopped');
}); });
editor.on('stop:my-command-modal:before', () => { editor.on('command:stop:before:my-command-modal:before', () => {
console.log('Before `my-command-modal` is stopped'); console.log('Before `my-command-modal` is stopped');
}); });
``` ```
@ -288,11 +288,11 @@ editor.on('stop:my-command-modal:before', () => {
If you need, you can also listen to all commands If you need, you can also listen to all commands
```js ```js
editor.on('run', commandId => { editor.on('command:run', commandId => {
console.log('Run', commandId); console.log('Run', commandId);
}); });
editor.on('stop', commandId => { editor.on('command:stop', commandId => {
console.log('Stop', commandId); console.log('Stop', commandId);
}); });
``` ```
@ -300,12 +300,12 @@ editor.on('stop', commandId => {
### Interrupt command flow ### Interrupt command flow
Sometimes you might need to interrupt the execution of an existant command due to some condition. In that case, you have to use `run:{COMMAND-ID}:before` event and set to `true` the abort option Sometimes you might need to interrupt the execution of an existant command due to some condition. In that case, you have to use `command:run:before:{COMMAND-ID}` event and set to `true` the abort option
```js ```js
const condition = 1; const condition = 1;
editor.on('run:my-command-modal:before', options => { editor.on('command:run:before:my-command-modal', options => {
if (condition) { if (condition) {
options.abort = true; options.abort = true;
console.log('Prevent `my-command-modal` from execution'); console.log('Prevent `my-command-modal` from execution');

4
docs/modules/I18n.md

@ -44,7 +44,7 @@ Now the editor will be translated in Italian for those browsers which default la
## Update strings ## Update strings
If you need to change some default language strings you can easily update them by using [I18n API](/api/i18n.html). If you need to change some default language strings you can easily update them by using [I18n API](/api/i18n.html).
To find the correth path of the string you can check the [`en` locale file] and follow its inner path inside the locale object. To find the correct path of the string you can check the [`en` locale file] and follow its inner path inside the locale object.
Let's say we want to update the default message of the empty state in Style Manager when no elements are selected. Let's say we want to update the default message of the empty state in Style Manager when no elements are selected.
@ -167,7 +167,7 @@ plugin-dir
- ... - ...
- src - src
- index.js - index.js
- locale // create the locale foldar in your src - locale // create the locale folder in your src
- en.js // All default strings should be placed here - en.js // All default strings should be placed here
``` ```

2
docs/modules/Plugins.md

@ -182,4 +182,4 @@ grapesjs.init({
## Boilerplate ## Boilerplate
For fast plugin development, we highly recommend using [grapesjs-cli](https://github.com/GrapesJS/cli) which helps to avoid the hassle of setting up all the dependencies and configurations for development and building (no need to touch Webpack o Babel configurations). For more information check the repository. For fast plugin development, we highly recommend using [grapesjs-cli](https://github.com/GrapesJS/cli) which helps to avoid the hassle of setting up all the dependencies and configurations for development and building (no need to touch Webpack or Babel configurations). For more information check the repository.

2
package.json

@ -1,7 +1,7 @@
{ {
"name": "grapesjs", "name": "grapesjs",
"description": "Free and Open Source Web Builder Framework", "description": "Free and Open Source Web Builder Framework",
"version": "0.21.8", "version": "0.21.12",
"author": "Artur Arseniev", "author": "Artur Arseniev",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"homepage": "http://grapesjs.com", "homepage": "http://grapesjs.com",

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];

153
src/asset_manager/index.ts

@ -14,18 +14,7 @@
* const assetManager = editor.AssetManager; * const assetManager = editor.AssetManager;
* ``` * ```
* *
* ## Available Events * {REPLACE_EVENTS}
* * `asset:open` - Asset Manager opened.
* * `asset:close` - Asset Manager closed.
* * `asset:add` - Asset added. The [Asset] is passed as an argument to the callback.
* * `asset:remove` - Asset removed. The [Asset] is passed as an argument to the callback.
* * `asset:update` - Asset updated. The updated [Asset] and the object containing changes are passed as arguments to the callback.
* * `asset:upload:start` - Before the upload is started.
* * `asset:upload:end` - After the upload is ended.
* * `asset:upload:error` - On any error in upload, passes the error as an argument.
* * `asset:upload:response` - On upload response, passes the result as an argument.
* * `asset` - Catch-all event for all the events mentioned above. An object containing all the available data about the triggered event is passed as an argument to the callback.
* * `asset:custom` - Event for handling custom Asset Manager UI.
* *
* ## Methods * ## Methods
* * [open](#open) * * [open](#open)
@ -45,67 +34,20 @@
import { debounce, isFunction } from 'underscore'; import { debounce, isFunction } from 'underscore';
import { ItemManagerModule } from '../abstract/Module'; import { ItemManagerModule } from '../abstract/Module';
import { AddOptions, RemoveOptions } from '../common';
import EditorModel from '../editor/model/Editor'; import EditorModel from '../editor/model/Editor';
import { ProjectData } from '../storage_manager';
import defaults, { AssetManagerConfig } from './config/config'; import defaults, { AssetManagerConfig } from './config/config';
import Asset from './model/Asset'; import Asset from './model/Asset';
import Assets from './model/Assets'; import Assets from './model/Assets';
import AssetsEvents, { AssetOpenOptions } from './types';
import AssetsView from './view/AssetsView'; import AssetsView from './view/AssetsView';
import FileUploaderView from './view/FileUploader'; import FileUploaderView from './view/FileUploader';
export type AssetEvent =
| 'asset'
| 'asset:open'
| 'asset:close'
| 'asset:add'
| 'asset:remove'
| 'asset:update'
| 'asset:custom'
| 'asset:upload:start'
| 'asset:upload:end'
| 'asset:upload:error'
| 'asset:upload:response';
export const evAll = 'asset';
export const evPfx = `${evAll}:`;
export const evSelect = `${evPfx}select`;
export const evUpdate = `${evPfx}update`;
export const evAdd = `${evPfx}add`;
export const evRemove = `${evPfx}remove`;
export const evRemoveBefore = `${evRemove}:before`;
export const evCustom = `${evPfx}custom`;
export const evOpen = `${evPfx}open`;
export const evClose = `${evPfx}close`;
export const evUpload = `${evPfx}upload`;
export const evUploadStart = `${evUpload}:start`;
export const evUploadEnd = `${evUpload}:end`;
export const evUploadError = `${evUpload}:error`;
export const evUploadRes = `${evUpload}:response`;
const assetCmd = 'open-assets';
const assetEvents = {
all: evAll,
select: evSelect,
update: evUpdate,
add: evAdd,
remove: evRemove,
removeBefore: evRemoveBefore,
custom: evCustom,
open: evOpen,
close: evClose,
uploadStart: evUploadStart,
uploadEnd: evUploadEnd,
uploadError: evUploadError,
uploadResponse: evUploadRes,
};
// TODO // TODO
type AssetProps = Record<string, any>; type AssetProps = Record<string, any>;
type OpenOptions = { const assetCmd = 'open-assets';
select?: (asset: Asset, complete: boolean) => void;
types?: string[];
accept?: string;
target?: any;
};
export default class AssetManager extends ItemManagerModule<AssetManagerConfig, Assets> { export default class AssetManager extends ItemManagerModule<AssetManagerConfig, Assets> {
storageKey = 'assets'; storageKey = 'assets';
@ -115,7 +57,7 @@ export default class AssetManager extends ItemManagerModule<AssetManagerConfig,
am?: AssetsView; am?: AssetsView;
fu?: FileUploaderView; fu?: FileUploaderView;
_bhv?: any; _bhv?: any;
events!: typeof assetEvents; events = AssetsEvents;
/** /**
* Initialize module * Initialize module
@ -124,11 +66,10 @@ export default class AssetManager extends ItemManagerModule<AssetManagerConfig,
*/ */
constructor(em: EditorModel) { constructor(em: EditorModel) {
// @ts-ignore // @ts-ignore
super(em, 'AssetManager', new Assets([], em), assetEvents, defaults); super(em, 'AssetManager', new Assets([], em), AssetsEvents, defaults);
const { all, config } = this; const { all, config } = this;
// @ts-ignore // @ts-ignore
this.assetsVis = new Assets([]); this.assetsVis = new Assets([]);
// @ts-ignore
const ppfx = config.pStylePrefix; const ppfx = config.pStylePrefix;
if (ppfx) { if (ppfx) {
config.stylePrefix = `${ppfx}${config.stylePrefix}`; config.stylePrefix = `${ppfx}${config.stylePrefix}`;
@ -143,40 +84,6 @@ export default class AssetManager extends ItemManagerModule<AssetManagerConfig,
return this; return this;
} }
__propEv(ev: string, ...data: any[]) {
this.em.trigger(ev, ...data);
this.getAll().trigger(ev, ...data);
}
__trgCustom() {
const bhv = this.__getBehaviour();
const custom = this.getConfig().custom;
if (!bhv.container && !(custom as any).open) {
return;
}
this.em.trigger(this.events.custom, this.__customData());
}
__customData() {
const bhv = this.__getBehaviour();
return {
am: this as AssetManager,
open: this.isOpen(),
assets: this.getAll().models,
types: bhv.types || [],
container: bhv.container,
close: () => this.close(),
remove: (asset: string | Asset, opts?: Record<string, any>) => this.remove(asset, opts),
select: (asset: Asset, complete: boolean) => {
const res = this.add(asset);
isFunction(bhv.select) && bhv.select(res, complete);
},
// extra
options: bhv.options || {},
};
}
/** /**
* Open the asset manager. * Open the asset manager.
* @param {Object} [options] Options for the asset manager. * @param {Object} [options] Options for the asset manager.
@ -197,7 +104,7 @@ export default class AssetManager extends ItemManagerModule<AssetManagerConfig,
* // with your custom types (you should have assets with those types declared) * // with your custom types (you should have assets with those types declared)
* assetManager.open({ types: ['doc'], ... }); * assetManager.open({ types: ['doc'], ... });
*/ */
open(options: OpenOptions = {}) { open(options: AssetOpenOptions = {}) {
const cmd = this.em.Commands; const cmd = this.em.Commands;
cmd.run(assetCmd, { cmd.run(assetCmd, {
types: ['image'], types: ['image'],
@ -246,7 +153,7 @@ export default class AssetManager extends ItemManagerModule<AssetManagerConfig,
* }); * });
* assetManager.add([{ src: 'img2.jpg' }, { src: 'img2.png' }]); * assetManager.add([{ src: 'img2.jpg' }, { src: 'img2.png' }]);
*/ */
add(asset: string | AssetProps | (string | AssetProps)[], opts: Record<string, any> = {}) { add(asset: string | AssetProps | (string | AssetProps)[], opts: AddOptions = {}) {
// Put the model at the beginning // Put the model at the beginning
if (typeof opts.at == 'undefined') { if (typeof opts.at == 'undefined') {
opts.at = 0; opts.at = 0;
@ -292,7 +199,7 @@ export default class AssetManager extends ItemManagerModule<AssetManagerConfig,
* const asset = assetManager.get('http://img.jpg'); * const asset = assetManager.get('http://img.jpg');
* assetManager.remove(asset); * assetManager.remove(asset);
*/ */
remove(asset: string | Asset, opts?: Record<string, any>) { remove(asset: string | Asset, opts?: RemoveOptions) {
return this.__remove(asset, opts); return this.__remove(asset, opts);
} }
@ -300,7 +207,7 @@ export default class AssetManager extends ItemManagerModule<AssetManagerConfig,
return this.getProjectData(); return this.getProjectData();
} }
load(data: Record<string, any>) { load(data: ProjectData) {
return this.loadProjectData(data); return this.loadProjectData(data);
} }
@ -416,8 +323,8 @@ export default class AssetManager extends ItemManagerModule<AssetManagerConfig,
onLoad() { onLoad() {
this.getAll().reset(this.config.assets); this.getAll().reset(this.config.assets);
const { em, events } = this; const { em, events } = this;
em.on(`run:${assetCmd}`, () => this.__propEv(events.open)); em.Commands.__onRun(assetCmd, () => this.__propEv(events.open));
em.on(`stop:${assetCmd}`, () => this.__propEv(events.close)); em.Commands.__onStop(assetCmd, () => this.__propEv(events.close));
} }
postRender(editorView: any) { postRender(editorView: any) {
@ -462,6 +369,40 @@ export default class AssetManager extends ItemManagerModule<AssetManagerConfig,
this.config.onDblClick = func; this.config.onDblClick = func;
} }
__propEv(ev: string, ...data: any[]) {
this.em.trigger(ev, ...data);
this.getAll().trigger(ev, ...data);
}
__trgCustom() {
const bhv = this.__getBehaviour();
const custom = this.getConfig().custom;
if (!bhv.container && !(custom as any).open) {
return;
}
this.em.trigger(this.events.custom, this.__customData());
}
__customData() {
const bhv = this.__getBehaviour();
return {
am: this as AssetManager,
open: this.isOpen(),
assets: this.getAll().models,
types: bhv.types || [],
container: bhv.container,
close: () => this.close(),
remove: (asset: string | Asset, opts?: Record<string, any>) => this.remove(asset, opts),
select: (asset: Asset, complete: boolean) => {
const res = this.add(asset);
isFunction(bhv.select) && bhv.select(res, complete);
},
// extra
options: bhv.options || {},
};
}
__behaviour(opts = {}) { __behaviour(opts = {}) {
return (this._bhv = { return (this._bhv = {
...(this._bhv || {}), ...(this._bhv || {}),

95
src/asset_manager/types.ts

@ -0,0 +1,95 @@
import Asset from './model/Asset';
export type AssetEvent = `${AssetsEvents}`;
export interface AssetOpenOptions {
select?: (asset: Asset, complete: boolean) => void;
types?: string[];
accept?: string;
target?: any;
}
/**{START_EVENTS}*/
export enum AssetsEvents {
/**
* @event `asset:add` New asset added to the collection. The [Asset] is passed as an argument to the callback.
* @example
* editor.on('asset:add', (asset) => { ... });
*/
add = 'asset:add',
/**
* @event `asset:remove` Asset removed from the collection. The [Asset] is passed as an argument to the callback.
* @example
* editor.on('asset:remove', (asset) => { ... });
*/
remove = 'asset:remove',
removeBefore = 'asset:remove:before',
/**
* @event `asset:update` Asset updated. The [Asset] and the object containing changes are passed as arguments to the callback.
* @example
* editor.on('asset:update', (asset, updatedProps) => { ... });
*/
update = 'asset:update',
/**
* @event `asset:open` Asset Manager opened.
* @example
* editor.on('asset:open', () => { ... });
*/
open = 'asset:open',
/**
* @event `asset:close` Asset Manager closed.
* @example
* editor.on('asset:close', () => { ... });
*/
close = 'asset:close',
/**
* @event `asset:upload:start` Asset upload start.
* @example
* editor.on('asset:upload:start', () => { ... });
*/
uploadStart = 'asset:upload:start',
/**
* @event `asset:upload:end` Asset upload end.
* @example
* editor.on('asset:upload:end', (result) => { ... });
*/
uploadEnd = 'asset:upload:end',
/**
* @event `asset:upload:error` Asset upload error.
* @example
* editor.on('asset:upload:error', (error) => { ... });
*/
uploadError = 'asset:upload:error',
/**
* @event `asset:upload:response` Asset upload response.
* @example
* editor.on('asset:upload:response', (res) => { ... });
*/
uploadResponse = 'asset:upload:response',
/**
* @event `asset:custom` Event to use in case of [custom Asset Manager UI](https://grapesjs.com/docs/modules/Assets.html#customization).
* @example
* editor.on('asset:custom', ({ container, assets, ... }) => { ... });
*/
custom = 'asset:custom',
/**
* @event `asset` Catch-all event for all the events mentioned above. An object containing all the available data about the triggered event is passed as an argument to the callback.
* @example
* editor.on('asset', ({ event, model, ... }) => { ... });
*/
all = 'asset',
}
/**{END_EVENTS}*/
// need this to avoid the TS documentation generator to break
export default AssetsEvents;

11
src/asset_manager/view/FileUploader.ts

@ -1,3 +1,4 @@
import AssetManager from '..';
import { View } from '../../common'; import { View } from '../../common';
import EditorModel from '../../editor/model/Editor'; import EditorModel from '../../editor/model/Editor';
import fetch from '../../utils/fetch'; import fetch from '../../utils/fetch';
@ -18,7 +19,7 @@ export default class FileUploaderView extends View {
pfx: string; pfx: string;
ppfx: string; ppfx: string;
em: EditorModel; em: EditorModel;
module: any; module: AssetManager;
target: any; target: any;
uploadId: string; uploadId: string;
disabled: boolean; disabled: boolean;
@ -80,7 +81,7 @@ export default class FileUploaderView extends View {
*/ */
onUploadStart() { onUploadStart() {
const { module } = this; const { module } = this;
module && module.__propEv('asset:upload:start'); module?.__propEv(module.events.uploadStart);
} }
/** /**
@ -90,7 +91,7 @@ export default class FileUploaderView extends View {
*/ */
onUploadEnd(res: any) { onUploadEnd(res: any) {
const { $el, module } = this; const { $el, module } = this;
module && module.__propEv('asset:upload:end', res); module?.__propEv(module.events.uploadEnd, res);
const input = $el.find('input'); const input = $el.find('input');
input && input.val(''); input && input.val('');
} }
@ -104,7 +105,7 @@ export default class FileUploaderView extends View {
const { module } = this; const { module } = this;
console.error(err); console.error(err);
this.onUploadEnd(err); this.onUploadEnd(err);
module && module.__propEv('asset:upload:error', err); module?.__propEv(module.events.uploadError, err);
} }
/** /**
@ -121,7 +122,7 @@ export default class FileUploaderView extends View {
json = text; json = text;
} }
module && module.__propEv('asset:upload:response', json); module?.__propEv(module.events.uploadResponse, json);
if (config.autoAdd && target) { if (config.autoAdd && target) {
target.add(json.data, { at: 0 }); target.add(json.data, { at: 0 });

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));

2
src/block_manager/model/Block.ts

@ -14,7 +14,7 @@ export interface BlockProperties {
/** /**
* The content of the block. Might be an HTML string or a [Component Defintion](/modules/Components.html#component-definition) * The content of the block. Might be an HTML string or a [Component Defintion](/modules/Components.html#component-definition)
*/ */
content: string | ComponentDefinition; content: string | ComponentDefinition | (string | ComponentDefinition)[];
/** /**
* HTML string for the media/icon of the block, eg. `<svg ...`, `<img ...`, etc. * HTML string for the media/icon of the block, eg. `<svg ...`, `<img ...`, etc.
* @default '' * @default ''

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

20
src/canvas/index.ts

@ -40,7 +40,7 @@ import Canvas from './model/Canvas';
import CanvasSpot, { CanvasSpotBuiltInTypes, CanvasSpotProps } from './model/CanvasSpot'; import CanvasSpot, { CanvasSpotBuiltInTypes, CanvasSpotProps } from './model/CanvasSpot';
import CanvasSpots from './model/CanvasSpots'; import CanvasSpots from './model/CanvasSpots';
import Frame from './model/Frame'; import Frame from './model/Frame';
import { CanvasEvents, ToWorldOption } from './types'; import { CanvasEvents, CanvasRefreshOptions, ToWorldOption } from './types';
import CanvasView, { FitViewportOptions } from './view/CanvasView'; import CanvasView, { FitViewportOptions } from './view/CanvasView';
import FrameView from './view/FrameView'; import FrameView from './view/FrameView';
@ -834,6 +834,24 @@ export default class CanvasModule extends Module<CanvasConfig> {
return this.canvasView?.getRectToScreen(boxRect); return this.canvasView?.getRectToScreen(boxRect);
} }
/**
* Update canvas for spots/tools positioning.
* @param {Object} [opts] Options.
* @param {Object} [opts.spots=false] Update the position of spots.
*/
refresh(opts: CanvasRefreshOptions = {}) {
const { em, events, canvasView } = this;
canvasView?.clearOff();
if (opts.spots || opts.all) {
this.refreshSpots();
em.trigger('canvas:updateTools'); // this should be deprecated
}
em.set('canvasOffset', this.getOffset()); // this should be deprecated
em.trigger(events.refresh, opts);
}
refreshSpots() { refreshSpots() {
this.spots.refresh(); this.spots.refresh();
} }

2
src/canvas/model/Canvas.ts

@ -42,7 +42,7 @@ export default class Canvas extends ModuleModel<CanvasModule> {
init() { init() {
const { em } = this; const { em } = this;
const mainPage = em.Pages.getMain(); const mainPage = em.Pages._initPage();
this.set('frames', mainPage.getFrames()); this.set('frames', mainPage.getFrames());
this.updateDevice({ frame: mainPage.getMainFrame() }); this.updateDevice({ frame: mainPage.getMainFrame() });
} }

4
src/canvas/model/CanvasSpots.ts

@ -4,6 +4,7 @@ import { ModuleCollection } from '../../abstract';
import { Debounced, ObjectAny } from '../../common'; import { Debounced, ObjectAny } from '../../common';
import EditorModel from '../../editor/model/Editor'; import EditorModel from '../../editor/model/Editor';
import CanvasSpot, { CanvasSpotProps } from './CanvasSpot'; import CanvasSpot, { CanvasSpotProps } from './CanvasSpot';
import { ComponentsEvents } from '../../dom_components/types';
export default class CanvasSpots extends ModuleCollection<CanvasSpot> { export default class CanvasSpots extends ModuleCollection<CanvasSpot> {
refreshDbn: Debounced; refreshDbn: Debounced;
@ -15,7 +16,8 @@ export default class CanvasSpots extends ModuleCollection<CanvasSpot> {
this.on('remove', this.onRemove); this.on('remove', this.onRemove);
const { em } = this; const { em } = this;
this.refreshDbn = debounce(() => this.refresh(), 0); this.refreshDbn = debounce(() => this.refresh(), 0);
const evToRefreshDbn = 'component:resize styleable:change component:input component:update frame:updated undo redo';
const evToRefreshDbn = `component:resize styleable:change component:input ${ComponentsEvents.update} frame:updated undo redo`;
this.listenTo(em, evToRefreshDbn, () => this.refreshDbn()); this.listenTo(em, evToRefreshDbn, () => this.refreshDbn());
} }

18
src/canvas/types.ts

@ -10,6 +10,14 @@ export interface GetBoxRectOptions extends ToScreenOption {
local?: boolean; local?: boolean;
} }
export interface CanvasRefreshOptions {
/**
* Refresh canvas spots.
*/
spots?: boolean;
all?: boolean;
}
/**{START_EVENTS}*/ /**{START_EVENTS}*/
export enum CanvasEvents { export enum CanvasEvents {
/** /**
@ -101,6 +109,16 @@ export enum CanvasEvents {
*/ */
pointer = 'canvas:pointer', pointer = 'canvas:pointer',
/**
* @event `canvas:refresh` Canvas was refreshed to update elements on top,
* like spots/tools (eg. via `editor.Canvas.refresh()` or on frame resize).
* @example
* editor.on('canvas:refresh', (canvasRefreshOptions) => {
* console.log('Canvas refreshed with options:', canvasRefreshOptions);
* });
*/
refresh = 'canvas:refresh',
/** /**
* @event `canvas:frame:load` Frame loaded in canvas. * @event `canvas:frame:load` Frame loaded in canvas.
* The event is triggered right after iframe's `onload`. * The event is triggered right after iframe's `onload`.

2
src/canvas/view/CanvasView.ts

@ -95,7 +95,7 @@ export default class CanvasView extends ModuleView<Canvas> {
this.className = `${pfx}canvas ${ppfx}no-touch-actions${!em.config.customUI ? ` ${pfx}canvas-bg` : ''}`; this.className = `${pfx}canvas ${ppfx}no-touch-actions${!em.config.customUI ? ` ${pfx}canvas-bg` : ''}`;
this.clsUnscale = `${pfx}unscale`; this.clsUnscale = `${pfx}unscale`;
this._initFrames(); this._initFrames();
this.listenTo(em, 'change:canvasOffset', this.clearOff); this.listenTo(em, events.refresh, this.clearOff);
this.listenTo(em, 'component:selected', this.checkSelected); this.listenTo(em, 'component:selected', this.checkSelected);
this.listenTo(em, `${events.coords} ${events.zoom}`, this.updateFrames); this.listenTo(em, `${events.coords} ${events.zoom}`, this.updateFrames);
this.listenTo(model, 'change:frames', this._onFramesUpdate); this.listenTo(model, 'change:frames', this._onFramesUpdate);

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 } = {}) {

23
src/commands/index.ts

@ -19,14 +19,7 @@
* commands.add(...); * commands.add(...);
* ``` * ```
* *
** ## Available Events * {REPLACE_EVENTS}
* * `run:{commandName}` - Triggered when some command is called to run (eg. editor.runCommand('preview'))
* * `stop:{commandName}` - Triggered when some command is called to stop (eg. editor.stopCommand('preview'))
* * `run:{commandName}:before` - Triggered before the command is called
* * `stop:{commandName}:before` - Triggered before the command is called to stop
* * `abort:{commandName}` - Triggered when the command execution is aborted (`editor.on(`run:preview:before`, opts => opts.abort = 1);`)
* * `run` - Triggered on run of any command. The id and the result are passed as arguments to the callback
* * `stop` - Triggered on stop of any command. The id and the result are passed as arguments to the callback
* *
* ## Methods * ## Methods
* * [add](#add) * * [add](#add)
@ -49,6 +42,7 @@ import { Module } from '../abstract';
import Component, { eventDrag } from '../dom_components/model/Component'; import Component, { eventDrag } from '../dom_components/model/Component';
import Editor from '../editor/model/Editor'; import Editor from '../editor/model/Editor';
import { ObjectAny } from '../common'; import { ObjectAny } from '../common';
import CommandsEvents from './types';
export type CommandEvent = 'run' | 'stop' | `run:${string}` | `stop:${string}` | `abort:${string}`; export type CommandEvent = 'run' | 'stop' | `run:${string}` | `stop:${string}` | `abort:${string}`;
@ -105,6 +99,7 @@ export default class CommandsModule extends Module<CommandsConfig & { pStylePref
defaultCommands: Record<string, Command> = {}; defaultCommands: Record<string, Command> = {};
commands: Record<string, CommandObject> = {}; commands: Record<string, CommandObject> = {};
active: Record<string, any> = {}; active: Record<string, any> = {};
events = CommandsEvents;
/** /**
* @private * @private
@ -146,7 +141,7 @@ export default class CommandsModule extends Module<CommandsConfig & { pStylePref
const trg = opts.target as Component | undefined; const trg = opts.target as Component | undefined;
const trgs = trg ? [trg] : [...ed.getSelectedAll()]; const trgs = trg ? [trg] : [...ed.getSelectedAll()];
const targets = trgs.map(trg => trg.delegate?.move?.(trg) || trg).filter(Boolean); const targets = trgs.map(trg => trg.delegate?.move?.(trg) || trg).filter(Boolean);
const target = targets[0] as Component | undefined; const target = targets[targets.length - 1] as Component | undefined;
const nativeDrag = event?.type === 'dragstart'; const nativeDrag = event?.type === 'dragstart';
const modes = ['absolute', 'translate']; const modes = ['absolute', 'translate'];
@ -441,6 +436,16 @@ export default class CommandsModule extends Module<CommandsConfig & { pStylePref
return new cmd(this.config); return new cmd(this.config);
} }
__onRun(id: string, clb: () => void) {
const { em, events } = this;
em.on(`${events.runCommand}${id}`, clb);
}
__onStop(id: string, clb: () => void) {
const { em, events } = this;
em.on(`${events.stopCommand}${id}`, clb);
}
destroy() { destroy() {
this.defaultCommands = {}; this.defaultCommands = {};
this.commands = {}; this.commands = {};

71
src/commands/types.ts

@ -0,0 +1,71 @@
/**{START_EVENTS}*/
export enum CommandsEvents {
/**
* @event `command:run` Triggered on run of any command.
* @example
* editor.on('command:run', ({ id, result, options }) => {
* console.log('Command id', id, 'command result', result);
* });
*/
run = 'command:run',
_run = 'run',
/**
* @event `command:run:COMMAND_ID` Triggered on run of a specific command.
* @example
* editor.on('command:run:my-command', ({ result, options }) => { ... });
*/
runCommand = 'command:run:',
_runCommand = 'run:',
/**
* @event `command:run:before:COMMAND_ID` Triggered before the command is called.
* @example
* editor.on('command:run:before:my-command', ({ options }) => { ... });
*/
runBeforeCommand = 'command:run:before:',
/**
* @event `command:abort:COMMAND_ID` Triggered when the command execution is aborted.
* @example
* editor.on('command:abort:my-command', ({ options }) => { ... });
*
* // The command could be aborted during the before event
* editor.on('command:run:before:my-command', ({ options }) => {
* if (someCondition) {
* options.abort = true;
* }
* });
*/
abort = 'command:abort:',
_abort = 'abort:',
/**
* @event `command:stop` Triggered on stop of any command.
* @example
* editor.on('command:stop', ({ id, result, options }) => {
* console.log('Command id', id, 'command result', result);
* });
*/
stop = 'command:stop',
_stop = 'stop',
/**
* @event `command:stop:COMMAND_ID` Triggered on stop of a specific command.
* @example
* editor.on('command:run:my-command', ({ result, options }) => { ... });
*/
stopCommand = 'command:stop:',
_stopCommand = 'stop:',
/**
* @event `command:stop:before:COMMAND_ID` Triggered before the command is called to stop.
* @example
* editor.on('command:stop:before:my-command', ({ options }) => { ... });
*/
stopBeforeCommand = 'command:stop:before:',
}
/**{END_EVENTS}*/
// need this to avoid the TS documentation generator to break
export default CommandsEvents;

33
src/commands/view/CommandAbstract.ts

@ -2,6 +2,7 @@ import CanvasModule from '../../canvas';
import { Model, ObjectAny } from '../../common'; import { Model, ObjectAny } from '../../common';
import Editor from '../../editor'; import Editor from '../../editor';
import EditorModel from '../../editor/model/Editor'; import EditorModel from '../../editor/model/Editor';
import CommandsEvents from '../types';
interface ICommand<O extends ObjectAny = any> { interface ICommand<O extends ObjectAny = any> {
run?: CommandAbstract<O>['run']; run?: CommandAbstract<O>['run'];
@ -108,18 +109,24 @@ export default class CommandAbstract<O extends ObjectAny = any> extends Model {
* @private * @private
* */ * */
callRun(editor: Editor, options: any = {}) { callRun(editor: Editor, options: any = {}) {
const id = this.id; const { id } = this;
editor.trigger(`run:${id}:before`, options); editor.trigger(`${CommandsEvents.runBeforeCommand}${id}`, { options });
editor.trigger(`${CommandsEvents._runCommand}${id}:before`, options);
if (options && options.abort) { if (options.abort) {
editor.trigger(`abort:${id}`, options); editor.trigger(`${CommandsEvents.abort}${id}`, { options });
editor.trigger(`${CommandsEvents._abort}${id}`, options);
return; return;
} }
const sender = options.sender || editor; const sender = options.sender || editor;
const result = this.run(editor, sender, options); const result = this.run(editor, sender, options);
editor.trigger(`run:${id}`, result, options); const data = { id, result, options };
editor.trigger('run', id, result, options); editor.trigger(`${CommandsEvents.runCommand}${id}`, data);
editor.trigger(CommandsEvents.run, data);
// deprecated
editor.trigger(`${CommandsEvents._runCommand}${id}`, result, options);
editor.trigger(CommandsEvents._run, id, result, options);
return result; return result;
} }
@ -130,12 +137,18 @@ export default class CommandAbstract<O extends ObjectAny = any> extends Model {
* @private * @private
* */ * */
callStop(editor: Editor, options: any = {}) { callStop(editor: Editor, options: any = {}) {
const id = this.id; const { id } = this;
const sender = options.sender || editor; const sender = options.sender || editor;
editor.trigger(`stop:${id}:before`, options); editor.trigger(`${CommandsEvents.stopBeforeCommand}${id}`, { options });
editor.trigger(`${CommandsEvents._stopCommand}${id}:before`, options);
const result = this.stop(editor, sender, options); const result = this.stop(editor, sender, options);
editor.trigger(`stop:${id}`, result, options); const data = { id, result, options };
editor.trigger('stop', id, result, options); editor.trigger(`${CommandsEvents.stopCommand}${id}`, data);
editor.trigger(CommandsEvents.stop, data);
// deprecated
editor.trigger(`${CommandsEvents._stopCommand}${id}`, result, options);
editor.trigger(CommandsEvents._stop, id, result, options);
return result; return result;
} }

2
src/commands/view/Fullscreen.ts

@ -72,12 +72,10 @@ export default {
const pfx = this.enable(targetEl || editor.getContainer()); const pfx = this.enable(targetEl || editor.getContainer());
this.fsChanged = this.fsChanged.bind(this, pfx); this.fsChanged = this.fsChanged.bind(this, pfx);
document.addEventListener(pfx + 'fullscreenchange', this.fsChanged); document.addEventListener(pfx + 'fullscreenchange', this.fsChanged);
editor.trigger('change:canvasOffset');
}, },
stop(editor, sender) { stop(editor, sender) {
if (sender && sender.set) sender.set('active', false); if (sender && sender.set) sender.set('active', false);
this.disable(); this.disable();
if (editor) editor.trigger('change:canvasOffset');
}, },
} as CommandObject<{ target?: HTMLElement | string }, { [k: string]: any }>; } as CommandObject<{ target?: HTMLElement | string }, { [k: string]: any }>;

49
src/commands/view/SelectComponent.ts

@ -7,6 +7,7 @@ import { getComponentModel, getComponentView, getUnitFromValue, getViewEl, hasWi
import { CommandObject } from './CommandAbstract'; import { CommandObject } from './CommandAbstract';
import { CanvasSpotBuiltInTypes } from '../../canvas/model/CanvasSpot'; import { CanvasSpotBuiltInTypes } from '../../canvas/model/CanvasSpot';
import { ResizerOptions } from '../../utils/Resizer'; import { ResizerOptions } from '../../utils/Resizer';
import { ComponentsEvents } from '../../dom_components/types';
let showOffsets: boolean; let showOffsets: boolean;
/** /**
@ -30,6 +31,8 @@ let showOffsets: boolean;
* *
*/ */
export default { export default {
activeResizer: false,
init() { init() {
this.onSelect = debounce(this.onSelect, 0); this.onSelect = debounce(this.onSelect, 0);
bindAll( bindAll(
@ -77,6 +80,7 @@ export default {
const { parentNode } = em.getContainer()!; const { parentNode } = em.getContainer()!;
const method = enable ? 'on' : 'off'; const method = enable ? 'on' : 'off';
const methods = { on, off }; const methods = { on, off };
const eventCmpUpdate = ComponentsEvents.update;
!listenToEl.length && parentNode && listenToEl.push(parentNode as HTMLElement); !listenToEl.length && parentNode && listenToEl.push(parentNode as HTMLElement);
const trigger = (win: Window, body: HTMLBodyElement) => { const trigger = (win: Window, body: HTMLBodyElement) => {
methods[method](body, 'mouseover', this.onHover); methods[method](body, 'mouseover', this.onHover);
@ -87,13 +91,13 @@ export default {
}; };
methods[method](window, 'resize', this.onFrameUpdated); methods[method](window, 'resize', this.onFrameUpdated);
methods[method](listenToEl, 'scroll', this.onContainerChange); methods[method](listenToEl, 'scroll', this.onContainerChange);
em[method]('component:toggled component:update undo redo', this.onSelect, this); em[method](`component:toggled ${eventCmpUpdate} undo redo`, this.onSelect, this);
em[method]('change:componentHovered', this.onHovered, this); em[method]('change:componentHovered', this.onHovered, this);
em[method]('component:resize styleable:change component:input', this.updateGlobalPos, this); em[method]('component:resize styleable:change component:input', this.updateGlobalPos, this);
em[method]('component:update:toolbar', this._upToolbar, this); em[method](`${eventCmpUpdate}:toolbar`, this._upToolbar, this);
em[method]('change:canvasOffset', this.updateAttached, this);
em[method]('frame:updated', this.onFrameUpdated, this); em[method]('frame:updated', this.onFrameUpdated, this);
em[method]('canvas:updateTools', this.onFrameUpdated, this); em[method]('canvas:updateTools', this.onFrameUpdated, this);
em[method](em.Canvas.events.refresh, this.updateAttached, this);
em.Canvas.getFrames().forEach(frame => { em.Canvas.getFrames().forEach(frame => {
const { view } = frame; const { view } = frame;
const win = view?.getWindow(); const win = view?.getWindow();
@ -288,23 +292,30 @@ export default {
if (em.get('_cmpDrag')) return em.set('_cmpDrag'); if (em.get('_cmpDrag')) return em.set('_cmpDrag');
const el = ev.target as HTMLElement; const el = ev.target as HTMLElement;
let model = getComponentModel(el); let cmp = getComponentModel(el);
if (!model) { if (!cmp) {
let parentEl = el.parentNode; let parentEl = el.parentNode;
while (!model && parentEl && !isDoc(parentEl)) { while (!cmp && parentEl && !isDoc(parentEl)) {
model = getComponentModel(parentEl); cmp = getComponentModel(parentEl);
parentEl = parentEl.parentNode; parentEl = parentEl.parentNode;
} }
} }
if (model) { if (cmp) {
// Avoid selection of inner text components during editing if (
if (em.isEditing() && !model.get('textable') && model.isChildOf('text')) { em.isEditing() &&
// Avoid selection of inner text components during editing
((!cmp.get('textable') && cmp.isChildOf('text')) ||
// Prevents selecting another component if the pointer was pressed and
// dragged outside of the editing component
em.getEditing() !== cmp)
) {
return; return;
} }
this.select(model, ev);
this.select(cmp, ev);
} }
}, },
@ -396,12 +407,17 @@ export default {
...resizableOpts ...resizableOpts
} = isObject(resizable) ? resizable : {}; } = isObject(resizable) ? resizable : {};
if (hasCustomResize || !el) return; if (hasCustomResize || !el || this.activeResizer) return;
let modelToStyle: any; let modelToStyle: any;
const { config } = em; const { config } = em;
const pfx = config.stylePrefix || ''; const pfx = config.stylePrefix || '';
const resizeClass = `${pfx}resizing`; const resizeClass = `${pfx}resizing`;
const self = this;
const resizeEventOpts = {
component: model,
el,
};
const toggleBodyClass = (method: string, e: any, opts: any) => { const toggleBodyClass = (method: string, e: any, opts: any) => {
const docs = opts.docs; const docs = opts.docs;
@ -445,20 +461,23 @@ export default {
config.unitHeight = getUnitFromValue(currentHeight); config.unitHeight = getUnitFromValue(currentHeight);
config.unitWidth = getUnitFromValue(currentWidth); config.unitWidth = getUnitFromValue(currentWidth);
} }
self.activeResizer = true;
editor.trigger('component:resize', { ...resizeEventOpts, type: 'start' });
}, },
// Update all positioned elements (eg. component toolbar) // Update all positioned elements (eg. component toolbar)
onMove(ev) { onMove(ev) {
onMove(ev); onMove(ev);
editor.trigger('component:resize'); editor.trigger('component:resize', { ...resizeEventOpts, type: 'move' });
}, },
onEnd(ev, opts) { onEnd(ev, opts) {
onEnd(ev, opts); onEnd(ev, opts);
toggleBodyClass('remove', ev, opts); toggleBodyClass('remove', ev, opts);
editor.trigger('component:resize'); editor.trigger('component:resize', { ...resizeEventOpts, type: 'end' });
canvas.toggleFramesEvents(true); canvas.toggleFramesEvents(true);
showOffsets = true; showOffsets = true;
self.activeResizer = false;
}, },
updateTarget(el, rect, options) { updateTarget(el, rect, options) {
@ -580,7 +599,7 @@ export default {
}, },
onFrameResize() { onFrameResize() {
this.canvas.refreshSpots(); this.canvas.refresh({ all: true });
}, },
updateTools() { updateTools() {

3
src/commands/view/ShowOffset.ts

@ -1,7 +1,6 @@
import { isUndefined } from 'underscore'; import { isUndefined } from 'underscore';
import { CanvasSpotBuiltInTypes } from '../../canvas/model/CanvasSpot'; import { CanvasSpotBuiltInTypes } from '../../canvas/model/CanvasSpot';
import { $ } from '../../common'; import { $ } from '../../common';
import { isTextNode } from '../../utils/dom';
import { CommandObject } from './CommandAbstract'; import { CommandObject } from './CommandAbstract';
export default { export default {
@ -18,7 +17,7 @@ export default {
const zoom = this.em.getZoomDecimal(); const zoom = this.em.getZoomDecimal();
const el = opt.el as HTMLElement | undefined; const el = opt.el as HTMLElement | undefined;
if (!config.showOffsets || !el || isTextNode(el) || (!config.showOffsetsSelected && state == 'Fixed')) { if (!config.showOffsets || !(el instanceof HTMLElement) || (!config.showOffsetsSelected && state == 'Fixed')) {
editor.stopCommand(`${this.id}`, opts); editor.stopCommand(`${this.id}`, opts);
return; return;
} }

9
src/common/index.ts

@ -7,7 +7,7 @@ export type Debounced = Function & { cancel(): void };
export type SetOptions = Backbone.ModelSetOptions & { avoidStore?: boolean }; export type SetOptions = Backbone.ModelSetOptions & { avoidStore?: boolean };
export type AddOptions = Backbone.AddOptions & { temporary?: boolean }; export type AddOptions = Backbone.AddOptions & { temporary?: boolean; action?: string };
export type DisableOptions = { fromMove?: boolean }; export type DisableOptions = { fromMove?: boolean };
@ -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);

5
src/css_composer/index.ts

@ -39,6 +39,7 @@ import { ItemManagerModule } from '../abstract/Module';
import EditorModel from '../editor/model/Editor'; import EditorModel from '../editor/model/Editor';
import Component from '../dom_components/model/Component'; import Component from '../dom_components/model/Component';
import { ObjectAny, PrevToNewIdMap } from '../common'; import { ObjectAny, PrevToNewIdMap } from '../common';
import { UpdateStyleOptions } from '../domain_abstract/model/StyleableModel';
/** @private */ /** @private */
interface RuleOptions { interface RuleOptions {
@ -53,7 +54,7 @@ interface RuleOptions {
} }
/** @private */ /** @private */
interface SetRuleOptions extends RuleOptions { interface SetRuleOptions extends RuleOptions, UpdateStyleOptions {
/** /**
* If the rule exists already, merge passed styles instead of replacing them. * If the rule exists already, merge passed styles instead of replacing them.
*/ */
@ -61,7 +62,7 @@ interface SetRuleOptions extends RuleOptions {
} }
/** @private */ /** @private */
export interface GetSetRuleOptions { export interface GetSetRuleOptions extends UpdateStyleOptions {
state?: string; state?: string;
mediaText?: string; mediaText?: string;
addOpts?: ObjectAny; addOpts?: ObjectAny;

18
src/css_composer/model/CssRule.ts

@ -128,14 +128,14 @@ export default class CssRule extends StyleableModel<CssRuleProperties> {
clone(): CssRule { clone(): CssRule {
const opts = { ...this.opt }; const opts = { ...this.opt };
const attr = { ...this.attributes }; const attr = { ...this.attributes };
attr.selectors = this.get('selectors')!.map(s => s.clone()) as Selector[]; attr.selectors = this.get('selectors')!.map(s => s.clone() as Selector);
// @ts-ignore // @ts-ignore
return new this.constructor(attr, opts); return new this.constructor(attr, opts);
} }
ensureSelectors(m: any, c: any, opts: any) { ensureSelectors(m: any, c: any, opts: any) {
const { em } = this; const { em } = this;
const sm = em?.get('SelectorManager'); const sm = em?.Selectors;
const toListen = [this, 'change:selectors', this.ensureSelectors]; const toListen = [this, 'change:selectors', this.ensureSelectors];
let sels = this.getSelectors() as any; let sels = this.getSelectors() as any;
this.stopListening(...toListen); this.stopListening(...toListen);
@ -193,7 +193,7 @@ export default class CssRule extends StyleableModel<CssRuleProperties> {
escape: (str: string) => (CSS && CSS.escape ? CSS.escape(str) : str), escape: (str: string) => (CSS && CSS.escape ? CSS.escape(str) : str),
}; };
// @ts-ignore // @ts-ignore
const selectors = this.get('selectors').getFullString(0, selOpts); const selectors = this.getSelectors().getFullString(0, selOpts);
const stateStr = state && !opts.skipState ? `:${state}` : ''; const stateStr = state && !opts.skipState ? `:${state}` : '';
selectors && result.push(`${selectors}${stateStr}`); selectors && result.push(`${selectors}${stateStr}`);
addSelector && !opts.skipAdd && result.push(addSelector); addSelector && !opts.skipAdd && result.push(addSelector);
@ -235,12 +235,12 @@ export default class CssRule extends StyleableModel<CssRuleProperties> {
getDevice() { getDevice() {
const { em } = this; const { em } = this;
const { atRuleType, mediaText } = this.attributes; const { atRuleType, mediaText } = this.attributes;
const devices = em?.get('DeviceManager').getDevices() || []; const devices = em?.Devices.getDevices() || [];
const deviceDefault = devices.filter((d: any) => d.getWidthMedia() === '')[0]; const deviceDefault = devices.filter(d => d.getWidthMedia() === '')[0];
if (atRuleType !== 'media' || !mediaText) { if (atRuleType !== 'media' || !mediaText) {
return deviceDefault || null; return deviceDefault || null;
} }
return devices.filter((d: any) => d.getWidthMedia() === getMediaLength(mediaText))[0] || null; return devices.filter(d => d.getWidthMedia() === getMediaLength(mediaText))[0] || null;
} }
/** /**
@ -253,8 +253,8 @@ export default class CssRule extends StyleableModel<CssRuleProperties> {
getState() { getState() {
const { em } = this; const { em } = this;
const stateValue = this.get('state'); const stateValue = this.get('state');
const states = em?.get('SelectorManager').getStates() || []; const states = em?.Selectors.getStates() || [];
return states.filter((s: any) => s.getName() === stateValue)[0] || null; return states.filter(s => s.getName() === stateValue)[0] || null;
} }
/** /**
@ -265,7 +265,7 @@ export default class CssRule extends StyleableModel<CssRuleProperties> {
* console.log(cmp?.toHTML()); * console.log(cmp?.toHTML());
*/ */
getComponent() { getComponent() {
const sel = this.getSelectors() as any; const sel = this.getSelectors();
const sngl = sel.length == 1 && sel.at(0); const sngl = sel.length == 1 && sel.at(0);
const cmpId = sngl && sngl.isId() && sngl.get('name'); const cmpId = sngl && sngl.isId() && sngl.get('name');
return (cmpId && this.em?.Components.getById(cmpId)) || null; return (cmpId && this.em?.Components.getById(cmpId)) || null;

151
src/dom_components/index.ts

@ -53,7 +53,7 @@
* *
* @module Components * @module Components
*/ */
import { debounce, isArray, isEmpty, isFunction, isString, result } from 'underscore'; import { debounce, isArray, isBoolean, isEmpty, isFunction, isString, isSymbol, result } from 'underscore';
import { ItemManagerModule } from '../abstract/Module'; import { ItemManagerModule } from '../abstract/Module';
import { AddOptions, ObjectAny } from '../common'; import { AddOptions, ObjectAny } from '../common';
import EditorModel from '../editor/model/Editor'; import EditorModel from '../editor/model/Editor';
@ -103,6 +103,19 @@ import ComponentWrapperView from './view/ComponentWrapperView';
import ComponentsView from './view/ComponentsView'; import ComponentsView from './view/ComponentsView';
import ComponentDataVariable, { type as typeVariable } from './model/ComponentDataVariable'; import ComponentDataVariable, { type as typeVariable } from './model/ComponentDataVariable';
import ComponentDataVariableView from './view/ComponentDataVariableView'; import ComponentDataVariableView from './view/ComponentDataVariableView';
import ComponentHead, { type as typeHead } from './model/ComponentHead';
import {
getSymbolMain,
getSymbolInstances,
getSymbolsToUpdate,
isSymbolMain,
isSymbolInstance,
detachSymbolInstance,
isSymbolRoot,
} from './model/SymbolUtils';
import { ComponentsEvents, SymbolInfo } from './types';
import Symbols from './model/Symbols';
import { BlockProperties } from '../block_manager/model/Block';
export type ComponentEvent = export type ComponentEvent =
| 'component:create' | 'component:create'
@ -136,6 +149,7 @@ export interface AddComponentTypeOptions {
isComponent?: (el: HTMLElement) => boolean | ComponentDefinitionDefined | undefined; isComponent?: (el: HTMLElement) => boolean | ComponentDefinitionDefined | undefined;
model?: Partial<ComponentModelDefinition> & ThisType<ComponentModelDefinition & Component>; model?: Partial<ComponentModelDefinition> & ThisType<ComponentModelDefinition & Component>;
view?: Partial<ComponentViewDefinition> & ThisType<ComponentViewDefinition & ComponentView>; view?: Partial<ComponentViewDefinition> & ThisType<ComponentViewDefinition & ComponentView>;
block?: boolean | Partial<BlockProperties>;
extend?: string; extend?: string;
extendView?: string; extendView?: string;
extendFn?: string[]; extendFn?: string[];
@ -258,15 +272,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,
@ -293,8 +312,11 @@ export default class ComponentManager extends ItemManagerModule<DomComponentsCon
//name = "DomComponents"; //name = "DomComponents";
storageKey = 'components'; storageKey = 'components';
keySymbols = 'symbols';
shallow?: Component; shallow?: Component;
symbols: Symbols;
events = ComponentsEvents;
/** /**
* Initialize module. Called on a new instance of the editor with configurations passed * Initialize module. Called on a new instance of the editor with configurations passed
@ -304,18 +326,20 @@ export default class ComponentManager extends ItemManagerModule<DomComponentsCon
*/ */
constructor(em: EditorModel) { constructor(em: EditorModel) {
super(em, 'DomComponents', new Components(undefined, { em })); super(em, 'DomComponents', new Components(undefined, { em }));
const { config } = this;
this.symbols = new Symbols([], { em, config, domc: this });
if (em) { if (em) {
//@ts-ignore //@ts-ignore
this.config.components = em.config.components || this.config.components; this.config.components = em.config.components || this.config.components;
} }
for (var name in defaults) { for (let name in defaults) {
//@ts-ignore //@ts-ignore
if (!(name in this.config)) this.config[name] = defaults[name]; if (!(name in this.config)) this.config[name] = defaults[name];
} }
var ppfx = this.config.pStylePrefix; const ppfx = this.config.pStylePrefix;
if (ppfx) this.config.stylePrefix = ppfx + this.config.stylePrefix; if (ppfx) this.config.stylePrefix = ppfx + this.config.stylePrefix;
// Load dependencies // Load dependencies
@ -331,8 +355,14 @@ export default class ComponentManager extends ItemManagerModule<DomComponentsCon
return this; return this;
} }
postLoad() {
const { em, symbols } = this;
const { UndoManager } = em;
UndoManager.add(symbols);
}
load(data: any) { load(data: any) {
return this.loadProjectData(data, { const result = this.loadProjectData(data, {
onResult: (result: Component) => { onResult: (result: Component) => {
let wrapper = this.getWrapper()!; let wrapper = this.getWrapper()!;
@ -351,10 +381,16 @@ export default class ComponentManager extends ItemManagerModule<DomComponentsCon
} }
}, },
}); });
this.symbols.reset(data[this.keySymbols] || []);
return result;
} }
store() { store() {
return {}; return {
[this.keySymbols]: this.symbols,
};
} }
/** /**
@ -490,12 +526,12 @@ export default class ComponentManager extends ItemManagerModule<DomComponentsCon
*/ */
addType(type: string, methods: AddComponentTypeOptions) { addType(type: string, methods: AddComponentTypeOptions) {
const { em } = this; const { em } = this;
const { model = {}, view = {}, isComponent, extend, extendView, extendFn = [], extendFnView = [] } = methods; const { model = {}, view = {}, isComponent, extend, extendView, extendFn = [], extendFnView = [], block } = methods;
const compType = this.getType(type); const compType = this.getType(type);
const extendType = this.getType(extend!); const extendType = this.getType(extend!);
const extendViewType = this.getType(extendView!); const extendViewType = this.getType(extendView!);
const typeToExtend = extendType ? extendType : compType ? compType : this.getType('default'); const typeToExtend = extendType ? extendType : compType ? compType : this.getType('default');
const modelToExt = typeToExtend.model; const modelToExt = typeToExtend.model as typeof Component;
const viewToExt = extendViewType ? extendViewType.view : typeToExtend.view; const viewToExt = extendViewType ? extendViewType.view : typeToExtend.view;
// Function for extending source object methods // Function for extending source object methods
@ -516,12 +552,16 @@ export default class ComponentManager extends ItemManagerModule<DomComponentsCon
if (typeof model === 'object') { if (typeof model === 'object') {
const modelDefaults = { defaults: model.defaults }; const modelDefaults = { defaults: model.defaults };
delete model.defaults; delete model.defaults;
const typeExtends = new Set(modelToExt.typeExtends);
typeExtends.add(modelToExt.getDefaults().type);
methods.model = modelToExt.extend( methods.model = modelToExt.extend(
{ {
...model, ...model,
...getExtendedObj(extendFn, model, modelToExt), ...getExtendedObj(extendFn, model, modelToExt),
}, },
{ {
typeExtends,
isComponent: compType && !extendType && !isComponent ? modelToExt.isComponent : isComponent || (() => 0), isComponent: compType && !extendType && !isComponent ? modelToExt.isComponent : isComponent || (() => 0),
} }
); );
@ -550,6 +590,16 @@ export default class ComponentManager extends ItemManagerModule<DomComponentsCon
this.componentTypes.unshift(methods as any); this.componentTypes.unshift(methods as any);
} }
if (block) {
const defBlockProps: BlockProperties = {
id: type,
label: type,
content: { type },
};
const blockProps: BlockProperties = block === true ? defBlockProps : { ...defBlockProps, ...block };
em.Blocks.add(blockProps.id || type, blockProps);
}
const event = `component:type:${compType ? 'update' : 'add'}`; const event = `component:type:${compType ? 'update' : 'add'}`;
em?.trigger(event, compType || methods); em?.trigger(event, compType || methods);
@ -673,6 +723,87 @@ export default class ComponentManager extends ItemManagerModule<DomComponentsCon
return isComponent(obj); return isComponent(obj);
} }
/**
* Add a new symbol from a component.
* If the passed component is not a symbol, it will be converted to an instance and will return the main symbol.
* If the passed component is already an instance, a new instance will be created and returned.
* If the passed component is the main symbol, a new instance will be created and returned.
* @param {[Component]} component Component from which create a symbol.
* @returns {[Component]}
* @example
* const symbol = cmp.addSymbol(editor.getSelected());
* // cmp.getSymbolInfo(symbol).isSymbol === true;
*/
addSymbol(component: Component) {
if (isSymbol(component) && !isSymbolRoot(component)) {
return;
}
const symbol = component.clone({ symbol: true });
isSymbolMain(symbol) && this.symbols.add(symbol);
this.em.trigger('component:toggled');
return symbol;
}
/**
* Get the array of main symbols.
* @returns {Array<[Component]>}
* @example
* const symbols = cmp.getSymbols();
* // [Component, Component, ...]
* // Removing the main symbol will detach all the relative instances.
* symbols[0].remove();
*/
getSymbols() {
return [...this.symbols.models];
}
/**
* Detach symbol instance from the main one.
* The passed symbol instance will become a regular component.
* @param {[Component]} component The component symbol to detach.
* @example
* const cmpInstance = editor.getSelected();
* // cmp.getSymbolInfo(cmpInstance).isInstance === true;
* cmp.detachSymbol(cmpInstance);
* // cmp.getSymbolInfo(cmpInstance).isInstance === false;
*/
detachSymbol(component: Component) {
if (isSymbolInstance(component)) {
detachSymbolInstance(component);
}
}
/**
* Get info about the symbol.
* @param {[Component]} component Component symbol from which to get the info.
* @returns {Object} Object containing symbol info.
* @example
* cmp.getSymbolInfo(editor.getSelected());
* // > { isSymbol: true, isMain: false, isInstance: true, ... }
*/
getSymbolInfo(component: Component, opts: { withChanges?: string } = {}): SymbolInfo {
const isMain = isSymbolMain(component);
const mainRef = getSymbolMain(component);
const isInstance = !!mainRef;
const instances = (isMain ? getSymbolInstances(component) : getSymbolInstances(mainRef)) || [];
const main = mainRef || (isMain ? component : undefined);
const relatives = getSymbolsToUpdate(component, { changed: opts.withChanges });
const isSymbol = isMain || isInstance;
const isRoot = isSymbol && isSymbolRoot(component);
return {
isSymbol,
isMain,
isInstance,
isRoot,
main,
instances: instances,
relatives: relatives || [],
};
}
/** /**
* Check if a component can be moved inside another one. * Check if a component can be moved inside another one.
* @param {[Component]} target The target component is the one that is supposed to receive the source one. * @param {[Component]} target The target component is the one that is supposed to receive the source one.

426
src/dom_components/model/Component.ts

@ -12,7 +12,7 @@ import {
keys, keys,
} from 'underscore'; } from 'underscore';
import { shallowDiff, capitalize, isEmptyObj, isObject, toLowerCase } from '../../utils/mixins'; import { shallowDiff, capitalize, isEmptyObj, isObject, toLowerCase } from '../../utils/mixins';
import StyleableModel, { StyleProps } from '../../domain_abstract/model/StyleableModel'; import StyleableModel, { StyleProps, UpdateStyleOptions } from '../../domain_abstract/model/StyleableModel';
import { Model } from 'backbone'; import { Model } from 'backbone';
import Components from './Components'; import Components from './Components';
import Selector from '../../selector_manager/model/Selector'; import Selector from '../../selector_manager/model/Selector';
@ -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,
@ -37,9 +38,24 @@ import CssRule, { CssRuleJSON } from '../../css_composer/model/CssRule';
import Trait from '../../trait_manager/model/Trait'; import Trait from '../../trait_manager/model/Trait';
import { ToolbarButtonProps } from './ToolbarButton'; import { ToolbarButtonProps } from './ToolbarButton';
import { TraitProperties } from '../../trait_manager/types'; import { TraitProperties } from '../../trait_manager/types';
import { ActionLabelComponents, ComponentsEvents } from '../types';
import ItemView from '../../navigator/view/ItemView';
import {
getSymbolMain,
getSymbolInstances,
initSymbol,
isSymbol,
isSymbolMain,
isSymbolRoot,
updateSymbolCls,
updateSymbolComps,
updateSymbolProps,
} from './SymbolUtils';
export interface IComponent extends ExtractMethods<Component> {} export interface IComponent extends ExtractMethods<Component> {}
export interface SetAttrOptions extends SetOptions, UpdateStyleOptions {}
const escapeRegExp = (str: string) => { const escapeRegExp = (str: string) => {
return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'); return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&');
}; };
@ -50,8 +66,8 @@ export const eventDrag = 'component:drag';
export const keySymbols = '__symbols'; export const keySymbols = '__symbols';
export const keySymbol = '__symbol'; export const keySymbol = '__symbol';
export const keySymbolOvrd = '__symbol_ovrd'; export const keySymbolOvrd = '__symbol_ovrd';
export const keyUpdate = 'component:update'; export const keyUpdate = ComponentsEvents.update;
export const keyUpdateInside = `${keyUpdate}-inside`; export const keyUpdateInside = ComponentsEvents.updateInside;
/** /**
* The Component object represents a single node of our template structure, so when you update its properties the changes are * The Component object represents a single node of our template structure, so when you update its properties the changes are
@ -89,7 +105,7 @@ export const keyUpdateInside = `${keyUpdate}-inside`;
* @property {Array<String>} [unstylable=[]] Indicate an array of style properties which should be hidden from the style manager. Default: `[]` * @property {Array<String>} [unstylable=[]] Indicate an array of style properties which should be hidden from the style manager. Default: `[]`
* @property {Boolean} [highlightable=true] It can be highlighted with 'dotted' borders if true. Default: `true` * @property {Boolean} [highlightable=true] It can be highlighted with 'dotted' borders if true. Default: `true`
* @property {Boolean} [copyable=true] True if it's possible to clone the component. Default: `true` * @property {Boolean} [copyable=true] True if it's possible to clone the component. Default: `true`
* @property {Boolean} [resizable=false] Indicates if it's possible to resize the component. It's also possible to pass an object as [options for the Resizer](https://github.com/GrapesJS/grapesjs/blob/master/src/utils/Resizer.js). Default: `false` * @property {Boolean} [resizable=false] Indicates if it's possible to resize the component. It's also possible to pass an object as [options for the Resizer](https://github.com/GrapesJS/grapesjs/blob/master/src/utils/Resizer.ts). Default: `false`
* @property {Boolean} [editable=false] Allow to edit the content of the component (used on Text components). Default: `false` * @property {Boolean} [editable=false] Allow to edit the content of the component (used on Text components). Default: `false`
* @property {Boolean} [layerable=true] Set to `false` if you need to hide the component inside Layers. Default: `true` * @property {Boolean} [layerable=true] Set to `false` if you need to hide the component inside Layers. Default: `true`
* @property {Boolean} [selectable=true] Allow component to be selected when clicked. Default: `true` * @property {Boolean} [selectable=true] Allow component to be selected when clicked. Default: `true`
@ -164,6 +180,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')!;
} }
@ -216,6 +236,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
ccid!: string; ccid!: string;
views!: ComponentView[]; views!: ComponentView[];
view?: ComponentView; view?: ComponentView;
viewLayer?: ItemView;
frame?: Frame; frame?: Frame;
rule?: CssRule; rule?: CssRule;
prevColl?: Components; prevColl?: Components;
@ -226,7 +247,8 @@ export default class Component extends StyleableModel<ComponentProperties> {
* @ts-ignore */ * @ts-ignore */
collection!: Components; collection!: Components;
initialize(props = {}, opt: ComponentOptions = {}) { constructor(props: ComponentProperties = {}, opt: ComponentOptions) {
super(props, opt);
bindAll(this, '__upSymbProps', '__upSymbCls', '__upSymbComps'); bindAll(this, '__upSymbProps', '__upSymbCls', '__upSymbComps');
const em = opt.em; const em = opt.em;
@ -290,8 +312,8 @@ export default class Component extends StyleableModel<ComponentProperties> {
this.__postAdd(); this.__postAdd();
this.init(); this.init();
this.__isSymbolOrInst() && this.__initSymb(); isSymbol(this) && initSymbol(this);
em && em.trigger('component:create', this); em?.trigger(ComponentsEvents.create, this, opt);
} }
} }
@ -309,7 +331,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
__postRemove() { __postRemove() {
const { em } = this; const { em } = this;
const um = em?.get('UndoManager'); const um = em?.UndoManager;
if (um) { if (um) {
um.remove(this.components()); um.remove(this.components());
um.remove(this.getSelectors()); um.remove(this.getSelectors());
@ -359,6 +381,23 @@ export default class Component extends StyleableModel<ComponentProperties> {
this.emitUpdate('toolbar'); this.emitUpdate('toolbar');
} }
__getAllById() {
const { em } = this;
return em ? em.Components.allById() : {};
}
__upSymbProps(m: any, opts: SymbolToUpOptions = {}) {
updateSymbolProps(this, opts);
}
__upSymbCls(m: any, c: any, opts = {}) {
updateSymbolCls(this, opts);
}
__upSymbComps(m: Component, c: Components, o: any) {
updateSymbolComps(this, m, c, o);
}
/** /**
* Check component's type * Check component's type
* @param {string} type Component type * @param {string} type Component type
@ -406,6 +445,26 @@ export default class Component extends StyleableModel<ComponentProperties> {
return this.get('dmode') || ''; return this.get('dmode') || '';
} }
/**
* Set symbol override.
* By setting override to `true`, none of its property changes will be propagated to relative symbols.
* By setting override to specific properties, changes of those properties will be skipped from propagation.
* @param {Boolean|String|Array<String>} value
* @example
* component.setSymbolOverride(['children', 'classes']);
*/
setSymbolOverride(value?: boolean | string | string[]) {
this.set(keySymbolOvrd, (isString(value) ? [value] : value) ?? 0);
}
/**
* Get symbol override value.
* @returns {Boolean|Array<String>}
*/
getSymbolOverride(): boolean | string[] | undefined {
return this.get(keySymbolOvrd);
}
/** /**
* Find inner components by query string. * Find inner components by query string.
* **ATTENTION**: this method works only with already rendered component * **ATTENTION**: this method works only with already rendered component
@ -523,7 +582,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
const coll = this.collection; const coll = this.collection;
const at = coll.indexOf(this); const at = coll.indexOf(this);
coll.remove(this); coll.remove(this);
const result = coll.add(el, { ...opts, at }); const result = coll.add(el, { ...opts, at }) as C | C[];
return isArray(result) ? result : [result]; return isArray(result) ? result : [result];
} }
@ -531,7 +590,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
* Emit changes for each updated attribute * Emit changes for each updated attribute
* @private * @private
*/ */
attrUpdated(m: any, v: any, opts: any = {}) { attrUpdated(m: any, v: any, opts: SetAttrOptions = {}) {
const attrs = this.get('attributes')!; const attrs = this.get('attributes')!;
// Handle classes // Handle classes
const classes = attrs.class; const classes = attrs.class;
@ -540,12 +599,16 @@ export default class Component extends StyleableModel<ComponentProperties> {
// Handle style // Handle style
const style = attrs.style; const style = attrs.style;
style && this.setStyle(style); style && this.setStyle(style, opts);
delete attrs.style; delete attrs.style;
const attrPrev = { ...this.previous('attributes') }; const attrPrev = { ...this.previous('attributes') };
const diff = shallowDiff(attrPrev, this.get('attributes')!); const diff = shallowDiff(attrPrev, this.get('attributes')!);
keys(diff).forEach(pr => this.trigger(`change:attributes:${pr}`, this, diff[pr], opts)); keys(diff).forEach(pr => {
const attrKey = `attributes:${pr}`;
this.trigger(`change:${attrKey}`, this, diff[pr], opts);
this.em?.trigger(`${keyUpdate}:${attrKey}`, this, diff[pr], opts);
});
} }
/** /**
@ -556,7 +619,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
* @example * @example
* component.setAttributes({ id: 'test', 'data-key': 'value' }); * component.setAttributes({ id: 'test', 'data-key': 'value' });
*/ */
setAttributes(attrs: ObjectAny, opts: SetOptions = {}) { setAttributes(attrs: ObjectAny, opts: SetAttrOptions = {}) {
this.set('attributes', { ...attrs }, opts); this.set('attributes', { ...attrs }, opts);
return this; return this;
} }
@ -569,7 +632,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
* @example * @example
* component.addAttributes({ 'data-key': 'value' }); * component.addAttributes({ 'data-key': 'value' });
*/ */
addAttributes(attrs: ObjectAny, opts: SetOptions = {}) { addAttributes(attrs: ObjectAny, opts: SetAttrOptions = {}) {
return this.setAttributes( return this.setAttributes(
{ {
...this.getAttributes({ noClass: true }), ...this.getAttributes({ noClass: true }),
@ -613,6 +676,10 @@ export default class Component extends StyleableModel<ComponentProperties> {
if (rule) { if (rule) {
return rule.getStyle(prop); return rule.getStyle(prop);
} }
// Return empty style if not rule have been found. We cannot return inline style with the next return
// because else on load inline style is set a #id or .class style
return {};
} }
return super.getStyle.call(this, prop); return super.getStyle.call(this, prop);
@ -625,7 +692,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
* @example * @example
* component.setStyle({ color: 'red' }); * component.setStyle({ color: 'red' });
*/ */
setStyle(prop: StyleProps = {}, opts: any = {}) { setStyle(prop: StyleProps = {}, opts: UpdateStyleOptions = {}) {
const { opt, em } = this; const { opt, em } = this;
if (avoidInline(em) && !opt.temporary && !opts.inline) { if (avoidInline(em) && !opt.temporary && !opts.inline) {
@ -671,9 +738,9 @@ export default class Component extends StyleableModel<ComponentProperties> {
// Add style // Add style
if (!opts.noStyle) { if (!opts.noStyle) {
const style = this.get('style'); const style = this.getStyle({ inline: true });
if (isObject(style) && !isEmptyObj(style)) { if (isObject(style) && !isEmptyObj(style)) {
attributes.style = this.styleToString({ inline: 1 }); attributes.style = this.styleToString({ inline: true });
} }
} }
@ -689,8 +756,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
if ( if (
// Symbols should always have an id // Symbols should always have an id
this.__getSymbol() || isSymbol(this) ||
this.__getSymbols() ||
// Components with script should always have an id // Components with script should always have an id
this.get('script-export') || this.get('script-export') ||
this.get('script') this.get('script')
@ -773,254 +839,6 @@ export default class Component extends StyleableModel<ComponentProperties> {
return classStr ? classStr.split(' ') : []; return classStr ? classStr.split(' ') : [];
} }
__logSymbol(type: string, toUp: Component[], opts: any = {}) {
const symbol = this.__getSymbol();
const symbols = this.__getSymbols();
if (!symbol && !symbols) return;
this.em.log(type, { model: this, toUp, context: 'symbols', opts });
}
__initSymb() {
if (this.__symbReady) return;
this.on('change', this.__upSymbProps);
this.__symbReady = true;
}
__isSymbol() {
return isArray(this.get(keySymbols));
}
__isSymbolOrInst() {
return !!(this.__isSymbol() || this.get(keySymbol));
}
__isSymbolTop() {
const parent = this.parent();
const symb = this.__isSymbolOrInst();
return symb && (!parent || (parent && !parent.__isSymbol() && !parent.__getSymbol()));
}
__isSymbolNested() {
if (!this.__isSymbolOrInst() || this.__isSymbolTop()) return false;
const symbTopSelf = (this.__isSymbol() ? this : this.__getSymbol())!.__getSymbTop();
const symbTop = this.__getSymbTop();
const symbTopMain = symbTop.__isSymbol() ? symbTop : symbTop.__getSymbol();
return symbTopMain !== symbTopSelf;
}
__getAllById() {
const { em } = this;
return em ? em.Components.allById() : {};
}
__getSymbol(): Component | undefined {
let symb = this.get(keySymbol);
if (symb && isString(symb)) {
const ref = this.__getAllById()[symb];
if (ref) {
symb = ref;
this.set(keySymbol, ref);
} else {
symb = 0;
}
}
return symb;
}
__getSymbols(): Component[] | undefined {
let symbs = this.get(keySymbols);
if (symbs && isArray(symbs)) {
symbs.forEach((symb, idx) => {
if (symb && isString(symb)) {
symbs[idx] = this.__getAllById()[symb];
}
});
symbs = symbs.filter(symb => symb && !isString(symb));
}
return symbs;
}
__isSymbOvrd(prop = '') {
const ovrd = this.get(keySymbolOvrd);
const [prp] = prop.split(':');
const props = prop !== prp ? [prop, prp] : [prop];
return ovrd === true || (isArray(ovrd) && props.some(p => ovrd.indexOf(p) >= 0));
}
__getSymbToUp(opts: SymbolToUpOptions = {}) {
let result: Component[] = [];
const { changed } = opts;
if (
opts.fromInstance ||
opts.noPropagate ||
opts.fromUndo ||
// Avoid updating others if the current component has override
(changed && this.__isSymbOvrd(changed))
) {
return result;
}
const symbols = this.__getSymbols() || [];
const symbol = this.__getSymbol();
const all = symbol ? [symbol, ...(symbol.__getSymbols() || [])] : symbols;
result = all
.filter(s => s !== this)
// Avoid updating those with override
.filter(s => !(changed && s.__isSymbOvrd(changed)));
return result;
}
__getSymbTop(opts?: any) {
let result: Component = this;
let parent = this.parent(opts);
while (parent && (parent.__isSymbol() || parent.__getSymbol())) {
result = parent;
parent = parent.parent(opts);
}
return result;
}
__upSymbProps(m: any, opts: SymbolToUpOptions = {}) {
const changed = this.changedAttributes() || {};
const attrs = changed.attributes || {};
delete changed.status;
delete changed.open;
delete changed[keySymbols];
delete changed[keySymbol];
delete changed[keySymbolOvrd];
delete changed.attributes;
delete attrs.id;
if (!isEmptyObj(attrs)) changed.attributes = attrs;
if (!isEmptyObj(changed)) {
const toUp = this.__getSymbToUp(opts);
// Avoid propagating overrides to other symbols
keys(changed).map(prop => {
if (this.__isSymbOvrd(prop)) delete changed[prop];
});
this.__logSymbol('props', toUp, { opts, changed });
toUp.forEach(child => {
const propsChanged = { ...changed };
// Avoid updating those with override
keys(propsChanged).map(prop => {
if (child.__isSymbOvrd(prop)) delete propsChanged[prop];
});
child.set(propsChanged, { fromInstance: this, ...opts });
});
}
}
__upSymbCls(m: any, c: any, opts = {}) {
const toUp = this.__getSymbToUp(opts);
this.__logSymbol('classes', toUp, { opts });
toUp.forEach(child => {
// @ts-ignore This will propagate the change up to __upSymbProps
child.set('classes', this.get('classes'), { fromInstance: this });
});
this.__changesUp(opts);
}
__upSymbComps(m: Component, c: Components, o: any) {
const optUp = o || c || {};
const { fromInstance, fromUndo } = optUp;
const toUpOpts = { fromInstance, fromUndo };
const isTemp = m.opt.temporary;
// Reset
if (!o) {
const toUp = this.__getSymbToUp({
...toUpOpts,
changed: 'components:reset',
});
// @ts-ignore
const cmps = m.models as Component[];
this.__logSymbol('reset', toUp, { components: cmps });
toUp.forEach(symb => {
const newMods = cmps.map(mod => mod.clone({ symbol: true }));
// @ts-ignore
symb.components().reset(newMods, { fromInstance: this, ...c });
});
// Add
} else if (o.add) {
let addedInstances: Component[] = [];
const isMainSymb = !!this.__getSymbols();
const toUp = this.__getSymbToUp({
...toUpOpts,
changed: 'components:add',
});
if (toUp.length) {
const addSymb = m.__getSymbol();
addedInstances = (addSymb ? addSymb.__getSymbols() : m.__getSymbols()) || [];
addedInstances = [...addedInstances];
addedInstances.push(addSymb ? addSymb : m);
}
!isTemp &&
this.__logSymbol('add', toUp, {
opts: o,
addedInstances: addedInstances.map(c => c.cid),
added: m.cid,
});
// Here, before appending a new symbol, I have to ensure there are no previously
// created symbols (eg. used mainly when drag components around)
toUp.forEach(symb => {
const symbTop = symb.__getSymbTop();
const symbPrev = addedInstances.filter(addedInst => {
const addedTop = addedInst.__getSymbTop({ prev: 1 });
return symbTop && addedTop && addedTop === symbTop;
})[0];
const toAppend = symbPrev || m.clone({ symbol: true, symbolInv: isMainSymb });
symb.append(toAppend, { fromInstance: this, ...o });
});
// Remove
} else {
// Remove instance reference from the symbol
const symb = m.__getSymbol();
symb &&
!o.temporary &&
symb.set(
keySymbols,
symb.__getSymbols()!.filter(i => i !== m)
);
// Propagate remove only if the component is an inner symbol
if (!m.__isSymbolTop()) {
const changed = 'components:remove';
const { index } = o;
const parent = m.parent();
const opts = { fromInstance: m, ...o };
const isSymbNested = m.__isSymbolNested();
let toUpFn = (symb: Component) => {
const symbPrnt = symb.parent();
symbPrnt && !symbPrnt.__isSymbOvrd(changed) && symb.remove(opts);
};
// Check if the parent allows the removing
let toUp = !parent?.__isSymbOvrd(changed) ? m.__getSymbToUp(toUpOpts) : [];
if (isSymbNested) {
toUp = parent?.__getSymbToUp({ ...toUpOpts, changed })!;
toUpFn = symb => {
const toRemove = symb.components().at(index);
toRemove && toRemove.remove({ fromInstance: parent, ...opts });
};
}
!isTemp &&
this.__logSymbol('remove', toUp, {
opts: o,
removed: m.cid,
isSymbNested,
});
toUp.forEach(toUpFn);
}
}
this.__changesUp(optUp);
}
initClasses(m?: any, c?: any, opts: any = {}) { initClasses(m?: any, c?: any, opts: any = {}) {
const event = 'change:classes'; const event = 'change:classes';
const { class: attrCls, ...restAttr } = this.get('attributes') || {}; const { class: attrCls, ...restAttr } = this.get('attributes') || {};
@ -1125,8 +943,11 @@ export default class Component extends StyleableModel<ComponentProperties> {
return comp; return comp;
} }
}); });
const result = this.components().add(toAppend, opts); const result = this.components().add(toAppend, {
return isArray(result) ? result : [result]; action: ActionLabelComponents.add,
...opts,
});
return result as T[];
} }
/** /**
@ -1145,7 +966,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')!;
@ -1409,8 +1230,8 @@ export default class Component extends StyleableModel<ComponentProperties> {
/** /**
* Override original clone method * Override original clone method
* @private * @private
*/ * @ts-ignore */
clone(opt: { symbol?: boolean; symbolInv?: boolean } = {}) { clone(opt: { symbol?: boolean; symbolInv?: boolean } = {}): this {
const em = this.em; const em = this.em;
const attr = { ...this.attributes }; const attr = { ...this.attributes };
const opts = { ...this.opt }; const opts = { ...this.opt };
@ -1425,7 +1246,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
// @ts-ignore // @ts-ignore
attr.traits = []; attr.traits = [];
if (this.__isSymbolTop()) { if (isSymbolRoot(this)) {
opt.symbol = true; opt.symbol = true;
} }
@ -1461,32 +1282,32 @@ export default class Component extends StyleableModel<ComponentProperties> {
// Symbols // Symbols
// If I clone an inner symbol, I have to reset it // If I clone an inner symbol, I have to reset it
cloned.set(keySymbols, 0); cloned.set(keySymbols, 0);
const symbol = this.__getSymbol(); const symbol = getSymbolMain(this);
const symbols = this.__getSymbols(); const symbols = getSymbolInstances(this);
if (!opt.symbol && (symbol || symbols)) { if (!opt.symbol && (symbol || symbols)) {
cloned.set(keySymbol, 0); cloned.set(keySymbol, 0);
cloned.set(keySymbols, 0); cloned.set(keySymbols, 0);
} else if (symbol) { } else if (symbol) {
// Contains already a reference to a symbol // Contains already a reference to a symbol
symbol.set(keySymbols, [...symbol.__getSymbols()!, cloned]); symbol.set(keySymbols, [...getSymbolInstances(symbol)!, cloned]);
cloned.__initSymb(); initSymbol(cloned);
} else if (opt.symbol) { } else if (opt.symbol) {
// Request to create a symbol // Request to create a symbol
if (this.__isSymbol()) { if (isSymbolMain(this)) {
// Already a symbol, cloned should be an instance // Already a symbol, cloned should be an instance
this.set(keySymbols, [...symbols!, cloned]); this.set(keySymbols, [...symbols!, cloned]);
cloned.set(keySymbol, this); cloned.set(keySymbol, this);
cloned.__initSymb(); initSymbol(cloned);
} else if (opt.symbolInv) { } else if (opt.symbolInv) {
// Inverted, cloned is the instance, the origin is the main symbol // Inverted, cloned is the instance, the origin is the main symbol
this.set(keySymbols, [cloned]); this.set(keySymbols, [cloned]);
cloned.set(keySymbol, this); cloned.set(keySymbol, this);
[this, cloned].map(i => i.__initSymb()); [this, cloned].map(i => initSymbol(i));
} else { } else {
// Cloned becomes the main symbol // Cloned becomes the main symbol
cloned.set(keySymbols, [this]); cloned.set(keySymbols, [this]);
[this, cloned].map(i => i.__initSymb()); [this, cloned].map(i => initSymbol(i));
this.set(keySymbol, cloned); this.set(keySymbol, cloned);
} }
} }
@ -1526,6 +1347,14 @@ export default class Component extends StyleableModel<ComponentProperties> {
); );
} }
/**
* Update component name.
* @param {String} name New name.
*/
setName(name?: string, opts: SetOptions = {}) {
this.set('custom-name', name, opts);
}
/** /**
* Get the icon string * Get the icon string
* @return {String} * @return {String}
@ -1572,7 +1401,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
const tag = customTag || model.get('tagName'); const tag = customTag || model.get('tagName');
const sTag = model.get('void'); const sTag = model.get('void');
const customAttr = opts.attributes; const customAttr = opts.attributes;
let attributes = this.getAttrToHTML(); let attributes = this.getAttrToHTML(opts);
delete opts.tag; delete opts.tag;
// Get custom attributes if requested // Get custom attributes if requested
@ -1590,7 +1419,8 @@ export default class Component extends StyleableModel<ComponentProperties> {
forEach(props, (value, key) => { forEach(props, (value, key) => {
const skipProps = ['classes', 'attributes', 'components']; const skipProps = ['classes', 'attributes', 'components'];
if (key[0] !== '_' && skipProps.indexOf(key) < 0) { if (key[0] !== '_' && skipProps.indexOf(key) < 0) {
attributes[`data-gjs-${key}`] = isArray(value) || isObject(value) ? JSON.stringify(value) : value; attributes[`data-gjs-${key}`] =
isArray(value) || isObject(value) ? JSON.stringify(value) : isBoolean(value) ? `${value}` : value;
} }
}); });
} }
@ -1642,10 +1472,10 @@ export default class Component extends StyleableModel<ComponentProperties> {
* @return {Object} * @return {Object}
* @private * @private
*/ */
getAttrToHTML() { getAttrToHTML(opts?: ToHTMLOptions) {
const attrs = this.getAttributes(); const attrs = this.getAttributes();
if (avoidInline(this.em)) { if (avoidInline(this.em) && opts?.keepInlineStyle !== true) {
delete attrs.style; delete attrs.style;
} }
@ -1881,7 +1711,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
const { em } = this; const { em } = this;
const coll = this.collection; const coll = this.collection;
const remove = () => { const remove = () => {
coll && coll.remove(this, { ...opts, action: 'remove-component' }); coll && coll.remove(this, { action: ActionLabelComponents.remove, ...opts });
// Component without parent // Component without parent
if (!coll) { if (!coll) {
this.components('', opts); this.components('', opts);
@ -1889,7 +1719,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
} }
}; };
const rmOpts = { ...opts }; const rmOpts = { ...opts };
[this, em].map(i => i.trigger('component:remove:before', this, remove, rmOpts)); [this, em].map(i => i.trigger(ComponentsEvents.removeBefore, this, remove, rmOpts));
!rmOpts.abort && remove(); !rmOpts.abort && remove();
return this; return this;
} }
@ -1915,8 +1745,9 @@ export default class Component extends StyleableModel<ComponentProperties> {
if (sameParent && at && at > index) { if (sameParent && at && at > index) {
opts.at = at - 1; opts.at = at - 1;
} }
this.remove({ temporary: 1 }); const action = ActionLabelComponents.move;
component.append(this, opts); this.remove({ action, temporary: 1 });
component.append(this, { action, ...opts });
this.emitUpdate(); this.emitUpdate();
} }
} }
@ -1940,7 +1771,10 @@ export default class Component extends StyleableModel<ComponentProperties> {
if (!cmp) return false; if (!cmp) return false;
return this instanceof cmp; // A tiny hack to make isInstanceOf work properly where there a multiple inheritance
const { typeExtends } = this.constructor as typeof Component;
return this instanceof cmp || typeExtends.has(type);
} }
/** /**
@ -2030,11 +1864,13 @@ export default class Component extends StyleableModel<ComponentProperties> {
selector && selector.set({ name: id, label: id }); selector && selector.set({ name: id, label: id });
} }
static typeExtends = new Set<string>();
static getDefaults() { static getDefaults() {
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) };
} }
@ -2104,11 +1940,9 @@ export default class Component extends StyleableModel<ComponentProperties> {
} }
static getList(model: Component) { static getList(model: Component) {
const { opt = {} } = model; const { em } = model;
// @ts-ignore const dm = em?.Components;
const { domc, em } = opt; return dm?.componentsById ?? {};
const dm = domc || em?.Components;
return dm ? dm.componentsById : {};
} }
static checkId( static checkId(

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;
}
}

5
src/dom_components/model/ComponentImage.ts

@ -2,6 +2,7 @@ import { result } from 'underscore';
import Component from './Component'; import Component from './Component';
import { toLowerCase, buildBase64UrlFromSvg, hasWin } from '../../utils/mixins'; import { toLowerCase, buildBase64UrlFromSvg, hasWin } from '../../utils/mixins';
import { ObjectStrings } from '../../common'; import { ObjectStrings } from '../../common';
import { ComponentOptions, ComponentProperties } from './types';
const svgAttrs = const svgAttrs =
'xmlns="http://www.w3.org/2000/svg" width="100" viewBox="0 0 24 24" style="fill: rgba(0,0,0,0.15); transform: scale(0.75)"'; 'xmlns="http://www.w3.org/2000/svg" width="100" viewBox="0 0 24 24" style="fill: rgba(0,0,0,0.15); transform: scale(0.75)"';
@ -35,8 +36,8 @@ export default class ComponentImage extends Component {
}; };
} }
initialize(props: any, opts: any) { constructor(props: ComponentProperties = {}, opt: ComponentOptions) {
super.initialize(props, opts); super(props, opt);
const { src } = this.get('attributes')!; const { src } = this.get('attributes')!;
if (src && buildBase64UrlFromSvg(result(this, 'defaults').src) !== src) { if (src && buildBase64UrlFromSvg(result(this, 'defaults').src) !== src) {
this.set('src', src, { silent: true }); this.set('src', src, { silent: true });

6
src/dom_components/model/ComponentMap.ts

@ -1,5 +1,6 @@
import ComponentImage from './ComponentImage'; import ComponentImage from './ComponentImage';
import { toLowerCase } from '../../utils/mixins'; import { toLowerCase } from '../../utils/mixins';
import { ComponentOptions, ComponentProperties } from './types';
export default class ComponentMap extends ComponentImage { export default class ComponentMap extends ComponentImage {
/** @ts-ignore */ /** @ts-ignore */
@ -49,10 +50,11 @@ export default class ComponentMap extends ComponentImage {
}; };
} }
initialize(props: any, opts: any) { constructor(props: ComponentProperties = {}, opt: ComponentOptions) {
super(props, opt);
if (this.get('src')) this.parseFromSrc(); if (this.get('src')) this.parseFromSrc();
else this.updateSrc(); else this.updateSrc();
super.initialize(props, opts);
this.listenTo(this, 'change:address change:zoom change:mapType', this.updateSrc); this.listenTo(this, 'change:address change:zoom change:mapType', this.updateSrc);
} }

5
src/dom_components/model/ComponentTable.ts

@ -1,5 +1,6 @@
import Component from './Component'; import Component from './Component';
import { toLowerCase } from '../../utils/mixins'; import { toLowerCase } from '../../utils/mixins';
import { ComponentOptions, ComponentProperties } from './types';
const type = 'table'; const type = 'table';
@ -14,8 +15,8 @@ export default class ComponentTable extends Component {
}; };
} }
initialize(props: any, opts: any) { constructor(props: ComponentProperties = {}, opt: ComponentOptions) {
super.initialize(props, opts); super(props, opt);
const components = this.get('components')!; const components = this.get('components')!;
!components.length && components.add({ type: 'tbody' }); !components.length && components.add({ type: 'tbody' });
} }

5
src/dom_components/model/ComponentTableBody.ts

@ -1,5 +1,6 @@
import Component from './Component'; import Component from './Component';
import { toLowerCase } from '../../utils/mixins'; import { toLowerCase } from '../../utils/mixins';
import { ComponentOptions, ComponentProperties } from './types';
const type = 'tbody'; const type = 'tbody';
@ -17,8 +18,8 @@ export default class ComponentTableBody extends Component {
}; };
} }
initialize(props: any, opts: any) { constructor(props: ComponentProperties = {}, opt: ComponentOptions) {
super.initialize(props, opts); super(props, opt);
const components = this.get('components')!; const components = this.get('components')!;
let columns = this.get('columns'); let columns = this.get('columns');
let rows = this.get('rows'); let rows = this.get('rows');

5
src/dom_components/model/ComponentText.ts

@ -1,5 +1,6 @@
import { isFunction } from 'underscore'; import { isFunction } from 'underscore';
import Component from './Component'; import Component from './Component';
import { ComponentOptions, ComponentProperties } from './types';
export default class ComponentText extends Component { export default class ComponentText extends Component {
get defaults() { get defaults() {
@ -12,8 +13,8 @@ export default class ComponentText extends Component {
}; };
} }
initialize(props: any, opts: any) { constructor(props: ComponentProperties = {}, opt: ComponentOptions) {
super.initialize(props, opts); super(props, opt);
this.__checkInnerChilds(); this.__checkInnerChilds();
} }

6
src/dom_components/model/ComponentVideo.ts

@ -1,6 +1,7 @@
import { ObjectAny } from '../../common'; import { ObjectAny } from '../../common';
import { isDef, isEmptyObj, toLowerCase } from '../../utils/mixins'; import { isDef, isEmptyObj, toLowerCase } from '../../utils/mixins';
import ComponentImage from './ComponentImage'; import ComponentImage from './ComponentImage';
import { ComponentOptions, ComponentProperties } from './types';
const type = 'video'; const type = 'video';
const yt = 'yt'; const yt = 'yt';
@ -38,14 +39,13 @@ export default class ComponentVideo extends ComponentImage {
}; };
} }
initialize(props: any, opts: any) { constructor(props: ComponentProperties = {}, opt: ComponentOptions) {
this.em = opts.em; super(props, opt);
if (this.get('src')) this.parseFromSrc(); if (this.get('src')) this.parseFromSrc();
this.updatePropsFromAttr(); this.updatePropsFromAttr();
this.updateTraits(); this.updateTraits();
this.on('change:provider', this.updateTraits); this.on('change:provider', this.updateTraits);
this.on('change:videoId change:provider', this.updateSrc); this.on('change:videoId change:provider', this.updateSrc);
super.initialize(props, opts);
} }
updatePropsFromAttr() { updatePropsFromAttr() {

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);

145
src/dom_components/model/Components.ts

@ -1,12 +1,22 @@
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, 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,
ComponentAddType,
ComponentDefinition,
ComponentDefinitionDefined,
ComponentProperties,
} from './types';
import ComponentText from './ComponentText'; import ComponentText from './ComponentText';
import ComponentWrapper from './ComponentWrapper';
import { ComponentsEvents } from '../types';
import { isSymbolInstance, isSymbolRoot, updateSymbolComps } from './SymbolUtils';
export const getComponentIds = (cmp?: Component | Component[] | Components, res: string[] = []) => { export const getComponentIds = (cmp?: Component | Component[] | Components, res: string[] = []) => {
if (!cmp) return []; if (!cmp) return [];
@ -69,33 +79,42 @@ const getComponentsFromDefs = (
}; };
export interface ComponentsOptions { export interface ComponentsOptions {
em?: EditorModel; em: EditorModel;
config?: DomComponentsConfig; config?: DomComponentsConfig;
domc?: ComponentManager; domc?: ComponentManager;
} }
interface AddComponentOptions extends AddOptions {
previousModels?: Component[];
keepIds?: string[];
}
export default class Components extends Collection</** export default class Components extends Collection</**
* Keep this format to avoid errors in TS bundler */ * Keep this format to avoid errors in TS bundler */
/** @ts-ignore */ /** @ts-ignore */
Component> { Component> {
opt!: ComponentsOptions; opt!: ComponentsOptions;
config?: DomComponentsConfig; config?: DomComponentsConfig;
em!: EditorModel; em: EditorModel;
domc?: ComponentManager; domc?: ComponentManager;
parent?: Component; parent?: Component;
__firstAdd?: any;
initialize(models: any, opt: ComponentsOptions = {}) { constructor(models: any, opt: ComponentsOptions) {
super(models, opt);
this.opt = opt; this.opt = opt;
this.listenTo(this, 'add', this.onAdd); this.listenTo(this, 'add', this.onAdd);
this.listenTo(this, 'remove', this.removeChildren); this.listenTo(this, 'remove', this.removeChildren);
this.listenTo(this, 'reset', this.resetChildren); this.listenTo(this, 'reset', this.resetChildren);
const { em, config } = opt; const { em, config } = opt;
this.config = config; this.config = config;
this.em = em!; this.em = em;
this.domc = opt.domc || em?.Components; this.domc = opt.domc || em?.Components;
} }
get events() {
return this.domc?.events!;
}
resetChildren(models: Components, opts: { previousModels?: Component[]; keepIds?: string[] } = {}) { resetChildren(models: Components, opts: { previousModels?: Component[]; keepIds?: string[] } = {}) {
const coll = this; const coll = this;
const prev = opts.previousModels || []; const prev = opts.previousModels || [];
@ -175,16 +194,21 @@ Component> {
sels.remove(rulesRemoved.map(rule => rule.getSelectors().at(0))); sels.remove(rulesRemoved.map(rule => rule.getSelectors().at(0)));
if (!removed.opt.temporary) { if (!removed.opt.temporary) {
em.Commands.run('core:component-style-clear', { em.Commands.run('core:component-style-clear', { target: removed });
target: removed,
});
removed.removed(); removed.removed();
removed.trigger('removed'); removed.trigger('removed');
em.trigger('component:remove', removed); em.trigger(ComponentsEvents.remove, removed);
if (domc && isSymbolInstance(removed) && isSymbolRoot(removed)) {
domc.symbols.__trgEvent(domc.events.symbolInstanceRemove, { component: removed }, true);
}
} }
const inner = removed.components(); const inner = removed.components();
inner.forEach(it => this.removeChildren(it, coll, opts)); inner.forEach(it => {
updateSymbolComps(it, it, inner, { ...opts, skipRefsUp: true });
this.removeChildren(it, coll, opts);
});
} }
// Remove stuff registered in DomComponents.handleChanges // Remove stuff registered in DomComponents.handleChanges
@ -228,12 +252,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,17 +282,20 @@ Component> {
}); });
} }
return parsed.html; return components;
} }
/** @ts-ignore */ add(model: Exclude<ComponentAddType, string>, opt?: AddComponentOptions): Component;
add(models: ComponentAdd, opt: AddOptions & { previousModels?: Component[]; keepIds?: string[] } = {}) { add(models: ComponentAddType[], opt?: AddComponentOptions): Component[];
add(models: ComponentAdd, opt?: AddComponentOptions): Component | Component[];
add(models: unknown, opt: AddComponentOptions = {}): unknown {
if (models == undefined) return;
opt.keepIds = [...(opt.keepIds || []), ...getComponentIds(opt.previousModels)]; opt.keepIds = [...(opt.keepIds || []), ...getComponentIds(opt.previousModels)];
if (isString(models)) { if (isString(models)) {
models = this.parseString(models, opt)!; models = this.parseString(models, opt)!;
} else if (isArray(models)) { } else if (isArray(models)) {
models = [...models];
models.forEach((item: string, index: number) => { models.forEach((item: string, index: number) => {
if (isString(item)) { if (isString(item)) {
const nodes = this.parseString(item, opt); const nodes = this.parseString(item, opt);
@ -262,21 +304,19 @@ Component> {
}); });
} }
const isMult = isArray(models); const processedModels = (isArray(models) ? models : [models])
// @ts-ignore .filter(Boolean)
models = (isMult ? models : [models]).filter(Boolean).map((model: any) => this.processDef(model)); .map((model: any) => this.processDef(model));
// @ts-ignore
models = isMult ? flatten(models as any, 1) : models[0];
const result = Collection.prototype.add.apply(this, [models as any, opt]); models = isArray(models) ? flatten(processedModels as any, 1) : processedModels[0];
this.__firstAdd = result;
return result; return super.add(models as any, opt);
} }
/** /**
* Process component definition. * Process component definition.
*/ */
processDef(mdl: any) { processDef(mdl: Component | ComponentDefinition | ComponentDefinitionDefined) {
// Avoid processing Models // Avoid processing Models
if (mdl.cid && mdl.ccid) return mdl; if (mdl.cid && mdl.ccid) return mdl;
const { em, config = {} } = this; const { em, config = {} } = this;
@ -287,12 +327,14 @@ Component> {
model = { ...model }; // Avoid 'Cannot delete property ...' model = { ...model }; // Avoid 'Cannot delete property ...'
const modelPr = processor(model); const modelPr = processor(model);
if (modelPr) { if (modelPr) {
//@ts-ignore
each(model, (val, key) => delete model[key]); each(model, (val, key) => delete model[key]);
extend(model, modelPr); extend(model, modelPr);
} }
} }
// React JSX preset // React JSX preset
//@ts-ignore
if (model.$$typeof && typeof model.props == 'object') { if (model.$$typeof && typeof model.props == 'object') {
model = { ...model }; model = { ...model };
model.props = { ...model.props }; model.props = { ...model.props };
@ -301,6 +343,7 @@ Component> {
const { parserHtml } = parser; const { parserHtml } = parser;
each(model, (value, key) => { each(model, (value, key) => {
//@ts-ignore
if (!includes(['props', 'type'], key)) delete model[key]; if (!includes(['props', 'type'], key)) delete model[key];
}); });
const { props } = model; const { props } = model;
@ -332,8 +375,7 @@ Component> {
const avoidInline = em && em.getConfig().avoidInlineStyle; const avoidInline = em && em.getConfig().avoidInlineStyle;
domc && domc.Component.ensureInList(model); domc && domc.Component.ensureInList(model);
// @ts-ignore if (!isEmpty(style) && !avoidInline && em && em.getConfig().forceClass && !opts.temporary) {
if (!isEmpty(style) && !avoidInline && em && em.get && em.getConfig().forceClass && !opts.temporary) {
const name = model.cid; const name = model.cid;
em.Css.setClassRule(name, style); em.Css.setClassRule(name, style);
model.setStyle({}); model.setStyle({});
@ -341,34 +383,17 @@ Component> {
} }
model.__postAdd({ recursive: true }); model.__postAdd({ recursive: true });
// this.__onAddEnd();
}
// __onAddEnd = debounce(function () { if (em && !opts.temporary) {
// // TODO to check symbols on load, probably this might be removed as symbols const triggerAdd = (model: Component) => {
// // are always recovered from the model em.trigger(ComponentsEvents.add, model, opts);
// // const { domc } = this; model.components().forEach(comp => triggerAdd(comp));
// // const allComp = (domc && domc.allById()) || {}; };
// // const firstAdd = this.__firstAdd; triggerAdd(model);
// // const toCheck = isArray(firstAdd) ? firstAdd : [firstAdd];
// // const silent = { silent: true }; if (domc && isSymbolInstance(model) && isSymbolRoot(model)) {
// // const onAll = comps => { domc.symbols.__trgEvent(domc.events.symbolInstanceAdd, { component: model }, true);
// // comps.forEach(comp => { }
// // const symbol = comp.get(keySymbols); }
// // const symbolOf = comp.get(keySymbol); }
// // if (symbol && isArray(symbol) && isString(symbol[0])) {
// // comp.set(
// // keySymbols,
// // symbol.map(smb => allComp[smb]).filter(i => i),
// // silent
// // );
// // }
// // if (isString(symbolOf)) {
// // comp.set(keySymbol, allComp[symbolOf], silent);
// // }
// // onAll(comp.components());
// // });
// // };
// // onAll(toCheck);
// });
} }

282
src/dom_components/model/SymbolUtils.ts

@ -0,0 +1,282 @@
import { isArray, isString, keys } from 'underscore';
import Component, { keySymbol, keySymbolOvrd, keySymbols } from './Component';
import { SymbolToUpOptions } from './types';
import { isEmptyObj } from '../../utils/mixins';
import Components from './Components';
export const isSymbolMain = (cmp: Component) => isArray(cmp.get(keySymbols));
export const isSymbolInstance = (cmp: Component) => !!cmp.get(keySymbol);
export const isSymbol = (cmp: Component) => !!(isSymbolMain(cmp) || isSymbolInstance(cmp));
export const isSymbolRoot = (symbol: Component) => {
const parent = symbol.parent();
return isSymbol(symbol) && (!parent || !isSymbol(parent));
};
export const isSymbolNested = (symbol: Component) => {
if (!isSymbol(symbol)) return false;
const symbTopSelf = getSymbolTop(isSymbolMain(symbol) ? symbol : getSymbolMain(symbol)!);
const symbTop = getSymbolTop(symbol);
const symbTopMain = isSymbolMain(symbTop) ? symbTop : getSymbolMain(symbTop);
return symbTopMain !== symbTopSelf;
};
export const initSymbol = (symbol: Component) => {
if (symbol.__symbReady) return;
symbol.on('change', symbol.__upSymbProps);
symbol.__symbReady = true;
};
export const getSymbolMain = (symbol: Component): Component | undefined => {
let result = symbol.get(keySymbol);
if (result && isString(result)) {
const ref = symbol.__getAllById()[result];
if (ref) {
result = ref;
symbol.set(keySymbol, ref);
} else {
result = 0;
}
}
return result || undefined;
};
export const getSymbolInstances = (symbol?: Component): Component[] | undefined => {
let symbs = symbol?.get(keySymbols);
if (symbs && isArray(symbs)) {
symbs.forEach((symb, idx) => {
if (symb && isString(symb)) {
symbs[idx] = symbol!.__getAllById()[symb];
}
});
symbs = symbs.filter(symb => symb && !isString(symb));
}
return symbs || undefined;
};
export const isSymbolOverride = (symbol?: Component, prop = '') => {
const ovrd = symbol?.get(keySymbolOvrd);
const [prp] = prop.split(':');
const props = prop !== prp ? [prop, prp] : [prop];
return ovrd === true || (isArray(ovrd) && props.some(p => ovrd.indexOf(p) >= 0));
};
export const getSymbolsToUpdate = (symb: Component, opts: SymbolToUpOptions = {}) => {
let result: Component[] = [];
const { changed } = opts;
if (
opts.fromInstance ||
opts.noPropagate ||
opts.fromUndo ||
// Avoid updating others if the current component has override
(changed && isSymbolOverride(symb, changed))
) {
return result;
}
const symbols = getSymbolInstances(symb) || [];
const symbol = getSymbolMain(symb);
const all = symbol ? [symbol, ...(getSymbolInstances(symbol) || [])] : symbols;
result = all
.filter(s => s !== symb)
// Avoid updating those with override
.filter(s => !(changed && isSymbolOverride(s, changed)));
return result;
};
export const getSymbolTop = (symbol: Component, opts?: any) => {
let result = symbol;
let parent = symbol.parent(opts);
// while (parent && (isSymbolMain(parent) || getSymbol(parent))) {
while (parent && isSymbol(parent)) {
result = parent;
parent = parent.parent(opts);
}
return result;
};
export const detachSymbolInstance = (symbol: Component, opts: { skipRefs?: boolean } = {}) => {
const symbolMain = getSymbolMain(symbol);
const symbs = symbolMain && getSymbolInstances(symbolMain);
!opts.skipRefs &&
symbs &&
symbolMain.set(
keySymbols,
symbs.filter(s => s !== symbol)
);
symbol.set(keySymbol, 0);
symbol.components().forEach(s => detachSymbolInstance(s, opts));
};
export const logSymbol = (symb: Component, type: string, toUp: Component[], opts: any = {}) => {
const symbol = getSymbolMain(symb);
const symbols = getSymbolInstances(symb);
if (!symbol && !symbols) {
return;
}
symb.em.log(type, { model: symb, toUp, context: 'symbols', opts });
};
export const updateSymbolProps = (symbol: Component, opts: SymbolToUpOptions = {}) => {
const changed = symbol.changedAttributes() || {};
const attrs = changed.attributes || {};
delete changed.status;
delete changed.open;
delete changed[keySymbols];
delete changed[keySymbol];
delete changed[keySymbolOvrd];
delete changed.attributes;
delete attrs.id;
if (!isEmptyObj(attrs)) {
changed.attributes = attrs;
}
if (!isEmptyObj(changed)) {
const toUp = getSymbolsToUpdate(symbol, opts);
// Avoid propagating overrides to other symbols
keys(changed).map(prop => {
if (isSymbolOverride(symbol, prop)) delete changed[prop];
});
logSymbol(symbol, 'props', toUp, { opts, changed });
toUp.forEach(child => {
const propsChanged = { ...changed };
// Avoid updating those with override
keys(propsChanged).map(prop => {
if (isSymbolOverride(child, prop)) delete propsChanged[prop];
});
child.set(propsChanged, { fromInstance: symbol, ...opts });
});
}
};
export const updateSymbolCls = (symbol: Component, opts: any = {}) => {
const toUp = getSymbolsToUpdate(symbol, opts);
logSymbol(symbol, 'classes', toUp, { opts });
toUp.forEach(child => {
// @ts-ignore This will propagate the change up to __upSymbProps
child.set('classes', symbol.get('classes'), { fromInstance: symbol });
});
symbol.__changesUp(opts);
};
export const updateSymbolComps = (symbol: Component, m: Component, c: Components, o: any) => {
const optUp = o || c || {};
const { fromInstance, fromUndo } = optUp;
const toUpOpts = { fromInstance, fromUndo };
const isTemp = m.opt.temporary;
// Reset
if (!o) {
const coll = m as unknown as Components;
const toUp = getSymbolsToUpdate(symbol, {
...toUpOpts,
changed: 'components:reset',
});
const cmps = coll.models;
const newSymbols = new Set<Component>();
logSymbol(symbol, 'reset', toUp, { components: cmps });
toUp.forEach(rel => {
const relCmps = rel.components();
const toReset = cmps.map((cmp, i) => {
// This particular case here is to handle reset from `resetFromString`
// where we can receive an array of regulat components or already
// existing symbols (updated already before reset)
if (!isSymbol(cmp) || newSymbols.has(cmp)) {
newSymbols.add(cmp);
return cmp.clone({ symbol: true });
}
return relCmps.at(i);
});
relCmps.reset(toReset, { fromInstance: symbol, ...c } as any);
});
// Add
} else if (o.add) {
let addedInstances: Component[] = [];
const isMainSymb = !!getSymbolInstances(symbol);
const toUp = getSymbolsToUpdate(symbol, {
...toUpOpts,
changed: 'components:add',
});
if (toUp.length) {
const addSymb = getSymbolMain(m);
addedInstances = (addSymb ? getSymbolInstances(addSymb) : getSymbolInstances(m)) || [];
addedInstances = [...addedInstances];
addedInstances.push(addSymb ? addSymb : m);
}
!isTemp &&
logSymbol(symbol, 'add', toUp, {
opts: o,
addedInstances: addedInstances.map(c => c.cid),
added: m.cid,
});
// Here, before appending a new symbol, I have to ensure there are no previously
// created symbols (eg. used mainly when drag components around)
toUp.forEach(symb => {
const symbTop = getSymbolTop(symb);
const symbPrev = addedInstances.filter(addedInst => {
const addedTop = getSymbolTop(addedInst, { prev: 1 });
return symbTop && addedTop && addedTop === symbTop;
})[0];
const toAppend = symbPrev || m.clone({ symbol: true, symbolInv: isMainSymb });
symb.append(toAppend, { fromInstance: symbol, ...o });
});
// Remove
} else {
// Remove instance reference from the symbol
const symb = getSymbolMain(m);
symb &&
!o.temporary &&
symb.set(
keySymbols,
getSymbolInstances(symb)!.filter(i => i !== m)
);
// Propagate remove only if the component is an inner symbol
if (!isSymbolRoot(m) && !o.skipRefsUp) {
const changed = 'components:remove';
const { index } = o;
const parent = m.parent();
const opts = { fromInstance: m, ...o };
const isSymbNested = isSymbolRoot(m);
let toUpFn = (symb: Component) => {
const symbPrnt = symb.parent();
symbPrnt && !isSymbolOverride(symbPrnt, changed) && symb.remove(opts);
};
// Check if the parent allows the removing
let toUp = !isSymbolOverride(parent, changed) ? getSymbolsToUpdate(m, toUpOpts) : [];
if (isSymbNested) {
toUp = parent! && getSymbolsToUpdate(parent, { ...toUpOpts, changed })!;
toUpFn = symb => {
const toRemove = symb.components().at(index);
toRemove && toRemove.remove({ fromInstance: parent, ...opts });
};
}
!isTemp &&
logSymbol(symbol, 'remove', toUp, {
opts: o,
removed: m.cid,
isSymbNested,
});
toUp.forEach(toUpFn);
}
}
symbol.__changesUp(optUp);
};

56
src/dom_components/model/Symbols.ts

@ -0,0 +1,56 @@
import { debounce } from 'underscore';
import { Debounced, ObjectAny } from '../../common';
import Component from './Component';
import Components from './Components';
import { detachSymbolInstance, getSymbolInstances } from './SymbolUtils';
interface PropsComponentUpdate {
component: Component;
changed: ObjectAny;
options: ObjectAny;
}
export default class Symbols extends Components {
refreshDbn: Debounced;
constructor(...args: ConstructorParameters<typeof Components>) {
super(...args);
this.refreshDbn = debounce(() => this.refresh(), 0);
const { events } = this;
this.on(events.update, this.onUpdate);
this.on(events.updateInside, this.onUpdateDeep);
}
removeChildren(component: Component, coll?: Components, opts: any = {}) {
super.removeChildren(component, coll, opts);
getSymbolInstances(component)?.forEach(i => detachSymbolInstance(i, { skipRefs: true }));
this.__trgEvent(this.events.symbolMainRemove, { component });
}
onAdd(...args: Parameters<Components['onAdd']>) {
super.onAdd(...args);
const [component] = args;
this.__trgEvent(this.events.symbolMainAdd, { component });
}
onUpdate(props: PropsComponentUpdate) {
this.__trgEvent(this.events.symbolMainUpdate, props);
}
onUpdateDeep(props: PropsComponentUpdate) {
this.__trgEvent(this.events.symbolMainUpdateDeep, props);
}
refresh() {
const { em, events } = this;
em.trigger(events.symbol);
}
__trgEvent(event: string, props: ObjectAny, isInstance = false) {
const { em, events } = this;
const eventType = isInstance ? events.symbolInstance : events.symbolMain;
em.trigger(event, props);
em.trigger(eventType, { ...props, event });
this.refreshDbn();
}
}

33
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';
@ -10,15 +10,23 @@ import ComponentView from '../view/ComponentView';
import Component from './Component'; import Component from './Component';
import Components from './Components'; import Components from './Components';
import { ToolbarButtonProps } from './ToolbarButton'; import { ToolbarButtonProps } from './ToolbarButton';
import { ParseNodeOptions } from '../../parser/config/config';
export type DragMode = 'translate' | 'absolute' | ''; 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 ComponentStackItem { export interface AddComponentsOption extends AddOptions, OptionAsDocument {}
interface ComponentWithCheck<C extends Component> {
new (props: any, opt: ComponentOptions): C;
isComponent(node: HTMLElement, opts?: ParseNodeOptions): ComponentDefinitionDefined | undefined | boolean;
}
export interface ComponentStackItem<C extends Component = Component, CV extends ComponentView<C> = ComponentView<C>> {
id: string; id: string;
model: typeof Component; model: ComponentWithCheck<C>;
view: typeof ComponentView<any>; view: new (opt: any) => CV;
} }
/** /**
@ -132,7 +140,7 @@ export interface ComponentProperties {
*/ */
copyable?: boolean; copyable?: boolean;
/** /**
* Indicates if it's possible to resize the component. It's also possible to pass an object as [options for the Resizer](https://github.com/GrapesJS/grapesjs/blob/master/src/utils/Resizer.js). Default: `false` * Indicates if it's possible to resize the component. It's also possible to pass an object as [options for the Resizer](https://github.com/GrapesJS/grapesjs/blob/master/src/utils/Resizer.ts). Default: `false`
*/ */
resizable?: boolean | ResizerOptions; resizable?: boolean | ResizerOptions;
/** /**
@ -260,11 +268,11 @@ export interface ComponentModelProperties extends ComponentProperties {
[key: string]: any; [key: string]: any;
} }
type ComponentAddType = Component | ComponentDefinition | ComponentDefinitionDefined | string; export type ComponentAddType = Component | ComponentDefinition | ComponentDefinitionDefined | string;
export type ComponentAdd = ComponentAddType | ComponentAddType[]; export type ComponentAdd = ComponentAddType | ComponentAddType[];
export type ToHTMLOptions = { export interface ToHTMLOptions extends OptionAsDocument {
/** /**
* Custom tagName. * Custom tagName.
*/ */
@ -280,16 +288,21 @@ export type ToHTMLOptions = {
*/ */
altQuoteAttr?: boolean; altQuoteAttr?: boolean;
/**
* Keep inline style set intentionally by users with `setStyle({}, { inline: true })`
*/
keepInlineStyle?: boolean;
/** /**
* You can pass an object of custom attributes to replace with the current ones * 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. * 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;
config?: DomComponentsConfig; config: DomComponentsConfig;
frame?: Frame; frame?: Frame;
temporary?: boolean; temporary?: boolean;
avoidChildren?: boolean; avoidChildren?: boolean;

106
src/dom_components/types.ts

@ -0,0 +1,106 @@
import Component from './model/Component';
export enum ActionLabelComponents {
remove = 'component:remove',
add = 'component:add',
move = 'component:move',
}
export interface SymbolInfo {
isSymbol: boolean;
isMain: boolean;
isInstance: boolean;
isRoot: boolean;
main?: Component;
instances: Component[];
relatives: Component[];
}
export enum ComponentsEvents {
/**
* @event `component:add` New component added.
* @example
* editor.on('component:add', (component) => { ... });
*/
add = 'component:add',
/**
* @event `component:remove` Component removed.
* @example
* editor.on('component:remove', (component) => { ... });
*/
remove = 'component:remove',
removeBefore = 'component:remove:before',
/**
* @event `component:create` Component created.
* @example
* editor.on('component:create', (component) => { ... });
*/
create = 'component:create',
/**
* @event `component:update` Component is updated, the component is passed as an argument to the callback.
* @example
* editor.on('component:update', (component) => { ... });
*/
update = 'component:update',
updateInside = 'component:update-inside',
/**
* @event `symbol:main:add` Added new main symbol.
* @example
* editor.on('symbol:main:add', ({ component }) => { ... });
*/
symbolMainAdd = 'symbol:main:add',
/**
* @event `symbol:main:update` Main symbol updated.
* @example
* editor.on('symbol:main:update', ({ component }) => { ... });
*/
symbolMainUpdate = 'symbol:main:update',
symbolMainUpdateDeep = 'symbol:main:update-deep',
/**
* @event `symbol:main:remove` Main symbol removed.
* @example
* editor.on('symbol:main:remove', ({ component }) => { ... });
*/
symbolMainRemove = 'symbol:main:remove',
/**
* @event `symbol:main` Catch-all event related to main symbol updates.
* @example
* editor.on('symbol:main', ({ event, component }) => { ... });
*/
symbolMain = 'symbol:main',
/**
* @event `symbol:instance:add` Added new root instance symbol.
* @example
* editor.on('symbol:instance:add', ({ component }) => { ... });
*/
symbolInstanceAdd = 'symbol:instance:add',
/**
* @event `symbol:instance:remove` Root instance symbol removed.
* @example
* editor.on('symbol:instance:remove', ({ component }) => { ... });
*/
symbolInstanceRemove = 'symbol:instance:remove',
/**
* @event `symbol:instance` Catch-all event related to instance symbol updates.
* @example
* editor.on('symbol:instance', ({ event, component }) => { ... });
*/
symbolInstance = 'symbol:instance',
/**
* @event `symbol` Catch-all event for any symbol update (main or instance).
* @example
* editor.on('symbol', () => { ... });
*/
symbol = 'symbol',
}

2
src/dom_components/view/ComponentImageView.ts

@ -103,7 +103,7 @@ export default class ComponentImageView<TComp extends ComponentImage = Component
onLoad() { onLoad() {
// Used to update component tools box (eg. toolbar, resizer) once the image is loaded // Used to update component tools box (eg. toolbar, resizer) once the image is loaded
this.em.trigger('change:canvasOffset'); this.em.Canvas.refresh({ all: true });
} }
noDrag(ev: Event) { noDrag(ev: Event) {

4
src/dom_components/view/ComponentLabelView.ts

@ -1,4 +1,4 @@
import ComponentLabel from '../model/ComponentLabel'; import ComponentLabel from '../model/ComponentLabel';
import ComponentView from './ComponentView'; import ComponentLinkView from './ComponentLinkView';
export default class ComponentLabelView extends ComponentView<ComponentLabel> {} export default class ComponentLabelView extends ComponentLinkView<ComponentLabel> {}

4
src/dom_components/view/ComponentLinkView.ts

@ -1,7 +1,7 @@
import ComponentLink from '../model/ComponentLink'; import ComponentLink from '../model/ComponentLink';
import ComponentView from './ComponentView'; import ComponentTextView from './ComponentTextView';
export default class ComponentLinkView extends ComponentView<ComponentLink> { export default class ComponentLinkView<TComp extends ComponentLink = ComponentLink> extends ComponentTextView<TComp> {
render() { render() {
super.render(); super.render();
// I need capturing instead of bubbling as bubbled clicks from other // I need capturing instead of bubbling as bubbled clicks from other

2
src/dom_components/view/ComponentTextView.ts

@ -10,7 +10,7 @@ import ComponentText from '../model/ComponentText';
import { ComponentDefinition } from '../model/types'; import { ComponentDefinition } from '../model/types';
import ComponentView from './ComponentView'; import ComponentView from './ComponentView';
export default class ComponentTextView extends ComponentView<ComponentText> { export default class ComponentTextView<TComp extends ComponentText = ComponentText> extends ComponentView<TComp> {
rte?: RichTextEditorModule; rte?: RichTextEditorModule;
rteEnabled?: boolean; rteEnabled?: boolean;
activeRte?: RichTextEditor; activeRte?: RichTextEditor;

3
src/dom_components/view/ComponentView.ts

@ -293,8 +293,9 @@ TComp> {
const { model, em } = this; const { model, em } = this;
if (avoidInline(em) && !opts.inline) { if (avoidInline(em) && !opts.inline) {
// Move inline styles to CSSRule
const styleOpts = this.__cmpStyleOpts; const styleOpts = this.__cmpStyleOpts;
const style = model.getStyle(styleOpts); const style = model.getStyle({ inline: true, ...styleOpts });
!isEmpty(style) && model.setStyle(style, styleOpts); !isEmpty(style) && model.setStyle(style, styleOpts);
} else { } else {
this.setAttribute('style', model.styleToString(opts)); this.setAttribute('style', model.styleToString(opts));

18
src/dom_components/view/ComponentsView.ts

@ -46,18 +46,8 @@ export default class ComponentsView extends View {
* @param {Object} opts * @param {Object} opts
* @private * @private
* */ * */
addTo(model: Component, coll: any = {}, opts: { temporary?: boolean } = {}) { addTo(model: Component) {
const { em } = this; this.addToCollection(model, null, this.collection.indexOf(model));
const i = this.collection.indexOf(model);
this.addToCollection(model, null, i);
if (em && !opts.temporary) {
const triggerAdd = (model: Component) => {
em.trigger('component:add', model);
model.components().forEach(comp => triggerAdd(comp));
};
triggerAdd(model);
}
} }
/** /**
@ -69,10 +59,8 @@ export default class ComponentsView extends View {
* @return {Object} Object rendered * @return {Object} Object rendered
* @private * @private
* */ * */
addToCollection(model: Component, fragmentEl?: DocumentFragment | null, index?: number) { addToCollection(model: Component, fragment?: DocumentFragment | null, index?: number) {
// if (!this.compView) this.compView = require('./ComponentView').default;
const { config, opts, em } = this; const { config, opts, em } = this;
const fragment = fragmentEl || null;
const { frameView } = config; const { frameView } = config;
const sameFrameView = frameView?.model && model.getView(frameView.model); const sameFrameView = frameView?.model && model.getView(frameView.model);
const dt = opts.componentTypes || em?.Components.getTypes(); const dt = opts.componentTypes || em?.Components.getTypes();

6
src/domain_abstract/model/StyleableModel.ts

@ -1,14 +1,16 @@
import { isArray, isString, keys } from 'underscore'; import { isArray, isString, keys } from 'underscore';
import { Model, ObjectAny, ObjectHash } from '../../common'; import { Model, ObjectAny, ObjectHash, SetOptions } from '../../common';
import ParserHtml from '../../parser/model/ParserHtml'; import ParserHtml from '../../parser/model/ParserHtml';
import Selectors from '../../selector_manager/model/Selectors'; import Selectors from '../../selector_manager/model/Selectors';
import { shallowDiff } from '../../utils/mixins'; import { shallowDiff } from '../../utils/mixins';
export type StyleProps = Record<string, string | string[]>; export type StyleProps = Record<string, string | string[]>;
export type UpdateStyleOptions = ObjectAny & { export type UpdateStyleOptions = SetOptions & {
partial?: boolean; partial?: boolean;
addStyle?: StyleProps; addStyle?: StyleProps;
inline?: boolean;
noEvent?: boolean;
}; };
const parserHtml = ParserHtml(); const parserHtml = ParserHtml();

10
src/domain_abstract/ui/InputColor.ts

@ -101,10 +101,9 @@ export default class InputColor extends Input {
const ppfx = this.ppfx; const ppfx = this.ppfx;
const { onChange } = opts; const { onChange } = opts;
var colorEl = $(`<div class="${this.ppfx}field-color-picker"></div>`); const colorEl = $(`<div class="${this.ppfx}field-color-picker"></div>`);
var cpStyle = colorEl.get(0)!.style; const cpStyle = colorEl.get(0)!.style;
var elToAppend = em && em.config ? em.config.el : ''; const colorPickerConfig = (em && em.getConfig && em.getConfig().colorPicker) || {};
var colorPickerConfig = (em && em.getConfig && em.getConfig().colorPicker) || {};
this.movedColor = ''; this.movedColor = '';
let changed = false; let changed = false;
@ -123,8 +122,7 @@ export default class InputColor extends Input {
// @ts-ignore // @ts-ignore
colorEl.spectrum({ colorEl.spectrum({
color: model.getValue() || false, color: model.getValue() || false,
containerClassName: `${ppfx}one-bg ${ppfx}two-color`, containerClassName: `${ppfx}one-bg ${ppfx}two-color ${ppfx}editor-sp`,
appendTo: elToAppend || 'body',
maxSelectionSize: 8, maxSelectionSize: 8,
showPalette: true, showPalette: true,
showAlpha: true, showAlpha: true,

4
src/domain_abstract/ui/InputNumber.ts

@ -140,7 +140,7 @@ export default class InputNumber extends Input {
upArrowClick() { upArrowClick() {
const { model } = this; const { model } = this;
const step = model.get('step'); const step = model.get('step');
let value = parseFloat(model.get('value')); let value = parseFloat(this.getInputEl().value);
this.setValue(this.normalizeValue(value + step)); this.setValue(this.normalizeValue(value + step));
this.elementUpdated(); this.elementUpdated();
} }
@ -151,7 +151,7 @@ export default class InputNumber extends Input {
downArrowClick() { downArrowClick() {
const { model } = this; const { model } = this;
const step = model.get('step'); const step = model.get('step');
const value = parseFloat(model.get('value')); const value = parseFloat(this.getInputEl().value);
this.setValue(this.normalizeValue(value - step)); this.setValue(this.normalizeValue(value - step));
this.elementUpdated(); this.elementUpdated();
} }

10
src/editor/config/config.ts

@ -7,6 +7,7 @@ import { DeviceManagerConfig } from '../../device_manager/config/config';
import { I18nConfig } from '../../i18n/config'; import { I18nConfig } from '../../i18n/config';
import { ModalConfig } from '../../modal_dialog/config/config'; import { ModalConfig } from '../../modal_dialog/config/config';
import { LayerManagerConfig } from '../../navigator/config/config'; import { LayerManagerConfig } from '../../navigator/config/config';
import { KeymapsConfig } from '../../keymaps/config';
import { PageManagerConfig } from '../../pages/types'; import { PageManagerConfig } from '../../pages/types';
import { PanelsConfig } from '../../panels/config/config'; import { PanelsConfig } from '../../panels/config/config';
import { ParserConfig } from '../../parser/config/config'; import { ParserConfig } from '../../parser/config/config';
@ -22,6 +23,7 @@ import { DomComponentsConfig } from '../../dom_components/config/config';
import { HTMLGeneratorBuildOptions } from '../../code_manager/model/HtmlGenerator'; import { HTMLGeneratorBuildOptions } from '../../code_manager/model/HtmlGenerator';
import { CssGeneratorBuildOptions } from '../../code_manager/model/CssGenerator'; import { CssGeneratorBuildOptions } from '../../code_manager/model/CssGenerator';
import { ObjectAny } from '../../common'; import { ObjectAny } from '../../common';
import { ColorPickerOptions } from '../../utils/ColorPicker';
export interface EditorConfig { export interface EditorConfig {
/** /**
@ -355,6 +357,11 @@ export interface EditorConfig {
*/ */
commands?: CommandsConfig; commands?: CommandsConfig;
/**
* Configurations for keymaps
*/
keymaps?: KeymapsConfig;
/** /**
* Configurations for Css Composer. * Configurations for Css Composer.
*/ */
@ -415,10 +422,11 @@ export interface EditorConfig {
*/ */
customUI?: boolean; customUI?: boolean;
el?: HTMLElement; el?: HTMLElement;
/** /**
* Color picker options. * Color picker options.
*/ */
colorPicker?: ObjectAny; colorPicker?: ColorPickerOptions;
pStylePrefix?: string; pStylePrefix?: string;
} }

3
src/editor/index.ts

@ -55,7 +55,8 @@
* @module docsjs.Editor * @module docsjs.Editor
*/ */
import { IBaseModule } from '../abstract/Module'; import { IBaseModule } from '../abstract/Module';
import AssetManager, { AssetEvent } from '../asset_manager'; import AssetManager from '../asset_manager';
import { AssetEvent } from '../asset_manager/types';
import BlockManager, { BlockEvent } from '../block_manager'; import BlockManager, { BlockEvent } from '../block_manager';
import CanvasModule, { CanvasEvent } from '../canvas'; import CanvasModule, { CanvasEvent } from '../canvas';
import CodeManagerModule from '../code_manager'; import CodeManagerModule from '../code_manager';

4
src/editor/model/Editor.ts

@ -913,9 +913,7 @@ export default class EditorModel extends Model {
* @public * @public
*/ */
refreshCanvas(opts: any = {}) { refreshCanvas(opts: any = {}) {
this.set('canvasOffset', null); this.Canvas.refresh({ spots: opts.tools });
this.set('canvasOffset', this.Canvas.getOffset());
opts.tools && this.trigger('canvas:updateTools');
} }
/** /**

22
src/editor/view/EditorView.ts

@ -5,15 +5,14 @@ import EditorModel from '../model/Editor';
export default class EditorView extends View<EditorModel> { export default class EditorView extends View<EditorModel> {
constructor(model: EditorModel) { constructor(model: EditorModel) {
super({ model }); super({ model });
//const { model } = this; const { Panels, UndoManager } = model;
const { Panels, UndoManager } = model.attributes;
model.view = this; model.view = this;
model.once('change:ready', () => { model.once('change:ready', () => {
Panels.active(); Panels.active();
Panels.disableButtons(); Panels.disableButtons();
UndoManager.clear(); UndoManager.clear();
setTimeout(() => { setTimeout(() => {
model.trigger('load', model.get('Editor')); model.trigger('load', model.Editor);
model.clearDirtyCount(); model.clearDirtyCount();
}); });
}); });
@ -21,35 +20,30 @@ export default class EditorView extends View<EditorModel> {
render() { render() {
const { $el, model } = this; const { $el, model } = this;
const { Panels, Canvas } = model.attributes; const { Panels, Canvas, config, modules } = model;
const { config, modules } = model;
const pfx = config.stylePrefix; const pfx = config.stylePrefix;
const classNames = [`${pfx}editor`]; const classNames = [`${pfx}editor`];
!config.customUI && classNames.push(`${pfx}one-bg ${pfx}two-color`); !config.customUI && classNames.push(`${pfx}one-bg ${pfx}two-color`);
// @ts-ignore
const contEl = $(config.el || `body ${config.container}`); const contEl = $(config.el || `body ${config.container}`);
config.cssIcons && appendStyles(config.cssIcons, { unique: true, prepand: true }); config.cssIcons && appendStyles(config.cssIcons, { unique: true, prepand: true });
$el.empty(); $el.empty();
// @ts-ignore config.width && contEl.css('width', config.width);
if (config.width) contEl.css('width', config.width); config.height && contEl.css('height', config.height);
// @ts-ignore
if (config.height) contEl.css('height', config.height);
$el.append(Canvas.render()); $el.append(Canvas.render());
$el.append(Panels.render()); $el.append(Panels.render());
// Load shallow editor // Load shallow editor
const shallow = model.get('shallow'); const { shallow } = model;
const shallowCanvasEl = shallow.get('Canvas').render(); const shallowCanvasEl = shallow.Canvas.render();
shallowCanvasEl.style.display = 'none'; shallowCanvasEl.style.display = 'none';
$el.append(shallowCanvasEl); $el.append(shallowCanvasEl);
$el.attr('class', classNames.join(' ')); $el.attr('class', classNames.join(' '));
// @ts-ignore
contEl.addClass(`${pfx}editor-cont`).empty().append($el); contEl.addClass(`${pfx}editor-cont`).empty().append($el);
modules.forEach(md => md.postRender && md.postRender(this)); modules.forEach(md => md.postRender?.(this));
return this; return this;
} }

29
src/i18n/index.ts

@ -19,10 +19,7 @@
* const i18n = editor.I18n; * const i18n = editor.I18n;
* ``` * ```
* *
* ### Events * {REPLACE_EVENTS}
* * `i18n:add` - New set of messages is added
* * `i18n:update` - The set of messages is updated
* * `i18n:locale` - Locale changed
* *
* @module I18n * @module I18n
*/ */
@ -31,10 +28,11 @@ import { Module } from '../abstract';
import EditorModel from '../editor/model/Editor'; import EditorModel from '../editor/model/Editor';
import { hasWin, deepMerge } from '../utils/mixins'; import { hasWin, deepMerge } from '../utils/mixins';
import defaults, { I18nConfig } from './config'; import defaults, { I18nConfig } from './config';
import I18nEvents, { Messages } from './types';
type Messages = Required<I18nConfig>['messages'];
export default class I18nModule extends Module<I18nConfig & { stylePrefix?: string }> { export default class I18nModule extends Module<I18nConfig & { stylePrefix?: string }> {
events = I18nEvents;
/** /**
* Initialize module * Initialize module
* @param {Object} config Configurations * @param {Object} config Configurations
@ -65,9 +63,8 @@ export default class I18nModule extends Module<I18nConfig & { stylePrefix?: stri
* i18n.setLocale('it'); * i18n.setLocale('it');
*/ */
setLocale(locale: string) { setLocale(locale: string) {
const { em, config } = this; const { em, config, events } = this;
const evObj = { value: locale, valuePrev: config.locale }; em.trigger(events.locale, { value: locale, valuePrev: config.locale });
em && em.trigger('i18n:locale', evObj);
config.locale = locale; config.locale = locale;
return this; return this;
} }
@ -77,7 +74,7 @@ export default class I18nModule extends Module<I18nConfig & { stylePrefix?: stri
* @returns {String} Current locale value * @returns {String} Current locale value
*/ */
getLocale() { getLocale() {
return this.config.locale; return this.config.locale!;
} }
/** /**
@ -92,7 +89,7 @@ export default class I18nModule extends Module<I18nConfig & { stylePrefix?: stri
* i18n.getMessages('en'); * i18n.getMessages('en');
* // -> { hello: '...' } * // -> { hello: '...' }
*/ */
getMessages(lang: string, opts = {}) { getMessages(lang?: string, opts = {}) {
const messages = this.config.messages!; const messages = this.config.messages!;
lang && !messages[lang] && this._debug(`'${lang}' i18n lang not found`, opts); lang && !messages[lang] && this._debug(`'${lang}' i18n lang not found`, opts);
return lang ? messages[lang] : messages; return lang ? messages[lang] : messages;
@ -111,9 +108,9 @@ export default class I18nModule extends Module<I18nConfig & { stylePrefix?: stri
* // -> { en: { msg2: 'Msg 2 up', msg3: 'Msg 3', } } * // -> { en: { msg2: 'Msg 2 up', msg3: 'Msg 3', } }
*/ */
setMessages(msg: Messages) { setMessages(msg: Messages) {
const { em, config } = this; const { em, config, events } = this;
config.messages = msg; config.messages = msg;
em && em.trigger('i18n:update', msg); em.trigger(events.update, msg);
return this; return this;
} }
@ -130,9 +127,9 @@ export default class I18nModule extends Module<I18nConfig & { stylePrefix?: stri
* // -> { en: { msg1: 'Msg 1', msg2: 'Msg 2 up', msg3: 'Msg 3', } } * // -> { en: { msg1: 'Msg 1', msg2: 'Msg 2 up', msg3: 'Msg 3', } }
*/ */
addMessages(msg: Messages) { addMessages(msg: Messages) {
const { em } = this; const { em, events, config } = this;
const { messages } = this.config; const { messages } = config;
em && em.trigger('i18n:add', msg); em.trigger(events.add, msg);
this.setMessages(deepMerge(messages!, msg)); this.setMessages(deepMerge(messages!, msg));
return this; return this;

169
src/i18n/locale/id.js

@ -0,0 +1,169 @@
const traitInputAttr = { placeholder: 'cth. Ketik disini' };
export default {
assetManager: {
addButton: 'Tambah gambar',
inputPlh: 'http://path/menuju/gambar.jpg',
modalTitle: 'Pilih Gambar',
uploadTitle: 'Tarik gambar kesini atau klik upload',
},
// Here just as a reference, GrapesJS core doesn't contain any block,
// so this should be omitted from other local files
blockManager: {
labels: {
// 'block-id': 'Block Label',
},
categories: {
// 'category-id': 'Category Label',
},
},
domComponents: {
names: {
'': 'Kotak',
wrapper: 'Badan',
text: 'Teks',
comment: 'Komentar',
image: 'Gambar',
video: 'Video',
label: 'Label',
link: 'Link',
map: 'Peta',
tfoot: 'Kaki tabel',
tbody: 'Badan tabel',
thead: 'Kepala tabel',
table: 'Tabel',
row: 'Baris tabel',
cell: 'Cell tabel',
},
},
deviceManager: {
device: 'Perangkat',
devices: {
desktop: 'Desktop',
tablet: 'Tablet',
mobileLandscape: 'Mobile Landscape',
mobilePortrait: 'Mobile Portrait',
},
},
panels: {
buttons: {
titles: {
preview: 'Pra-tayang',
fullscreen: 'Tampilan penuh',
'sw-visibility': 'Lihat komponen',
'export-template': 'Lihat kode',
'open-sm': 'Buka Manajemen Style',
'open-tm': 'Pengaturan',
'open-layers': 'Buka Layer Manager',
'open-blocks': 'Buka Blocks',
},
},
},
selectorManager: {
label: 'Class',
selected: 'Terpilih',
emptyState: '- State -',
states: {
hover: 'Hover',
active: 'Klik',
'nth-of-type(2n)': 'Rata/Ganjil',
},
},
styleManager: {
empty: 'Pilih elemen sebelum menggunakan Manajemen Style',
layer: 'Layer',
fileButton: 'Gambar',
sectors: {
general: 'Umum',
layout: 'Pemetaan',
typography: 'Tipografi',
decorations: 'Dekorasi',
extra: 'Ekstra',
flex: 'Flex',
dimension: 'Dimensi',
},
// Default names for sub properties in Composite and Stack types.
// Other labels are generated directly from their property names (eg. 'font-size' will be 'Font size').
properties: {
'text-shadow-h': 'X',
'text-shadow-v': 'Y',
'text-shadow-blur': 'Blur',
'text-shadow-color': 'Warna',
'box-shadow-h': 'X',
'box-shadow-v': 'Y',
'box-shadow-blur': 'Blur',
'box-shadow-spread': 'Spread',
'box-shadow-color': 'Warna',
'box-shadow-type': 'Tipe',
'margin-top-sub': 'Atas',
'margin-right-sub': 'Kanan',
'margin-bottom-sub': 'Bawah',
'margin-left-sub': 'Kiri',
'padding-top-sub': 'Atas',
'padding-right-sub': 'Kanan',
'padding-bottom-sub': 'Bawah',
'padding-left-sub': 'Kiri',
'border-width-sub': 'Panjang',
'border-style-sub': 'Gaya',
'border-color-sub': 'Warna',
'border-top-left-radius-sub': 'Atas Kiri',
'border-top-right-radius-sub': 'Atas Kanan',
'border-bottom-right-radius-sub': 'Bawah Kanan',
'border-bottom-left-radius-sub': 'Bawah Kiri',
'transform-rotate-x': 'Putar X',
'transform-rotate-y': 'Putar Y',
'transform-rotate-z': 'Putar Z',
'transform-scale-x': 'Scale X',
'transform-scale-y': 'Scale Y',
'transform-scale-z': 'Scale Z',
'transition-property-sub': 'Properti',
'transition-duration-sub': 'Durasi',
'transition-timing-function-sub': 'Timing',
'background-image-sub': 'Gambar',
'background-repeat-sub': 'Berulang',
'background-position-sub': 'Posisi',
'background-attachment-sub': 'Lampiran',
'background-size-sub': 'Ukuran',
},
// Translate options in style properties
// options: {
// float: { // Id of the property
// ...
// left: 'Left', // {option id}: {Option label}
// }
// }
},
traitManager: {
empty: 'Pilih elemen terlebih dulu sebelum menggunakan Manajemen Trait',
label: 'Pengaturan komponen',
categories: {
categoryId: 'Label kategori',
},
traits: {
// The core library generates the name by their `name` property
labels: {
title: 'Judul',
href: 'Url (href)',
// id: 'Id',
// alt: 'Alt',
},
// In a simple trait, like text input, these are used on input attributes
attributes: {
id: traitInputAttr,
alt: traitInputAttr,
title: traitInputAttr,
href: { placeholder: 'cth. https://google.com' },
},
// In a trait like select, these are used to translate option names
options: {
target: {
false: 'Jendela ini',
_blank: 'Jendela baru',
},
},
},
},
storageManager: {
recover: 'Apakah kamu ingin mengembalikan draft yang belum tersimpan?',
},
};

31
src/i18n/types.ts

@ -0,0 +1,31 @@
import { I18nConfig } from './config';
export type Messages = Required<I18nConfig>['messages'];
/**{START_EVENTS}*/
export enum I18nEvents {
/**
* @event `i18n:add` New set of messages is added.
* @example
* editor.on('i18n:add', (messages) => { ... });
*/
add = 'i18n:add',
/**
* @event `i18n:update` The set of messages is updated.
* @example
* editor.on('i18n:update', (messages) => { ... });
*/
update = 'i18n:update',
/**
* @event `i18n:locale` Locale changed.
* @example
* editor.on('i18n:locale', ({ value, valuePrev }) => { ... });
*/
locale = 'i18n:locale',
}
/**{END_EVENTS}*/
// need this to avoid the TS documentation generator to break
export default I18nEvents;

8
src/navigator/index.ts

@ -47,6 +47,7 @@ import EditorModel from '../editor/model/Editor';
import { hasWin, isComponent, isDef } from '../utils/mixins'; import { hasWin, isComponent, isDef } from '../utils/mixins';
import defaults, { LayerManagerConfig } from './config/config'; import defaults, { LayerManagerConfig } from './config/config';
import View from './view/ItemView'; import View from './view/ItemView';
import { ComponentsEvents } from '../dom_components/types';
interface LayerData { interface LayerData {
name: string; name: string;
@ -74,7 +75,7 @@ const events = {
const styleOpts = { mediaText: '' }; const styleOpts = { mediaText: '' };
const propsToListen = ['open', 'status', 'locked', 'custom-name', 'components', 'classes'] const propsToListen = ['open', 'status', 'locked', 'custom-name', 'components', 'classes']
.map(p => `component:update:${p}`) .map(p => `${ComponentsEvents.update}:${p}`)
.join(' '); .join(' ');
const isStyleHidden = (style: any = {}) => { const isStyleHidden = (style: any = {}) => {
@ -195,7 +196,7 @@ export default class LayerManager extends Module<LayerManagerConfig> {
style.display = 'none'; style.display = 'none';
} }
component.setStyle(style, styleOpts); component.setStyle(style, styleOpts as any);
this.updateLayer(component); this.updateLayer(component);
this.em.trigger('component:toggled'); // Updates Style Manager #2938 this.em.trigger('component:toggled'); // Updates Style Manager #2938
} }
@ -324,7 +325,6 @@ export default class LayerManager extends Module<LayerManagerConfig> {
} }
if (selected && scrollLayers) { if (selected && scrollLayers) {
// @ts-ignore
const el = selected.viewLayer?.el; const el = selected.viewLayer?.el;
el?.scrollIntoView(scrollLayers); el?.scrollIntoView(scrollLayers);
} }
@ -367,7 +367,7 @@ export default class LayerManager extends Module<LayerManagerConfig> {
__isLayerable(cmp: Component): boolean { __isLayerable(cmp: Component): boolean {
const tag = cmp.get('tagName'); const tag = cmp.get('tagName');
const hideText = this.config.hideTextnode; const hideText = this.config.hideTextnode;
const isValid = !hideText || (!cmp.is('textnode') && tag !== 'br'); const isValid = !hideText || (!cmp.isInstanceOf('textnode') && tag !== 'br');
return isValid && cmp.get('layerable')!; return isValid && cmp.get('layerable')!;
} }

39
src/navigator/view/ItemView.ts

@ -4,7 +4,6 @@ import Component from '../../dom_components/model/Component';
import ComponentView from '../../dom_components/view/ComponentView'; import ComponentView from '../../dom_components/view/ComponentView';
import EditorModel from '../../editor/model/Editor'; import EditorModel from '../../editor/model/Editor';
import { isEnterKey, isEscKey } from '../../utils/dom'; import { isEnterKey, isEscKey } from '../../utils/dom';
import { getModel } from '../../utils/mixins';
import LayerManager from '../index'; import LayerManager from '../index';
import ItemsView from './ItemsView'; import ItemsView from './ItemsView';
import { getOnComponentDrag, getOnComponentDragEnd, getOnComponentDragStart } from '../../commands'; import { getOnComponentDrag, getOnComponentDragEnd, getOnComponentDragStart } from '../../commands';
@ -21,12 +20,13 @@ export type ItemViewProps = ViewOptions & {
}; };
const inputProp = 'contentEditable'; const inputProp = 'contentEditable';
const dataToggleMove = 'data-toggle-move';
export default class ItemView extends View { export default class ItemView extends View {
events() { events() {
return { return {
'mousedown [data-toggle-move]': 'startSort', [`mousedown [${dataToggleMove}]`]: 'startSort',
'touchstart [data-toggle-move]': 'startSort', [`touchstart [${dataToggleMove}]`]: 'startSort',
'click [data-toggle-visible]': 'toggleVisibility', 'click [data-toggle-visible]': 'toggleVisibility',
'click [data-toggle-open]': 'toggleOpening', 'click [data-toggle-open]': 'toggleOpening',
'click [data-toggle-select]': 'handleSelect', 'click [data-toggle-select]': 'handleSelect',
@ -77,7 +77,7 @@ export default class ItemView extends View {
</div> </div>
<div class="${pfx}layer-item-right"> <div class="${pfx}layer-item-right">
<div class="${this.clsCount}" data-count>${count || ''}</div> <div class="${this.clsCount}" data-count>${count || ''}</div>
<div class="${this.clsMove}" data-toggle-move>${move || ''}</div> <div class="${this.clsMove}" ${dataToggleMove}>${move || ''}</div>
</div> </div>
</div> </div>
<div class="${this.clsChildren}"></div> <div class="${this.clsChildren}"></div>
@ -96,8 +96,8 @@ export default class ItemView extends View {
return this.config.stylePrefix; return this.config.stylePrefix;
} }
opt: any; opt: ItemViewProps;
module: any; module: LayerManager;
config: any; config: any;
sorter: any; sorter: any;
/** @ts-ignore */ /** @ts-ignore */
@ -152,13 +152,13 @@ export default class ItemView extends View {
['change:open', this.updateOpening], ['change:open', this.updateOpening],
['change:layerable', this.updateLayerable], ['change:layerable', this.updateLayerable],
['change:style:display', this.updateVisibility], ['change:style:display', this.updateVisibility],
['change:draggable', this.updateMove],
['rerender:layer', this.render], ['rerender:layer', this.render],
['change:name change:custom-name', this.updateName], ['change:name change:custom-name', this.updateName],
// @ts-ignore // @ts-ignore
].forEach(item => this.listenTo(model, item[0], item[1])); ].forEach(item => this.listenTo(model, item[0], item[1]));
this.$el.data('model', model); this.$el.data('model', model);
this.$el.data('collection', components); this.$el.data('collection', components);
// @ts-ignore
model.viewLayer = this; model.viewLayer = this;
onInit.bind(this)({ onInit.bind(this)({
component: model, component: model,
@ -184,6 +184,15 @@ export default class ItemView extends View {
this.getVisibilityEl()[method](`${pfx}layer-off`); this.getVisibilityEl()[method](`${pfx}layer-off`);
} }
updateMove() {
const { model, config } = this;
const el = this.getItemContainer().find(`[${dataToggleMove}]`)[0];
if (el) {
const isDraggable = model.get('draggable') && config.sortable;
el.style.display = isDraggable ? '' : 'none';
}
}
/** /**
* Toggle visibility * Toggle visibility
* @param Event * @param Event
@ -220,22 +229,18 @@ export default class ItemView extends View {
*/ */
handleEditEnd(ev?: KeyboardEvent) { handleEditEnd(ev?: KeyboardEvent) {
ev?.stopPropagation(); ev?.stopPropagation();
const { em, $el, clsNoEdit, clsEdit } = this; const { em, $el, clsNoEdit, clsEdit, model } = this;
const inputEl = this.getInputName(); const inputEl = this.getInputName();
const name = inputEl.textContent!; const name = inputEl.textContent!;
inputEl.scrollLeft = 0; inputEl.scrollLeft = 0;
inputEl[inputProp] = 'false'; inputEl[inputProp] = 'false';
this.setName(name, { component: this.model, propName: 'custom-name' }); model.setName(name);
em.setEditing(false); em.setEditing(false);
$el.find(`.${this.inputNameCls}`).addClass(clsNoEdit).removeClass(clsEdit); $el.find(`.${this.inputNameCls}`).addClass(clsNoEdit).removeClass(clsEdit);
// Ensure to always update the layer name #4544 // Ensure to always update the layer name #4544
this.updateName(); this.updateName();
} }
setName(name: string, { propName }: { propName: string; component?: Component }) {
this.model.set(propName, name);
}
/** /**
* Get the input containing the name of the component * Get the input containing the name of the component
* @return {HTMLElement} * @return {HTMLElement}
@ -357,7 +362,7 @@ export default class ItemView extends View {
const countEl = itemEl.find('[data-count]'); const countEl = itemEl.find('[data-count]');
title[count ? 'removeClass' : 'addClass'](clsNoChild); title[count ? 'removeClass' : 'addClass'](clsNoChild);
countEl.html(count || ''); countEl.html(`${count || ''}`);
!count && module.setOpen(model, false); !count && module.setOpen(model, false);
} }
@ -390,6 +395,7 @@ export default class ItemView extends View {
remove(...args: []) { remove(...args: []) {
View.prototype.remove.apply(this, args); View.prototype.remove.apply(this, args);
delete this.model.viewLayer;
this.__clearItems(); this.__clearItems();
return this; return this;
} }
@ -422,16 +428,13 @@ export default class ItemView extends View {
el.find(`.${this.clsChildren}`).append(children); el.find(`.${this.clsChildren}`).append(children);
} }
if (!model.get('draggable') || !config.sortable) {
el.children(`.${this.clsMove}`).remove();
}
!module.isVisible(model) && (this.className += ` ${pfx}hide`); !module.isVisible(model) && (this.className += ` ${pfx}hide`);
hidden && (this.className += ` ${ppfx}hidden`); hidden && (this.className += ` ${ppfx}hidden`);
el.attr('class', this.className!); el.attr('class', this.className!);
this.updateStatus(); this.updateStatus();
this.updateOpening(); this.updateOpening();
this.updateVisibility(); this.updateVisibility();
this.updateMove();
this.__render(); this.__render();
this._rendered = true; this._rendered = true;
return this; return this;

59
src/navigator/view/ItemsView.ts

@ -1,13 +1,17 @@
import { isUndefined } from 'underscore';
import { View } from '../../common'; import { View } 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 ItemView from './ItemView'; import ItemView from './ItemView';
import Components from '../../dom_components/model/Components';
export default class ItemsView extends View { export default class ItemsView extends View {
items: ItemView[]; items: ItemView[];
opt: any; opt: any;
config: any; config: any;
parentView: ItemView; parentView: ItemView;
/** @ts-ignore */
collection!: Components;
constructor(opt: any = {}) { constructor(opt: any = {}) {
super(opt); super(opt);
@ -47,11 +51,9 @@ export default class ItemsView extends View {
} }
removeChildren(removed: Component) { removeChildren(removed: Component) {
// @ts-ignore
const view = removed.viewLayer; const view = removed.viewLayer;
if (!view) return; if (!view) return;
view.remove(); view.remove();
// @ts-ignore
delete removed.viewLayer; delete removed.viewLayer;
} }
@ -62,8 +64,7 @@ export default class ItemsView extends View {
* @return Object * @return Object
* */ * */
addTo(model: Component) { addTo(model: Component) {
var i = this.collection.indexOf(model); this.addToCollection(model, null, this.collection.indexOf(model));
this.addToCollection(model, null, i);
} }
/** /**
@ -74,41 +75,47 @@ export default class ItemsView extends View {
* *
* @return Object Object created * @return Object Object created
* */ * */
addToCollection(model: Component, fragmentEl: DocumentFragment | null, index?: number) { addToCollection(model: Component, fragment: DocumentFragment | null, index?: number) {
const { parentView, opt, config } = this; const { parentView, opt, config, el } = this;
const { ItemView, opened, module, level, sorter } = opt; const { ItemView, opened, module, level, sorter } = opt;
const fragment = fragmentEl || null; const item: ItemView =
const item = new ItemView({ model.viewLayer ||
ItemView, new ItemView({
level, ItemView,
model, level,
parentView, model,
config, parentView,
sorter, config,
opened, sorter,
module, opened,
}); module,
});
const rendered = item.render().el; const rendered = item.render().el;
if (fragment) { if (fragment) {
fragment.appendChild(rendered); fragment.appendChild(rendered);
} else { } else {
if (typeof index !== 'undefined') { const parent = el;
var method = 'before'; const children = parent.childNodes;
if (!isUndefined(index)) {
const lastIndex = children.length == index;
// If the added model is the last of collection // If the added model is the last of collection
// need to change the logic of append // need to change the logic of append
if (this.$el.children().length == index) { if (lastIndex) {
index--; index--;
method = 'after';
} }
// In case the added is new in the collection index will be -1 // In case the added is new in the collection index will be -1
if (index < 0) { if (lastIndex || !children.length) {
this.$el.append(rendered); parent.appendChild(rendered);
} else { } else {
// @ts-ignore parent.insertBefore(rendered, children[index]);
this.$el.children().eq(index)[method](rendered);
} }
} else this.$el.append(rendered); } else {
parent.appendChild(rendered);
}
} }
this.items.push(item); this.items.push(item);
return rendered; return rendered;

8
src/pages/index.ts

@ -93,7 +93,7 @@ export default class PageManager extends ItemManagerModule<PageManagerConfig, Pa
const opt = { silent: true }; const opt = { silent: true };
const configPages = config.pages?.map(page => new Page(page, { em, config })) || []; const configPages = config.pages?.map(page => new Page(page, { em, config })) || [];
pages.add(configPages, opt); pages.add(configPages, opt);
const mainPage = !pages.length ? this.add({ type: typeMain }, opt) : this.getMain(); const mainPage = !pages.length ? this.add({ type: typeMain }, opt) : this._initPage();
mainPage && this.select(mainPage, opt); mainPage && this.select(mainPage, opt);
} }
@ -101,7 +101,7 @@ export default class PageManager extends ItemManagerModule<PageManagerConfig, Pa
const { em, events } = this; const { em, events } = this;
const lm = em.Layers; const lm = em.Layers;
const mainComp = page.getMainComponent(); const mainComp = page.getMainComponent();
lm && mainComp && lm.setRoot(mainComp as any); lm && mainComp && lm.setRoot(mainComp);
em.trigger(events.select, page, m.previous('selected')); em.trigger(events.select, page, m.previous('selected'));
this.__onChange(chnSel, page, opts); this.__onChange(chnSel, page, opts);
} }
@ -242,6 +242,10 @@ export default class PageManager extends ItemManagerModule<PageManagerConfig, Pa
return result; return result;
} }
_initPage() {
return this.get(this.config.selected!) || this.getMain();
}
_createId() { _createId() {
const pages = this.getAll(); const pages = this.getAll();
const len = pages.length + 16; const len = pages.length + 16;

11
src/pages/types.ts

@ -1,7 +1,16 @@
import { ModuleConfig } from '../abstract/Module'; import { ModuleConfig } from '../abstract/Module';
import { PageProperties } from './model/Page';
export interface PageManagerConfig extends ModuleConfig { export interface PageManagerConfig extends ModuleConfig {
pages?: any[]; /**
* Default pages.
*/
pages?: PageProperties[];
/**
* ID of the page to select on editor load.
*/
selected?: string;
} }
export interface SelectableOption { export interface SelectableOption {

2
src/panels/view/PanelView.ts

@ -83,7 +83,7 @@ export default class PanelView extends ModuleView<Panel> {
avoidContainerUpdate: true, avoidContainerUpdate: true,
prefix: editor.getConfig().stylePrefix, prefix: editor.getConfig().stylePrefix,
onEnd() { onEnd() {
em && em.trigger('change:canvasOffset'); em.Canvas.refresh({ all: true });
}, },
posFetcher: (el: HTMLElement, { target }: any) => { posFetcher: (el: HTMLElement, { target }: any) => {
const style = el.style as any; const style = el.style as any;

33
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.
@ -32,11 +48,25 @@ export interface HTMLParserOptions {
*/ */
allowUnsafeAttr?: boolean; allowUnsafeAttr?: boolean;
/**
* Allow unsafe HTML attribute values (eg. `src="javascript:..."`).
* @default false
*/
allowUnsafeAttrValue?: boolean;
/** /**
* When false, removes empty text nodes when parsed, unless they contain a space. * When false, removes empty text nodes when parsed, unless they contain a space.
* @default false * @default false
*/ */
keepEmptyTextNodes?: boolean; keepEmptyTextNodes?: boolean;
/**
* Custom transformer to run before passing the input HTML to the parser.
* A common use case might be to sanitize the input string.
* @example
* preParser: htmlString => DOMPurify.sanitize(htmlString)
*/
preParser?: (input: string, opts: { editor: Editor }) => string;
} }
export interface ParserConfig { export interface ParserConfig {
@ -84,6 +114,7 @@ const config: ParserConfig = {
htmlType: 'text/html', htmlType: 'text/html',
allowScripts: false, allowScripts: false,
allowUnsafeAttr: false, allowUnsafeAttr: false,
allowUnsafeAttrValue: false,
keepEmptyTextNodes: false, keepEmptyTextNodes: false,
}, },
}; };

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

338
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,175 @@ 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!;
// 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;
}
model.attributes[nodeName] = nodeValue; detectNode(node: HTMLElement, opts: ParseNodeOptions = {}) {
const { compTypes } = this;
let result: ComponentDefinitionDefined = {};
if (compTypes) {
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];
let obj = compType.model.isComponent(node, opts);
if (obj) {
if (typeof obj !== 'object') {
obj = { type: compType.id };
}
result = obj;
break;
}
} }
} }
}
// 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 as HTMLTemplateElement).content?.childNodes || 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 as HTMLTemplateElement).content?.childNodes || 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) { // Try to keep meaningful whitespaces when possible (#5984)
model.type = 'text'; // Ref: https://github.com/GrapesJS/grapesjs/pull/5719#discussion_r1518531999
if (!opts.keepEmptyTextNodes) {
const content = node.nodeValue || '';
const isFirstOrLast = i === 0 || i === nodesLen - 1;
const hasNewLive = content.includes('\n');
if (content != ' ' && !content.trim() && (isFirstOrLast || hasNewLive)) {
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,15 +312,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 el = isFunction(cf.parserHtml) ? cf.parserHtml(str, options) : BrowserParserHtml(str, options); const { preParser, asDocument } = options;
const scripts = el.querySelectorAll('script'); const input = isFunction(preParser) ? preParser(str, { editor: em?.getEditor()! }) : str;
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; let i = scripts.length;
// Support previous `configMain.allowScripts` option // Support previous `configMain.allowScripts` option
@ -319,48 +338,59 @@ 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) { if (!options.allowUnsafeAttr || !options.allowUnsafeAttrValue) {
this.__clearUnsafeAttr(el); 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 && em.trigger(`${event}:root`, { input: str, 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 && em.trigger(event, { input: str, output: res }); em?.trigger(event, { input, output: res });
return res; return res;
}, },
__clearUnsafeAttr(node: HTMLElement) { __sanitizeNode(node: HTMLElement, opts: HTMLParserOptions) {
const attrs = node.attributes || []; const attrs = node.attributes || [];
const nodes = node.childNodes || []; const nodes = node.childNodes || [];
const toRemove: string[] = []; const toRemove: string[] = [];
each(attrs, attr => { each(attrs, attr => {
const name = attr.nodeName || ''; const name = attr.nodeName || '';
name.indexOf('on') === 0 && toRemove.push(name); const value = attr.nodeValue || '';
!opts.allowUnsafeAttr && name.startsWith('on') && toRemove.push(name);
!opts.allowUnsafeAttrValue && value.startsWith('javascript:') && toRemove.push(name);
}); });
toRemove.map(name => node.removeAttribute(name)); toRemove.map(name => node.removeAttribute(name));
each(nodes, node => this.__clearUnsafeAttr(node as HTMLElement)); each(nodes, node => this.__sanitizeNode(node as HTMLElement, opts));
}, },
}; };
}; };

4
src/rich_text_editor/index.ts

@ -46,10 +46,12 @@ import { createEl, cx, on, removeEl } from '../utils/dom';
import { hasWin, isDef } from '../utils/mixins'; import { hasWin, isDef } from '../utils/mixins';
import defaults, { CustomRTE, RichTextEditorConfig } from './config/config'; import defaults, { CustomRTE, RichTextEditorConfig } from './config/config';
import RichTextEditor, { RichTextEditorAction } from './model/RichTextEditor'; import RichTextEditor, { RichTextEditorAction } from './model/RichTextEditor';
import CanvasEvents from '../canvas/types';
import { ComponentsEvents } from '../dom_components/types';
export type RichTextEditorEvent = 'rte:enable' | 'rte:disable' | 'rte:custom'; export type RichTextEditorEvent = 'rte:enable' | 'rte:disable' | 'rte:custom';
const eventsUp = 'change:canvasOffset frame:scroll component:update'; const eventsUp = `${CanvasEvents.refresh} frame:scroll ${ComponentsEvents.update}`;
export const evEnable = 'rte:enable'; export const evEnable = 'rte:enable';
export const evDisable = 'rte:disable'; export const evDisable = 'rte:disable';

7
src/selector_manager/index.ts

@ -87,6 +87,7 @@ import { ItemManagerModule } from '../abstract/Module';
import { StyleModuleParam } from '../style_manager'; import { StyleModuleParam } from '../style_manager';
import StyleableModel from '../domain_abstract/model/StyleableModel'; import StyleableModel from '../domain_abstract/model/StyleableModel';
import CssRule from '../css_composer/model/CssRule'; import CssRule from '../css_composer/model/CssRule';
import { ComponentsEvents } from '../dom_components/types';
export type SelectorEvent = 'selector:add' | 'selector:remove' | 'selector:update' | 'selector:state' | 'selector'; export type SelectorEvent = 'selector:add' | 'selector:remove' | 'selector:update' | 'selector:state' | 'selector';
@ -158,9 +159,9 @@ export default class SelectorManager extends ItemManagerModule<SelectorManagerCo
}); });
em.on('change:state', (m, value) => em.trigger(evState, value)); em.on('change:state', (m, value) => em.trigger(evState, value));
this.model.on('change:cFirst', (m, value) => em.trigger('selector:type', value)); this.model.on('change:cFirst', (m, value) => em.trigger('selector:type', value));
em.on('component:toggled component:update:classes', this.__updateSelectedByComponents); const eventCmpUpdateCls = `${ComponentsEvents.update}:classes`;
const listenTo = em.on(`component:toggled ${eventCmpUpdateCls}`, this.__updateSelectedByComponents);
'component:toggled component:update:classes change:device styleManager:update selector:state selector:type style:target'; const listenTo = `component:toggled ${eventCmpUpdateCls} change:device styleManager:update selector:state selector:type style:target`;
this.model.listenTo(em, listenTo, () => this.__update()); this.model.listenTo(em, listenTo, () => this.__update());
} }

20
src/selector_manager/model/Selector.ts

@ -1,6 +1,7 @@
import { result, forEach, keys } from 'underscore'; import { result, forEach, keys } from 'underscore';
import { Model } from '../../common'; import { Model } from '../../common';
import EditorModel from '../../editor/model/Editor'; import EditorModel from '../../editor/model/Editor';
import { SelectorManagerConfig } from '../config/config';
const TYPE_CLASS = 1; const TYPE_CLASS = 1;
const TYPE_ID = 2; const TYPE_ID = 2;
@ -14,6 +15,15 @@ export interface SelectorProps {
protected?: boolean; protected?: boolean;
} }
export interface SelectorPropsCustom extends SelectorProps {
[key: string]: unknown;
}
export interface SelectorOptions {
config?: SelectorManagerConfig;
em?: EditorModel;
}
/** /**
* @typedef Selector * @typedef Selector
* @property {String} name Selector name, eg. `my-class` * @property {String} name Selector name, eg. `my-class`
@ -23,7 +33,7 @@ export interface SelectorProps {
* @property {Boolean} [private=false] If true, it can't be seen by the Style Manager, but it will be rendered in the canvas and in export code. * @property {Boolean} [private=false] If true, it can't be seen by the Style Manager, but it will be rendered in the canvas and in export code.
* @property {Boolean} [protected=false] If true, it can't be removed from the attached component. * @property {Boolean} [protected=false] If true, it can't be removed from the attached component.
*/ */
export default class Selector extends Model<SelectorProps & { [key: string]: unknown }> { export default class Selector extends Model<SelectorPropsCustom> {
defaults() { defaults() {
return { return {
name: '', name: '',
@ -40,12 +50,12 @@ export default class Selector extends Model<SelectorProps & { [key: string]: unk
static readonly TYPE_CLASS = TYPE_CLASS; static readonly TYPE_CLASS = TYPE_CLASS;
static readonly TYPE_ID = TYPE_ID; static readonly TYPE_ID = TYPE_ID;
em: EditorModel; em?: EditorModel;
/** /**
* @hideconstructor * @hideconstructor
*/ */
constructor(props: any, opts: any = {}) { constructor(props: SelectorPropsCustom, opts: SelectorOptions = {}) {
super(props, opts); super(props, opts);
const { config = {} } = opts; const { config = {} } = opts;
const name = this.get('name'); const name = this.get('name');
@ -159,7 +169,7 @@ export default class Selector extends Model<SelectorProps & { [key: string]: unk
let obj = Model.prototype.toJSON.call(this, [opts]); let obj = Model.prototype.toJSON.call(this, [opts]);
const defaults = result(this, 'defaults'); const defaults = result(this, 'defaults');
if (em && em.getConfig().avoidDefaults) { if (em?.getConfig().avoidDefaults) {
forEach(defaults, (value, key) => { forEach(defaults, (value, key) => {
if (obj[key] === value) { if (obj[key] === value) {
delete obj[key]; delete obj[key];
@ -191,7 +201,7 @@ export default class Selector extends Model<SelectorProps & { [key: string]: unk
* @private * @private
*/ */
static escapeName(name: string) { static escapeName(name: string) {
return `${name}`.trim().replace(/([^a-z0-9\w-\\:@\\/]+)/gi, '-'); return `${name}`.trim().replace(/\s+/g, '-');
} }
} }

7
src/selector_manager/view/ClassTagsView.ts

@ -9,6 +9,7 @@ import Component from '../../dom_components/model/Component';
import Selector from '../model/Selector'; import Selector from '../model/Selector';
import Selectors from '../model/Selectors'; import Selectors from '../model/Selectors';
import CssRule from '../../css_composer/model/CssRule'; import CssRule from '../../css_composer/model/CssRule';
import { ComponentsEvents } from '../../dom_components/types';
export default class ClassTagsView extends View<Selector> { export default class ClassTagsView extends View<Selector> {
template({ labelInfo, labelHead, iconSync, iconAdd, pfx, ppfx }: any) { template({ labelInfo, labelHead, iconSync, iconAdd, pfx, ppfx }: any) {
@ -83,8 +84,10 @@ export default class ClassTagsView extends View<Selector> {
this.em = em; this.em = em;
this.componentChanged = debounce(this.componentChanged.bind(this), 0); this.componentChanged = debounce(this.componentChanged.bind(this), 0);
this.checkSync = debounce(this.checkSync.bind(this), 0); this.checkSync = debounce(this.checkSync.bind(this), 0);
const toList = 'component:toggled component:update:classes'; const eventCmpUpdate = ComponentsEvents.update;
const toListCls = 'component:update:classes change:state'; const evClsUp = `${eventCmpUpdate}:classes`;
const toList = `component:toggled ${evClsUp}`;
const toListCls = `${evClsUp} ${eventCmpUpdate}:attributes:id change:state`;
this.listenTo(em, toList, this.componentChanged); this.listenTo(em, toList, this.componentChanged);
this.listenTo(em, 'styleManager:update', this.componentChanged); this.listenTo(em, 'styleManager:update', this.componentChanged);
this.listenTo(em, toListCls, this.__handleStateChange); this.listenTo(em, toListCls, this.__handleStateChange);

38
src/style_manager/index.ts

@ -83,6 +83,7 @@ import { PropertySelectProps } from './model/PropertySelect';
import { PropertyNumberProps } from './model/PropertyNumber'; import { PropertyNumberProps } from './model/PropertyNumber';
import PropertyStack, { PropertyStackProps } from './model/PropertyStack'; import PropertyStack, { PropertyStackProps } from './model/PropertyStack';
import PropertyComposite from './model/PropertyComposite'; import PropertyComposite from './model/PropertyComposite';
import { ComponentsEvents } from '../dom_components/types';
export type PropertyTypes = PropertyStackProps | PropertySelectProps | PropertyNumberProps; export type PropertyTypes = PropertyStackProps | PropertySelectProps | PropertyNumberProps;
@ -168,7 +169,8 @@ export default class StyleManager extends ItemManagerModule<
this.model = model; this.model = model;
// Triggers for the selection refresh and properties // Triggers for the selection refresh and properties
const ev = 'component:toggled component:update:classes change:state change:device frame:resized selector:type'; const eventCmpUpdate = ComponentsEvents.update;
const ev = `component:toggled ${eventCmpUpdate}:classes change:state change:device frame:resized selector:type`;
this.upAll = debounce(() => this.__upSel(), 0); this.upAll = debounce(() => this.__upSel(), 0);
model.listenTo(em, ev, this.upAll as any); model.listenTo(em, ev, this.upAll as any);
// Clear state target on any component selection change, without debounce (#4208) // Clear state target on any component selection change, without debounce (#4208)
@ -523,7 +525,7 @@ export default class StyleManager extends ItemManagerModule<
* options: [{ id: 'value1', label: 'Some label' }, ...], * options: [{ id: 'value1', label: 'Some label' }, ...],
* }) * })
*/ */
addBuiltIn(prop: string, definition: Omit<PropertyProps, 'property'> & { proeperty?: 'string' }) { addBuiltIn(prop: string, definition: PropertyProps) {
return this.builtIn.add(prop, definition); return this.builtIn.add(prop, definition);
} }
@ -597,6 +599,8 @@ export default class StyleManager extends ItemManagerModule<
const cmp = target.toHTML ? target : target.getComponent(); const cmp = target.toHTML ? target : target.getComponent();
const optsSel = { array: true } as const; const optsSel = { array: true } as const;
let cmpRules: CssRule[] = []; let cmpRules: CssRule[] = [];
let tagNameRules: CssRule[] = [];
let invisibleAndOtherRules: CssRule[] = [];
let otherRules: CssRule[] = []; let otherRules: CssRule[] = [];
let rules: CssRule[] = []; let rules: CssRule[] = [];
@ -605,19 +609,41 @@ export default class StyleManager extends ItemManagerModule<
? [] ? []
: cssC.getRules().filter(rule => { : cssC.getRules().filter(rule => {
const rSels = rule.getSelectors().map(s => s.getFullName()); const rSels = rule.getSelectors().map(s => s.getFullName());
return !!rSels.length && rSels.every(rSel => values.indexOf(rSel) >= 0);
// rSels is equal to 0 when rule selectors contain tagName like : p {}, .logo path {}, ul li {}
if (rSels.length === 0) {
return false;
}
return rSels.every(rSel => values.indexOf(rSel) >= 0);
}); });
}; };
const rulesByTagName = (tagName: string) => {
return !tagName ? [] : cssC.getRules().filter(rule => rule.selectorsToString() === tagName);
};
// Componente related rule // Componente related rule
if (cmp) { if (cmp) {
cmpRules = cssC.getRules(`#${cmp.getId()}`); cmpRules = cssC.getRules(`#${cmp.getId()}`);
tagNameRules = rulesByTagName(cmp.get('tagName'));
otherRules = sel ? rulesBySelectors(sel.getSelectors().getFullName(optsSel)) : []; otherRules = sel ? rulesBySelectors(sel.getSelectors().getFullName(optsSel)) : [];
rules = otherRules.concat(cmpRules); rules = otherRules.concat(tagNameRules).concat(cmpRules);
} else { } else {
cmpRules = sel ? cssC.getRules(`#${sel.getId()}`) : []; cmpRules = sel ? cssC.getRules(`#${sel.getId()}`) : [];
otherRules = rulesBySelectors(target.getSelectors().getFullName(optsSel)); tagNameRules = rulesByTagName(sel?.get('tagName') || '');
rules = cmpRules.concat(otherRules); // Get rules set on invisible selectors like private one
const allCmpClasses = sel?.getSelectors().getFullName(optsSel) || [];
const invisibleSel = allCmpClasses.filter(
(item: string) =>
target
.getSelectors()
.getFullName(optsSel)
.findIndex(sel => sel === item) === -1
);
// Get rules set on active and visible selectors
invisibleAndOtherRules = rulesBySelectors(invisibleSel.concat(target.getSelectors().getFullName(optsSel)));
rules = tagNameRules.concat(cmpRules).concat(invisibleAndOtherRules);
} }
const all = rules const all = rules

4
src/styles/scss/_gjs_canvas.scss

@ -271,6 +271,10 @@ $guide_pad: 5px !default;
outline: none; outline: none;
z-index: 1; z-index: 1;
} }
/* Always place the tools above the highlighter */
##{$app-prefix}tools {
z-index: 2;
}
/* This simulate body behaviour */ /* This simulate body behaviour */
// > div:first-child { // > div:first-child {

32
src/styles/scss/main.scss

@ -444,17 +444,18 @@ $colorsAll: (one, var(--gjs-primary-color)),
/********* Spectrum **********/ /********* Spectrum **********/
.#{$app-prefix}editor-cont { .#{$app-prefix}editor-sp {
border: 1px solid var(--gjs-main-dark-color);
box-shadow: 0 0 7px var(--gjs-main-dark-color);
border-radius: 3px;
}
.#{$app-prefix}editor-sp {
.sp-hue, .sp-slider{ cursor: row-resize;} .sp-hue, .sp-slider{ cursor: row-resize;}
.sp-color, .sp-dragger{ cursor: crosshair;} .sp-color, .sp-dragger{ cursor: crosshair;}
.sp-alpha-inner, .sp-alpha-handle{cursor: col-resize;} .sp-alpha-inner, .sp-alpha-handle{cursor: col-resize;}
.sp-hue{ left: 90%; } .sp-hue{ left: 90%; }
.sp-color{right: 15%;} .sp-color{right: 15%;}
.sp-container {
border: 1px solid var(--gjs-main-dark-color);
box-shadow: 0 0 7px var(--gjs-main-dark-color);
border-radius: 3px;
}
.sp-picker-container{border:none;} .sp-picker-container{border:none;}
.colpick_dark .colpick_color { outline: 1px solid var(--gjs-main-dark-color);} .colpick_dark .colpick_color { outline: 1px solid var(--gjs-main-dark-color);}
.sp-cancel, .sp-cancel:hover { .sp-cancel, .sp-cancel:hover {
@ -484,14 +485,17 @@ $colorsAll: (one, var(--gjs-primary-color)),
width: 100%; width: 100%;
position: relative; position: relative;
text-align: right; text-align: right;
}
.sp-container button, .sp-container button:hover, .sp-container button:active{ .sp-choose,
background: var(--gjs-main-dark-color); .sp-choose:hover,
border-color: var(--gjs-main-dark-color); .sp-choose:active {
color: var(--gjs-font-color); background: var(--gjs-main-dark-color);
text-shadow: none; border-color: var(--gjs-main-dark-color);
box-shadow: none; color: var(--gjs-font-color);
padding: 3px 5px; text-shadow: none;
box-shadow: none;
padding: 3px 5px;
}
} }
.sp-palette-container{ .sp-palette-container{
border: none; border: none;

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

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save