📜 ⬆️ ⬇️

jsiedit: idea and creation of a convenient WYSIWYM plug-in editor with an example for Habrahabr

Introduction


The article describes an approach to creating a convenient Javascript tool for online text editing. As an example, created a prototype for editing articles on Habré ( described below ). With it, now and make changes to this article.

I was faced with the task of choosing an online editor for texts on the site. The most obvious solution would be one of the WYSIWYG editors. But I did not like this option for several reasons. First, many vulnerabilities of popular CMS systems are associated with WYSIWYG editors. Secondly, after publication, the text will often differ from what was in the editor. Thirdly, such editors are difficult to expand to support new tags and elements. Therefore, I stopped at the WYSIWYM editor.


')
Simultaneously with the choice of the WYSIWYM editor, a question arose with the choice of a markup language. Should I use Wiki or Markdown syntax, maybe TeX-like language or even HTML directly, and for some tasks, bbCode may be enough? After some reflection, I came to the conclusion that data can be stored in any format, but with the obligatory clear separation of content and attributes. This will ensure that even a change in the display algorithms does not distort the information. As for editing, the user can be given the opportunity to change the data in a convenient way.

Problem


I have a huge complaint about existing implementations of online editors. They are inconvenient because the view is separate from the code. Of course, it’s worth arguing that this is the basis of WYSIWYM. That's right, but let's consider a specific situation.

Suppose that you write an article on Habré or the answer to the forum. The syntax of the corresponding tags is familiar, therefore there are no problems with entering text. If you need to insert an image or otherwise select an element, you can select the appropriate tag on the toolbar or enter it manually. The first version of the text is ready, but to check the formatting before sending you will need to click the "Preview" button. And only here appears not only the source code with all the tags, but also its specific presentation.

In the course of viewing the already formatted text, find a typo or want to correct and supplement something. There is a problem. An erroneous place has already been found in the preview area, but to correct it, it is necessary to return to the editor, where among the set of tags to find the fragment that is to be corrected.

This problem is well seen in the wiki or with the CMS pages, when, when you try to slightly correct a sentence, you have to edit the entire document. And the larger the document, the harder the user. He has already found a place that should be corrected in a visual representation, but he needs to re-go through this entire search path, but already in the source code.

Decision


The ability to dynamically connect an editor for a fragment selected by a user looks natural. Found and looked at the following implementations: Jeditable , jquery-in-place-editor , jQuery Plugin: In-Line Text Edit , Ajax.InPlaceEditor , EditableGrid , InlineEdit 3 . The disadvantage of all is the same: they allow you to load the editor only for a single element, because they do not support formats. Therefore, it was decided to make the editor himself and "reinvent the wheel."

So jsiedit appeared.

During the writing of the article I came across Redactor , which allows you to turn on the editor for the whole text, but there again the block is set in advance, and this is also the WYSIWYG editor. Closest to my idea is Redactor Air mode , but this is only formatting.

Jsiedit goals and objectives

At the initial stage of implementation, the following key requirements were identified:


Implementation

So, it was necessary to solve two problems. Let's start with the ability to edit formatted text. Take any set of tags, for example, bbCode. Suppose that initially the document was created in this format and the text is stored on the server with bbCode tags. When the page is displayed to the user on the server, bbCode tags are converted to the corresponding HTML constructs.
Now the user wants to edit part of the already formatted text. It turns out that we need to get the original bbCode tags for the selected fragment. There may be two approaches. First, you can dynamically (on the client) convert the HTML code into the corresponding bbCode text. Secondly, you can save bbCode tag information in HTML attribute tags in advance:
<p> ,   <b data-bbCode="b"> </b> .</p> 

You can also consider the option when bbCode is dynamically requested from the server for specific elements, but this will require more serious work on the server side.
When designing, I did not choose one of these three options, but decided to use the callback function so that the developer himself decided how the HTML presentation should be converted to the required format. In the examples I used dynamic HTML conversion.

After completing the input, you must perform the reverse transformation and save the changes to the server. In this case, it was again decided to use the callback function and allow the developer to decide for himself what to do.

Text selection

Now consider the second task - providing the user the opportunity to choose which piece of text he wants to edit. There is a good article Range, TextRange and Selection about mouse selection , so I’ll not describe the objects and Javascript functions.

