Browse Source

Merge branch 'dev' into datasources-json-api

pull/6132/head
danstarns 1 year ago
parent
commit
675e78dd48
  1. 2
      .eslintrc.js
  2. 4
      .github/actions/setup-project/action.yml
  3. 6
      .github/workflows/quality.yml
  4. 24
      .github/workflows/test.yml
  5. 5
      .prettierignore
  6. 4
      CONTRIBUTING.md
  7. 8
      docs/Home.md
  8. 18
      docs/api/data_source_manager.md
  9. 18
      docs/modules/Assets.md
  10. 38
      docs/modules/Commands.md
  11. 36
      docs/modules/Components.md
  12. 78
      docs/modules/DataSources.md
  13. 13
      package.json
  14. 122
      packages/cli/README.md
  15. 3
      packages/cli/babel.config.js
  16. 42
      packages/cli/index.html
  17. 10
      packages/cli/jest.config.ts
  18. 57
      packages/cli/package.json
  19. 6
      packages/cli/src/banner.txt
  20. 143
      packages/cli/src/build.ts
  21. 174
      packages/cli/src/cli.ts
  22. 217
      packages/cli/src/init.ts
  23. 3
      packages/cli/src/main.ts
  24. 48
      packages/cli/src/serve.ts
  25. 8
      packages/cli/src/template/.gitignore-t
  26. 7
      packages/cli/src/template/.npmignore-t
  27. 145
      packages/cli/src/template/README.md
  28. 23
      packages/cli/src/template/package.json
  29. 9
      packages/cli/src/template/src/blocks.js
  30. 12
      packages/cli/src/template/src/components.js
  31. 29
      packages/cli/src/template/src/index.js
  32. 20
      packages/cli/src/template/tsconfig.json
  33. 123
      packages/cli/src/utils.ts
  34. 129
      packages/cli/src/webpack.config.ts
  35. 359
      packages/cli/test/utils.spec.ts
  36. 21
      packages/cli/tsconfig.json
  37. 62
      packages/cli/webpack.cli.ts
  38. 14
      packages/core/package.json
  39. 4
      packages/core/src/asset_manager/config/config.ts
  40. 4
      packages/core/src/asset_manager/index.ts
  41. 4
      packages/core/src/block_manager/config/config.ts
  42. 14
      packages/core/src/block_manager/index.ts
  43. 17
      packages/core/src/block_manager/model/Block.ts
  44. 9
      packages/core/src/block_manager/view/BlockView.ts
  45. 4
      packages/core/src/canvas/config/config.ts
  46. 25
      packages/core/src/canvas/index.ts
  47. 3
      packages/core/src/canvas/view/CanvasView.ts
  48. 4
      packages/core/src/code_manager/config/config.ts
  49. 4
      packages/core/src/code_manager/index.ts
  50. 4
      packages/core/src/commands/config/config.ts
  51. 14
      packages/core/src/commands/index.ts
  52. 16
      packages/core/src/commands/view/MoveComponent.ts
  53. 51
      packages/core/src/commands/view/SelectPosition.ts
  54. 4
      packages/core/src/common/index.ts
  55. 4
      packages/core/src/css_composer/config/config.ts
  56. 7
      packages/core/src/css_composer/index.ts
  57. 7
      packages/core/src/css_composer/model/CssRule.ts
  58. 18
      packages/core/src/css_composer/view/CssRuleView.ts
  59. 32
      packages/core/src/data_sources/index.ts
  60. 7
      packages/core/src/data_sources/model/DataRecord.ts
  61. 5
      packages/core/src/data_sources/model/DataSource.ts
  62. 24
      packages/core/src/data_sources/model/DataVariableListenerManager.ts
  63. 11
      packages/core/src/data_sources/types.ts
  64. 4
      packages/core/src/device_manager/config/config.ts
  65. 4
      packages/core/src/device_manager/index.ts
  66. 6
      packages/core/src/dom_components/config/config.ts
  67. 26
      packages/core/src/dom_components/index.ts
  68. 75
      packages/core/src/dom_components/model/Component.ts
  69. 13
      packages/core/src/dom_components/model/ComponentWrapper.ts
  70. 5
      packages/core/src/dom_components/model/Components.ts
  71. 6
      packages/core/src/dom_components/model/types.ts
  72. 8
      packages/core/src/dom_components/view/ComponentView.ts
  73. 10
      packages/core/src/dom_components/view/ComponentsView.ts
  74. 34
      packages/core/src/domain_abstract/model/StyleableModel.ts
  75. 4
      packages/core/src/editor/config/config.ts
  76. 3
      packages/core/src/editor/index.ts
  77. 2
      packages/core/src/editor/model/Editor.ts
  78. 4
      packages/core/src/i18n/config.ts
  79. 4
      packages/core/src/i18n/index.ts
  80. 4
      packages/core/src/keymaps/config.ts
  81. 4
      packages/core/src/keymaps/index.ts
  82. 4
      packages/core/src/modal_dialog/config/config.ts
  83. 4
      packages/core/src/modal_dialog/index.ts
  84. 4
      packages/core/src/navigator/config/config.ts
  85. 4
      packages/core/src/navigator/index.ts
  86. 17
      packages/core/src/navigator/view/ItemView.ts
  87. 51
      packages/core/src/navigator/view/ItemsView.ts
  88. 2
      packages/core/src/pages/index.ts
  89. 10
      packages/core/src/pages/model/Pages.ts
  90. 4
      packages/core/src/panels/config/config.ts
  91. 9
      packages/core/src/panels/index.ts
  92. 9
      packages/core/src/parser/config/config.ts
  93. 4
      packages/core/src/parser/index.ts
  94. 19
      packages/core/src/parser/model/ParserHtml.ts
  95. 4
      packages/core/src/rich_text_editor/config/config.ts
  96. 4
      packages/core/src/rich_text_editor/index.ts
  97. 4
      packages/core/src/selector_manager/config/config.ts
  98. 5
      packages/core/src/selector_manager/index.ts
  99. 4
      packages/core/src/storage_manager/config/config.ts
  100. 4
      packages/core/src/storage_manager/index.ts

2
.eslintrc.js

@ -52,5 +52,5 @@ module.exports = {
'max-len': ['error', { code: 300 }],
'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 1 }],
},
ignorePatterns: ['*/docs/api/*', 'dist'],
ignorePatterns: ['*/docs/api/*', 'dist', 'packages/cli/src/template/**/*.*', '*/locale/*', 'stats.json'],
};

4
.github/actions/setup-project/action.yml

@ -5,7 +5,7 @@ inputs:
pnpm-version:
description: 'The version of pnpm to use for installing dependencies.'
required: false
default: 8.6.3
default: 9.10.0
node-version:
description: 'The version of Node.js to use for building the project.'
required: false
@ -25,5 +25,5 @@ runs:
run: pnpm install
shell: bash
- name: Build project
run: pnpm build --force
run: pnpm build
shell: bash

6
.github/workflows/quality.yml

@ -11,13 +11,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
- name: TS Check
run: pnpm ts:check
- name: Lint
run: pnpm lint
- name: Format Check
run: pnpm format:check
- name: Build
run: pnpm build --force
- name: Test
run: pnpm test
- name: Docs
run: pnpm docs:api

24
.github/workflows/test.yml

@ -0,0 +1,24 @@
name: GrapesJS Tests
on:
push:
branches: [dev]
pull_request:
branches: [dev]
jobs:
test-core:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
- name: Core Tests
run: pnpm test
working-directory: ./packages/core
test-cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
- name: CLI Tests
run: pnpm test
working-directory: ./packages/cli

5
.prettierignore

