GrapesJS / grapesjs

Free and Open source Web Builder Framework. Next generation tool for building templates without coding
https://grapesjs.com
BSD 3-Clause "New" or "Revised" License
22.4k stars 4.06k forks source link

Question: Dragging blocks into a Text block? #481

Closed mathieuk closed 5 years ago

mathieuk commented 6 years ago

This is not a bug but an implementation question. If this is not the right place to ask these questions, please let me know.

We're working on an implementation where we want to use GrapesJS to allow users to create an e-mail template. As part of this implementation we are working to create mail-merge functionality: we've introduced the concept op 'merge-fields' or 'placeholders' which we will replace with the proper values on the server side. This means we send over the components JSON structure and turn it into HTML server-side, replacing values as we go.

So, as an example, one of our users might enter the text: Hello <<username>> and we'll replace that merge-field <<username>> with the proper field.

But, we haven't been able to implement this this way quite yet as we're not able to drag these blocks into a Text block. We can only drag it around it. So, for now we're extending the RTE with a merge-field 'inline block' ( <input type=text readonly class=mergeField data-isMergefield=1 />) and creating a merge-field block with the same HTML in the block manager. Implementing a DomComponent type to recognize it offers a method to configure it. But it feels suboptimal, we'd really like to be able to drag that mergefield block onto the right place in the textfield.

To allow this, I imagine GrapesJS would have to be able to grab the textNode and split it in to (atleast) two textnodes and a tag for the merge-field but I'm not sure where to start with this. Could you advise as to how we might implement this?

mathieuk commented 6 years ago

I suspect #287 is actually the same question.

artf commented 6 years ago

Have you checked API-Rich-Text-Editor? You can add a custom action like this

editor.RichTextEditor.add('custom-vars', {
  icon: `<select class="gjs-field">
        <option value="">- Select -</option>
        <option value="[[firstname]]">FirstName</option>
        <option value="[[lastname]]">LastName</option>
        <option value="[[age]]">Age</option>
      </select>`,
    // Bind the 'result' on 'change' listener
  event: 'change',
  result: (rte, action) => rte.insertHTML(action.btn.firstChild.value),
  // Reset the select on change
  update: (rte, action) => { action.btn.firstChild.value = "";}
})

rte-action

mathieuk commented 6 years ago

Yes, I've used that and I've pretty much got that working. The difference is that I am not using a text placeholder like you are. I'm actually inserting a block (with a corresponding 'type') so that I can further configure these placeholders (for instance, a field might be a Datetime field and my user may want to configure the exact output format for that datetime). I used HTML5 drag'n'drop to implement this and that works pretty nice, bút...

It feels like departing from the expected user interface. I feel I should be able to drag a mergefield from the 'blocks' and onto the right position. I've been toying around with the Sorter to allow this and I'm up to this:

image

I now have the problem that the Sorter very much wants actual blocks to align to so I have some more tweaking to do. For now implementing this has required changed in the ComponentTextView (mostly: dont clear out the toolbar for 'mergefields') and the Sorter (if I'm hovering a mergefield over a textblock, insert HTML into the activeRTE instead of appending a block).

I'm currently working on making the dragging work more reliably (for some reason, whenever I drag right the field gets appended to the textnode instead of at the cursor position, works fine when dragging left :) ), making the sorter ignore the idea of 'blocks' when within a textfield, showing the proper placeholder in that case (I'd want to see the actual cursor) and cleaning things up.

Not sure on how to approach the Sorter issue at this point, short of specialcasing textblocks so if you have any ideas in that area I'd love to hear them. You can see some of the hacky code I've made so far over at https://github.com/mathieuk/grapesjs/commit/d58c5ee5306c358cd19509f6b8affe9bb60493ed .

artf commented 6 years ago

Well @mathieuk I've never taken into account the possibility to add stuff inside a text component, mainly because it might lead to strange behaviors (probably even from UX), but it is definitely an interesting proposal. About your Sorter question, I'd suggest creating a new property for blocks, eg.