The question remains convenience by the user. Imagine that I selected a few words in the sentence with the mouse and launched the editor. What exactly did I want to edit: only these two words, the whole sentence or the whole paragraph? And if I selected words not completely, but only several letters? In this case, I think that it should be possible to edit the entire paragraph, that is, the enclosing tag. But the next ambient tag may not be <p>, but another, for example, <b>:
 <p>   <b> (!) </b>.</p> 

If we select "(!)", Then the editor should be displayed only for "internal (!) Selection" or for the whole paragraph? I believe that here the user needs to be given the opportunity to edit the entire paragraph, but for flexibility it was decided to enable the callback function, which for each DOM element will indicate whether activation of the editor is possible. A similar function can be implemented like this:
 function jsiedit_fn_sample_tag_check(elem) { switch (elem.tagName) { case 'P': case 'DIV': case 'SPAN': return true; } return false; } 


The result is the following algorithm for determining the selected range:
 elem = range.commonAncestorContainer; //     while (elem && !fn_is_valid_for_edit(elem)) //  callback  { elem = elem.parentNode; //    } 


All right, but here we can wait for a trap. Let's consider the following example:
 <div>...     ... <p>      , , .  ,</p> <p>    <b>  .</b>       .</p> ...       ...</div> 

If we just take commonAncestorContainer , we will get a <div> and give the user all the text for editing. On the other hand, the user most likely wants to edit only the two highlighted paragraphs. In this case, we need to expand each selection to full coverage of the tags <p> and stop.
The Range object has suitable properties: startContainer and endContainer . But here it is necessary to align the containers to one level, so that they were brothers. The result is the following code:
 var prnt = rng.commonAncestorContainer; //     var sc = rng.startContainer; //  ""  var ec = rng.endContainer; //  ""  if ((sc == prnt) || (ec == prnt)) //          { sc = prnt; ec = prnt; } else //       { while (sc.parentNode != prnt) { sc = sc.parentNode; } while (ec.parentNode != prnt) { ec = ec.parentNode; } } 

To this code, you must add the previous code to check the possibility of editing a particular block. Use the setStartBefore and setEndAfter methods to create a range:
  var rng_new = document.createRange(); rng_new.setStartBefore(sc); rng_new.setEndAfter(ec); 


This completes the task of determining the selected block.

Editor Display

The next step was to display the editor. It can be displayed in a separate window, be fixed on the initial page, but I was interested in the option when the editor appears in the place of the edited text itself. Initially, the task seemed quite simple and the following code was written:
  var tarea = document.createElement("textarea"); //     tarea.value = fn_get_text_for_range(rng_new); //     tarea.style.width = rng_new.startContainer.clientWidth; //    rng_new.startContainer.parentNode.insertBefore(tarea, rng_new.startContainer); //       rng_new.deleteContents(); //      

When executing this code, the reality was very different from the predicted result. The reason was that the startContainer attribute turned out to be the “ancestor” for my selection. I can explain this by saying that the new range starts before the selected block. As a result, I decided to use the previously calculated variables sc and ec .
The next surprise was associated with an attempt to add an object to the range. In practice, it turned out that the added object fell into the range and was destroyed by the next line. To avoid this, the editing area began to be created after the range. Additionally, I decided to approximately determine the height for the editor. The result is the following code:
  var tarea = document.createElement("textarea"); //     tarea.value = fn_get_text_for_range(rng_new); //     tarea.style.width = ec.clientWidth + 'px'; //    if (sc == ec) { tarea.style.height = Math.min(document.body.clientHeight / 2, sc.clientHeight) + 'px'; //    } else { tarea.style.height = Math.min(document.body.clientHeight / 2, sc.clientHeight + ec.clientHeight) + 'px'; //    } ec.parentNode.insertBefore(tarea, ec.nextSibling); //     rng_new.deleteContents(); //      

A pair of buttons has been added to the textarea for the ability to save results and cancel editing.

The last question was the challenge to the editor. The editor can be activated, for example, by pressing a certain button on the page, from the context menu, automatically when you select the text while pressing Alt, etc. I decided to add the simplest method - displaying the button next to the place where the selection was stopped.

This required adding a mouseup event handler . The position for the output button determined by the attributes pageX and pageY . It turned out about the following:
 function jsiedit_mouseup(event) { var btn = document.createElement("input"); btn.type = "button"; btn.value = "Edit"; btn.style.position = 'absolute'; btn.style.top = event.pageY + 'px'; btn.style.left = event.pageX + 'px'; btn.onclick = fn_start_editor; document.body.appendChild(btn); } function jsiedit_onload() { document.body.addEventListener("mouseup", jsiedit_mouseup, false); } document.addEventListener("DOMContentLoaded", jsiedit_onload, false); 

