DEV Community

Steve Alex
Steve Alex

Posted on

1 1

A rudimentary Stimulus WYSIWYG markdown editor

After about a month of refactoring one of my Rails applications, I though I should document what all I did. I first started to write in a .md document using Sublime Text. After few paragraphs I previewed what little I wrote. This back and forth was going to be a pain and I though, maybe I should add a javascript markdown editor.

I did a quick search and found a few from the 10 best.. or 7 best posts. Half of them had not been changed in years. I've been using Stimulus for about a year after about a four month effort of de-jQuering three applications. That included getting away from CSS frameworks that relied on jQuery (Zurb Foundation in my case) and just using the simplistic but effective W3.CSS. I did another quick search for stimulus javascript editor and up popped a link Build a Markdown Editor in Stimulus.js like in Vue.js.

I thought this was amazing - a WYSIWYG editor with a 4 line HTML template and an 8 line Stimulus controller! Of course it used a javascript package marked to do the heavy lifting.

I threw the 12 lines in a demo page, after changing to slim, and it worked. Wow I said, but it does not have any of the little buttons for Bold, List, etc. Well I didn't have anything else to do yesterday (or wanted to do!), so I started tinkering.

The HTML grew to about 24 lines, most of it for the added buttons (just abbreviated text buttons - missing a few).

.w3-container[data-controller="marked" style="max-height: 90vh;"]
  h3 Marked Demo
  .w3-row-padding
    .w3-half
      = tag.button('B', data:{action:"click->marked#bold"})
      = tag.button('I', data:{action:"click->marked#italic"})
      = tag.button('BI', data:{action:"click->marked#italicBold"})
      = tag.button('H1', data:{action:"click->marked#h1"})
      = tag.button('H2', data:{action:"click->marked#h2"})
      = tag.button('H3', data:{action:"click->marked#h3"})
      = tag.button('H4', data:{action:"click->marked#h4"})
      = tag.button('H5', data:{action:"click->marked#h5"})
      = tag.button('H6', data:{action:"click->marked#h6"})
      = tag.button('BQ>', data:{action:"click->marked#backQuote"})
      = tag.button('BT`', data:{action:"click->marked#backTick"})
      = tag.button('UL', data:{action:"click->marked#ul"})
      = tag.button('OL', data:{action:"click->marked#ol"})
      = tag.button('FC', data:{action:"click->marked#fencedCode"})
      = tag.button('LK', data:{action:"click->marked#anchor"})
      = tag.button('IM', data:{action:"click->marked#image"})
      = tag.textarea('', data:{action:"input->marked#convertToMarkdown change->marked#convertToMarkdown",marked_target:"markup"},style:'width:100%;height:75vh;')
    .w3-half
      = tag.button('Rendered Markdown')
      = tag.div('',data:{marked_target:'viewer'},style:"border:solid black 1px;height:75vh;overflow:scroll;")
Enter fullscreen mode Exit fullscreen mode

The controller grew to about 160 lines, most of it in a getInputSelection function that I found. I can't find the link in my history, but did find the jsfiddle page.

import { Controller } from "stimulus"
import marked from 'marked/lib/marked.js'

export default class extends Controller {

  static targets = ["viewer","markup"]

  connect() {
  }

  convertToMarkdown(event) {
    this.viewerTarget.innerHTML = marked(event.target.value, {sanitized: true})
  }

  trigger(){
    var chg = new Event('change',{ bubbles: true })
    this.markup.dispatchEvent(chg)
    // marked does not pick up js insertions, trigger a change
  }


  h1() {
    var selected = this.markup.value.substring(this.range.start,this.range.end)
    this.insert('\n# '+selected)
  }

  h2() {
    var selected = this.markup.value.substring(this.range.start,this.range.end)
    this.insert('\n## '+selected + ' ')
  }

  h3() {
    var selected = this.markup.value.substring(this.range.start,this.range.end)
    this.insert('\n### '+selected)
  }

  h4() {
    var selected = this.markup.value.substring(this.range.start,this.range.end)
    this.insert('\n#### '+selected)
  }

  h5() {
    var selected = this.markup.value.substring(this.range.start,this.range.end)
    this.insert('\n##### '+selected)
  } 