blockManager.add('my-block', {
     label: 'Block',
     textable: 1, // allow the block to be inserted inside text components
     content: `...`,
})

and differentiate the sorter's behavior by this property

mathieuk commented 6 years ago

@artf so, I've taken your advice and went that route and i now have a fairly functional situation where it lets you drag a mergefield onto a textview, lets you move it around and lets you move it between textviews and other blocks.

image

You can see my implementation over at https://github.com/mathieuk/grapesjs/tree/mergefields/src .
Would you be interested in having this in core? Would you need any additional changes for that?

artf commented 6 years ago

Wow @mathieuk, this is amazing 😍 it'd awesome to have it in core

mathieuk commented 6 years ago

@artf Great! Any ideas on what form that should take? The merge fields approach that I'm using is kinda specific to our solution where we process the JSON generated by GrapesJS server-side. Any suggestions as to what type of sample block we should add to be draggable onto the textfield by default?

NorthstarTech commented 6 years ago

I go through with your code @matthieuk , but its not working properly. It doesnot allow to add inside the text. It always drag on a separate textfield. I also added the textable:1 . here is my code. Need your help asap. Thanks.

bm.add('mergefield', { label: 'Merge Field', textable: 1, attributes: {class:'fa fa-image'}, content: { type: 'mergefield', attributes: { 'data-mergefield': 1, 'data-highlightable': 1, readonly: 'true', value: 'mergefield' }, style: { height: '100px', width: '200px', display: 'inline-block', border: '1px dashed #455699', 'text-align': 'center', 'background-color': '#000000', 'padding': '3px', 'border-radius': '5px', 'color': 'black' } } });

domc.addType('mergefield', { model: defaultModel.extend({ toolbar: [ { attributes: {class: 'fa fa-arrows'}, command: 'tlb-move', } ], defaults: Object.assign({}, defaultModel.prototype.defaults, { 'custom-name': "Merge field", tagName: 'input', class: 'merge-field', badgable: true, highlightable: true, editable: true, droppable: false, draggable: true, removable: true,

                 traits: [
                 {
                     label: "mergefield",
                     type: 'select',
                     name: 'value',
                     options: [
                       {value: 'invoice.invoicenumber', name: "invoice.invoicenumber"},
                       {value: 'invoice.invoicedate', name: "invoice.invoicedate"},
                       {value: 'company.name', name: "company.name"},
                       {value: 'company.business_regnr', name: "company.business_regnr1"},
                     ]
             }
                 ]
               }),
             }, {
               toolbar: [
                   {
                       attributes: {class: 'fa fa-arrows'},
                       command: 'tlb-move',
                   }
               ],
               isComponent(el) {
                 if(el.tagName == 'INPUT' && el.dataset.mergefield == "1") {
                   return {type: 'mergefield'};
             }
               },
             }),
             view: defaultView.extend({

                     events: {
                       'click': function(e) { 
                           console.log("click"); 
                           },
                      //  'dragstart': function(e) {
                      //     e.target.id = 'mergefield-' + (new Date()).getTime();
                      //     console.log('dragstart', e.target.id);
                      //     e.dataTransfer.setData('mergefield', e.target.id);
                      // },
                      'keydown': function(e) {
                          if (e.key == 'Backspace') {
                              e.target.parentNode.removeChild(e.target);
                          }
                          // console.log("KEYDOWN:", e);
                      }
                     }
             }),
           }); 
kickbk commented 6 years ago

Hmm, exactly what we need as well. Would love to see such functionality merged into the core.

artf commented 6 years ago

@mathieuk can I just use any block content? I mean something like this:

blockManager.add('my-block', {
     label: 'Block',
     textable: 1, 
     content: `<div style="...">custom stuff / custom components</div>`,
})

I don't care about thecontent, I know that with textable I'm able to drag it inside text components. Would be awesome if you set up a demo just to test it online :) (with something like codepan/codesandbox)