This code does not work correctly, because it creates a new “Edit” button each time the mouse button is released. To correct, it is enough to save the current state in a global variable and change it depending on user actions.

At this first version of jsiedit was ready.

Editor for Habrahabr

As a demonstration of possibilities, it was decided to create a prototype for Habr. I felt the need for such an editor when writing the first article, because it turned out to be great, and without a preview it was difficult and inconvenient to look for errors. The situation was aggravated by the prohibition on resizing the input form. As a result, the original text was written in notepad. Here you can read about running an example to try it yourself.

The created editor should allow editing any text in the preview area, and after saving, edit both the edited text and its source in the input field. It was supposed to use a bookmarklet as a launch. Actually, this is how it all works.

Consider the problems encountered during the creation of the editor.

No paragraphs

The first difficulty was the fact that there are no paragraphs in the Habr texts. Instead, when saving, just line breaks are used <br />. As a result, the edited block should be limited to some tag-boundaries. Let's look at the following generated HTML code:
   <br> <h4></h4><br>   ,       "P".<br> <br>  ,    <b> <i></i> </b>.     "BR"<br>    .<br> <br>   

All text is in one DIV, so the previous algorithm will return all text for editing. In this case, we need to “cut out” the block between the two nearest BR tags. To do this, we can move on the properties of previousSibling and nextSibling .

The most difficult at this stage was the choice of approach to the definition of functions for checking nodes. As a result, it was decided that the function would produce an array of logical properties for this node:
  1. This node can be independently selected.
  2. The node itself must be included in the selection.
  3. This node can be a common ancestor to choose from.
  4. This node can be a limiting node when choosing brothers.

The result was the following check function:
 function jsiedit_fn_sample_check_node(node) { switch (node.tagName) { case 'BR': return [false, false, false, true]; case 'P': return [true, true, false, true]; case 'DIV': case 'SPAN': return [true, false, true, true]; } return false; } 


If we take into account the specifics of the preview on Habré, then we get the following function:
 function jsiedit_fn_sample_habr_check_node(node) { switch (node.tagName) { case 'BR': return [false, false, false, true]; case 'DIV': if (node.className == 'content html_format') return [true, false, true, true]; } return false; } 