@ -1,3 +1,6 @@
docs/**/*.md
dist/
pnpm-lock.yaml
pnpm-lock.yaml
packages/cli/src/template/**/*.*
**/locale/**
stats.json

4
CONTRIBUTING.md

@ -7,7 +7,7 @@ Thank you for your interest in contributing to GrapesJS! We welcome all types of
### Prerequisites
- Node.js (version 20 LTS)
- pnpm (version 8.6.3 or later)
- pnpm (version 9.10.0 or later)
### Setup
@ -21,7 +21,7 @@ Thank you for your interest in contributing to GrapesJS! We welcome all types of
2. Install pnpm globally:
```bash
npm install -g pnpm@8.6.3
npm install -g pnpm@9.10.0
```
3. Clone the repository:

8
docs/Home.md

@ -172,7 +172,7 @@ You could add other commands to enable interactions with blocks. Check [Built-in
## Style Manager
Any HTML structure requires, at some point, a proper style, so to meet this need the Style Manager was added as a built-in feature in GrapesJS. Style manager is composed by sectors, which group inside different types of CSS properties. So you can add, for instance, a `Dimension` sector for `width` and `height`, and another one as `Typography` for `font-size` and `color`. So it's up to you decide how organize sectors.
Any HTML structure requires, at some point, a proper style, so to meet this need the Style Manager was added as a built-in feature in GrapesJS. Style manager is composed by sectors, which group inside different types of CSS properties. So you can add, for instance, a `Dimension` sector for `width` and `height`, and another one as `Typography` for `font-size` and `color`. So it's up to you to decide how to organize sectors.
To enable this module we rely on a built-in command `open-sm`, which shows up the Style Manager, which we gonna bind to another button in a separate panel
@ -228,7 +228,7 @@ Selecting one of the component will show up the Style Manager with default secto
[[img/default-sm.jpg]]
As we exploring different configurations inside GrapesJS we gonna overwrite all the default sectors to create some custom one
As we explore different configurations inside GrapesJS we gonna overwrite all the default sectors to create some custom one
Let's put a few sectors with use of `buildProps` which helps us building common properties
@ -356,7 +356,7 @@ styleManager : {
...
```
As you can see using `buildProps` actually will save you a lot of work. You could also mix this techniques to obtain custom properties in less time. For example, let's see how can we setup the same width but with a different value of `min`:
As you can see using `buildProps` actually will save you a lot of work. You could also mix these techniques to obtain custom properties in less time. For example, let's see how we can setup the same width but with a different value of `min`:
```js
...
@ -402,7 +402,7 @@ var editor = grapesjs.init({
...
```
Worth noting the defaut `id` parameter which adds a prefix for all keys to store. If you check the localStorage inside your DOM panel you'll see something like `{ 'gjs-components': '<div>....' ...}` in this way it prevents the risk of collisions, quite common with localStorage use in large applications.
Worth noting the default `id` parameter which adds a prefix for all keys to store. If you check the localStorage inside your DOM panel you'll see something like `{ 'gjs-components': '<div>....' ...}` in this way it prevents the risk of collisions, quite common with localStorage use in large applications.
Storing data locally it's easy and fast but useless in some common cases. In the next example we'll see how to setup a remote storage, which is not far from the previous one

18
docs/api/data_source_manager.md

@ -126,6 +126,22 @@ const [dataSource, dataRecord, propPath] = dsm.fromPath('my_data_source_id.recor
Returns **[DataSource?, DataRecord?, [String][7]?]** An array containing the data source,
data record, and optional property path.
## store
Store data sources to a JSON object.
Returns **[Array][8]** Stored data sources.
## load
Load data sources from a JSON object.
### Parameters
* `data` **[Object][6]** The data object containing data sources.
Returns **[Object][6]** Loaded data sources.
[1]: #add
[2]: #get
@ -139,3 +155,5 @@ data record, and optional property path.
[6]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object
[7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String
[8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array

18
docs/modules/Assets.md

@ -63,7 +63,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.ts)
If you want a complete list of available properties check out the source [AssetImage Model](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/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)
@ -78,7 +78,7 @@ editor.runCommand('open-assets');
```
Worth nothing that by doing this you can't do much with assets (if you double click on them nothing happens) and this is because you've not indicated any target. Try just to select an image in your canvas and run this in console (you should first make the editor globally available `window.editor = editor;` in your script)
Worth noting that by doing this you can't do much with assets (if you double click on them nothing happens) and this is because you've not indicated any target. Try just to select an image in your canvas and run this in console (you should first make the editor globally available `window.editor = editor;` in your script)
```js
editor.runCommand('open-assets', {
@ -225,7 +225,7 @@ am.getAll().length; // <- 3
am.getAllVisible().length; // <- 3
```
Ok, now let's show only assets form the first category
Ok, now let's show only assets from the first category
```js
const assets = am.getAll();
@ -325,7 +325,7 @@ Here an example of using custom Asset Manager with a Vue component.
The example above is the right way if you need to replace the default UI, but as you might notice we append the mounted element to the container `props.container.appendChild(this.$el);`.
This is required as the Asset Manager, by default, is placed in the [Modal](/modules/Modal.html).
How to approach the case when your Asset Manager is a completely independent/external module (eg. should be showed in its own custom modal)? Not a problem, you can bind the Asset Manager state via `assetManager.custom.open`.
How to approach the case when your Asset Manager is a completely independent/external module (eg. should be shown in its own custom modal)? Not a problem, you can bind the Asset Manager state via `assetManager.custom.open`.
```js
const editor = grapesjs.init({
@ -339,7 +339,7 @@ const editor = grapesjs.init({
// Init and open your external Asset Manager
// ...
// IMPORTANT:
// When the external library is closed you have to comunicate
// When the external library is closed you have to communicate
// this state back to the editor, otherwise GrapesJS will think
// the Asset Manager is still open.
// example: myAssetManager.on('close', () => props.close())
@ -357,10 +357,10 @@ It's important to declare also the `close` function, the editor should be able t
<!--
### Define new Asset type
Generally speaking, images aren't the only asset you'll use, it could be a `video`, `svg-icon`, or any other kind of `document`. Each type of asset is applied in our templates/pages differently. If you need to change the image of the Component all you need is another `url` in `src` attribute. However In case of a `svg-icon`, its not the same, you might want to replace the element with a new `<svg>` content. Besides this you also have to deal with the presentation/preview of the asset inside the panel/modal. For example, showing a thumbnail for big images or the possibility to preview videos.
Generally speaking, images aren't the only asset you'll use, it could be a `video`, `svg-icon`, or any other kind of `document`. Each type of asset is applied in our templates/pages differently. If you need to change the image of the Component all you need is another `url` in `src` attribute. However In case of a `svg-icon`, it's not the same, you might want to replace the element with a new `<svg>` content. Besides this you also have to deal with the presentation/preview of the asset inside the panel/modal. For example, showing a thumbnail for big images or the possibility to preview videos.
Defining a new asset it means we have to push on top of the 'Stack of Types' a new layer. This stack is iterated over by the editor at any addition of the asset and tries to associate the correct type.
Defining a new asset means we have to push on top of the 'Stack of Types' a new layer. This stack is iterated over by the editor at any addition of the asset and tries to associate the correct type.
```js
am.add('https://.../image.png');
@ -429,7 +429,7 @@ am.addType('svg-icon', {
// `getPreview()` and `getInfo()` are just few helpers, you can
// override the entire template with `template()`
// Check the base `template()` here:
// https://github.com/GrapesJS/grapesjs/blob/dev/src/asset_manager/view/AssetView.js
// https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/asset_manager/view/AssetView.ts
getPreview() {
return `<div style="text-align: center">${this.model.get('svgContent')}</div>`;
},
@ -543,7 +543,7 @@ am.addType('image', {
// but you can eventually extend some other type
view: {
// If you want to see more methods to extend check out
// https://github.com/GrapesJS/grapesjs/blob/dev/src/asset_manager/view/AssetImageView.js
// https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/asset_manager/view/AssetImageView.ts
onRemove(e) {
e.stopPropagation();
const model = this.model;

38
docs/modules/Commands.md

@ -88,25 +88,25 @@ Until now there is nothing exciting except a common entry point for functions, b
GrapesJS comes along with some default set of commands and you can get a list of all currently available commands via `editor.Commands.getAll()`. This will give you an object of all available commands, so, also those added later, like via plugins. You can recognize default commands by their namespace `core:*`, we also recommend to use namespaces in your own custom commands, but let's get a look more in detail here:
- [`core:canvas-clear`](https://github.com/GrapesJS/grapesjs/blob/dev/src/commands/view/CanvasClear.ts) - Clear all the content from the canvas (HTML and CSS)
- [`core:component-delete`](https://github.com/GrapesJS/grapesjs/blob/dev/src/commands/view/ComponentDelete.ts) - Delete a component
- [`core:component-enter`](https://github.com/GrapesJS/grapesjs/blob/dev/src/commands/view/ComponentEnter.ts) - Select the first children component of the selected one
- [`core:component-exit`](https://github.com/GrapesJS/grapesjs/blob/dev/src/commands/view/ComponentExit.ts) - Select the parent component of the current selected one
- [`core:component-next`](https://github.com/GrapesJS/grapesjs/blob/dev/src/commands/view/ComponentNext.ts) - Select the next sibling component
- [`core:component-prev`](https://github.com/GrapesJS/grapesjs/blob/dev/src/commands/view/ComponentPrev.ts) - Select the previous sibling component
- [`core:component-outline`](https://github.com/GrapesJS/grapesjs/blob/dev/src/commands/view/SwitchVisibility.ts) - Enable outline border on components
- [`core:component-offset`](https://github.com/GrapesJS/grapesjs/blob/dev/src/commands/view/ShowOffset.ts) - Enable components offset (margins, paddings)
- [`core:component-select`](https://github.com/GrapesJS/grapesjs/blob/dev/src/commands/view/SelectComponent.ts) - Enable the process of selecting components in the canvas
- [`core:copy`](https://github.com/GrapesJS/grapesjs/blob/dev/src/commands/view/CopyComponent.ts) - Copy the current selected component
- [`core:paste`](https://github.com/GrapesJS/grapesjs/blob/dev/src/commands/view/PasteComponent.ts) - Paste copied component
- [`core:preview`](https://github.com/GrapesJS/grapesjs/blob/dev/src/commands/view/Preview.ts) - Show the preview of the template in canvas
- [`core:fullscreen`](https://github.com/GrapesJS/grapesjs/blob/dev/src/commands/view/Fullscreen.ts) - Set the editor fullscreen
- [`core:open-code`](https://github.com/GrapesJS/grapesjs/blob/dev/src/commands/view/ExportTemplate.ts) - Open a default panel with the template code
- [`core:open-layers`](https://github.com/GrapesJS/grapesjs/blob/dev/src/commands/view/OpenLayers.ts) - Open a default panel with layers
- [`core:open-styles`](https://github.com/GrapesJS/grapesjs/blob/dev/src/commands/view/OpenStyleManager.ts) - Open a default panel with the style manager
- [`core:open-traits`](https://github.com/GrapesJS/grapesjs/blob/dev/src/commands/view/OpenTraitManager.ts) - Open a default panel with the trait manager
- [`core:open-blocks`](https://github.com/GrapesJS/grapesjs/blob/dev/src/commands/view/OpenBlocks.ts) - Open a default panel with the blocks
- [`core:open-assets`](https://github.com/GrapesJS/grapesjs/blob/dev/src/commands/view/OpenAssets.ts) - Open a default panel with the assets
- [`core:canvas-clear`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/commands/view/CanvasClear.ts) - Clear all the content from the canvas (HTML and CSS)
- [`core:component-delete`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/commands/view/ComponentDelete.ts) - Delete a component
- [`core:component-enter`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/commands/view/ComponentEnter.ts) - Select the first children component of the selected one
- [`core:component-exit`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/commands/view/ComponentExit.ts) - Select the parent component of the current selected one
- [`core:component-next`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/commands/view/ComponentNext.ts) - Select the next sibling component
- [`core:component-prev`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/commands/view/ComponentPrev.ts) - Select the previous sibling component
- [`core:component-outline`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/commands/view/SwitchVisibility.ts) - Enable outline border on components
- [`core:component-offset`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/commands/view/ShowOffset.ts) - Enable components offset (margins, paddings)
- [`core:component-select`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/commands/view/SelectComponent.ts) - Enable the process of selecting components in the canvas
- [`core:copy`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/commands/view/CopyComponent.ts) - Copy the current selected component
- [`core:paste`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/commands/view/PasteComponent.ts) - Paste copied component
- [`core:preview`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/commands/view/Preview.ts) - Show the preview of the template in canvas
- [`core:fullscreen`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/commands/view/Fullscreen.ts) - Set the editor fullscreen
- [`core:open-code`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/commands/view/ExportTemplate.ts) - Open a default panel with the template code
- [`core:open-layers`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/commands/view/OpenLayers.ts) - Open a default panel with layers
- [`core:open-styles`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/commands/view/OpenStyleManager.ts) - Open a default panel with the style manager
- [`core:open-traits`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/commands/view/OpenTraitManager.ts) - Open a default panel with the trait manager
- [`core:open-blocks`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/commands/view/OpenBlocks.ts) - Open a default panel with the blocks
- [`core:open-assets`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/commands/view/OpenAssets.ts) - Open a default panel with the assets
- `core:undo` - Call undo operation
- `core:redo` - Call redo operation
<!-- * `core:canvas-move` -->

36
docs/modules/Components.md

@ -179,24 +179,24 @@ A more advanced use case of custom components is an implementation of a custom r
Here below you can see the list of built-in component types, ordered by their position in the **Component Type Stack**
- [`cell`](https://github.com/GrapesJS/grapesjs/blob/dev/src/dom_components/model/ComponentTableCell.ts) - Component for handle `<td>` and `<th>` elements
- [`row`](https://github.com/GrapesJS/grapesjs/blob/dev/src/dom_components/model/ComponentTableRow.ts) - Component for handle `<tr>` elements
- [`table`](https://github.com/GrapesJS/grapesjs/blob/dev/src/dom_components/model/ComponentTable.ts) - Component for handle `<table>` elements
- [`thead`](https://github.com/GrapesJS/grapesjs/blob/dev/src/dom_components/model/ComponentTableHead.ts) - Component for handle `<thead>` elements
- [`tbody`](https://github.com/GrapesJS/grapesjs/blob/dev/src/dom_components/model/ComponentTableBody.ts) - Component for handle `<tbody>` elements
- [`tfoot`](https://github.com/GrapesJS/grapesjs/blob/dev/src/dom_components/model/ComponentTableFoot.ts) - Component for handle `<tfoot>` elements
- [`map`](https://github.com/GrapesJS/grapesjs/blob/dev/src/dom_components/model/ComponentMap.ts) - Component for handle `<a>` elements
- [`link`](https://github.com/GrapesJS/grapesjs/blob/dev/src/dom_components/model/ComponentLink.ts) - Component for handle `<a>` elements
- [`label`](https://github.com/GrapesJS/grapesjs/blob/dev/src/dom_components/model/ComponentLabel.ts) - Component for handle properly `<label>` elements
- [`video`](https://github.com/GrapesJS/grapesjs/blob/dev/src/dom_components/model/ComponentVideo.ts) - Component for videos
- [`image`](https://github.com/GrapesJS/grapesjs/blob/dev/src/dom_components/model/ComponentImage.ts) - Component for images
- [`script`](https://github.com/GrapesJS/grapesjs/blob/dev/src/dom_components/model/ComponentScript.ts) - Component for handle `<script>` elements
- [`svg`](https://github.com/GrapesJS/grapesjs/blob/dev/src/dom_components/model/ComponentSvg.ts) - Component for handle SVG elements
- [`comment`](https://github.com/GrapesJS/grapesjs/blob/dev/src/dom_components/model/ComponentComment.ts) - Component for comments (might be useful for email editors)
- [`textnode`](https://github.com/GrapesJS/grapesjs/blob/dev/src/dom_components/model/ComponentTextNode.ts) - Similar to the textnode in DOM definition, so a text element without a tag element.
- [`text`](https://github.com/GrapesJS/grapesjs/blob/dev/src/dom_components/model/ComponentText.ts) - A simple text component that can be edited inline
- [`wrapper`](https://github.com/GrapesJS/grapesjs/blob/dev/src/dom_components/model/ComponentWrapper.ts) - The canvas need to contain a root component, a wrapper, this component was made to identify it
- [`default`](https://github.com/GrapesJS/grapesjs/blob/dev/src/dom_components/model/Component.ts) Default base component
- [`cell`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/dom_components/model/ComponentTableCell.ts) - Component for handle `<td>` and `<th>` elements
- [`row`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/dom_components/model/ComponentTableRow.ts) - Component for handle `<tr>` elements
- [`table`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/dom_components/model/ComponentTable.ts) - Component for handle `<table>` elements
- [`thead`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/dom_components/model/ComponentTableHead.ts) - Component for handle `<thead>` elements
- [`tbody`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/dom_components/model/ComponentTableBody.ts) - Component for handle `<tbody>` elements
- [`tfoot`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/dom_components/model/ComponentTableFoot.ts) - Component for handle `<tfoot>` elements
- [`map`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/dom_components/model/ComponentMap.ts) - Component for handle `<a>` elements
- [`link`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/dom_components/model/ComponentLink.ts) - Component for handle `<a>` elements
- [`label`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/dom_components/model/ComponentLabel.ts) - Component for handle properly `<label>` elements
- [`video`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/dom_components/model/ComponentVideo.ts) - Component for videos
- [`image`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/dom_components/model/ComponentImage.ts) - Component for images
- [`script`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/dom_components/model/ComponentScript.ts) - Component for handle `<script>` elements
- [`svg`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/dom_components/model/ComponentSvg.ts) - Component for handle SVG elements
- [`comment`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/dom_components/model/ComponentComment.ts) - Component for comments (might be useful for email editors)
- [`textnode`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/dom_components/model/ComponentTextNode.ts) - Similar to the textnode in DOM definition, so a text element without a tag element.
- [`text`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/dom_components/model/ComponentText.ts) - A simple text component that can be edited inline
- [`wrapper`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/dom_components/model/ComponentWrapper.ts) - The canvas need to contain a root component, a wrapper, this component was made to identify it
- [`default`](https://github.com/GrapesJS/grapesjs/blob/dev/packages/core/src/dom_components/model/Component.ts) Default base component
## Define Custom Component Type

78
docs/modules/DataSources.md

@ -159,6 +159,82 @@ const testDataSource = {
In this example, the `onRecordSetValue` transformer ensures that the `content` property is always an uppercase string.
## Storing DataSources in Project JSON
GrapesJS allows you to control whether a DataSource should be stored statically in the project JSON. This is useful for managing persistent data across project saves and loads.
### Using the `skipFromStorage` Key
When creating a DataSource, you can use the `skipFromStorage` key to specify whether it should be included in the project JSON.
**Example: Creating a DataSource with `skipFromStorage`**
```ts
const persistentDataSource = {
id: 'persistent-datasource',
records: [
{ id: 'id1', content: 'This data will be saved' },
{ id: 'id2', color: 'blue' },
],
};
editor.DataSources.add(persistentDataSource);
const temporaryDataSource = {
id: 'temporary-datasource',
records: [
{ id: 'id1', content: 'This data will not be saved' },
],
skipFromStorage: true,
};
editor.DataSources.add(temporaryDataSource);
```
In this example, `persistentDataSource` will be included in the project JSON when the project is saved, while `temporaryDataSource` will not.
### Benefits of Using `skipFromStorage`
1. **Persistent Configuration**: Store configuration data that should persist across project saves and loads.
2. **Default Data**: Include default data that should always be available in the project.
3. **Selective Storage**: Choose which DataSources to include in the project JSON, optimizing storage and load times.
### Accessing Stored DataSources
When a project is loaded, GrapesJS will automatically restore the DataSources that were saved. You can then access and use these DataSources as usual.
```ts
// After loading a project
const loadedDataSource = editor.DataSources.get('persistent-datasource');
console.log(loadedDataSource.getRecord('id1').get('content')); // Outputs: "This data will be saved"
```
Remember that DataSources with `skipFromStorage: true` will not be available after a project is loaded unless you add them programmatically.
## Record Mutability
DataSource records are mutable by default, but can be set as immutable to prevent modifications. Use the mutable flag when creating records to control this behavior.
```ts
const dataSource = {
id: 'my-datasource',
records: [
{ id: 'id1', content: 'Mutable content' },
{ id: 'id2', content: 'Immutable content', mutable: false },
],
};
editor.DataSources.add(dataSource);
const ds = editor.DataSources.get('my-datasource');
ds.getRecord('id1').set('content', 'Updated content'); // Succeeds
ds.getRecord('id2').set('content', 'New content'); // Throws error
```
Immutable records cannot be modified or removed, ensuring data integrity for critical information.
## Benefits of Using DataSources
DataSources are integrated with GrapesJS's runtime and BackboneJS models, enabling dynamic updates and synchronization between your data and UI components. This allows you to:
@ -207,4 +283,4 @@ In this example, a counter is dynamically updated and displayed in the UI, demon
1. Injecting configuration
2. Managing global themes
3. Mocking & testing
4. Third-party integrations
4. Third-party integrations

13
package.json

@ -1,23 +1,26 @@
{
"name": "@grapesjs/monorepo",
"version": "0.0.0",
"packageManager": "pnpm@8.6.3",
"packageManager": "pnpm@9.10.0",
"scripts": {
"start": "pnpm --filter grapesjs start",
"test": "pnpm --filter grapesjs test",
"test": "pnpm -r run test",
"docs": "pnpm --filter @grapesjs/docs docs",
"docs:api": "pnpm --filter @grapesjs/docs docs:api",
"lint": "eslint .",
"build": "pnpm --filter \"!@grapesjs/docs\" build",
"ts:check": "pnpm --filter grapesjs ts:check",
"clean": "find . -type d \\( -name \"node_modules\" -o -name \"build\" -o -name \"dist\" \\) -exec rm -rf {} + && rm ./pnpm-lock.yaml",
"format": "prettier . --write",
"format:check": "prettier . --check"
"format": "prettier . --write --ignore-path .prettierignore",
"format:check": "prettier . --check --ignore-path .prettierignore"
},
"devDependencies": {
"@babel/cli": "7.24.8",
"@babel/core": "7.25.2",
"@babel/preset-env": "7.25.4",
"@babel/preset-typescript": "7.24.7",
"@babel/runtime": "7.25.6",
"babel-loader": "9.1.3",
"@jest/globals": "29.7.0",
"@types/jest": "29.5.12",
"@types/node": "22.4.1",
@ -62,6 +65,6 @@
},
"engines": {
"node": ">=20",
"pnpm": ">=8"
"pnpm": ">=9"
}
}

122
packages/cli/README.md

@ -0,0 +1,122 @@
# GrapesJS CLI
[![npm](https://img.shields.io/npm/v/grapesjs-cli.svg)](https://www.npmjs.com/package/grapesjs-cli)
![grapesjs-cli](https://user-images.githubusercontent.com/11614725/67523496-0ed41300-f6af-11e9-9850-7175355f2946.jpg)
A simple CLI library for helping in GrapesJS plugin development.
The goal of this package is to avoid the hassle of setting up all the dependencies and configurations for the plugin development by centralizing and speeding up the necessary steps during the process.
- Fast project scaffolding
- No need to touch Babel and Webpack configurations
## Plugin from 0 to 100
Create a production-ready plugin in a few simple steps.
- Create a folder for your plugin and init some preliminary steps
```sh
mkdir grapesjs-my-plugin
cd grapesjs-my-plugin
npm init -y
git init
```
- Install the package
```sh
npm i -D grapesjs-cli
```
- Init your plugin project by following few steps
```sh
npx grapesjs-cli init
```
You can also skip all the questions with `-y` option or pass all the answers via options (to see all available options run `npx grapesjs-cli init --help`)
```sh
npx grapesjs-cli init -y --user=YOUR-GITHUB-USERNAME
```
- The command will scaffold the `src` directory and a bunch of other files inside your project. The `src/index.js` will be the entry point of your plugin. Before starting developing your plugin run the development server and open the printed URL (eg. the default is http://localhost:8080)
```sh
npx grapesjs-cli serve
```
If you need a custom port use the `-p` option
```sh
npx grapesjs-cli serve -p 8081
```
Under the hood we use `webpack-dev-server` and you can pass its option via CLI in this way
```sh
npx grapesjs-cli serve --devServer='{"https": true}'
```
- Once the development is finished you can build your plugin and generate the minified file ready for production
```sh
npx grapesjs-cli build
```
- Before publishing your package remember to complete your README.md file with all the available options, components, blocks and so on.
For a better user engagement create a simple live demo by using services like [JSFiddle](https://jsfiddle.net) [CodeSandbox](https://codesandbox.io) [CodePen](https://codepen.io) and link it in your README. To help you in this process we'll print all the necessary HTML/CSS/JS in your README, so it will be just a matter of copy-pasting on some of those services.
## Customization
### Customize webpack config
If you need to customize the webpack configuration, you can create `webpack.config.js` file in the root dir of your project and export a function, which should return the new configuration object. Check the example below.
```js
// YOUR-PROJECT-DIR/webpack.config.js
// config is the default configuration
export default ({ config }) => {
// This is how you can distinguish the `build` command from the `serve`
const isBuild = config.mode === 'production';
return {
...config,
module: {
rules: [
{
/* extra rule */
},
...config.module.rules,
],
},
};
};
```
## Generic CLI usage
Show all available commands
```sh
grapesjs-cli
```
Show available options for a command
```sh
grapesjs-cli COMMAND --help
```
Run the command
```sh
grapesjs-cli COMMAND --OPT1 --OPT2=VALUE
```
## License
MIT

3
packages/cli/babel.config.js

@ -0,0 +1,3 @@
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
};

42
packages/cli/index.html

@ -0,0 +1,42 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title><%= title %></title>
<link
href="<%= pathGjsCss ? pathGjsCss : 'https://unpkg.com/grapesjs@' + gjsVersion + '/dist/css/grapes.min.css' %>"
rel="stylesheet"
/>
<script src="<%= pathGjs ? pathGjs : 'https://unpkg.com/grapesjs@' + gjsVersion %>"></script>
<style>
body,
html {
height: 100%;
margin: 0;
}
</style>
</head>
<body>
<div id="gjs" style="height: 0px; overflow: hidden">
<div style="margin: 100px 100px 25px; padding: 25px; font: caption">
This is a demo content generated from <b>GrapesJS CLI</b>. For the development, you should create a _index.html
template file (might be a copy of this one) and on the next server start the new file will be served, and it
will be ignored by git.
</div>
</div>
<script type="text/javascript">
window.onload = () => {
window.editor = grapesjs.init({
height: '100%',
container: '#gjs',
showOffsets: 1,
noticeOnUnload: 0,
storageManager: false,
fromElement: true,
plugins: ['<%= name %>'],
});
};
</script>
</body>
</html>

10
packages/cli/jest.config.ts

@ -0,0 +1,10 @@
import type { Config } from 'jest';
const config: Config = {
testEnvironment: 'node',
verbose: true,
modulePaths: ['<rootDir>/src'],
testMatch: ['<rootDir>/test/**/*.(t|j)s'],
};
export default config;

57
packages/cli/package.json

@ -0,0 +1,57 @@
{
"name": "grapesjs-cli",
"version": "4.1.3",
"description": "GrapesJS CLI tool for the plugin development",
"bin": {
"grapesjs-cli": "dist/cli.js"
},
"files": [
"dist"
],
"scripts": {
"build": "cross-env BUILD_MODE=production webpack --config ./webpack.cli.ts",
"build:watch": "webpack --config ./webpack.cli.ts --watch",
"lint": "eslint src",
"patch": "npm version patch -m 'Bump v%s'",
"test": "jest"
},
"repository": {
"type": "git",
"url": "https://github.com/GrapesJS/grapesjs.git"
},
"keywords": [
"grapesjs",
"plugin",
"dev",
"cli"
],
"author": "Artur Arseniev",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/core": "7.25.2",
"@babel/plugin-transform-runtime": "7.25.4",
"@babel/preset-env": "7.25.4",
"@babel/runtime": "7.25.6",
"babel-loader": "9.1.3",
"chalk": "^4.1.2",
"core-js": "3.38.1",
"dts-bundle-generator": "^8.0.1",
"html-webpack-plugin": "5.6.0",
"inquirer": "^8.2.5",
"listr": "^0.14.3",
"lodash.template": "^4.5.0",
"rimraf": "^4.1.2",
"spdx-license-list": "^6.6.0",
"terser-webpack-plugin": "^5.3.10",
"webpack": "5.94.0",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.1.0",
"yargs": "^17.6.2"
},
"devDependencies": {
"@types/webpack-node-externals": "^3.0.0",
"copy-webpack-plugin": "^11.0.0",
"fork-ts-checker-webpack-plugin": "^8.0.0",
"webpack-node-externals": "^3.0.0"
}
}

6
packages/cli/src/banner.txt

@ -0,0 +1,6 @@
  ______ _______ ________ ____
  / ____/________ _____ ___ _____ / / ___/ / ____/ / / _/
 / / __/ ___/ __ `/ __ \/ _ \/ ___/_ / /\__ \______/ / / / / /