gordon-matt commented 6 years ago

@mathieuk / @artf I'm desperate for this functionality right now as well. Any idea when this will be merged to core?

artf commented 6 years ago

I hope to hear more from @mathieuk about this :)

kickbk commented 6 years ago

For those looking for a ckeditor 4 merge fields plugin, this works: https://github.com/57u To activate:

'gjs-plugin-ckeditor': {
    options: {
        ...
        extraPlugins: '...,strinsert',
        toolbar: [
            ...
            {name: 'Merge Fields', items: [ 'strinsert' ]},
        ],
        strinsert_strings: [
            {'value': '*|FIRSTNAME|*', 'name': 'First name'},
            {'value': '*|LASTNAME|*', 'name': 'Last name'},
            {'value': '*|INVITEURL|*', 'name': 'Activate invite URL'},
        ],
        // Optionally add the below settings
        strinsert_button_label: 'Merge Fields',
        strinsert_button_title: 'Insert Merge Field',
        ...
    }
},
tvkit commented 6 years ago

Any progress on this cool capability, @mathieuk ?

LKozakewycz commented 6 years ago

@mathieuk @artf

I'm looking at using the textable method and have built grapesjs with your implementation but I'm still struggling to get the component to drop into the middle of the RTE. Was there something specific you did to get this to work?

mathieuk commented 6 years ago

I haven't had the time to work on this any further yet, sorry @artf. I might later this year. @LKozakewycz I'm a bit rusty on the details, but I recall that having to set ContentEditable was crucial to getting accurate ranges from the textselection APIs in the various browsers. That is: the textblock must be in content-editable mode during the whole time you're dragging the mergefield over it. That was one of the challenges of getting it right..

Does that help?

LKozakewycz commented 6 years ago

@mathieuk - I've been working on it lately and have so far come up with this, very similar to yours:

ezgif-1-307632a1f2

I know for a fact that setting contenteditable="false" on the merge field component itself will let the RTE know it is to be handled as a whole block, because unfortunatly at this moment the RTE will see it as just more editable text (i.e, you can edit the text inside the component). By doing this, you can use backspace to delete the merge field as a whole.

The trick is... trying to set contenteditable as a permanent attribute. I don't know if it's the RTE activation, but I'm working out how to get contenteditable to stick.

LKozakewycz commented 6 years ago

Found it. Set contenteditable attribute on the component to false and ensure the attribute is not skipped over if explicitly set.

//... ParserHtml.js
          } else if (nodeName == 'contenteditable') {
            // Explicitly set contenteditable attributes should not be ignored
            if ( nodeValue === 'false' && !model.editable ) {
              model.attributes[nodeName] = 'false';
            }
            continue;
          }
//...

I'll be doing some more work on this and hopefully share in the future.

artf commented 6 years ago

Thanks for the help @LKozakewycz looking forward to your updates. BTW, before applying your changes I'd like to understand why contenteditable is removed when parsed, honestly, I'd expect to see it kept

LKozakewycz commented 6 years ago

@artf In this case, contenteditable is set to false on the merge field only because it let's the merge field show as a single block and not a bunch of characters. It stops the cursor from accessing the contents of the merge field block and allows for deleting it entirely using backspace.

In my version, I intend to use modals to display an advanced merge field selector which can be populated by meta data in my back-end. One of the challenges with this is trying to understand why the modal doesn't appear automatically when I drop it in the canvas (just like the image block).

BlazedCode commented 5 years ago

@mathieuk Thank you so much for sharing this idea with everyone. I'm trying to implement something exactly like this into an email template editor that we are busy developing. I have some of it working, tested a couple of different methods to get something like this working. The idea to use a "input tag" works nicely. However, how do you plan on allowing the user to style the placeholder/merge-field value (For example, make it bold or change the color)? Another big issue that I'm stuck with is replacing the "placeholder tag" server side to save the email template. Are you using a tool to replace it with a "span" tag or something? And how do you identify the variable value from the mergefield/placeholder (for example firstName or invoiceNumber), to know what to replace it with on the server side?