Taking into account the new function, the block for determining the boundaries of the selected data was rewritten:
function jsiedit_get_bounds
 function jsiedit_get_bounds(fn_check_node) { var sel = window.getSelection(); //    if (!(typeof sel === 'undefined')) // ,  -   { if (sel.rangeCount == 1) //      { var rng = sel.getRangeAt(0); //  range   var prnt = rng.commonAncestorContainer; //     var sc = rng.startContainer; //  ""  var ec = rng.endContainer; //  ""  if ((prnt.tagName == 'DIV') || (prnt.tagName == 'SPAN')) { if (prnt == sc) sc = prnt.childNodes.item(rng.startOffset); if (prnt == ec) ec = prnt.childNodes.item(rng.endOffset); } var chk = fn_check_node(prnt); var include_bounds = [true, true]; //     if (chk && chk[2] && (sc != prnt) && (ec != prnt)) //      ,       { while (sc.parentNode != prnt) { sc = sc.parentNode; } while (ec.parentNode != prnt) { ec = ec.parentNode; } } else if (chk && chk[0]) //     ,  { return [prnt, chk[1]]; } else //   ,    { while (prnt.parentNode) { chk = fn_check_node(prnt.parentNode); if (chk && chk[2]) { sc = prnt; ec = prnt; prnt = prnt.parentNode; break; } else if (chk && chk[0]) { return [prnt.parentNode, chk[1]]; } prnt = prnt.parentNode; if (!prnt.parentNode) return false; } } chk = fn_check_node(sc); if (chk && chk[0]) //      { } else { while (sc.previousSibling) //  ""  { sc = sc.previousSibling; chk = fn_check_node(sc); if (chk && chk[3]) { include_bounds[0] = chk[1]; //      break; } } } chk = fn_check_node(ec); if (chk && chk[0]) //      { } else { while (ec.nextSibling) //  ""  { ec = ec.nextSibling; chk = fn_check_node(ec); if (chk && chk[3]) { include_bounds[1] = chk[1]; //      break; } } } return [sc, ec, include_bounds[0], include_bounds[1]]; } } return false; } 

During the verification of the first version of jsiedit, sometimes instead of the selected fragment in the editor appeared all the text. The reason for this turned out to be that at the beginning of selection on a void, the system returned the ancestor as startContainer , and in startOffset the child element was saved from which the selection was coming. The situation is similar with the ending. So I had to use the following code:
 if ((prnt.tagName == 'DIV') || (prnt.tagName == 'SPAN')) { if (prnt == sc) sc = prnt.childNodes.item(rng.startOffset); if (prnt == ec) ec = prnt.childNodes.item(rng.endOffset); } 

It is possible that it would be more correct to perform a check by the type of tag, but for the prototype I was also satisfied with this check.

Text conversion

The work of the editor required two conversion functions. The first is to create a code in the Habra markup language from the fragment selected on the page. This is a fairly simple part, since you can do a DOM traversal and convert the tags separately.

The inverse transformation can be performed using the preview mechanism already existing on the Habrahabr - send the corrected text to the server and get the HTML code back. But I decided to do this conversion on the client side. Originally tried to find a ready-made HTML parser on Javascript. Unfortunately, the found implementations did not suit me. Then I realized that I would need to write a parser from scratch and reinvent the next bike. Since this is not a quick deal, it was decided to postpone the parser for individual articles, and for the prototype to find at least a temporary solution. As the simplest approach, it was decided to simply add a transformation for non-standard tags. The result was the following function:
 jsiedit_fn_sample_habr_produce = function(src) { var rep = [ [/<source\s+lang=/gi, '<pre><code class='], [/<\/source>/gi, '</code></pre>'], [/\n/g, '<br>'], [/<hh\s+user=['"]([^'"]+)['"]\s*\/>/gi, '<a href="http://habrahabr.ru/users/$1/" class="user_link">$1</a>'], [/<spoiler\s+title=['"]([^'"]+)['"]>/gi, '<div class="spoiler"><b class="spoiler_title">$1<\/b><div class="spoiler_text">'], [/<\/spoiler>/gi, '</div></div>'] ]; var str = src; var i; for (i = 0; i < rep.length; i++ ) { str = str.replace(rep[i][0], rep[i][1]); } return str; }; 

But then I had to deal with this function in detail. There was a problem with the program codes - tag <source> . All tags included in it should not be interpreted, but they should be interpreted outside these blocks. Because of this, the formatting began to “break”.
One of the working solutions was to automatically replace all the characters "<" with "& lt;" when receiving the code. Although it worked, the resulting code in the editor was too ugly. As a result, proceeded to refine the function. The algorithm chose the following: find all the pieces of code that include the code, and then replace all the critical characters inside them. The result is the following conversion:
 jsiedit_fn_sample_habr_produce = function(src) { var fn_source = function(s) { var res = s.match(/^<source\s+lang=([^>]*)>([\s\S]*)<\/source>$/i); if (res) return '<pre><code class=' + res[1] + '>' + res[2].replace(/</g,'<').replace(/>/g,'>') + '</code></pre>'; res = s.match(/^<source>([\s\S]*)<\/source>$/i); if (res) return '<pre><code>' + res[1].replace(/</g,'<').replace(/>/g,'>') + '</code></pre>'; res = s.match(/^<pre>([\s\S]*)<\/pre>$/i); if (res) return '<pre>' + res[1].replace(/</g,'<').replace(/>/g,'>') + '</pre>'; return s.replace(/</g,'<').replace(/>/g,'>'); }; var rep = [ [/(<source\s([\s\S])*?<\/source>)|(<source>([\s\S])*?<\/source>)|(<pre>([\s\S])*?<\/pre>)/gi, fn_source], [/<anchor>([^<]*)<\/anchor>/gi, '<a name="$1"></a>'], [/\n/g, '<br>'], [/<hh\s+user=['"]([^'"]+)['"]\s*\/>/gi, '<a href="http://habrahabr.ru/users/$1/" class="user_link">$1</a>'], [/<spoiler\s+title=['"]([^'"]+)['"]>/gi, '<div class="spoiler"><b class="spoiler_title">$1<\/b><div class="spoiler_text">'], [/<\/spoiler>/gi, '</div></div>'] ]; var str = src; var i; for (i = 0; i < rep.length; i++ ) { str = str.replace(rep[i][0], rep[i][1]); } return str; }; 

It is likely that this code can be made much nicer, but now the editor for Habr was created only as a prototype - the rationale for the idea. As needed, functionality can be improved and additional tags added. I think that regular expressions will be enough. The idea of ​​implementing the parser has lowered the priority.

Conclusion


Summing up, I want to emphasize that the purpose of this article was to describe the idea of ​​creating a javascript in-place WYSIWYM editor and a specific approach to implementation. I would be glad if such an editor would interest someone else. Please write your opinion. Maybe there are already more interesting solutions?

Questions and answers

Q1: Why is xxxxxxx not used? Why aren't all errors processed? Why does the code not work in the yyyyyyy browser?
A1: I tried to explain the idea of ​​an in-place WYSIWYM editor. The prototype is a proof of concept that works in FF18, where now I write these lines. While I was the only interested consumer. If you are interested in the development of this library, then write. The information provided is sufficient to ensure the work in the required browsers, to connect the necessary library or framework.
Addition: The script is adjusted to work in browsers based on WebKit.

Q2: Why the bookmarklet for Habr does not highlight the syntax when saving? Why aren't all tags supported?
O2: If there is interest, then all this can be realized. Of the remaining tags, first of all I would implement <video>.

Q3: What are your next plans?
A3: It will depend very much on your reaction. First, we need to add support for editing articles (now I can not check), and not just the creation of new ones. (already added) Secondly, the elimination of known errors and the expansion of the list of supported tags. Then, most likely, I'll start with Add-On for FF to get rid of the bookmarklet.

Q4: Where can I find the source code?
O4: Everything is on the project page on github: http://praestans.github.com/jsiedit/ .

Q5: How to use jsiedit for Habrahabr?
O5: It is easiest to start as a bookmarklet ( what is it ). When creating a new theme, you need to make a preview, after which an editor will be activated in the preview area when the mouse is selected. When you save all the data from the preview area will be transferred to the input window.

Here is an image with detailed instructions.

This is a link to a bookmarklet (it should be bookmarked). Here is the self-formatted text of a bookmarklet:
 javascript: (function () { var a = document.createElement('script'); a.type = 'text/javascript'; a.src = 'http://praestans.github.com/jsiedit/lib/habr_bmk.js'; document.getElementsByTagName('head')[0].appendChild(a); })(); 


Warning: only the following tags are supported: A, ANCHOR, B, BLOCKQUOTE, BR, EM, H1, H2, H3, H4, H5, H6, HABRACUT, HH, HR, I, IMG, LI, OL, SOURCE, STRIKE, STRONG, SUB, SUP, TABLE, TD, TH, TR, U, UL. When using the next (new for yourself) tag using the preview, check that the tag is interpreted correctly. For some use cases, incorrect formatting may be obtained, and the text of the corresponding element will disappear.

Q6: What else do you need to know?
A6: At the moment there are several errors and features of work known to me:
1. When saving lists between add there is <br> <br> between <li> blocks, so the list starts to “disperse”. If the new <li> tag is on the end line of the previous one, there will not be any extra gaps.
2. After saving the last paragraph, sometimes, it changes places with the last but one. I suppose you need to understand the functions of working with ranges and inserts in the DOM.
3. If you select a large block of text, this block will be hidden, and the editor will be at the very beginning of the block and you will need to scroll up. It is necessary to add a function that would ensure the editor’s visibility on the screen at the start of editing.
4. One of the features of the implementation is that the program itself generates the source text markup based on HTML text preview.Since the original author's code is not available, all tags are presented in the same way, in the current version - in capital letters.
5. As a result of the fact that the program performs the conversion of the entered text into html, and then back into the habrabra code, with conversion errors, it is impossible to return the text back to correct even minimal errors. If <source> blocks are in the “corrupted” code, the angle brackets "<" and ">" will be replaced by "& lt;" and "& gt;" But after fixing them for the <source> and </ source> tags, everything should be correct again.
6. When editing an article, the text of the <habracut> tag disappears, since it is not in the preview.

Addition 1:Added the ability to edit existing articles, and not just create new ones.
Also fixed a bug due to which the script did not work in WebKit based browsers. Thanks Leksat and tkf . The reason was that it was natural for me to set the default value for the function parameter. Firefox accepted everything, but chrome does not understand the following construction:
 function(param1, param2=default_value) 

Source: https://habr.com/ru/post/166043/


All Articles