/ /_/ / / / /_/ / /_/ / __(__ ) /_/ /___/ /_____/ /___/ /____/ /
\____/_/ \__,_/ .___/\___/____/\____//____/ \____/_____/___/
  /_/

143
packages/cli/src/build.ts

@ -0,0 +1,143 @@
import {
printRow,
printError,
buildWebpackArgs,
normalizeJsonOpt,
copyRecursiveSync,
rootResolve,
babelConfig,
log,
writeFile,
} from './utils';
import { generateDtsBundle } from 'dts-bundle-generator';
import webpack from 'webpack';
import fs from 'fs';
import webpackConfig from './webpack.config';
import { exec } from 'child_process';
import chalk from 'chalk';
import rimraf from 'rimraf';
import { transformFileSync } from '@babel/core';
interface BuildOptions {
verbose?: boolean;
patch?: boolean;
statsOutput?: string;
localePath?: string;
dts?: 'include' | 'skip' | 'only';
}
/**
* Build locale files
* @param {Object} opts
*/
export const buildLocale = async (opts: BuildOptions = {}) => {
const { localePath } = opts;
if (!fs.existsSync(rootResolve(localePath))) return;
printRow('Start building locale files...', { lineDown: 0 });
await rimraf('locale');
const localDst = rootResolve('locale');
copyRecursiveSync(rootResolve(localePath), localDst);
// Create locale/index.js file
let result = '';
fs.readdirSync(localDst).forEach((file) => {
const name = file.split('.')[0];
result += `export { default as ${name} } from './${name}'\n`;
});
fs.writeFileSync(`${localDst}/index.js`, result);
// Compile files
const babelOpts = { ...babelConfig(buildWebpackArgs(opts) as any) };
fs.readdirSync(localDst).forEach((file) => {
const filePath = `${localDst}/${file}`;
const compiled = transformFileSync(filePath, babelOpts).code;
fs.writeFileSync(filePath, compiled);
});
printRow('Locale files building completed successfully!');
};
/**
* Build TS declaration file
* @param {Object} opts
*/
export const buildDeclaration = async (opts: BuildOptions = {}) => {
const filePath = rootResolve('src/index.ts');
if (!fs.existsSync(filePath)) return;
printRow('Start building TS declaration file...', { lineDown: 0 });
const entry = { filePath, output: { noBanner: true } };
const bundleOptions = { preferredConfigPath: rootResolve('tsconfig.json') };
const result = generateDtsBundle([entry], bundleOptions)[0];
await writeFile(rootResolve('dist/index.d.ts'), result);
printRow('TS declaration file building completed successfully!');
};
/**
* Build the library files
* @param {Object} opts
*/
export default (opts: BuildOptions = {}) => {
printRow('Start building the library...');
const isVerb = opts.verbose;
const { dts } = opts;
isVerb && log(chalk.yellow('Build config:\n'), opts, '\n');
const buildWebpack = () => {
const buildConf = {
...webpackConfig({
production: 1,
args: buildWebpackArgs(opts),
cmdOpts: opts,
}),
...normalizeJsonOpt(opts, 'config'),
};
if (dts === 'only') {
return buildDeclaration(opts);
}
webpack(buildConf, async (err, stats) => {
const errors = err || (stats ? stats.hasErrors() : false);
const statConf = {
hash: false,
colors: true,
builtAt: false,
entrypoints: false,
modules: false,
...normalizeJsonOpt(opts, 'stats'),
};
if (stats) {
opts.statsOutput && fs.writeFileSync(rootResolve(opts.statsOutput), JSON.stringify(stats.toJson()));
isVerb && log(chalk.yellow('Stats config:\n'), statConf, '\n');
const result = stats.toString(statConf);
log(result, '\n');
}
await buildLocale(opts);
if (dts !== 'skip') {
await buildDeclaration(opts);
}
if (errors) {
printError(`Error during building`);
console.error(err);
} else {
printRow('Building completed successfully!');
}
});
};
if (opts.patch) {
isVerb && log(chalk.yellow('Patch the version'), '\n');
exec('npm version --no-git-tag-version patch', buildWebpack);
} else {
buildWebpack();
}
};

174
packages/cli/src/cli.ts

@ -0,0 +1,174 @@
import yargs from 'yargs';
import fs from 'fs';
import path from 'path';
import { serve, build, init } from './main';
import chalk from 'chalk';
import { printError } from './utils';
import { version } from '../package.json';
yargs.usage(chalk.green.bold(fs.readFileSync(path.resolve(__dirname, './banner.txt'), 'utf8') + `\nv${version}`));
const webpackOptions = (yargs) => {
yargs
.positional('config', {
describe: 'webpack configuration options',
type: 'string',
default: '{}',
})
.positional('babel', {
describe: 'Babel configuration object',
type: 'string',
default: '{}',
})
.positional('targets', {
describe: 'Browser targets in browserslist query',
type: 'string',
default: '> 0.25%, not dead',
})
.positional('entry', {
describe: 'Library entry point',
type: 'string',
default: 'src/index',
})
.positional('output', {
describe: 'Build destination directory',
type: 'string',
default: 'dist',
});
};
export const createCommands = (yargs) => {
return yargs
.command(
['serve [port]', 'server'],
'Start the server',
(yargs) => {
yargs
.positional('devServer', {
describe: 'webpack-dev-server options',
type: 'string',
default: '{}',
})
.positional('host', {
alias: 'h',
describe: 'Host to bind on',
type: 'string',
default: 'localhost',
})
.positional('port', {
alias: 'p',
describe: 'Port to bind on',
type: 'number',
default: 8080,
})
.positional('htmlWebpack', {
describe: 'html-webpack-plugin options',
type: 'string',
default: '{}',
});
webpackOptions(yargs);
},
(argv) => serve(argv),
)
.command(
'build',
'Build the source',
(yargs) => {
yargs
.positional('stats', {
describe: 'Options for webpack Stats instance',
type: 'string',
default: '{}',
})
.positional('statsOutput', {
describe: 'Specify the path where to output webpack stats file (eg. "stats.json")',
type: 'string',
default: '',
})
.positional('patch', {
describe: 'Increase automatically the patch version',
type: 'boolean',
default: true,
})
.positional('localePath', {
describe: 'Path to the directory containing locale files',
type: 'string',
default: 'src/locale',
})
.positional('dts', {
describe: 'Generate typescript dts file ("include", "skip", "only")',
type: 'string',
default: 'include',
});
webpackOptions(yargs);
},
(argv) => build(argv),
)
.command(
'init',
'Init GrapesJS plugin project',
(yargs) => {
yargs
.positional('yes', {
alias: 'y',
describe: 'All default answers',
type: 'boolean',
default: false,
})
.positional('name', {
describe: 'Name of the project',
type: 'string',
})
.positional('rName', {
describe: 'Repository name',
type: 'string',
})
.positional('user', {
describe: 'Repository username',
type: 'string',
})
.positional('components', {
describe: 'Indicate to include custom component types API',
type: 'boolean',
})
.positional('blocks', {
describe: 'Indicate to include blocks API',
type: 'boolean',
})
.positional('i18n', {
describe: 'Indicate to include the support for i18n',
type: 'boolean',
})
.positional('license', {
describe: 'License of the project',
type: 'string',
});
},
(argv) => init(argv),
)
.options({
verbose: {
alias: 'v',
description: 'Run with verbose logging',
type: 'boolean', // boolean | number | string
default: false,
},
})
.recommendCommands()
.strict();
};
export const argsToOpts = async () => {
return await createCommands(yargs).parse();
};
export const run = async (opts = {}) => {
try {
let options = await argsToOpts();
if (!options._.length) yargs.showHelp();
} catch (error) {
printError((error.stack || error).toString());
}
};
run();

217
packages/cli/src/init.ts

@ -0,0 +1,217 @@
import inquirer from 'inquirer';
import { printRow, isUndefined, log, ensureDir } from './utils';
import Listr from 'listr';
import path from 'path';
import fs from 'fs';
import spdxLicenseList from 'spdx-license-list/full';
import template from 'lodash.template';
import { version } from '../package.json';
interface InitOptions {
license?: string;
name?: string;
components?: boolean;
blocks?: boolean;
i18n?: boolean;
verbose?: boolean;
rName?: string;
user?: string;
yes?: boolean;
}
const tmpPath = './template';
const rootPath = process.cwd();
const getName = (str: string) =>
str
.replace(/\_/g, '-')
.split('-')
.filter((i) => i)
.map((i) => i[0].toUpperCase() + i.slice(1))
.join(' ');
const getTemplateFileContent = (pth: string) => {
const pt = path.resolve(__dirname, `${tmpPath}/${pth}`);
return fs.readFileSync(pt, 'utf8');
};
const resolveRoot = (pth: string) => {
return path.resolve(rootPath, pth);
};
const resolveLocal = (pth: string) => {
return path.resolve(__dirname, `${tmpPath}/${pth}`);
};
const createSourceFiles = async (opts: InitOptions = {}) => {
const rdmSrc = getTemplateFileContent('README.md');
const rdmDst = resolveRoot('README.md');
const indxSrc = getTemplateFileContent('src/index.js');
const indxDst = resolveRoot('src/index.js');
const indexCnt = getTemplateFileContent('_index.html');
const indexDst = resolveRoot('_index.html');
const license = spdxLicenseList[opts.license];
const licenseTxt =
license &&
(license.licenseText || '')
.replace('<year>', `${new Date().getFullYear()}-current`)
.replace('<copyright holders>', opts.name);
ensureDir(indxDst);
// write src/_index.html
fs.writeFileSync(indxDst, template(indxSrc)(opts).trim());
// write _index.html
fs.writeFileSync(indexDst, template(indexCnt)(opts));
// Write README.md
fs.writeFileSync(rdmDst, template(rdmSrc)(opts));
// write LICENSE
licenseTxt && fs.writeFileSync(resolveRoot('LICENSE'), licenseTxt);
// Copy files
fs.copyFileSync(resolveLocal('.gitignore-t'), resolveRoot('.gitignore'));
fs.copyFileSync(resolveLocal('.npmignore-t'), resolveRoot('.npmignore'));
fs.copyFileSync(resolveLocal('tsconfig.json'), resolveRoot('tsconfig.json'));
};
const createFileComponents = (opts: InitOptions = {}) => {
const filepath = 'src/components.js';
const cmpSrc = resolveLocal(filepath);
const cmpDst = resolveRoot(filepath);
opts.components && fs.copyFileSync(cmpSrc, cmpDst);
};
const createFileBlocks = (opts: InitOptions = {}) => {
const filepath = 'src/blocks.js';
const blkSrc = resolveLocal(filepath);
const blkDst = resolveRoot(filepath);
opts.blocks && fs.copyFileSync(blkSrc, blkDst);
};
const createI18n = (opts = {}) => {
const enPath = 'src/locale/en.js';
const tmpEn = getTemplateFileContent(enPath);
const dstEn = resolveRoot(enPath);
ensureDir(dstEn);
fs.writeFileSync(dstEn, template(tmpEn)(opts));
};
const createPackage = (opts = {}) => {
const filepath = 'package.json';
const cnt = getTemplateFileContent(filepath);
const dst = resolveRoot(filepath);
fs.writeFileSync(
dst,
template(cnt)({
...opts,
version,
}),
);
};
const checkBoolean = (value) => (value && value !== 'false' ? true : false);
export const initPlugin = async (opts: InitOptions = {}) => {
printRow('Start project creation...');
opts.components = checkBoolean(opts.components);
opts.blocks = checkBoolean(opts.blocks);
opts.i18n = checkBoolean(opts.i18n);
const tasks = new Listr([
{
title: 'Creating initial source files',
task: () => createSourceFiles(opts),
},
{
title: 'Creating custom Component Type file',
task: () => createFileComponents(opts),
enabled: () => opts.components,
},
{
title: 'Creating Blocks file',
task: () => createFileBlocks(opts),
enabled: () => opts.blocks,
},
{
title: 'Creating i18n structure',
task: () => createI18n(opts),
enabled: () => opts.i18n,
},
{
title: 'Update package.json',
task: () => createPackage(opts),
},
]);
await tasks.run();
};
export default async (opts: InitOptions = {}) => {
const rootDir = path.basename(process.cwd());
const questions = [];
const { verbose, name, rName, user, yes, components, blocks, i18n, license } = opts;
let results = {
name: name || getName(rootDir),
rName: rName || rootDir,
user: user || 'YOUR-USERNAME',
components: isUndefined(components) ? true : components,
blocks: isUndefined(blocks) ? true : blocks,
i18n: isUndefined(i18n) ? true : i18n,
license: license || 'MIT',
};
printRow(`Init the project${verbose ? ' (verbose)' : ''}...`);
if (!yes) {
!name &&
questions.push({
name: 'name',
message: 'Name of the project',
default: results.name,
});
!rName &&
questions.push({
name: 'rName',
message: 'Repository name (used also as the plugin name)',
default: results.rName,
});
!user &&
questions.push({
name: 'user',
message: 'Repository username (eg. on GitHub/Bitbucket)',
default: results.user,
});
isUndefined(components) &&
questions.push({
type: 'boolean',
name: 'components',
message: 'Will you need to add custom Component Types?',
default: results.components,
});
isUndefined(blocks) &&
questions.push({
type: 'boolean',
name: 'blocks',
message: 'Will you need to add Blocks?',
default: results.blocks,
});
isUndefined(i18n) &&
questions.push({
type: 'boolean',
name: 'i18n',
message: 'Do you want to setup i18n structure in this plugin?',
default: results.i18n,
});
!license &&
questions.push({
name: 'license',
message: 'License of the project',
default: results.license,
});
}
const answers = await inquirer.prompt(questions);
results = {
...results,
...answers,
};
verbose && log({ results, opts });
await initPlugin(results);
printRow('Project created! Happy coding');
};

3
packages/cli/src/main.ts

@ -0,0 +1,3 @@
export { default as init } from './init';
export { default as build } from './build';
export { default as serve } from './serve';

48
packages/cli/src/serve.ts

@ -0,0 +1,48 @@
import { printRow, buildWebpackArgs, log, normalizeJsonOpt } from './utils';
import webpack from 'webpack';
import webpackDevServer from 'webpack-dev-server';
import webpackConfig from './webpack.config';
import chalk from 'chalk';
interface ServeOptions {
host?: string;
port?: number;
verbose?: boolean;
}
/**
* Start up the development server
* @param {Object} opts
*/
export default (opts: ServeOptions = {}) => {
printRow('Start the development server...');
const { host, port } = opts;
const isVerb = opts.verbose;
const resultWebpackConf = {
...webpackConfig({ args: buildWebpackArgs(opts), cmdOpts: opts }),
...normalizeJsonOpt(opts, 'webpack'),
};
const devServerConf = {
...resultWebpackConf.devServer,
open: true,
...normalizeJsonOpt(opts, 'devServer'),
};
if (host !== 'localhost') {
devServerConf.host = host;
}
if (port !== 8080) {
devServerConf.port = port;
}
if (isVerb) {
log(chalk.yellow('Server config:\n'), opts, '\n');
log(chalk.yellow('DevServer config:\n'), devServerConf, '\n');
}
const compiler = webpack(resultWebpackConf);
const server = new webpackDevServer(devServerConf, compiler);
server.start();
};

8
packages/cli/src/template/.gitignore-t

@ -0,0 +1,8 @@
.DS_Store
private/
/locale
node_modules/
*.log
_index.html
dist/
stats.json

7
packages/cli/src/template/.npmignore-t

@ -0,0 +1,7 @@
.*
*.log
*.html
**/tsconfig.json
**/webpack.config.js
node_modules
src

145
packages/cli/src/template/README.md