ghost commented 5 years ago

@artf When applying this method for merge tags, the options drop down is duplicated when leaving and re-entering the editor. I assume it is caching the command and adding the custom-vars icon everytime, can you explain how to avoid this? screen shot 2019-02-04 at 1 51 00 pm

artf commented 5 years ago

@chris-robbins it works fine, by trying my old snippet, so be sure not adding that action every time you "re-enter the editor"

artf commented 5 years ago

Probably in the next release, this feature will be available. textable

So textable will be just another property, this will allow any component to be dropped inside Text components. Here is the code of the component from the example above:

// Define a component with `textable` property
editor.DomComponents.addType('var-placeholder', {
      model: {
        defaults: {
          textable: 1,
          placeholder: 'VARIABLE-1',
        },
        toHTML() {
          return `{{ ${this.get('placeholder')} }}`;
        },
      },
      // The view below it's just an example of creating a different UX
      view: {
        tagName: 'span',
        events: {
          'change': 'updatePlh',
        },
        // Update the model once the select is changed
        updatePlh(ev) {
          this.model.set({ placeholder: ev.target.value });
          this.updateProps();
        },
        // When we blur from a TextComponent, all its children components are
        // flattened via innerHTML and parsed by the editor. So to keep the state
        // of our props in sync with the model so we need to expose props in the HTML
        updateProps() {
          const { el, model } = this;
          el.setAttribute('data-gjs-placeholder',  model.get('placeholder'));
        },
        onRender() {
          const { model, el } = this;
          const currentPlh = model.get('placeholder');
          const select = document.createElement('select');
          const options = [ 'VARIABLE-1', 'VARIABLE-2', 'VARIABLE-3' ];
          select.innerHTML = options.map(item => `<option value="${item}" ${item === currentPlh ? 'selected' : ''}>
            ${item}
          </option>`).join('');
          while (el.firstChild) el.removeChild(el.firstChild);
          el.appendChild(select);
          select.setAttribute('style', 'padding: 5px; border-radius: 3px; border: none; -webkit-appearance: none;');
          this.updateProps();
        },
      }
    });

    // Use the component in blocks
    editor.BlockManager.add('simple-block', {
      label: 'Textable block',
      content: { type: 'var-placeholder' },
    });
dazakorn commented 5 years ago

Hello @artf, I'm using the source code to add var-placeholder but it does not work, should I do something extra in the editor so that the text allows to add the object var-placeholder between lines? In my case it is only added to the beginning or end of the text.

BlazedCode commented 5 years ago

Thanks @artf for all your hard work, really appreciate. I'll give this a try.

artf commented 5 years ago

Now available https://github.com/artf/grapesjs/releases/tag/v0.14.61

HelveticaScenario commented 5 years ago

@artf When applying this method for merge tags, the options drop down is duplicated when leaving and re-entering the editor. I assume it is caching the command and adding the custom-vars icon everytime, can you explain how to avoid this? screen shot 2019-02-04 at 1 51 00 pm

I've had this problem too, I think it has something to do with the rte being global under the hood. try adding it in this initialization object for the grapesjs instance, with something like this

grapesjs.init({
  //...
  richTextEditor: {
    actions: [
      'bold',
      'italic',
      'underline',
      'strikethrough',
      'link',
      {
        name: 'custom-vars',
        icon: `<select class="gjs-field">
                <option value="">- Select -</option>
                <option value="[[firstname]]">FirstName</option>
                <option value="[[lastname]]">LastName</option>
                <option value="[[age]]">Age</option>
              </select>`,
        // Bind the 'result' on 'change' listener
        event: 'change',
        result: (rte, action) => rte.insertHTML(action.btn.firstChild.value),
        // Reset the select on change
        update: (rte, action) => {
          action.btn.firstChild.value = ''
        },
      },
    ],
  },
  //...
})

this seems to do the trick

HelveticaScenario commented 5 years ago