  h6() {
    var selected = this.markup.value.substring(this.range.start,this.range.end)
    this.insert('\n###### '+selected)
  } 

  bold() {
    this.wrap('**')
  }

  italic() {
    this.wrap('*')
  }

  italicBold() {
    this.wrap('***')
  }

  backTick(){
    this.wrap('`')
  }

  ul() {
    var selected = this.markup.value.substring(this.range.start,this.range.end)
    selected = "\n* " + selected.replace(/\n/g,"\n* ")
    this.insert(selected)
  }

  ol() {
    var selected = this.markup.value.substring(this.range.start,this.range.end)
    selected = "\n1. " + selected.replace(/\n/g,"\n1. ")
    this.insert(selected)
  }

  fencedCode(){
    var selected = this.markup.value.substring(this.range.start,this.range.end)
    selected = "```

lang\n " + selected + "\n

```"
    this.insert(selected)
  }

  anchor(){
    var selected = this.markup.value.substring(this.range.start,this.range.end)
    selected = "[" + selected + "]" + '(http://...)'
    this.insert(selected)
  }

  image(){
    var selected = this.markup.value.substring(this.range.start,this.range.end)
    selected = "![" + selected + "]" + '(path/to/img.jpg)'
    this.insert(selected)
  }

  backQuote(){
    var selected = this.markup.value.substring(this.range.start,this.range.end)
    selected = ">" + selected
    this.insert(selected)
  }

  get markup() {
    return this.markupTarget
  }

  get range(){
    return this.getInputSelection(this.markup)
  }

  insert(selected){
    const len = this.markup.value.length
    this.markup.value = this.markup.value.substring(0,this.range.start) + selected + this.markup.value.substring(this.range.end,len)
    this.trigger()
  }

  wrap(wrapper){
    const len = this.markup.value.length
    const wrapie = wrapper + this.markup.value.substring(this.range.start,this.range.end) + wrapper
    this.markup.value = this.markup.value.substring(0,this.range.start) + wrapie + this.markup.value.substring(this.range.end,len)
    this.trigger()
  }

  getInputSelection(el) {
    var start = 0, end = 0, normalizedValue, range, textInputRange, len, endRange;

    if (typeof el.selectionStart == "number" && typeof el.selectionEnd == "number") {
        start = el.selectionStart;
        end = el.selectionEnd;
    } else {
        range = document.selection.createRange();

        if (range && range.parentElement() == el) {
            len = el.value.length;
            normalizedValue = el.value.replace(/\r\n/g, "\n");
            // Create a working TextRange that lives only in the input
            textInputRange = el.createTextRange();
            textInputRange.moveToBookmark(range.getBookmark());
            // Check if the start and end of the selection are at the very end
            // of the input, since moveStart/moveEnd doesn't return what we want
            // in those cases
            endRange = el.createTextRange();
            endRange.collapse(false);

            if (textInputRange.compareEndPoints("StartToEnd", endRange) > -1) {
                start = end = len;
            } else {
                start = -textInputRange.moveStart("character", -len);
                start += normalizedValue.slice(0, start).split("\n").length - 1;

                if (textInputRange.compareEndPoints("EndToEnd", endRange) > -1) {
                    end = len;
                } else {
                    end = -textInputRange.moveEnd("character", -len);
                    end += normalizedValue.slice(0, end).split("\n").length - 1;
                }
            }
        }
    }

    return {
        start: start,
        end: end
    };
  }

}  
Enter fullscreen mode Exit fullscreen mode

Well I felt proud of myself - but I ended up using sublime text to write this!! I did use the editor to tweak and add some stuff.

Stimulus is really amazing for someone who had a lot of problems with early rails javascript - made it through coffeescript - and now feel comfortable with stimulus and the little es6 javascript sprinkles.

Sentry image

Hands-on debugging session: instrument, monitor, and fix

Join Lazar for a hands-on session where you’ll build it, break it, debug it, and fix it. You’ll set up Sentry, track errors, use Session Replay and Tracing, and leverage some good ol’ AI to find and fix issues fast.

RSVP here →

Top comments (0)

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more