@ -0,0 +1,145 @@
# <%= name %>
## Live Demo
> **Show a live example of your plugin**
To make your plugin more engaging, create a simple live demo using online tools like [JSFiddle](https://jsfiddle.net), [CodeSandbox](https://codesandbox.io), or [CodePen](https://codepen.io). Include the demo link in your README. Adding a screenshot or GIF of the demo is a bonus.
Below, you'll find the necessary HTML, CSS, and JavaScript. Copy and paste this code into one of the tools mentioned. Once you're done, delete this section and update the link at the top with your demo.
### HTML
```html
<link href="https://unpkg.com/grapesjs/dist/css/grapes.min.css" rel="stylesheet" />
<script src="https://unpkg.com/grapesjs"></script>
<script src="https://unpkg.com/<%= rName %>"></script>
<div id="gjs"></div>
```
### JS
```js
const editor = grapesjs.init({
container: '#gjs',
height: '100%',
fromElement: true,
storageManager: false,
plugins: ['<%= rName %>'],
});
```
### CSS
```css
body,
html {
margin: 0;
height: 100%;
}
```
## Summary
- Plugin name: `<%= rName %>`
- Components
- `component-id-1`
- `component-id-2`
- ...
- Blocks
- `block-id-1`
- `block-id-2`
- ...
## Options
| Option | Description | Default |
| --------- | ------------------ | --------------- |
| `option1` | Description option | `default value` |
## Download
- CDN
- `https://unpkg.com/<%= rName %>`
- NPM
- `npm i <%= rName %>`
- GIT
- `git clone https://github.com/<%= user %>/<%= rName %>.git`
## Usage
Directly in the browser
```html
<link href="https://unpkg.com/grapesjs/dist/css/grapes.min.css" rel="stylesheet" />
<script src="https://unpkg.com/grapesjs"></script>
<script src="path/to/<%= rName %>.min.js"></script>
<div id="gjs"></div>
<script type="text/javascript">
var editor = grapesjs.init({
container: '#gjs',
// ...
plugins: ['<%= rName %>'],
pluginsOpts: {
'<%= rName %>': {
/* options */
},
},
});
</script>
```
Modern javascript
```js
import grapesjs from 'grapesjs';
import plugin from '<%= rName %>';
import 'grapesjs/dist/css/grapes.min.css';
const editor = grapesjs.init({
container : '#gjs',
// ...
plugins: [plugin],
pluginsOpts: {
[plugin]: { /* options */ }
}
// or
plugins: [
editor => plugin(editor, { /* options */ }),
],
});
```
## Development
Clone the repository
```sh
$ git clone https://github.com/<%= user %>/<%= rName %>.git
$ cd <%= rName %>
```
Install dependencies
```sh
npm i
```
Start the dev server
```sh
npm start
```
Build the source
```sh
npm run build
```
## License
MIT

23
packages/cli/src/template/package.json

@ -0,0 +1,23 @@
{
"name": "<%= rName %>",
"version": "1.0.0",
"description": "<%= name %>",
"main": "dist/index.js",
"repository": {
"type": "git",
"url": "https://github.com/<%= user %>/<%= rName %>.git"
},
"scripts": {
"start": "grapesjs-cli serve",
"build": "grapesjs-cli build",
"bump": "npm version patch -m 'Bump v%s'"
},
"keywords": [
"grapesjs",
"plugin"
],
"devDependencies": {
"grapesjs-cli": "^<%= version %>"
},
"license": "<%= license %>"
}

9
packages/cli/src/template/src/blocks.js

@ -0,0 +1,9 @@
export default (editor, opts = {}) => {
const bm = editor.BlockManager;
bm.add('MY-BLOCK', {
label: 'My block',
content: { type: 'MY-COMPONENT' },
// media: '<svg>...</svg>',
});
};

12
packages/cli/src/template/src/components.js

@ -0,0 +1,12 @@
export default (editor, opts = {}) => {
const domc = editor.DomComponents;
domc.addType('MY-COMPONENT', {
model: {
defaults: {
// Default props
},
},
view: {},
});
};

29
packages/cli/src/template/src/index.js

@ -0,0 +1,29 @@
<% if(components){ %>import loadComponents from './components';<% } %>
<% if(blocks){ %>import loadBlocks from './blocks';<% } %>
<% if(i18n){ %>import en from './locale/en';<% } %>
export default (editor, opts = {}) => {
const options = { ...{
<% if(i18n){ %>i18n: {},<% } %>
// default options
}, ...opts };
<% if(components){ %>// Add components
loadComponents(editor, options);<% } %>
<% if(blocks){ %>// Add blocks
loadBlocks(editor, options);<% } %>
<% if(i18n){ %>// Load i18n files
editor.I18n && editor.I18n.addMessages({
en,
...options.i18n,
});<% } %>
// TODO Remove
editor.on('load', () =>
editor.addComponents(
`<div style="margin:100px; padding:25px;">
Content loaded from the plugin
</div>`,
{ at: 0 }
))
};

20
packages/cli/src/template/tsconfig.json

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"sourceMap": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": false
},
"include": ["src"]
}

123
packages/cli/src/utils.ts

@ -0,0 +1,123 @@
import chalk from 'chalk';
import path from 'path';
import fs from 'fs';
import fsp from 'fs/promises';
export const isString = (val: any): val is string => typeof val === 'string';
export const isUndefined = (value: any) => typeof value === 'undefined';
export const isFunction = (value: any): value is Function => typeof value === 'function';
export const isObject = (val: any) => val !== null && !Array.isArray(val) && typeof val === 'object';
export const printRow = (str: string, { color = 'green', lineDown = 1 } = {}) => {
console.log('');
console.log(chalk[color].bold(str));
lineDown && console.log('');
};
export const printError = (str: string) => {
printRow(str, { color: 'red' });
};
export const log = (...args: any[]) => console.log.apply(this, args);
export const ensureDir = (filePath: string) => {
const dirname = path.dirname(filePath);
if (fs.existsSync(dirname)) return true;
fs.mkdirSync(dirname);
return ensureDir(dirname);
};
/**
* Normalize JSON options
* @param opts Options
* @param key Options name to normalize
* @returns {Object}
*/
export const normalizeJsonOpt = (opts: Record<string, any>, key: string) => {
let devServerOpt = opts[key] || {};
if (isString(devServerOpt)) {
try {
devServerOpt = JSON.parse(devServerOpt);
} catch (e) {
printError(`Error while parsing "${key}" option`);
printError(e);
devServerOpt = {};
}
}
return devServerOpt;
};
export const buildWebpackArgs = (opts: Record<string, any>) => {
return {
...opts,
babel: normalizeJsonOpt(opts, 'babel'),
htmlWebpack: normalizeJsonOpt(opts, 'htmlWebpack'),
};
};
export const copyRecursiveSync = (src: string, dest: string) => {
const exists = fs.existsSync(src);
const isDir = exists && fs.statSync(src).isDirectory();
if (isDir) {
fs.mkdirSync(dest);
fs.readdirSync(src).forEach((file) => {
copyRecursiveSync(path.join(src, file), path.join(dest, file));
});
} else if (exists) {
fs.copyFileSync(src, dest);
}
};
export const isPathExists = async (path: string) => {
try {
await fsp.access(path);
return true;
} catch {
return false;
}
};
export const writeFile = async (filePath: string, data: string) => {
try {
const dirname = path.dirname(filePath);
const exist = await isPathExists(dirname);
if (!exist) {
await fsp.mkdir(dirname, { recursive: true });
}
await fsp.writeFile(filePath, data, 'utf8');
} catch (err) {
throw new Error(err);
}
};
export const rootResolve = (val: string) => path.resolve(process.cwd(), val);
export const originalRequire = () => {
// @ts-ignore need this to use the original 'require.resolve' as it's replaced by webpack
return __non_webpack_require__;
};
export const resolve = (value: string) => {
return originalRequire().resolve(value);
};
export const babelConfig = (opts: { targets?: string } = {}) => ({
presets: [
[
resolve('@babel/preset-env'),
{
targets: opts.targets,
// useBuiltIns: 'usage', // this makes the build much bigger
// corejs: 3,
},
],
],
plugins: [resolve('@babel/plugin-transform-runtime')],
});

129
packages/cli/src/webpack.config.ts

@ -0,0 +1,129 @@
import { babelConfig, rootResolve, isFunction, isObject, log, resolve, originalRequire } from './utils';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import TerserPlugin from 'terser-webpack-plugin';
import chalk from 'chalk';
import path from 'path';
import fs from 'fs';
import webpack from 'webpack';
const dirCwd = process.cwd();
let plugins = [];
export default (opts: Record<string, any> = {}): any => {
const pkgPath = path.join(dirCwd, 'package.json');
const rawPackageJson = fs.readFileSync(pkgPath) as unknown as string;
const pkg = JSON.parse(rawPackageJson);
const { args, cmdOpts = {} } = opts;
const { htmlWebpack = {} } = args;
const name = pkg.name;
const isProd = opts.production;
const banner = `/*! ${name} - ${pkg.version} */`;
if (!isProd) {
const fname = 'index.html';
const index = `${dirCwd}/${fname}`;
const indexDev = `${dirCwd}/_${fname}`;
let template = path.resolve(__dirname, `./../${fname}`);
if (fs.existsSync(indexDev)) {
template = indexDev;
} else if (fs.existsSync(index)) {
template = index;
}
plugins.push(
new HtmlWebpackPlugin({
inject: 'head',
template,
...htmlWebpack,
templateParameters: {
name,
title: name,
gjsVersion: 'latest',
pathGjs: '',
pathGjsCss: '',
...(htmlWebpack.templateParameters || {}),
},
}),
);
}
const outPath = path.resolve(dirCwd, args.output);
const modulesPaths = ['node_modules', path.join(__dirname, '../node_modules')];
let config = {
entry: path.resolve(dirCwd, args.entry),
mode: isProd ? 'production' : 'development',
devtool: isProd ? 'source-map' : 'eval',
optimization: {
minimizer: [
new TerserPlugin({
extractComments: false,
terserOptions: {
compress: {
evaluate: false, // Avoid breaking gjs scripts
},
output: {
comments: false,
quote_style: 3, // Preserve original quotes
preamble: banner, // banner here instead of BannerPlugin
},
},
}),
],
},
output: {
path: outPath,
filename: 'index.js',
library: name,
libraryTarget: 'umd',
globalObject: `typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : this)`,
},
module: {
rules: [
{
test: /\.tsx?$/,
loader: resolve('ts-loader'),
exclude: /node_modules/,
options: {
context: rootResolve(''),
configFile: rootResolve('tsconfig.json'),
},
},
{
test: /\.js$/,
loader: resolve('babel-loader'),
include: /src/,
options: {
...babelConfig(args),
cacheDirectory: true,
...args.babel,
},
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
modules: modulesPaths,
},
plugins,
};
// Try to load local webpack config
const localWebpackPath = rootResolve('webpack.config.js');
let localWebpackConf: any;
if (fs.existsSync(localWebpackPath)) {
const customWebpack = originalRequire()(localWebpackPath);
localWebpackConf = customWebpack.default || customWebpack;
}
if (isFunction(localWebpackConf)) {
const fnRes = localWebpackConf({ config, webpack, pkg });
config = isObject(fnRes) ? fnRes : config;
}
cmdOpts.verbose && log(chalk.yellow('Webpack config:\n'), config, '\n');
return config;
};

359
packages/cli/test/utils.spec.ts

@ -0,0 +1,359 @@
import {
isFunction,
isObject,
isString,
isUndefined,
printRow,
printError,
log,
ensureDir,
normalizeJsonOpt,
buildWebpackArgs,
copyRecursiveSync,
babelConfig,
originalRequire,
resolve,
rootResolve,
} from '../src/utils';
import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import * as process from 'process';
const typeTestValues = {
undefinedValue: undefined,
nullValue: null,
stringValue: 'hello',
emptyObject: {},
nonEmptyObject: { key: 'value' },
emptyArray: [],
functionValue: () => {},
numberValue: 42,
booleanValue: true,
dateValue: new Date(),
};
function runTypeCheck(typeCheckFunction: (value: any) => boolean) {
const keysWithPassingTypeChecks = Object.keys(typeTestValues).filter((key) => {
const value = typeTestValues[key];
return typeCheckFunction(value);
});
return keysWithPassingTypeChecks;
}
jest.mock('fs');
jest.mock('fs/promises');
describe('utils', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('isString', () => {
it('should correctly identify strings', () => {
const result = runTypeCheck(isString);
expect(result).toEqual(['stringValue']);
});
});
describe('isUndefined', () => {
it('should correctly identify undefined values', () => {
const result = runTypeCheck(isUndefined);
expect(result).toEqual(['undefinedValue']);
});
});
describe('isFunction', () => {
it('should correctly identify functions', () => {
const result = runTypeCheck(isFunction);
expect(result).toEqual(['functionValue']);
});
});
describe('isObject', () => {
it('should correctly identify objects', () => {
const result = runTypeCheck(isObject);
expect(result).toEqual(['emptyObject', 'nonEmptyObject', 'dateValue']);
});
});
describe('printRow', () => {
// TODO: We should refactor the function to make lineDown a boolean not a number
it('should console.log the given string with the specified color and line breaks', () => {
const str = 'Test string';
const color = 'blue';
const lineDown = 1;
console.log = jest.fn() as jest.Mock;
printRow(str, { color, lineDown });
expect(console.log).toHaveBeenCalledTimes(3); // 1 for empty line, 1 for colored string, 1 for line break
expect((console.log as jest.Mock).mock.calls[1][0]).toEqual(chalk[color].bold(str));
});
it('should not add a line break if lineDown is false', () => {
const str = 'Test string';
const color = 'green';
const lineDown = 0;
console.log = jest.fn();
printRow(str, { color, lineDown });
expect(console.log).toHaveBeenCalledTimes(2); // 1 for empty line, 1 for colored string
});
});
describe('printError', () => {
it('should print the given string in red', () => {
const str = 'Error message';
(console.log as jest.Mock).mockImplementation(() => {});
printError(str);
expect(console.log).toHaveBeenCalledTimes(3); // 1 for empty line, 1 for red string, 1 for line break
expect((console.log as jest.Mock).mock.calls[1][0]).toEqual(chalk.red.bold(str));
});
});
describe('log', () => {
it('should call console.log with the given arguments', () => {
const arg1 = 'Argument 1';
const arg2 = 'Argument 2';
console.log = jest.fn();
log(arg1, arg2);
expect(console.log).toHaveBeenCalledWith(arg1, arg2);
});
});
describe('ensureDir', () => {
it('should return true when the directory already exists', () => {
(fs.existsSync as jest.Mock).mockReturnValue(true);
const result = ensureDir('/path/to/file.txt');
expect(result).toBe(true);
expect(fs.existsSync).toHaveBeenCalledWith('/path/to');
expect(fs.mkdirSync).not.toHaveBeenCalled();
});
it('should create the directory when it does not exist', () => {
(fs.existsSync as jest.Mock).mockReturnValueOnce(false).mockReturnValueOnce(true);
const result = ensureDir('/path/to/file.txt');
expect(result).toBe(true);
expect(fs.existsSync).toHaveBeenCalledWith('/path/to');
expect(fs.mkdirSync).toHaveBeenCalledWith('/path/to');
});
it('should create parent directories recursively when they do not exist', () => {
(fs.existsSync as jest.Mock)
.mockReturnValueOnce(false) // Check /path/to (does not exist)
.mockReturnValueOnce(false) // Check /path (does not exist)
.mockReturnValueOnce(true); // Check / (root, exists)
const result = ensureDir('/path/to/file.txt');
expect(result).toBe(true);
expect(fs.existsSync).toHaveBeenCalledTimes(3); // /path/to, /path, /
expect(fs.mkdirSync).toHaveBeenCalledTimes(2); // /path, /path/to
});
});
describe('normalizeJsonOpt', () => {
it('should return the object if the option is already an object', () => {
const opts = { babel: { presets: ['@babel/preset-env'] } };
const result = normalizeJsonOpt(opts, 'babel');
expect(result).toEqual(opts.babel);
});
it('should parse and return the object if the option is a valid JSON string', () => {
const opts = { babel: '{"presets":["@babel/preset-env"]}' };
const result = normalizeJsonOpt(opts, 'babel');
expect(result).toEqual({ presets: ['@babel/preset-env'] });
});
it('should return an empty object if the option is an invalid JSON string', () => {
const opts = { babel: '{"presets":["@babel/preset-env"]' }; // Invalid JSON
const result = normalizeJsonOpt(opts, 'babel');
expect(result).toEqual({});
});
it('should return an empty object if the option is not provided', () => {
const opts = {};
const result = normalizeJsonOpt(opts, 'babel');
expect(result).toEqual({});
});
});
describe('buildWebpackArgs', () => {
it('should return the options with normalized JSON options for babel and htmlWebpack', () => {
const opts = {
babel: '{"presets":["@babel/preset-env"]}',
htmlWebpack: '{"template":"./src/index.html"}',
otherOption: 'someValue',
};
const result = buildWebpackArgs(opts);
expect(result).toEqual({
babel: { presets: ['@babel/preset-env'] },
htmlWebpack: { template: './src/index.html' },
otherOption: 'someValue',
});
});
it('should return empty objects for babel and htmlWebpack if they are invalid JSON strings', () => {
const opts = {
babel: '{"presets":["@babel/preset-env"]', // Invalid JSON
htmlWebpack: '{"template":"./src/index.html', // Invalid JSON
};
const result = buildWebpackArgs(opts);
expect(result).toEqual({
babel: {},
htmlWebpack: {},
});
});
it('should return the original objects if babel and htmlWebpack are already objects', () => {
const opts = {
babel: { presets: ['@babel/preset-env'] },
htmlWebpack: { template: './src/index.html' },
};
const result = buildWebpackArgs(opts);
expect(result).toEqual({
babel: opts.babel,
htmlWebpack: opts.htmlWebpack,
});
});
it('should handle missing babel and htmlWebpack keys gracefully', () => {
const opts = { otherOption: 'someValue' };
const result = buildWebpackArgs(opts);
expect(result).toEqual({
babel: {},
htmlWebpack: {},
otherOption: 'someValue',
});
});
});
describe('copyRecursiveSync', () => {
// TODO: Maybe this test case is a bit complex and we should think of an easier solution
it('should copy a directory and its contents recursively', () => {
/**
* First call: Mock as a directory with two files
* Subsequent calls: Mock as a file
*/
const existsSyncMock = (fs.existsSync as jest.Mock).mockReturnValue(true);
const statSyncMock = (fs.statSync as jest.Mock)
.mockReturnValueOnce({ isDirectory: () => true })
.mockReturnValue({ isDirectory: () => false });
const readdirSyncMock = (fs.readdirSync as jest.Mock)
.mockReturnValueOnce(['file1.txt', 'file2.txt'])
.mockReturnValue([]);
const copyFileSyncMock = (fs.copyFileSync as jest.Mock).mockImplementation(() => {});
copyRecursiveSync('/src', '/dest');
expect(existsSyncMock).toHaveBeenCalledWith('/src');
expect(statSyncMock).toHaveBeenCalledWith('/src');
expect(fs.mkdirSync).toHaveBeenCalledWith('/dest');
expect(readdirSyncMock).toHaveBeenCalledWith('/src');
expect(copyFileSyncMock).toHaveBeenCalledWith(
path.normalize('/src/file1.txt'),
path.normalize('/dest/file1.txt'),
);
expect(copyFileSyncMock).toHaveBeenCalledWith(
path.normalize('/src/file2.txt'),
path.normalize('/dest/file2.txt'),
);
});
it('should copy a file when source is a file', () => {
(fs.existsSync as jest.Mock).mockReturnValue(true);
(fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false });
copyRecursiveSync('/src/file.txt', '/dest/file.txt');
expect(fs.existsSync).toHaveBeenCalledWith('/src/file.txt');
expect(fs.statSync).toHaveBeenCalledWith('/src/file.txt');
expect(fs.copyFileSync).toHaveBeenCalledWith('/src/file.txt', '/dest/file.txt');
});
// Maybe we can change the behavior to throw an error if the `src` doesn't exist
it('should do nothing when source does not exist', () => {
(fs.existsSync as jest.Mock).mockReturnValue(false);
copyRecursiveSync('/src/file.txt', '/dest/file.txt');
expect(fs.existsSync).toHaveBeenCalledWith('/src/file.txt');
expect(fs.statSync).not.toHaveBeenCalled();
expect(fs.mkdirSync).not.toHaveBeenCalled();
expect(fs.copyFileSync).not.toHaveBeenCalled();
});
});
describe('rootResolve', () => {
it('should resolve a relative path to an absolute path', () => {
const result = rootResolve('src/index.js');
expect(result).toBe(path.join(process.cwd(), 'src/index.js'));
});
});
describe('originalRequire', () => {
it('should return the original require.resolve function', () => {
const originalRequireMock = jest.fn();
global.__non_webpack_require__ = originalRequireMock;
const result = originalRequire();
expect(result).toBe(originalRequireMock);
});
});
describe('resolve', () => {
it('should resolve a module path using the original require.resolve', () => {
const originalRequireMock = {
resolve: jest.fn().mockReturnValue('resolved/path'),
};
global.__non_webpack_require__ = originalRequireMock;
const result = resolve('my-module');
expect(result).toBe('resolved/path');
expect(originalRequireMock.resolve).toHaveBeenCalledWith('my-module');
});
});
describe('babelConfig', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('should return a Babel configuration object with specified presets and plugins', () => {
const result = babelConfig();
expect(result).toEqual({
presets: [[resolve('@babel/preset-env'), { targets: undefined }]],
plugins: [resolve('@babel/plugin-transform-runtime')],
});
});
it('should include the specified targets in the Babel configuration', () => {
const result = babelConfig({ targets: 'node 14' });
expect(result).toEqual({
presets: [[resolve('@babel/preset-env'), { targets: 'node 14' }]],
plugins: [resolve('@babel/plugin-transform-runtime')],
});
});
});
});