Probably in the next release, this feature will be available. textable

So textable will be just another property, this will allow any component to be dropped inside Text components. Here is the code of the component from the example above:

// Define a component with `textable` property
editor.DomComponents.addType('var-placeholder', {
      model: {
        defaults: {
          textable: 1,
          placeholder: 'VARIABLE-1',
        },
        toHTML() {
          return `{{ ${this.get('placeholder')} }}`;
        },
      },
    // The view below it's just an example of creating a different UX
      view: {
        tagName: 'span',
        events: {
          'change': 'updatePlh',
        },
        // Update the model once the select is changed
        updatePlh(ev) {
          this.model.set({ placeholder: ev.target.value });
          this.updateProps();
        },
        // When we blur from a TextComponent, all its children components are
        // flattened via innerHTML and parsed by the editor. So to keep the state
        // of our props in sync with the model so we need to expose props in the HTML
        updateProps() {
          const { el, model } = this;
          el.setAttribute('data-gjs-placeholder',  model.get('placeholder'));
        },
        onRender() {
          const { model, el } = this;
          const currentPlh = model.get('placeholder');
          const select = document.createElement('select');
          const options = [ 'VARIABLE-1', 'VARIABLE-2', 'VARIABLE-3' ];
          select.innerHTML = options.map(item => `<option value="${item}" ${item === currentPlh ? 'selected' : ''}>
          ${item}
        </option>`).join('');
          while (el.firstChild) el.removeChild(el.firstChild);
          el.appendChild(select);
          select.setAttribute('style', 'padding: 5px; border-radius: 3px; border: none; -webkit-appearance: none;');
          this.updateProps();
        },
      }
    });

  // Use the component in blocks
    editor.BlockManager.add('simple-block', {
      label: 'Textable block',
      content: { type: 'var-placeholder' },
    });

This code isn't working for me on 0.14.61, nothing is shown. It doesn't seem that the onRender method is ever called.

artf commented 5 years ago

This code isn't working for me on 0.14.61, nothing is shown. It doesn't seem that the onRender method is ever called.

@HelveticaScenario mmm seems like at the end, after adding other features, I've introduced some regression. Seems to work only if the textable component is placed outside before being dropped in the text one

UPDATE: found the issue, I'll fix it in the next release. Thanks for the report

aklatzke commented 5 years ago

@artf Are there any workarounds for this issue in the meantime?

artf commented 5 years ago

@artf Are there any workarounds for this issue in the meantime?

I'd have said it if there was one

eduardonunesp commented 5 years ago

@artf I'd like to know if you guys have an idea when the next release with the fix will be available, thanks for the fix!

AbdiasM commented 5 years ago

Has anyone tried dragging an Image block inside the Text block? I tried it by setting the textable : 1 of the Image, but it doesn't work.

bm.add("image", { label: opt.imageBlkLabel, category: category, textable: 1, attributes: { class: "gjs-fonts gjs-f-image" }, content: { type: 'image', style: { color: "black" }, activeOnRender: 1 }, activate: true });

mynamespace commented 4 years ago

Probably in the next release, this feature will be available. textable

So textable will be just another property, this will allow any component to be dropped inside Text components. Here is the code of the component from the example above:

// Define a component with `textable` property
editor.DomComponents.addType('var-placeholder', {
      model: {
        defaults: {
          textable: 1,
          placeholder: 'VARIABLE-1',
        },
        toHTML() {
          return `{{ ${this.get('placeholder')} }}`;
        },
      },
    // The view below it's just an example of creating a different UX
      view: {
        tagName: 'span',
        events: {
          'change': 'updatePlh',
        },
        // Update the model once the select is changed
        updatePlh(ev) {
          this.model.set({ placeholder: ev.target.value });
          this.updateProps();
        },
        // When we blur from a TextComponent, all its children components are
        // flattened via innerHTML and parsed by the editor. So to keep the state
        // of our props in sync with the model so we need to expose props in the HTML
        updateProps() {
          const { el, model } = this;
          el.setAttribute('data-gjs-placeholder',  model.get('placeholder'));
        },
        onRender() {
          const { model, el } = this;
          const currentPlh = model.get('placeholder');
          const select = document.createElement('select');
          const options = [ 'VARIABLE-1', 'VARIABLE-2', 'VARIABLE-3' ];
          select.innerHTML = options.map(item => `<option value="${item}" ${item === currentPlh ? 'selected' : ''}>
          ${item}
        </option>`).join('');
          while (el.firstChild) el.removeChild(el.firstChild);
          el.appendChild(select);
          select.setAttribute('style', 'padding: 5px; border-radius: 3px; border: none; -webkit-appearance: none;');
          this.updateProps();
        },
      }
    });

  // Use the component in blocks
    editor.BlockManager.add('simple-block', {
      label: 'Textable block',
      content: { type: 'var-placeholder' },
    });

this works for me, but how could we parse back (using isComponent()) when loading the content inside the editor? I would like to create merge tags/shortcodes with attributes using these draggable text components.

Joshmamroud commented 4 years ago

@artf Your example works when I don't try to drop it into a text block however when I try to drop it in a text block I get this error:

grapes.min.js:2 Uncaught TypeError: Cannot read property 'attributes' of undefined at r.updateAttributes (grapes.min.js:2) at renderAttributes (grapes.min.js:2) at render (grapes.min.js:2) at r.move (grapes.min.js:11) at grapes.min.js:11 at Array.forEach () at r.endMove (grapes.min.js:11) at Et (grapes.min.js:2) at r. (grapes.min.js:2) at r.endMove (grapes.min.js:2) at i (grapes.min.js:11) at t.value (grapes.min.js:11) at t.value (grapes.min.js:11) at Et (grapes.min.js:2) at HTMLDivElement. (grapes.min.js:2) at HTMLDivElement. (grapes.min.js:2)

I've literally copied and pasted the code from the example for both the dom component and block. I'm trying to add the placeholder-var block to the text block from the basic blocks plugin.

I don't know where this is coming from because it's minified and I can't figure out how to use the unminified version of the npm package.

Any help would be greatly appreciated.

Thanks!

jamejillagit commented 4 years ago

Hi @artf First of all, thank you for all your work. I am discovering Grapesjs and it is truly awesome.

I am experiencing the same issue as @Joshmamroud and was wondering if we are doing something wrong or whether there is some known issue with this code.

Any help would be very much appreciated.

Regards

ranashadab commented 4 years ago

@jamejillagit @Joshmamroud If you guys are have still not come across the answer, the thanks to @mwidner, this bug is due changes in new version. As mentioned in bug #2771 it still works in version 0.16.3. So, u guys can change to previous version like me if that serves the purpose.

mosh-tudor commented 3 years ago

I have the same error :-(

Uncaught TypeError: Cannot read property 'attributes' of undefined
    at r.updateAttributes (grapes.min.js:2)
    at renderAttributes (grapes.min.js:2)
    at render (grapes.min.js:2)
    at r.move (grapes.min.js:11)
    at grapes.min.js:11
    at Array.forEach (<anonymous>)
    at r.endMove (grapes.min.js:11)
    at Ft (grapes.min.js:2)
    at r.<anonymous> (grapes.min.js:2)
    at r.endMove (grapes.min.js:2)
    at i (grapes.min.js:11)
    at t.value (grapes.min.js:11)
    at t.value (grapes.min.js:11)
    at Ft (grapes.min.js:2)
    at HTMLDivElement.<anonymous> (grapes.min.js:2)
    at HTMLDivElement.<anonymous> (grapes.min.js:2)

(grapesjs - 0.16.27)

hanna404 commented 3 years ago

Hello I am having the same issue of @tudor-ooo and @Joshmamroud , did you guys found any solution ?

hanna404 commented 3 years ago

can we re-open this case @artf ?

artf commented 3 years ago

2771