7
\$\begingroup\$

A few days ago, I created this rich text editor, and posted it to reddit and got over 300 upvotes in less than 1 day. I am a beginner, so I don't believe that this project is very complex, but with your help I can improve it, and maybe someone will use this.

I put a lot of work into this and I just want it to get better. If you can help in any way I would appreciate it.

Live demo: https://panagram.herokuapp.com/

GitHub: https://github.com/Stoick001/Panagram.js

Editor:

import controlsPremade from './config';

const execute = (el, command, val) => {
  if (el && !command.includes('List')) {
    el.classList.toggle('active');
  }
  document.execCommand(command, false, val ? val: null);
}

export const init = (settings) => {
  const controls = controlsPremade[settings.controls];
  const ctrElement = settings.ctrElement;
  const outElement = settings.outElement;

  outElement.contentEditable = true;
  outElement.classList.add('output-el');

  ['click', 'touch'].forEach((evn) => {
    ctrElement.addEventListener(evn, (e) => {
      outElement.focus();
    });
  });

  outElement.addEventListener('keydown', event => {
    if (event.key === 'Tab') {
      event.preventDefault();
    } else if ((event.key === 'Enter' && document.queryCommandValue('formatBlock') === 'blockquote') || outElement.innerHTML === "" || outElement.innerHTML === "<br>") {
      setTimeout(() => {
        execute(null, 'formatBlock', '<div>');
      }, 0);
    }
  });

  controls.forEach(control => {
    const button = document.createElement('button');
    button.innerHTML = control.icon;
    button.title = control.title;
    button.setAttribute('type', 'button');
    button.classList.add('ctrl-btn');

    if (control.short) {
      document.addEventListener('keydown', (event) => {
        if (event.ctrlKey && event.key.toLowerCase() === control.short) {
            button.classList.toggle('active');
        }
      })
    }

    ['click', 'touch'].forEach((evn) => {
      if (control.state) {
        button.addEventListener(evn, () => execute(button, control.comName));
        ['keyup', 'mouseup'].forEach(cnt => {
          outElement.addEventListener(cnt, (e) => {
            if (document.queryCommandState(control.comName) && !control.comName.includes('List')) {
              button.classList.add('active');
            } else if (button.classList.contains('active')) {
              button.classList.remove('active');
            }
        });
      });
      } else if (control.formatBlock) {
        button.addEventListener(evn, () => execute(null, control.formatBlock, control.comName));
      } else {
        button.addEventListener(evn, () => {
          const val = (control.extra)();
          if (val) {
            execute(null, control.comName, val);
          }
        });
      }
    });

    ctrElement.append(button);
  });
}

Config file:

export default {
  defaultControls: [
    {
      icon: '<b>B</b>', //fixed matching in tag surrounding icon text.
      title: 'Bold',
      comName: 'bold',
      state: true,
      short: 'b',
    }, {
      icon: '<i>I</i>',
      title: 'Italic',
      comName: 'italic',
      state: true,
      short: 'i',
    }, {
      icon: '<u>U</u>',
      title: 'Underline',
      comName: 'underline',
      state: true,
      short: 'u',
    }, {
      icon: '<b>H<sub>1</sub></b>',
      title: 'Heading 1',
      comName: '<h1>',
      formatBlock: 'formatBlock'
    }, {
      icon: '<b>H<sub>2</sub></b>',
      title: 'Heading 2',
      comName: '<h2>',
      formatBlock: 'formatBlock'
    }, {
      icon: 'P',
      title: 'Paragraph',
      comName: '<p>',
      formatBlock: 'formatBlock'
    }, {
      icon: '&#8220; &#8221;',
      title: 'Quote',
      comName: '<blockquote>',
      formatBlock: 'formatBlock'
    }, {
      icon: '&#35;',
      title: 'Ordered List',
      comName: 'insertOrderedList',
      state: true,
    }, {
      icon: '&#8226;',
      title: 'Unordered List',
      comName: 'insertUnorderedList',
      state: true,
    }, {
      icon: '&#128279;',
      title: 'Link',
      comName: 'createLink',
      extra: () => window.prompt('Enter the link URL'),
    }, {
      icon: '&#128247;',
      title: 'Image',
      comName: 'insertImage',
      extra: () => window.prompt('Enter the link URL'),
    },
  ]
}

EDIT: All code is now up to date.

\$\endgroup\$
  • 1
    \$\begingroup\$ I was tempted to rollback version 4... but it doesn't look like anything changed that could have invalidated the existing answer. Please do not update the code in your question once you have received answers- doing so goes against the Question + Answer style of Code Review. This is not a forum where you should keep the most updated version in your question. Please see what you may and may not do after receiving answers. \$\endgroup\$ – Sᴀᴍ Onᴇᴌᴀ Apr 7 '18 at 23:44
  • \$\begingroup\$ OK, sorry. Didn't know that. Thanks :) \$\endgroup\$ – Stoik Apr 8 '18 at 7:27
2
\$\begingroup\$

Vet style commands.

I would like to say "Great stuff!" as the code quality is good, but unfortunately it has some problems that make using document.execCommand function not as straight forward as it could be.

The problem is that it does not check if the formatting requested makes intuitive sense. This can badly messes up the document and make it near impossible to correct.

Examples

  • It is possible to have nested styles when the selection crosses over the start or end of an existing style. Then depending on the document layout trying to fix the problem with the UI just makes it worse.

  • When pasting from another html document, the pasted elements can have styles that negate the UI controllable styles, effectively blocking the ability to edit style via the buttons, in part or all of the document.

Vet

To fix these problems you need to look at the document content and remove unwanted formatting/styles. You need to check the selection before calling a command and not allow counter intuitive actions. You need to remove empty (thus invisible) tags as they will interfere with formatting and making editing counter intuitive.

\$\endgroup\$
  • \$\begingroup\$ Hi, this code is out of the date, I have made a lot of changes so you can check them out on new links, I'll update the code soon. Edit: I have updated the code. \$\endgroup\$ – Stoik Apr 7 '18 at 15:54
1
\$\begingroup\$

I would suggest that whenever you create a lambda/anonymous function/closure to simply call one other function, use Function.bind() to create partially applied functions instead. That way, there will be one less function call in the stack.

So for instance:

['click', 'touch'].forEach((evn) => {
    ctrElement.addEventListener(evn, (e) => {
        outElement.focus();
    });
});

Can be re-written as:

['click', 'touch'].forEach((evn) => {
    ctrElement.addEventListener(evn, outElement.focus.bind(outElement));
});

And similarly,

setTimeout(() => {
      execute(null, 'formatBlock', '<div>');
}, 0);

Can be re-written as:

setTimeout(execute.bind(null, null, 'formatBlock', '<div>'), 0);

Bear in mind that for functions passed as the listener argument to EventTarget.addEventListener(), the first argument after any arguments bound via Function.bind() will be the Event reference.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.