21
packages/cli/tsconfig.json

@ -0,0 +1,21 @@
{
"compilerOptions": {
"allowJs": true,
"noImplicitThis": true,
"moduleResolution": "node",
"noUnusedLocals": true,
"allowUnreachableCode": false,
"module": "commonjs",
"target": "es2016",
"outDir": "dist",
"esModuleInterop": true,
"declaration": true,
"noImplicitReturns": false,
"noImplicitAny": false,
"strictNullChecks": false,
"resolveJsonModule": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true
},
"include": ["src/cli.ts"]
}

62
packages/cli/webpack.cli.ts

@ -0,0 +1,62 @@
import webpack, { type Configuration } from 'webpack';
import NodeExternals from 'webpack-node-externals';
import CopyPlugin from 'copy-webpack-plugin';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import { resolve } from 'path';
const MODE = process.env.BUILD_MODE === 'production' ? 'production' : 'development';
const config: Configuration = {
context: process.cwd(),
mode: MODE,
entry: './src/cli.ts',
output: {
filename: 'cli.js',
path: resolve(__dirname, 'dist'),
},
target: 'node',
stats: {
preset: 'minimal',
warnings: false,
},
module: {
rules: [
{
test: /\.(jsx?|tsx?)$/,
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true,
presets: ['@babel/preset-typescript'],
assumptions: {
setPublicClassFields: false,
},
},
},
exclude: [/node_modules/],
},
],
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx', '.d.ts'],
},
plugins: [
new ForkTsCheckerWebpackPlugin(),
new webpack.BannerPlugin({ banner: '#!/usr/bin/env node', raw: true }),
new CopyPlugin({
patterns: [
{ from: 'src/banner.txt', to: 'banner.txt' },
{
from: 'src/template',
to: 'template',
// Terser skip this file for minimization
info: { minimized: true },
},
],
}),
],
externalsPresets: { node: true },
externals: [NodeExternals()],
};
export default config;

14
packages/core/package.json

@ -36,13 +36,12 @@
"codemirror": "5.63.0",
"codemirror-formatting": "1.0.0",
"html-entities": "~1.4.0",
"promise-polyfill": "8.1.3",
"promise-polyfill": "8.3.0",
"underscore": "1.13.1"
},
"devDependencies": {
"@types/markdown-it": "14.1.2",
"@types/pretty": "2.0.3",
"grapesjs-cli": "4.1.3",
"grapesjs-cli": "workspace:^",
"jest-environment-jsdom": "29.7.0",
"jsdom": "24.1.1",
"npm-run-all": "4.1.5",
@ -69,12 +68,13 @@
"scripts": {
"build": "npm run build-all",
"build-all": "run-s build:*",
"build:js": "grapesjs-cli build --patch=false --targets=\"> 1%, ie 11, safari 8, not dead\" --statsOutput=\"stats.json\" --localePath=\"src/i18n/locale\"",
"build:mjs": "BUILD_MODULE=true grapesjs-cli build --dts='skip' --patch=false --targets=\"> 1%, ie 11, safari 8, not dead\"",
"build:js": "node node_modules/grapesjs-cli/dist/cli.js build --patch=false --targets=\"> 1%, ie 11, safari 8, not dead\" --statsOutput=\"stats.json\" --localePath=\"src/i18n/locale\"",
"build:mjs": "cross-env BUILD_MODULE=true node node_modules/grapesjs-cli/dist/cli.js build --dts='skip' --patch=false --targets=\"> 1%, ie 11, safari 8, not dead\"",
"build:css": "sass src/styles/scss/main.scss dist/css/grapes.min.css --no-source-map --style=compressed --load-path=node_modules",
"ts:build": "grapesjs-cli build --dts='only' --patch=false",
"ts:build": "node node_modules/grapesjs-cli/dist/cli.js build --dts='only' --patch=false",
"ts:check": "tsc --noEmit --esModuleInterop dist/index.d.ts",
"start": "run-p start:*",
"start:js": "grapesjs-cli serve",
"start:js": "node node_modules/grapesjs-cli/dist/cli.js serve",
"start:css": "npm run build:css -- --watch",
"test": "jest --forceExit",
"test:dev": "jest --watch"

4
packages/core/src/asset_manager/config/config.ts

@ -141,7 +141,7 @@ export interface AssetManagerConfig {
dropzoneContent?: string;
}
const config: AssetManagerConfig = {
const config: () => AssetManagerConfig = () => ({
assets: [],
noAssets: '',
stylePrefix: 'am-',
@ -163,6 +163,6 @@ const config: AssetManagerConfig = {
dropzone: false,
openAssetsOnDrop: true,
dropzoneContent: '',
};
});
export default config;

4
packages/core/src/asset_manager/index.ts

@ -37,7 +37,7 @@ import { ItemManagerModule } from '../abstract/Module';
import { AddOptions, RemoveOptions } from '../common';
import EditorModel from '../editor/model/Editor';
import { ProjectData } from '../storage_manager';
import defaults, { AssetManagerConfig } from './config/config';
import defConfig, { AssetManagerConfig } from './config/config';
import Asset from './model/Asset';
import Assets from './model/Assets';
import AssetsEvents, { AssetOpenOptions } from './types';
@ -66,7 +66,7 @@ export default class AssetManager extends ItemManagerModule<AssetManagerConfig,
*/
constructor(em: EditorModel) {
// @ts-ignore
super(em, 'AssetManager', new Assets([], em), AssetsEvents, defaults);
super(em, 'AssetManager', new Assets([], em), AssetsEvents, defConfig());
const { all, config } = this;
// @ts-ignore
this.assetsVis = new Assets([]);

4
packages/core/src/block_manager/config/config.ts

@ -38,11 +38,11 @@ export interface BlockManagerConfig {
custom?: boolean;
}
const config: BlockManagerConfig = {
const config: () => BlockManagerConfig = () => ({
appendTo: '',
blocks: [],
appendOnClick: false,
custom: false,
};
});
export default config;

14
packages/core/src/block_manager/index.ts

@ -31,7 +31,7 @@ import { ItemManagerModule } from '../abstract/Module';
import FrameView from '../canvas/view/FrameView';
import Component from '../dom_components/model/Component';
import EditorModel from '../editor/model/Editor';
import defaults, { BlockManagerConfig } from './config/config';
import defConfig, { BlockManagerConfig } from './config/config';
import Block, { BlockProperties } from './model/Block';
import Blocks from './model/Blocks';
import Categories from '../abstract/ModuleCategories';
@ -61,7 +61,7 @@ export default class BlockManager extends ItemManagerModule<BlockManagerConfig,
storageKey = '';
constructor(em: EditorModel) {
super(em, 'BlockManager', new Blocks(em.config.blockManager?.blocks || [], { em }), BlocksEvents, defaults);
super(em, 'BlockManager', new Blocks(em.config.blockManager?.blocks || [], { em }), BlocksEvents, defConfig());
// Global blocks collection
this.blocks = this.all;
@ -108,7 +108,13 @@ export default class BlockManager extends ItemManagerModule<BlockManagerConfig,
const { em, events, blocks } = this;
const content = block.getContent ? block.getContent() : block;
this._dragBlock = block;
em.set({ dragResult: null, dragContent: content });
em.set({
dragResult: null,
dragSource: {
content,
dragDef: block.getDragDef(),
},
});
[em, blocks].map((i) => i.trigger(events.dragStart, block, ev));
}
@ -145,7 +151,7 @@ export default class BlockManager extends ItemManagerModule<BlockManagerConfig,
}
}
em.set({ dragResult: null, dragContent: null });
em.set({ dragResult: null, dragSource: undefined });
if (block) {
[em, blocks].map((i) => i.trigger(events.dragEnd, cmp, block));

17
packages/core/src/block_manager/model/Block.ts

@ -2,19 +2,15 @@ import { Model } from '../../common';
import { isFunction } from 'underscore';
import Editor from '../../editor';
import Category, { CategoryProperties } from '../../abstract/ModuleCategory';
import { ComponentDefinition } from '../../dom_components/model/types';
import Blocks from './Blocks';
import { DraggableContent } from '../../utils/sorter/types';
/** @private */
export interface BlockProperties {
export interface BlockProperties extends DraggableContent {
/**
* Block label, eg. `My block`
*/
label: string;
/**
* The content of the block. Might be an HTML string or a [Component Defintion](/modules/Components.html#component-definition)
*/
content: string | ComponentDefinition | (string | ComponentDefinition)[];
/**
* HTML string for the media/icon of the block, eg. `<svg ...`, `<img ...`, etc.
* @default ''
@ -91,6 +87,7 @@ export default class Block extends Model<BlockProperties> {
disable: false,
onClick: undefined,
attributes: {},
dragDef: {},
};
}
@ -135,6 +132,14 @@ export default class Block extends Model<BlockProperties> {
return this.get('content');
}
/**
* Get block component dragDef
* @returns {ComponentDefinition}
*/
getDragDef() {
return this.get('dragDef');
}
/**
* Get block category label
* @returns {String}

9
packages/core/src/block_manager/view/BlockView.ts

@ -102,7 +102,7 @@ export default class BlockView extends View<Block> {
sorter.__currentBlock = model;
sorter.setDragHelper(this.el, e);
sorter.setDropContent(this.model.get('content'));
sorter.startSort(this.el);
sorter.startSort([this.el]);
on(document, 'mouseup', this.endDrag);
}
@ -126,12 +126,7 @@ export default class BlockView extends View<Block> {
off(document, 'mouseup', this.endDrag);
const sorter = this.config.getSorter();
// After dropping the block in the canvas the mouseup event is not yet
// triggerd on 'this.doc' and so clicking outside, the sorter, tries to move
// things (throws false positives). As this method just need to drop away
// the block helper I use the trick of 'moved = 0' to void those errors.
sorter.moved = 0;
sorter.endMove();
sorter.cancelDrag();
}
render() {

4
packages/core/src/canvas/config/config.ts

@ -91,7 +91,7 @@ export interface CanvasConfig {
infiniteCanvas?: boolean;
}
const config: CanvasConfig = {
const config: () => CanvasConfig = () => ({
stylePrefix: 'cv-',
scripts: [],
styles: [],
@ -107,6 +107,6 @@ const config: CanvasConfig = {
`,
notTextable: ['button', 'a', 'input[type=checkbox]', 'input[type=radio]'],
allowExternalDrop: true,
};
});
export default config;

25
packages/core/src/canvas/index.ts

@ -35,7 +35,7 @@ import Component from '../dom_components/model/Component';
import ComponentView from '../dom_components/view/ComponentView';
import EditorModel from '../editor/model/Editor';
import { getElement, getViewEl } from '../utils/mixins';
import defaults, { CanvasConfig } from './config/config';
import defConfig, { CanvasConfig } from './config/config';
import Canvas from './model/Canvas';
import CanvasSpot, { CanvasSpotBuiltInTypes, CanvasSpotProps } from './model/CanvasSpot';
import CanvasSpots from './model/CanvasSpots';
@ -43,6 +43,7 @@ import Frame from './model/Frame';
import { CanvasEvents, CanvasRefreshOptions, ToWorldOption } from './types';
import CanvasView, { FitViewportOptions } from './view/CanvasView';
import FrameView from './view/FrameView';
import { DragSource } from '../utils/sorter/types';
export type CanvasEvent = `${CanvasEvents}`;
@ -75,7 +76,7 @@ export default class CanvasModule extends Module<CanvasConfig> {
* @private
*/
constructor(em: EditorModel) {
super(em, 'Canvas', defaults);
super(em, 'Canvas', defConfig());
this.canvas = new Canvas(this);
this.spots = new CanvasSpots(this);
@ -498,7 +499,7 @@ export default class CanvasModule extends Module<CanvasConfig> {
* @return {Object}
* @private
*/
getMouseRelativeCanvas(ev: MouseEvent, opts: any) {
getMouseRelativeCanvas(ev: MouseEvent | { clientX: number; clientY: number }, opts: any) {
const zoom = this.getZoomDecimal();
const { top = 0, left = 0 } = this.getCanvasView().getPosition(opts) ?? {};
@ -508,6 +509,24 @@ export default class CanvasModule extends Module<CanvasConfig> {
};
}
/**
* Sets the drag source in the editor so it's used in Droppable.ts.
* This method can be used for custom drag-and-drop content by passing in a `DragSource` object.
*
* @param {DragSource<Component>} dragSource - The source object for the drag operation, containing the component being dragged.
*/
startDrag(dragSource: DragSource<Component>) {
this.em.set('dragSource', dragSource);
}
/**
* Ends the drag-and-drop process, resetting the drag source and clearing any drag results.
* This method can be used to finalize custom drag-and-drop content operations.
*/
endDrag() {
this.em.set({ dragResult: null, dragSource: undefined });
}
/**
* Check if the canvas is focused
* @returns {Boolean}

3
packages/core/src/canvas/view/CanvasView.ts

@ -589,7 +589,8 @@ export default class CanvasView extends ModuleView<Canvas> {
setTimeout(function() {
var item = document.getElementById('${id}');
if (!item) return;
(${scriptFnStr}.bind(item))(${scriptProps})
var script = (${scriptFnStr}).bind(item);
script(${scriptProps});
}, 1);`;
// #873
// Adding setTimeout will make js components work on init of the editor

4
packages/core/src/code_manager/config/config.ts

@ -12,9 +12,9 @@ export interface CodeManagerConfig {
optsCodeViewer?: Record<string, any>;
}
const config: CodeManagerConfig = {
const config: () => CodeManagerConfig = () => ({
stylePrefix: 'cm-',
optsCodeViewer: {},
};
});
export default config;

4
packages/core/src/code_manager/index.ts

@ -18,7 +18,7 @@
* @module CodeManager
*/
import { isUndefined } from 'underscore';
import defaults, { CodeManagerConfig } from './config/config';
import defConfig, { CodeManagerConfig } from './config/config';
import gHtml from './model/HtmlGenerator';
import gCss from './model/CssGenerator';
import gJson from './model/JsonGenerator';
@ -39,7 +39,7 @@ export default class CodeManagerModule extends Module<CodeManagerConfig & { pSty
EditorView = CodeEditorView;
constructor(em: EditorModel) {
super(em, 'CodeManager', defaults);
super(em, 'CodeManager', defConfig());
const { config } = this;
const ppfx = config.pStylePrefix;
if (ppfx) config.stylePrefix = ppfx + config.stylePrefix;

4
packages/core/src/commands/config/config.ts

@ -21,10 +21,10 @@ export interface CommandsConfig {
strict?: boolean;
}
const config: CommandsConfig = {
const config: () => CommandsConfig = () => ({
stylePrefix: 'com-',
defaults: {},
strict: true,
};
});
export default config;

14
packages/core/src/commands/index.ts

@ -37,7 +37,7 @@
import { isFunction, includes } from 'underscore';
import CommandAbstract, { Command, CommandOptions, CommandObject, CommandFunction } from './view/CommandAbstract';
import defaults, { CommandsConfig } from './config/config';
import defConfig, { CommandsConfig } from './config/config';
import { Module } from '../abstract';
import Component, { eventDrag } from '../dom_components/model/Component';
import Editor from '../editor/model/Editor';
@ -82,9 +82,11 @@ export const getOnComponentDrag = (em: Editor) => (data: any) => em.trigger(even
export const getOnComponentDragEnd =
(em: Editor, targets: Component[], opts: { altMode?: boolean } = {}) =>
(a: any, b: any, data: any) => {
targets.forEach((trg) => trg.set('status', trg.get('selectable') ? 'selected' : ''));
em.setSelected(targets);
targets[0].emitUpdate();
setTimeout(() => {
targets.forEach((trg) => trg.set('status', trg.get('selectable') ? 'selected' : ''));
em.setSelected(targets);
targets[0].emitUpdate();
});
em.trigger(`${eventDrag}:end`, data);
// Defer selectComponent in order to prevent canvas "freeze" #2692
@ -105,7 +107,7 @@ export default class CommandsModule extends Module<CommandsConfig & { pStylePref
* @private
*/
constructor(em: Editor) {
super(em, 'Commands', defaults);
super(em, 'Commands', defConfig());
const { config } = this;
const ppfx = config.pStylePrefix;
const { defaultCommands } = this;
@ -139,7 +141,7 @@ export default class CommandsModule extends Module<CommandsConfig & { pStylePref
const em = ed.getModel();
const { event } = opts;
const trg = opts.target as Component | undefined;
const trgs = trg ? [trg] : [...ed.getSelectedAll()];
const trgs = Array.isArray(trg) ? trg : trg ? [trg] : [...ed.getSelectedAll()];
const targets = trgs.map((trg) => trg.delegate?.move?.(trg) || trg).filter(Boolean);
const target = targets[targets.length - 1] as Component | undefined;
const nativeDrag = event?.type === 'dragstart';

16
packages/core/src/commands/view/MoveComponent.ts

@ -48,7 +48,7 @@ export default extend({}, SelectPosition, SelectComponent, {
this.cacheEl = null;
this.startSelectPosition(e.target, this.frameEl.contentDocument);
this.sorter.draggable = drag;
this.sorter.onEndMove = this.onEndMove.bind(this);
this.sorter.eventHandlers.legacyOnEndMove = this.onEndMove.bind(this);
this.stopSelectComponent();
this.$wrapper.off('mousedown', this.initSorter);
on(this.getContentWindow(), 'keydown', this.rollback);
@ -68,7 +68,7 @@ export default extend({}, SelectPosition, SelectComponent, {
var el = model.view.el;
this.startSelectPosition(el, this.frameEl.contentDocument);
this.sorter.draggable = drag;
this.sorter.onEndMove = this.onEndMoveFromModel.bind(this);
this.sorter.eventHandlers.legacyOnEndMove = this.onEndMoveFromModel.bind(this);
/*
this.sorter.setDragHelper(el);
@ -95,11 +95,10 @@ export default extend({}, SelectPosition, SelectComponent, {
const frameView = this.em.getCurrentFrame();
const el = lastModel.getEl(frameView?.model)!;
const doc = el.ownerDocument;
this.startSelectPosition(el, doc, { onStart: this.onStart });
this.sorter.draggable = lastModel.get('draggable');
this.sorter.toMove = models;
this.sorter.onMoveClb = this.onDrag;
this.sorter.onEndMove = this.onEndMoveFromModel.bind(this);
const elements = models.map((model) => model?.view?.el);
this.startSelectPosition(elements, doc, { onStart: this.onStart });
this.sorter.eventHandlers.legacyOnMoveClb = this.onDrag;
this.sorter.eventHandlers.legacyOnEndMove = this.onEndMoveFromModel.bind(this);
this.stopSelectComponent();
on(this.getContentWindow(), 'keydown', this.rollback);
},
@ -134,8 +133,7 @@ export default extend({}, SelectPosition, SelectComponent, {
rollback(e: any, force: boolean) {
var key = e.which || e.keyCode;
if (key == 27 || force) {
this.sorter.moved = false;
this.sorter.endMove();
this.sorter.cancelDrag();
}
return;
},

51
packages/core/src/commands/view/SelectPosition.ts

@ -1,36 +1,44 @@
import { $ } from '../../common';
import CanvasComponentNode from '../../utils/sorter/CanvasComponentNode';
import { DragDirection } from '../../utils/sorter/types';
import { CommandObject } from './CommandAbstract';
export default {
/**
* Start select position event
* @param {HTMLElement} trg
* @param {HTMLElement[]} sourceElements
* @private
* */
startSelectPosition(trg: HTMLElement, doc: Document, opts: any = {}) {
startSelectPosition(sourceElements: HTMLElement[], doc: Document, opts: any = {}) {
this.isPointed = false;
const utils = this.em.Utils;
const container = trg.ownerDocument.body;
const container = sourceElements[0].ownerDocument.body;
if (utils && !this.sorter)
this.sorter = new utils.Sorter({
// @ts-ignore
container,
placer: this.canvas.getPlacerEl(),
containerSel: '*',
itemSel: '*',
pfx: this.ppfx,
direction: 'a',
document: doc,
wmargin: 1,
nested: 1,
if (utils)
this.sorter = new utils.ComponentSorter({
em: this.em,
canvasRelative: 1,
scale: () => this.em.getZoomDecimal(),
treeClass: CanvasComponentNode,
containerContext: {
container,
containerSel: '*',
itemSel: '*',
pfx: this.ppfx,
document: doc,
placeholderElement: this.canvas.getPlacerEl()!,
},
positionOptions: {
windowMargin: 1,
canvasRelative: true,
},
dragBehavior: {
dragDirection: DragDirection.BothDirections,
nested: true,
},
});
if (opts.onStart) this.sorter.onStart = opts.onStart;
trg && this.sorter.startSort(trg, { container });
if (opts.onStart) this.sorter.eventHandlers.legacyOnStartSort = opts.onStart;
sourceElements &&
sourceElements.length > 0 &&
this.sorter.startSort(sourceElements.map((element) => ({ element })));
},
/**
@ -54,8 +62,7 @@ export default {
this.posTargetCollection = null;
this.posIndex = this.posMethod == 'after' && this.cDim.length !== 0 ? this.posIndex + 1 : this.posIndex; //Normalize
if (this.sorter) {
this.sorter.moved = 0;
this.sorter.endMove();
this.sorter.cancelDrag();
}
if (this.cDim) {
this.posIsLastEl = this.cDim.length !== 0 && this.posMethod == 'after' && this.posIndex == this.cDim.length;

4
packages/core/src/common/index.ts

@ -14,9 +14,11 @@ export type DisableOptions = { fromMove?: boolean };
export type LocaleOptions = { locale?: boolean };
export type UndoOptions = { fromUndo?: boolean };
export type WithHTMLParserOptions = { parserOptions?: HTMLParserOptions };
export type RemoveOptions = Backbone.Silenceable;
export type RemoveOptions = Backbone.Silenceable & UndoOptions & { dangerously?: boolean };
export type EventHandler = Backbone.EventHandler;

4
packages/core/src/css_composer/config/config.ts

@ -11,9 +11,9 @@ export interface CssComposerConfig {
rules?: Array<string>; // TODO
}
const config: CssComposerConfig = {
const config: () => CssComposerConfig = () => ({
stylePrefix: 'css-',
rules: [],
};
});
export default config;

7
packages/core/src/css_composer/index.ts

@ -31,7 +31,7 @@ import { isArray, isString, isUndefined } from 'underscore';
import { isObject } from '../utils/mixins';
import Selectors from '../selector_manager/model/Selectors';
import Selector from '../selector_manager/model/Selector';
import defaults, { CssComposerConfig } from './config/config';
import defConfig, { CssComposerConfig } from './config/config';
import CssRule, { CssRuleJSON, CssRuleProperties } from './model/CssRule';
import CssRules from './model/CssRules';
import CssRulesView from './view/CssRulesView';
@ -85,7 +85,7 @@ export default class CssComposer extends ItemManagerModule<CssComposerConfig & {
* @private
*/
constructor(em: EditorModel) {
super(em, 'CssComposer', null, {}, defaults);
super(em, 'CssComposer', null, {}, defConfig());
const { config } = this;
const ppfx = config.pStylePrefix;
@ -111,8 +111,7 @@ export default class CssComposer extends ItemManagerModule<CssComposerConfig & {
* @private
*/
postLoad() {
const um = this.em?.get('UndoManager');
um && um.add(this.getAll());
this.em.UndoManager.add(this.getAll());
}
store() {

7
packages/core/src/css_composer/model/CssRule.ts

@ -1,11 +1,13 @@
import { isEmpty, forEach, isString, isArray } from 'underscore';
import { Model, ObjectAny } from '../../common';
import { Model, ObjectAny, View } from '../../common';
import StyleableModel from '../../domain_abstract/model/StyleableModel';
import Selectors from '../../selector_manager/model/Selectors';
import { getMediaLength } from '../../code_manager/model/CssGenerator';
import { isEmptyObj, hasWin } from '../../utils/mixins';
import Selector, { SelectorProps } from '../../selector_manager/model/Selector';
import EditorModel from '../../editor/model/Editor';
import CssRuleView from '../view/CssRuleView';
import DataVariableListenerManager from '../../data_sources/model/DataVariableListenerManager';
/** @private */
export interface CssRuleProperties {
@ -92,6 +94,8 @@ export default class CssRule extends StyleableModel<CssRuleProperties> {
config: CssRuleProperties;
em?: EditorModel;
opt: any;
views: CssRuleView[] = [];
dataVariableListeners: Record<string, DataVariableListenerManager> = {};
defaults() {
return {
@ -117,6 +121,7 @@ export default class CssRule extends StyleableModel<CssRuleProperties> {
this.em = opt.em;
this.ensureSelectors(null, null, {});
this.on('change', this.__onChange);
this.setStyle(this.get('style'));
}
__onChange(m: CssRule, opts: any) {

18
packages/core/src/css_composer/view/CssRuleView.ts

@ -1,3 +1,4 @@
import FrameView from '../../canvas/view/FrameView';
import { View } from '../../common';
import CssRule from '../model/CssRule';
@ -11,9 +12,24 @@ export default class CssRuleView extends View<CssRule> {
this.listenTo(model, 'change', this.render);
this.listenTo(model, 'destroy remove', this.remove);
this.listenTo(model.get('selectors'), 'change', this.render);
model.setView(this);
}
// @ts-ignore
get frameView(): FrameView {
return this.config.frameView;
}
remove() {
super.remove();
this.model.removeView(this);
return this;
}
updateStyles() {
this.render();
}
/** @ts-ignore */
tagName() {
return 'style';
}

32
packages/core/src/data_sources/index.ts

@ -46,7 +46,7 @@ import { DataSourcesEvents, DataSourceProps } from './types';
import { Events } from 'backbone';
export default class DataSourceManager extends ItemManagerModule<ModuleConfig, DataSources> {
storageKey = '';
storageKey = 'dataSources';
events = DataSourcesEvents;
destroy(): void {}
@ -147,4 +147,34 @@ export default class DataSourceManager extends ItemManagerModule<ModuleConfig, D
return result;
}
/**
* Store data sources to a JSON object.
* @returns {Array} Stored data sources.
*/
store() {
const data: any[] = [];
this.all.forEach((dataSource) => {
const skipFromStorage = dataSource.get('skipFromStorage');
if (!skipFromStorage) {
data.push({
id: dataSource.id,
name: dataSource.get('name' as any),
records: dataSource.records.toJSON(),
skipFromStorage,
});
}
});
return { [this.storageKey]: data };
}
/**
* Load data sources from a JSON object.
* @param {Object} data The data object containing data sources.
* @returns {Object} Loaded data sources.
*/
load(data: any) {
return this.loadProjectData(data);
}
}

7
packages/core/src/data_sources/model/DataRecord.ts

@ -32,8 +32,11 @@ import EditorModel from '../../editor/model/Editor';
import { _StringKey } from 'backbone';
export default class DataRecord<T extends DataRecordProps = DataRecordProps> extends Model<T> {
public mutable: boolean;
constructor(props: T, opts = {}) {
super(props, opts);
this.mutable = props.mutable ?? true;
this.on('change', this.handleChange);
}
@ -137,6 +140,10 @@ export default class DataRecord<T extends DataRecordProps = DataRecordProps> ext
options?: SetOptions | undefined,
): this;
set(attributeName: unknown, value?: unknown, options?: SetOptions): DataRecord {
if (!this.isNew() && this.attributes.mutable === false) {
throw new Error('Cannot modify immutable record');
}
const onRecordSetValue = this.dataSource?.transformers?.onRecordSetValue;
const applySet = (key: string, val: unknown) => {

5
packages/core/src/data_sources/model/DataSource.ts

@ -152,6 +152,11 @@ export default class DataSource extends Model<DataSourceProps> {
* @name removeRecord
*/
removeRecord(id: string | number, opts?: RemoveOptions): DataRecord | undefined {
const record = this.getRecord(id);
if (record?.mutable === false && !opts?.dangerously) {
throw new Error('Cannot remove immutable record');
}
return this.records.remove(id, opts);
}

24
packages/core/src/data_sources/model/DataVariableListenerManager.ts

@ -29,14 +29,18 @@ export default class DataVariableListenerManager {
this.listenToDataVariable();
}
private onChange = () => {
const value = this.dataVariable.getDataValue();
this.updateValueFromDataVariable(value);
};
listenToDataVariable() {
const { em, dataVariable, model, updateValueFromDataVariable } = this;
const { em, dataVariable, model } = this;
const { path } = dataVariable.attributes;
const normPath = stringToPath(path || '').join('.');
const prevListeners = this.dataListeners || [];
const [ds, dr] = this.em.DataSources.fromPath(path);
prevListeners.forEach((ls) => model.stopListening(ls.obj, ls.event, updateValueFromDataVariable));
this.removeListeners();
const dataListeners: DataVariableListener[] = [];
ds && dataListeners.push({ obj: ds.records, event: 'add remove reset' });
@ -47,14 +51,14 @@ export default class DataVariableListenerManager {
{ obj: em, event: `${DataSourcesEvents.path}:${normPath}` },
);
dataListeners.forEach((ls) =>
model.listenTo(ls.obj, ls.event, () => {
const value = dataVariable.getDataValue();
updateValueFromDataVariable(value);
}),
);
dataListeners.forEach((ls) => model.listenTo(ls.obj, ls.event, this.onChange));
this.dataListeners = dataListeners;
}
private removeListeners() {
const { model } = this;
this.dataListeners.forEach((ls) => model.stopListening(ls.obj, ls.event, this.onChange));
this.dataListeners = [];
}
}

11
packages/core/src/data_sources/types.ts

@ -7,6 +7,11 @@ export interface DataRecordProps extends ObjectAny {
* Record id.
*/
id: string;
/**
* Specifies if the record is mutable. Defaults to `true`.
*/
mutable?: boolean;
}
export interface DataVariableListener {
@ -28,8 +33,12 @@ export interface DataSourceProps {
/**
* DataSource validation and transformation factories.
*/
transformers?: DataSourceTransformers;
/**
* If true will store the data source in the GrapesJS project.json file.
*/
skipFromStorage?: boolean;
}
export interface DataSourceTransformers {

4
packages/core/src/device_manager/config/config.ts

@ -25,7 +25,7 @@ export interface DeviceManagerConfig {
devices?: DeviceProperties[];
}
const config: DeviceManagerConfig = {
const config: () => DeviceManagerConfig = () => ({
default: '',
devices: [
{
@ -52,6 +52,6 @@ const config: DeviceManagerConfig = {
widthMedia: '480px',
},
],
};
});
export default config;

4
packages/core/src/device_manager/index.ts

@ -35,7 +35,7 @@
import { isString } from 'underscore';
import { ItemManagerModule } from '../abstract/Module';
import EditorModel from '../editor/model/Editor';
import defaults, { DeviceManagerConfig } from './config/config';
import defConfig, { DeviceManagerConfig } from './config/config';
import Device, { DeviceProperties } from './model/Device';
import Devices from './model/Devices';
import DevicesView from './view/DevicesView';
@ -74,7 +74,7 @@ export default class DeviceManager extends ItemManagerModule<
storageKey = '';
constructor(em: EditorModel) {
super(em, 'DeviceManager', new Devices(), deviceEvents, defaults);
super(em, 'DeviceManager', new Devices(), deviceEvents, defConfig());
this.devices = this.all;
this.config.devices?.forEach((device) => this.add(device, { silent: true }));
this.select(this.config.default || this.devices.at(0));

6
packages/core/src/dom_components/config/config.ts

@ -62,7 +62,7 @@ export interface DomComponentsConfig {
useFrameDoc?: boolean;
}
export default {
const config: () => DomComponentsConfig = () => ({
stylePrefix: 'comp-',
components: [],
draggableComponents: true,
@ -87,4 +87,6 @@ export default {
'track',
'wbr',
],
} as DomComponentsConfig;
});
export default config;

26
packages/core/src/dom_components/index.ts

@ -55,10 +55,10 @@
*/
import { debounce, isArray, isBoolean, isEmpty, isFunction, isString, isSymbol, result } from 'underscore';
import { ItemManagerModule } from '../abstract/Module';
import { AddOptions, ObjectAny } from '../common';
import { ObjectAny } from '../common';
import EditorModel from '../editor/model/Editor';
import { isComponent } from '../utils/mixins';
import defaults, { DomComponentsConfig } from './config/config';
import defConfig, { DomComponentsConfig } from './config/config';
import Component, { IComponent, keyUpdate, keyUpdateInside } from './model/Component';
import ComponentComment from './model/ComponentComment';
import ComponentFrame from './model/ComponentFrame';
@ -116,6 +116,8 @@ import {
isSymbolInstance,
detachSymbolInstance,
isSymbolRoot,
isSymbol as isSymbolComponent,
getSymbolTop,
} from './model/SymbolUtils';
import { ComponentsEvents, SymbolInfo } from './types';
import Symbols from './model/Symbols';
@ -332,7 +334,7 @@ export default class ComponentManager extends ItemManagerModule<DomComponentsCon
* @private
*/
constructor(em: EditorModel) {
super(em, 'DomComponents', new Components(undefined, { em }));
super(em, 'DomComponents', new Components(undefined, { em }), ComponentsEvents, defConfig());
const { config } = this;
this.symbols = new Symbols([], { em, config, domc: this });
@ -341,11 +343,6 @@ export default class ComponentManager extends ItemManagerModule<DomComponentsCon
this.config.components = em.config.components || this.config.components;
}
for (let name in defaults) {
//@ts-ignore
if (!(name in this.config)) this.config[name] = defaults[name];
}
const ppfx = this.config.pStylePrefix;
if (ppfx) this.config.stylePrefix = ppfx + this.config.stylePrefix;
@ -840,9 +837,20 @@ export default class ComponentManager extends ItemManagerModule<DomComponentsCon
target,
source: null,
};
if (!source || !target) return result;
// Check if the target and source belong to the same root symbol
if (isSymbolComponent(target) && source instanceof Component && isSymbolComponent(source)) {
const targetRootSymbol = getSymbolTop(target);
const targetMain = isSymbolMain(targetRootSymbol) ? targetRootSymbol : getSymbolMain(targetRootSymbol);
const sourceRootSymbol = getSymbolTop(source as Component);
const sourceMain = isSymbolMain(sourceRootSymbol) ? sourceRootSymbol : getSymbolMain(sourceRootSymbol);
const sameRoot = targetMain === sourceMain;
const differentInstance = targetRootSymbol !== sourceRootSymbol;
if (sameRoot && differentInstance) return { ...result, reason: CanMoveReason.TargetReject };
}
let srcModel = isComponent(source) ? source : null;
if (!srcModel) {

75
packages/core/src/dom_components/model/Component.ts

@ -20,13 +20,13 @@ import Selectors from '../../selector_manager/model/Selectors';
import Traits from '../../trait_manager/model/Traits';
import EditorModel from '../../editor/model/Editor';
import {
AddComponentsOption,
ComponentAdd,
ComponentDefinition,
ComponentDefinitionDefined,
ComponentOptions,
ComponentProperties,
DragMode,
ResetComponentsOptions,
SymbolToUpOptions,
ToHTMLOptions,
} from './types';
@ -213,6 +213,16 @@ export default class Component extends StyleableModel<ComponentProperties> {
return this.get('locked');
}
get frame() {
return this.opt.frame;
}
get page() {
return this.frame?.getPage();
}
preInit() {}
/**
* Hook method, called once the model is created
*/
@ -238,7 +248,6 @@ export default class Component extends StyleableModel<ComponentProperties> {
views!: ComponentView[];
view?: ComponentView;
viewLayer?: ItemView;
frame?: Frame;
rule?: CssRule;
prevColl?: Components;
__hasUm?: boolean;
@ -275,13 +284,13 @@ export default class Component extends StyleableModel<ComponentProperties> {
opt.em = em;
this.opt = opt;
this.em = em!;
this.frame = opt.frame;
this.config = opt.config || {};
this.set('attributes', {
...(result(this, 'defaults').attributes || {}),
...(this.get('attributes') || {}),
});
this.ccid = Component.createId(this, opt);
this.preInit();
this.initClasses();
this.initComponents();
this.initTraits();
@ -999,7 +1008,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
*/
components<T extends ComponentAdd | undefined>(
components?: T,
opts: AddComponentsOption = {},
opts: ResetComponentsOptions = {},
): undefined extends T ? Components : Component[] {
const coll = this.get('components')!;
@ -1428,19 +1437,43 @@ export default class Component extends StyleableModel<ComponentProperties> {
* // -> <span title="Custom attribute"></span>
*/
toHTML(opts: ToHTMLOptions = {}): string {
const model = this;
const attrs = [];
const customTag = opts.tag;
const tag = customTag || model.get('tagName');
const sTag = model.get('void');
const tag = customTag || this.get('tagName');
delete opts.tag;
const attr = this.__attrToString(opts);
const attrString = attr ? ` ${attr}` : '';
const inner = this.getInnerHTML(opts);
const skipEndTag = !inner && this.get('void');
let code = `<${tag}${attrString}${skipEndTag ? '/' : ''}>${inner}`;
!skipEndTag && (code += `</${tag}>`);
return code;
}
/**
* Get inner HTML of the component
* @param {Object} [opts={}] Same options of `toHTML`
* @returns {String} HTML string
*/
getInnerHTML(opts?: ToHTMLOptions) {
return this.__innerHTML(opts);
}
__innerHTML(opts: ToHTMLOptions = {}) {
const cmps = this.components();
return !cmps.length ? this.content : cmps.map((c) => c.toHTML(opts)).join('');
}
__attrToString(opts: ToHTMLOptions = {}) {
const attrs = [];
const customAttr = opts.attributes;
let attributes = this.getAttrToHTML(opts);
delete opts.tag;
// Get custom attributes if requested
if (customAttr) {
if (isFunction(customAttr)) {
attributes = customAttr(model, attributes) || {};
attributes = customAttr(this, attributes) || {};
} else if (isObject(customAttr)) {
attributes = customAttr;
}
@ -1448,7 +1481,6 @@ export default class Component extends StyleableModel<ComponentProperties> {
if (opts.withProps) {
const props = this.toJSON();
forEach(props, (value, key) => {
const skipProps = ['classes', 'attributes', 'components'];
if (key[0] !== '_' && skipProps.indexOf(key) < 0) {
@ -1478,26 +1510,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
}
}
const attrString = attrs.length ? ` ${attrs.join(' ')}` : '';
const inner = model.getInnerHTML(opts);
let code = `<${tag}${attrString}${sTag ? '/' : ''}>${inner}`;
!sTag && (code += `</${tag}>`);
return code;
}
/**
* Get inner HTML of the component
* @param {Object} [opts={}] Same options of `toHTML`
* @returns {String} HTML string
*/
getInnerHTML(opts?: ToHTMLOptions) {
return this.__innerHTML(opts);
}
__innerHTML(opts: ToHTMLOptions = {}) {
const cmps = this.components();
return !cmps.length ? this.content : cmps.map((c) => c.toHTML(opts)).join('');
return attrs.join(' ');
}
/**

13
packages/core/src/dom_components/model/ComponentWrapper.ts

@ -30,19 +30,16 @@ export default class ComponentWrapper extends Component {
};
}
constructor(...args: ConstructorParameters<typeof Component>) {
super(...args);
const props = args[0] || {};
const opts = args[1];
const cmp = opts?.em?.Components;
preInit() {
const { opt, attributes: props } = this;
const cmp = this.em?.Components;
const CmpHead = cmp?.getType(typeHead)?.model;
const CmpDef = cmp?.getType('default').model;
if (CmpHead) {
this.set(
{
head: new CmpHead({ ...props.head }, opts),
docEl: new CmpDef({ tagName: 'html', ...props.docEl }, opts),
head: new CmpHead({ ...props.head }, opt),
docEl: new CmpDef({ tagName: 'html', ...props.docEl }, opt),
},
{ silent: true },
);

5
packages/core/src/dom_components/model/Components.ts

@ -254,12 +254,13 @@ Component> {
parseString(value: string, opt: ParseStringOptions = {}) {
const { em, domc, parent } = this;
const asDocument = opt.asDocument && parent?.is('wrapper');
const isWrapper = parent?.is('wrapper');
const asDocument = opt.asDocument && isWrapper;
const cssc = em.Css;
const parsed = em.Parser.parseHtml(value, { asDocument, ...opt.parserOptions });
let components = parsed.html;
if (asDocument) {
if (isWrapper && parsed.doctype) {
const root = parent as ComponentWrapper;
const { components: bodyCmps, ...restBody } = (parsed.html as ComponentDefinitionDefined) || {};
const { components: headCmps, ...restHead } = parsed.head || {};

6
packages/core/src/dom_components/model/types.ts

@ -19,6 +19,12 @@ export type DraggableDroppableFn = (source: Component, target: Component, index?
export interface AddComponentsOption extends AddOptions, OptionAsDocument {}
export interface ResetComponentsOptions extends AddComponentsOption {
previousModels?: Component[];
keepIds?: string[];
skipDomReset?: boolean;
}
interface ComponentWithCheck<C extends Component> {
new (props: any, opt: ComponentOptions): C;
isComponent(node: HTMLElement, opts?: ParseNodeOptions): ComponentDefinitionDefined | undefined | boolean;

8
packages/core/src/dom_components/view/ComponentView.ts

@ -163,8 +163,10 @@ TComp> {
if (!this.__isDraggable()) return false;
event.stopPropagation();
event.preventDefault();
const selected = this.em.getSelectedAll();
const modelsToMove = selected.includes(this.model) ? selected : [this.model];
this.em.Commands.run('tlb-move', {
target: this.model,
target: modelsToMove,
event,
});
}
@ -302,6 +304,10 @@ TComp> {
}
}
updateStyles() {
this.updateStyle();
}
/**
* Update classe attribute
* @private

10
packages/core/src/dom_components/view/ComponentsView.ts

@ -7,6 +7,7 @@ import Component from '../model/Component';
import ComponentView from './ComponentView';
import FrameView from '../../canvas/view/FrameView';
import Components from '../model/Components';
import { ResetComponentsOptions } from '../model/types';
export default class ComponentsView extends View {
opts!: any;
@ -124,9 +125,12 @@ export default class ComponentsView extends View {
return rendered;
}
resetChildren(models: Components, { previousModels = [] } = {}) {
this.parentEl!.innerHTML = '';
previousModels.forEach((md) => this.removeChildren(md, this.collection));
resetChildren(models: Components, opts: ResetComponentsOptions = {}) {
const { previousModels } = opts;
if (!opts.skipDomReset) {
this.parentEl!.innerHTML = '';
}
previousModels?.forEach((md) => this.removeChildren(md, this.collection));
models.each((model) => this.addToCollection(model));
}

34
packages/core/src/domain_abstract/model/StyleableModel.ts

@ -1,5 +1,5 @@
import { isArray, isString, keys } from 'underscore';
import { Model, ObjectAny, ObjectHash, SetOptions } from '../../common';
import { Model, ObjectAny, ObjectHash, SetOptions, View } from '../../common';
import ParserHtml from '../../parser/model/ParserHtml';
import Selectors from '../../selector_manager/model/Selectors';
import { shallowDiff } from '../../utils/mixins';
@ -7,6 +7,9 @@ import EditorModel from '../../editor/model/Editor';
import StyleDataVariable from '../../data_sources/model/StyleDataVariable';
import { DataVariableType } from '../../data_sources/model/DataVariable';
import DataVariableListenerManager from '../../data_sources/model/DataVariableListenerManager';
import CssRuleView from '../../css_composer/view/CssRuleView';
import ComponentView from '../../dom_components/view/ComponentView';
import Frame from '../../canvas/model/Frame';
export type StyleProps = Record<
string,
@ -26,6 +29,8 @@ export type UpdateStyleOptions = SetOptions & {
noEvent?: boolean;
};
export type StyleableView = ComponentView | CssRuleView;
const parserHtml = ParserHtml();
export const getLastStyleValue = (value: string | string[]) => {
@ -35,6 +40,12 @@ export const getLastStyleValue = (value: string | string[]) => {
export default class StyleableModel<T extends ObjectHash = any> extends Model<T> {
em?: EditorModel;
dataVariableListeners: Record<string, DataVariableListenerManager> = {};
views: StyleableView[] = [];
constructor(attributes: T, options: { em?: EditorModel } = {}) {
super(attributes, options);
this.em = options.em;
}
/**
* Parse style string to an object
@ -153,6 +164,27 @@ export default class StyleableModel<T extends ObjectHash = any> extends Model<T>
style[prop] = value;
this.setStyle(style, { noEvent: true });
this.trigger(`change:style:${prop}`);
this.updateView();
}
getView(frame?: Frame) {
let { views, em } = this;
const frm = frame || em?.getCurrentFrameModel();
return frm ? views.find((v) => v.frameView === frm.view) : views[0];
}
setView(view: StyleableView) {
let { views } = this;
!views.includes(view) && views.push(view);
}
removeView(view: StyleableView) {
const { views } = this;
views.splice(views.indexOf(view), 1);
}
updateView() {
this.views.forEach((view) => view.updateStyles());
}
/**

4
packages/core/src/editor/config/config.ts

@ -432,7 +432,7 @@ export interface EditorConfig {
export type EditorConfigKeys = keyof EditorConfig;
const config: EditorConfig = {
const config: () => EditorConfig = () => ({
stylePrefix: 'gjs-',
components: '',
style: '',
@ -506,6 +506,6 @@ const config: EditorConfig = {
textViewCode: 'Code',
keepUnusedStyles: false,
customUI: false,
};
});
export default config;

3
packages/core/src/editor/index.ts

@ -89,7 +89,7 @@ import TraitManager from '../trait_manager';
import UndoManagerModule from '../undo_manager';
import UtilsModule from '../utils';
import html from '../utils/html';
import defaults, { EditorConfig, EditorConfigKeys } from './config/config';
import defConfig, { EditorConfig, EditorConfigKeys } from './config/config';
import EditorModel, { EditorLoadOptions } from './model/Editor';
import EditorView from './view/EditorView';
@ -132,6 +132,7 @@ export default class Editor implements IBaseModule<EditorConfig> {
config: EditorConfigType;
constructor(config: EditorConfig = {}, opts: any = {}) {
const defaults = defConfig();
this.config = {
...defaults,
...config,

2
packages/core/src/editor/model/Editor.ts

@ -73,6 +73,7 @@ const storableDeps: (new (em: EditorModel) => IModule & IStorableModule)[] = [
CssComposer,
PageManager,
ComponentManager,
DataSourceManager,
];
Extender({ $ });
@ -364,6 +365,7 @@ export default class EditorModel extends Model {
storageManager: false,
undoManager: false,
});
shallow.set({ isShallow: true });
// We only need to load a few modules
shallow.Pages.onLoad();
shallow.Canvas.postLoad();

4
packages/core/src/i18n/config.ts

@ -37,13 +37,13 @@ export interface I18nConfig {
messagesAdd?: Record<string, any>;
}
const config: I18nConfig = {
const config: () => I18nConfig = () => ({
locale: 'en',
localeFallback: 'en',
detectLocale: true,
debug: false,
messages: { en },
messagesAdd: undefined,
};
});
export default config;

4
packages/core/src/i18n/index.ts

@ -27,7 +27,7 @@ import { isUndefined, isString } from 'underscore';
import { Module } from '../abstract';
import EditorModel from '../editor/model/Editor';
import { hasWin, deepMerge } from '../utils/mixins';
import defaults, { I18nConfig } from './config';
import defConfig, { I18nConfig } from './config';
import I18nEvents, { Messages } from './types';
export default class I18nModule extends Module<I18nConfig & { stylePrefix?: string }> {
@ -39,7 +39,7 @@ export default class I18nModule extends Module<I18nConfig & { stylePrefix?: stri
* @private
*/
constructor(em: EditorModel) {
super(em, 'I18n', defaults);
super(em, 'I18n', defConfig());
const add = this.config.messagesAdd;
add && this.addMessages(add);

4
packages/core/src/keymaps/config.ts

@ -24,7 +24,7 @@ export interface KeymapsConfig {
defaults?: Record<string, Omit<Keymap, 'id'> & { opts?: KeymapOptions }>;
}
const config: KeymapsConfig = {
const config: () => KeymapsConfig = () => ({
defaults: {
'core:undo': {
keys: '⌘+z, ctrl+z',
@ -66,6 +66,6 @@ const config: KeymapsConfig = {
opts: { prevent: true },
},
},
};
});
export default config;

4
packages/core/src/keymaps/index.ts

@ -48,7 +48,7 @@ import { hasWin } from '../utils/mixins';
import keymaster from '../utils/keymaster';
import { Module } from '../abstract';
import EditorModel from '../editor/model/Editor';
import defaults, { Keymap, KeymapOptions, KeymapsConfig } from './config';
import defConfig, { Keymap, KeymapOptions, KeymapsConfig } from './config';
export type KeymapEvent = 'keymap:add' | 'keymap:remove' | 'keymap:emit' | `keymap:emit:${string}`;
@ -59,7 +59,7 @@ export default class KeymapsModule extends Module<KeymapsConfig & { name?: strin
keymaps: Record<string, Keymap>;
constructor(em: EditorModel) {
super(em, 'Keymaps', defaults);
super(em, 'Keymaps', defConfig());
this.keymaps = {};
}

4
packages/core/src/modal_dialog/config/config.ts

@ -26,13 +26,13 @@ export interface ModalConfig {
extend?: Record<string, any>;
}
const config: ModalConfig = {
const config: () => ModalConfig = () => ({
stylePrefix: 'mdl-',
title: '',
content: '',
backdrop: true,
custom: false,
extend: {},
};
});
export default config;

4
packages/core/src/modal_dialog/index.ts

@ -38,7 +38,7 @@ import { Module } from '../abstract';
import EditorView from '../editor/view/EditorView';
import EditorModel from '../editor/model/Editor';
import { createText } from '../utils/dom';
import defaults, { ModalConfig } from './config/config';
import defConfig, { ModalConfig } from './config/config';
import ModalM from './model/Modal';
import ModalView from './view/ModalView';
import { EventHandler } from '../common';
@ -54,7 +54,7 @@ export default class ModalModule extends Module<ModalConfig> {
* @private
*/
constructor(em: EditorModel) {
super(em, 'Modal', defaults);
super(em, 'Modal', defConfig());
this.model = new ModalM(this);
this.model.on('change:open', (m: ModalM, enable: boolean) => {

4
packages/core/src/navigator/config/config.ts

@ -108,7 +108,7 @@ export interface LayerManagerConfig {
extend?: Record<string, any>;
}
const config: LayerManagerConfig = {
const config: () => LayerManagerConfig = () => ({
stylePrefix: '',
appendTo: '',
sortable: true,
@ -124,6 +124,6 @@ const config: LayerManagerConfig = {
onInit: () => {},
onRender: () => {},
extend: {},
};
});
export default config;

4
packages/core/src/navigator/index.ts

@ -45,7 +45,7 @@ import Module from '../abstract/Module';
import Component from '../dom_components/model/Component';
import EditorModel from '../editor/model/Editor';
import { hasWin, isComponent, isDef } from '../utils/mixins';
import defaults, { LayerManagerConfig } from './config/config';
import defConfig, { LayerManagerConfig } from './config/config';
import View from './view/ItemView';
import { ComponentsEvents } from '../dom_components/types';
@ -91,7 +91,7 @@ export default class LayerManager extends Module<LayerManagerConfig> {
events = events;
constructor(em: EditorModel) {
super(em, 'LayerManager', defaults);
super(em, 'LayerManager', defConfig());
bindAll(this, 'componentChanged', '__onRootChange', '__onComponent');
this.model = new ModuleModel(this, { opened: {} });
// @ts-ignore

17
packages/core/src/navigator/view/ItemView.ts

@ -7,6 +7,8 @@ import { isEnterKey, isEscKey } from '../../utils/dom';
import LayerManager from '../index';
import ItemsView from './ItemsView';
import { getOnComponentDrag, getOnComponentDragEnd, getOnComponentDragStart } from '../../commands';
import Sorter from '../../utils/sorter/Sorter';
import LayersComponentNode from '../../utils/sorter/LayersComponentNode';
export type ItemViewProps = ViewOptions & {
ItemView: ItemView;
@ -99,7 +101,7 @@ export default class ItemView extends View {
opt: ItemViewProps;
module: LayerManager;
config: any;
sorter: any;
sorter: Sorter<Component, LayersComponentNode>;
/** @ts-ignore */
model!: Component;
parentView: ItemView;
@ -323,11 +325,14 @@ export default class ItemView extends View {
if (sorter) {
const toMove = model.delegate?.move?.(model) || model;
sorter.onStart = getOnComponentDragStart(em);
sorter.onMoveClb = getOnComponentDrag(em);
sorter.onEndMove = getOnComponentDragEnd(em, [toMove]);
const itemEl = (toMove as any).viewLayer?.el || ev.target;
sorter.startSort(itemEl);
sorter.eventHandlers = {
legacyOnStartSort: getOnComponentDragStart(em),
legacyOnMoveClb: getOnComponentDrag(em),
legacyOnEndMove: getOnComponentDragEnd(em, [toMove]),
...sorter.eventHandlers,
};
const element = (toMove as any).viewLayer?.el || ev.target;
sorter.startSort([{ element }]);
}
}

51
packages/core/src/navigator/view/ItemsView.ts

@ -5,10 +5,16 @@ import EditorModel from '../../editor/model/Editor';
import ItemView from './ItemView';
import Components from '../../dom_components/model/Components';
import LayerManager from '..';
import { DragDirection } from '../../utils/sorter/types';
import LayersComponentNode from '../../utils/sorter/LayersComponentNode';
import ComponentSorter from '../../utils/sorter/ComponentSorter';
export default class ItemsView extends View {
items: ItemView[];
opt: any;
opt: {
sorter: ComponentSorter<LayersComponentNode>;
[k: string]: any;
};
config: any;
parentView: ItemView;
module: LayerManager;
@ -34,17 +40,23 @@ export default class ItemsView extends View {
if (config.sortable && !this.opt.sorter) {
const utils = em.Utils;
this.opt.sorter = new utils.Sorter({
// @ts-ignore
container: config.sortContainer || this.el,
containerSel: `.${this.className}`,
itemSel: `.${pfx}layer`,
ignoreViewChildren: 1,
avoidSelectOnEnd: 1,
nested: 1,
ppfx,
pfx,
const container = config.sortContainer || this.el;
const placeholderElement = this.createPlaceholder(pfx);
this.opt.sorter = new utils.ComponentSorter({
em,
treeClass: LayersComponentNode,
containerContext: {
container: container,
containerSel: `.${this.className}`,
itemSel: `.${pfx}layer`,
pfx: config.pStylePrefix,
document: document,
placeholderElement: placeholderElement,
},
dragBehavior: {
dragDirection: DragDirection.Vertical,
nested: true,
},
});
}
@ -53,6 +65,23 @@ export default class ItemsView extends View {
opt.parent && this.$el.data('model', opt.parent);
}
/**
* Create placeholder
* @return {HTMLElement}
*/
private createPlaceholder(pfx: string) {
const el = document.createElement('div');
const ins = document.createElement('div');
this.el.parentNode;
el.className = pfx + 'placeholder';
el.style.display = 'none';
el.style.pointerEvents = 'none';
ins.className = pfx + 'placeholder-int';
el.appendChild(ins);
return el;
}
removeChildren(removed: Component) {
const view = removed.viewLayer;
if (!view) return;

2
packages/core/src/pages/index.ts

@ -75,7 +75,7 @@ export default class PageManager extends ItemManagerModule<PageManagerConfig, Pa
constructor(em: EditorModel) {
super(em, 'PageManager', new Pages([], em), PagesEvents);
bindAll(this, '_onPageChange');
const model = new ModuleModel({ _undo: true } as any);
const model = new ModuleModel(this, { _undo: true });
this.model = model;
this.pages.on('reset', (coll) => coll.at(0) && this.select(coll.at(0)));
this.pages.on('all', this.__onChange, this);

10
packages/core/src/pages/model/Pages.ts

@ -1,4 +1,4 @@
import { Collection } from '../../common';
import { Collection, RemoveOptions } from '../../common';
import EditorModel from '../../editor/model/Editor';
import Page from './Page';
@ -14,11 +14,13 @@ export default class Pages extends Collection<Page> {
};
}
onReset(m: Page, opts?: { previousModels?: Pages }) {
opts?.previousModels?.map((p) => this.onRemove(p));
onReset(m: Page, opts?: RemoveOptions & { previousModels?: Pages }) {
opts?.previousModels?.map((p) => this.onRemove(p, this, opts));
}
onRemove(removed?: Page) {
onRemove(removed?: Page, _p?: this, opts: RemoveOptions = {}) {
// Avoid removing frames if triggered from undo #6142
if (opts.fromUndo) return;
removed?.onRemove();
}
}

4
packages/core/src/panels/config/config.ts

@ -34,7 +34,7 @@ export interface PanelsConfig {
defaults?: PanelProps[];
}
const config: PanelsConfig = {
const config: () => PanelsConfig = () => ({
stylePrefix: 'pn-',
defaults: [
{
@ -109,6 +109,6 @@ const config: PanelsConfig = {
],
},
],
};
});
export default config;

9
packages/core/src/panels/index.ts

@ -27,7 +27,7 @@
*/
import { Module } from '../abstract';
import EditorModel from '../editor/model/Editor';
import defaults, { PanelsConfig } from './config/config';
import defConfig, { PanelsConfig } from './config/config';
import Panel, { PanelProperties } from './model/Panel';
import Panels from './model/Panels';
import PanelsView from './view/PanelsView';
@ -42,13 +42,8 @@ export default class PanelManager extends Module<PanelsConfig> {
* @private
*/
constructor(em: EditorModel) {
super(em, 'Panels', defaults);
super(em, 'Panels', defConfig());
this.panels = new Panels(this, this.config.defaults!);
for (var name in defaults) {
//@ts-ignore
if (!(name in this.config)) this.config[name] = defaults[name];
}
return this;
}
/**

9
packages/core/src/parser/config/config.ts

@ -60,6 +60,11 @@ export interface HTMLParserOptions extends OptionAsDocument {
*/
keepEmptyTextNodes?: boolean;
/**
* Indicate if or how to detect if the passed HTML string should be parsed as a document.
*/
detectDocument?: boolean | ((html: string) => boolean);
/**
* Custom transformer to run before passing the input HTML to the parser.
* A common use case might be to sanitize the input string.
@ -105,7 +110,7 @@ export interface ParserConfig {
optionsHtml?: HTMLParserOptions;
}
const config: ParserConfig = {
const config: () => ParserConfig = () => ({
textTags: ['br', 'b', 'i', 'u', 'a', 'ul', 'ol'],
textTypes: ['text', 'textnode', 'comment'],
parserCss: undefined,
@ -117,6 +122,6 @@ const config: ParserConfig = {
allowUnsafeAttrValue: false,
keepEmptyTextNodes: false,
},
};
});
export default config;

4
packages/core/src/parser/index.ts

@ -26,7 +26,7 @@
*/
import { Module } from '../abstract';
import EditorModel from '../editor/model/Editor';
import defaults, { HTMLParserOptions, ParserConfig } from './config/config';
import defConfig, { HTMLParserOptions, ParserConfig } from './config/config';
import ParserCss from './model/ParserCss';
import ParserHtml from './model/ParserHtml';
@ -35,7 +35,7 @@ export default class ParserModule extends Module<ParserConfig & { name?: string
parserCss: ReturnType<typeof ParserCss>;
constructor(em: EditorModel) {
super(em, 'Parser', defaults);
super(em, 'Parser', defConfig());
const { config } = this;
this.parserCss = ParserCss(em, config);
this.parserHtml = ParserHtml(em, config);

19
packages/core/src/parser/model/ParserHtml.ts

@ -5,6 +5,7 @@ import EditorModel from '../../editor/model/Editor';
import { HTMLParseResult, HTMLParserOptions, ParseNodeOptions, ParserConfig } from '../config/config';
import BrowserParserHtml from './BrowserParserHtml';
import { doctypeToString } from '../../utils/dom';
import { isDef } from '../../utils/mixins';
const modelAttrStart = 'data-gjs-';
const event = 'parse:html';
@ -313,12 +314,16 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo
const conf = em?.get('Config') || {};
const res: HTMLParseResult = { html: [] };
const cf = { ...config, ...opts };
const options = {
const preOptions = {
...config.optionsHtml,
// @ts-ignore Support previous `configParser.htmlType` option
htmlType: config.optionsHtml?.htmlType || config.htmlType,
...opts,
};
const options = {
...preOptions,
asDocument: this.__checkAsDocument(str, preOptions),
};
const { preParser, asDocument } = options;
const input = isFunction(preParser) ? preParser(str, { editor: em?.getEditor()! }) : str;
const parseRes = isFunction(cf.parserHtml) ? cf.parserHtml(input, options) : BrowserParserHtml(input, options);
@ -374,7 +379,7 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo
}
res.html = resHtml;
em?.trigger(event, { input, output: res });
em?.trigger(event, { input, output: res, options });
return res;
},
@ -392,6 +397,16 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo
toRemove.map((name) => node.removeAttribute(name));
each(nodes, (node) => this.__sanitizeNode(node as HTMLElement, opts));
},
__checkAsDocument(str: string, opts: HTMLParserOptions) {
if (isDef(opts.asDocument)) {
return opts.asDocument;
} else if (isFunction(opts.detectDocument)) {
return !!opts.detectDocument(str);
} else if (opts.detectDocument) {
return str.toLowerCase().trim().startsWith('<!doctype');
}
},
};
};

4
packages/core/src/rich_text_editor/config/config.ts

@ -80,11 +80,11 @@ export interface RichTextEditorConfig {
custom?: boolean;
}
const config: RichTextEditorConfig = {
const config: () => RichTextEditorConfig = () => ({
stylePrefix: 'rte-',
adjustToolbar: true,
actions: ['bold', 'italic', 'underline', 'strikethrough', 'link', 'wrap'],
custom: false,
};
});
export default config;

4
packages/core/src/rich_text_editor/index.ts

@ -44,7 +44,7 @@ import ComponentView from '../dom_components/view/ComponentView';
import EditorModel from '../editor/model/Editor';
import { createEl, cx, on, removeEl } from '../utils/dom';
import { hasWin, isDef } from '../utils/mixins';
import defaults, { CustomRTE, RichTextEditorConfig } from './config/config';
import defConfig, { CustomRTE, RichTextEditorConfig } from './config/config';
import RichTextEditor, { RichTextEditorAction } from './model/RichTextEditor';
import CanvasEvents from '../canvas/types';
import { ComponentsEvents } from '../dom_components/types';
@ -87,7 +87,7 @@ export default class RichTextEditorModule extends Module<RichTextEditorConfig &
*/
constructor(em: EditorModel) {
super(em, 'RichTextEditor', defaults);
super(em, 'RichTextEditor', defConfig());
const { config } = this;
const ppfx = config.pStylePrefix;

4
packages/core/src/selector_manager/config/config.ts

@ -132,7 +132,7 @@ export interface SelectorManagerConfig {
custom?: boolean;
}
const config: SelectorManagerConfig = {
const config: () => SelectorManagerConfig = () => ({
stylePrefix: 'clm-',
appendTo: '',
selectors: [],
@ -148,6 +148,6 @@ const config: SelectorManagerConfig = {
'<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"></path></svg>',
componentFirst: false,
custom: false,
};
});
export default config;

5
packages/core/src/selector_manager/index.ts

@ -76,7 +76,7 @@
import { isString, debounce, isObject, isArray, bindAll } from 'underscore';
import { isComponent, isRule } from '../utils/mixins';
import { Model, Collection, RemoveOptions, SetOptions, Debounced } from '../common';
import defaults, { SelectorManagerConfig } from './config/config';
import defConfig, { SelectorManagerConfig } from './config/config';
import Selector from './model/Selector';
import Selectors from './model/Selectors';
import State from './model/State';
@ -86,7 +86,6 @@ import Component from '../dom_components/model/Component';
import { ItemManagerModule } from '../abstract/Module';
import { StyleModuleParam } from '../style_manager';
import StyleableModel from '../domain_abstract/model/StyleableModel';
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';
@ -138,7 +137,7 @@ export default class SelectorManager extends ItemManagerModule<SelectorManagerCo
*/
constructor(em: EditorModel) {
super(em, 'SelectorManager', new Selectors([]), selectorEvents, defaults, { skipListen: true });
super(em, 'SelectorManager', new Selectors([]), selectorEvents, defConfig(), { skipListen: true });
bindAll(this, '__updateSelectedByComponents');
const { config } = this;
const ppfx = config.pStylePrefix;

4
packages/core/src/storage_manager/config/config.ts

@ -78,7 +78,7 @@ export interface StorageManagerConfig {
};
}
const config: StorageManagerConfig = {
const config: () => StorageManagerConfig = () => ({
id: 'gjs-',
type: 'local',
autosave: true,
@ -103,6 +103,6 @@ const config: StorageManagerConfig = {
onLoad: (result) => result,
},
},
};
});
export default config;

4
packages/core/src/storage_manager/index.ts

@ -42,7 +42,7 @@
import { isEmpty, isFunction } from 'underscore';
import { Module } from '../abstract';
import defaults, { StorageManagerConfig } from './config/config';
import defConfig, { StorageManagerConfig } from './config/config';
import LocalStorage from './model/LocalStorage';
import RemoteStorage from './model/RemoteStorage';
import EditorModel from '../editor/model/Editor';
@ -65,7 +65,7 @@ export default class StorageManager extends Module<
events = StorageEvents;
constructor(em: EditorModel) {
super(em, 'StorageManager', defaults);
super(em, 'StorageManager', defConfig());
const { config } = this;
if (config._disable) config.type = undefined;
this.storages = {};

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

Loading…
Cancel
Save