[html/js]思路记录(:写一个模仿medium的文本编辑器(未完成)

322 阅读8分钟

(在测试过程中,发现medium网站本身的脚本似乎都写得不是很稳定,使用过程中会出错,然后需要重新打开网页)

先看一下最终我完成的效果

1. 基础布局

思路:

  1. 外面一个container,大小全屏(或者占满父容器); 里面一个左右居中的story(文本内容区域),文本设置pading,
  2. story里一个title,一个p。 第一步完成后效果图:

image.png

2. 文本回车退格空格

  1. 文本可编辑的设置

先对元素设置contenEditable=“true”,然后设置css使得获得焦点时的自带外边框消失;

[contenteditable] {
    outline: 0px solid transparent;
    /* 默认情况下鼠标是箭头,这里要让它变成插入符的样子 */
    cursor: text;
}
  1. 设置文本占位符

设置可编辑区域的占位符有好些方法,这里写一种比较简单的方法。 方法来源

html元素添加自定义属性placeholder="占位符", css代码:

[contentEditable=true]:empty:before {
    content: attr(placeholder);
    opacity: .6;
}

此时效果

image.png

  1. 回车退格后的段落增添和focus变动

分析:

(1)默认情况下,在可编辑区域按下回车键后页面会自动新建一个div,所以首先要禁止此行为;

(2)在按下回车键的元素后添加一个p。需要注意的是如果是在标题区域按下的回车,那么证明我们新增添了一个第一段,那么此时应该把正文区域placeholder放在第一段,把原来的placeholder去掉。

 if (e.key == 'Enter') {
        // 阻止默认自动产生新的div
        e.preventDefault();

        //新建p
        const $newPara = document.createElement('p');
        $newPara.setAttribute('contentEditable', 'true');

        //如果是在标题后按退格,那么第一个p已经变成新的
        //需要把第一个p的placeholder加上,之前p的相关属性去掉
        const $fistPara = document.getElementsByClassName(STORY_PARA_FIST_CLASSNAME)[0];

        $newPara.className = STORY_CONTENT_CLASSNAMES;
        if ($curr.tagName == 'H3') {
            $fistPara.classList.remove(STORY_PARA_FIST_CLASSNAME);
            $fistPara.removeAttribute('placeholder')
            $newPara.classList.add(STORY_PARA_FIST_CLASSNAME);
            $newPara.setAttribute('placeholder', STORY_CONTENT_PLACEHOLDER);
        }

        $story.insertBefore($newPara, $curr.nextSibling);
        $newPara.focus();
    }

(3)按下delete键删除文字时,如果p内已无文字,则删除该p,focus到上一个p。如果仅存1个p,则不进行删除p的操作,而是回focus到标题区。

    else if (e.key == 'Backspace' && $curr.tagName == 'P') {
        if (!$curr.innerText) { //确认已经退格到头
            const $previousEle = $curr.previousSibling;

            // 当前最后一个p
            if (!$previousEle.tagName) { //最后一个p时返回文本节点,tagName为undefined
                focusAfterLastChar($storyTitle);
            }
            // p大于1个时
            if ($previousEle.tagName == 'P') {
                $story.removeChild($curr);
                focusAfterLastChar($previousEle);
            }
        }
    }

这里(2)(3)中的 focusAfterLastChar()方法是我自己定义的,目的是使focus位置能在文字的最后。

function focusAfterLastChar(ele) {
    const textRange = document.createRange(); //获得一个范围是全doc的range
    textRange.selectNodeContents(ele); //将range范围设定为要focus的p
    textRange.collapse(false); // collapsed状态的range内容是空的,而代表dom树中的一个点,这里把这个点的位置设置为p的结尾;
    const sel = window.getSelection(); //返回一个Seletion物件,代表当前用户选中或插入符的当前位置;
    sel.removeAllRanges();
    sel.addRange(textRange); //清除目前Seletion中的Range,把Range设定为前面设定的textRange
    ele.focus();
}

记录:此时写完这部分后测试有几个问题;

  1. 如果汉字输入一半(拼音阶段)就按了回车,最后的几个字母会被放到下一行去。
  2. 如果是title后新建的p,这个p按退格回不到title
  3. 如果该文本区域已经没有文字,回退到上一区域时,上一区域会被删掉一个文字。

漏掉的效果:

  1. 如果p有文字,手动把插入符放在了p最开头,那么此时按退格后应该把这段文字和上一段文字合并;

解决:

问题(1): 首先尝试在新建p之后直接强制把innerText设置为空,发现没有效果(即使放在keydown监听回调函数最后一行也没用);原因分析:『自动填充下一行文字』是发生在keydown事件之后的。补充下面的代码,能起到把innerText设置为空发生在keydown事件之后的效果,从而达到我们的目标。

     setTimeout(() => $newPara.innerText = '', 0);

问题(2) 分析:通过console.log可以发现新插入的p结点的前一个sibling是h3,而自己写的一个p结点的前一个是#Text,所以代码进行下面的修改

            if (!$previousEle.tagName || $previousEle.tagName == 'H3') { 
                focusAfterLastChar($storyTitle);
            }

问题(3) 分析:当该文本区无文字时,删除的操作并没有进行,而是会放到插入符变动到上一行后进行。所以我们直接阻止默认行为(删除行为)即可。

    else if (e.key == 'Backspace' && $curr.tagName == 'P') {
        if (!$curr.innerText) { 
            e.preventDefault(); // 加上这句
            console.log('f');

漏掉的效果1

分析:我们需要检测如果「插入符在开头and p有内容」,那么就把该p去掉,并在上一个p或h3的文本基础上添加上当前p里的文本。

以上分析中最重要的就是:「如何检测插入符在开头」。

自己想出来的一种方法是:如果p有内容但按下del后p的值未发生变化,说明插入符在开头。经测试,添加打印文本代码,按下退格后,打印出来的是删除前的。所以我们要拿到删除后的值就需要用timeout来拿。

研究这段代码过程中发现的问题是:当回退后,插入符需要插在上一个区域原本内容的最后,但是当执行插入后插入符会自动变到最开头,也就是说更改文本区域内容会影响插入符位置。

 if (isTitleElement($previousEle)) {
      focusAfterLastChar($storyTitle);
      setTimeout(() => { $storyTitle.innerText += textNew }, 3000)
      //用以上代码进行测试,会发现执行插入后插入符会自动位于开头位置,
      //如果插入文字与上面的focus对调,那么插入符会位于新文本最后,也不是我们想要的效果

最终在这里找到了答案。

// 把设置插入符的代码改为这个
function setCaret(ele, pos) {
    const range = document.createRange(ele);
    range.setStart(ele.childNodes[0], pos);
    range.collapse(true);

    const sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
}

// 退格键时的处理
if (e.key == 'Backspace' && $curr.tagName == 'P') {
        const $previousEle = $curr.previousSibling;
        if ($curr.innerText) {
            const textOriginal = $curr.innerText;
            // 我们要让获取新文本内容发生在回调函数之后,所以加上setTimeout
            let textNew;
            setTimeout(() => {
                textNew = $curr.innerText;
                if (textNew == textOriginal) {
                    //如果删除后和之前无区别,则说明插入符在开头,退格后与上一区域合并文本;
                    if (isTitleElement($previousEle)) {
                        $curr.innerText = '';
                        $storyTitle.innerText += textNew;
                        setCaret($storyTitle, textOriginal.length);
                    } else {
                        $story.removeChild($curr);
                        $previousEle.innerText += textNew;
                        setCaret($previousEle, textOriginal.length);
                    }
                }
            }, 0);
        }

        if (!$curr.innerText) { //确认已经退格到头
            e.preventDefault();
            // 当前最后一个p
            if (isTitleElement($previousEle)) {
                setCaret($storyTitle, $storyTitle.innerText.length);
            }
            // p大于1个时
            if ($previousEle.tagName == 'P') {
                $story.removeChild($curr);
                setCaret($previousEle, $previousEle.innerText.length);
            }
        }

在这个过程中,发现还漏掉了一个效果是:

漏掉的效果2:插入符在文字中间或开头时,按下回车后不仅要插入新的p,插入符后面的文字也应该被挪到下一区域去。

思路:按下回车时依旧需要判定插入符位置; 前面写效果1时网络上查判定插入符位置的代码非常复杂,自己的逃避心理使得自己想了一个更简单的解决方法,但是当按下的是回车键时,前面的方法就无法使用,看来还是需要写一个通用的判断插入符位置的函数才行... 在网络上找到了下面这段函数可以直接拿来用。

function getCaretCharacterOffsetWithin(element) {
    var caretOffset = 0;
    var doc = element.ownerDocument || element.document;
    var win = doc.defaultView || doc.parentWindow;
    var sel;
    if (typeof win.getSelection != "undefined") {
        sel = win.getSelection();
        if (sel.rangeCount > 0) {
            var range = win.getSelection().getRangeAt(0);
            var preCaretRange = range.cloneRange();
            preCaretRange.selectNodeContents(element);
            preCaretRange.setEnd(range.endContainer, range.endOffset);
            caretOffset = preCaretRange.toString().length;
        }
    } else if ( (sel = doc.selection) && sel.type != "Control") {
        var textRange = sel.createRange();
        var preCaretTextRange = doc.body.createTextRange();
        preCaretTextRange.moveToElementText(element);
        preCaretTextRange.setEndPoint("EndToEnd", textRange);
        caretOffset = preCaretTextRange.text.length;
    }
    return caretOffset;
}

回车键部分代码

    if (e.key == 'Enter') {
        // 阻止默认自动产生新的div
        e.preventDefault();

        //新建p
        const $newPara = document.createElement('P');
        $newPara.setAttribute('contentEditable', 'true');

        //如果是在标题后按退格,那么第一个p已经变成新的
        //需要把第一个p的placeholder加上,之前p的相关属性去掉
        const $fistPara = document.getElementsByClassName(STORY_PARA_FIST_CLASSNAME)[0];

        $newPara.className = STORY_CONTENT_CLASSNAMES;
        if ($curr.tagName == 'H3') {
            $fistPara.classList.remove(STORY_PARA_FIST_CLASSNAME);
            $fistPara.removeAttribute('placeholder')
            $newPara.classList.add(STORY_PARA_FIST_CLASSNAME);
            $newPara.setAttribute('placeholder', STORY_CONTENT_PLACEHOLDER);
        }

        //判定插入符的位置,如果在中间,则需要把插入符之后的部分挪到下一个p里
        const len = $curr.innerText.length
        const caretPos = len == 0 ? 0 : getCaretCharacterOffsetWithin($curr);
        setTimeout(() => {
            if (caretPos == len) {
                // 如果中文输入到一半就按回车,拼音会在keydown回调函数之后被放到下一行
                // 所以我们需要将innertext在之后执行,所以需要放到settimeout里
                $newPara.innerText = ''
            } else {
                $newPara.innerText = $curr.innerText.substring(caretPos, len);
                $curr.innerText = $curr.innerText.substring(0, caretPos);
            }
            $story.insertBefore($newPara, $curr.nextSibling);
            // 此时插入符在开头就可以了
            $newPara.focus();
        }, 0)
    }
    

补充:还修正了一个问题,上面退格时要调用setCaret函数,这块有点问题


function setCaret(ele, pos) {
    // 如果前一个区域里无文字,直接调用focus即可,不然下面的ele.childNodes会是空的,会报错
    if (ele.innerText.length == 0) {
        ele.focus();
    }

    const range = document.createRange(ele);
    range.setStart(ele.childNodes[0], pos);
    range.collapse(true);

    const sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
}

3. 添加每行文本前的提示符

要实现的效果:

  1. 每行文本为空时,出现下面的加号按钮,输入文字后加号按钮消失

image.png 2. 标题旁边有左边界线,但当focus不在标题时,左边界线消失;

image.png

  1. 标题不为空时,出现title字样

image.png

  1. 退格后如果还在该段,而且该段文本为空,加号按钮出现在该段,如果退到上一段,且上一段为空,则加号按钮出现在上一段。
  2. 回车后如果下一段为空,则加号按钮出现在下一段。

细节样式:

  1. 插入符颜色是灰色,不是默认的黑色 实现这个很简单,css直接就有现成的属性caret-color可以使用

  2. 去掉拼写自动纠正下面的红色虚线 直接用元素属性spellcheck="false"即可,可以把它放在父容器上。

11.13更新

发现昨天代码中有错误,又进行了修改。然后添加了一些注释。然后接着写代码。

  1. 先用伪元素实现标题框前的边框和title字样
/* 标题为空时不显示title */

.story__title:empty::after {
    content: '';
}


/* 标题focus时才显示边界线 */

.story__title:focus::after {
    opacity: 1;
}
  1. 写出加号按钮的css和html基本样式
  2. 实现点击到一行时,加号按钮移动到该行,基本思路是:当某一行被focus时,获取被focus的元素的top值和left值,然后据此设定加号按钮的top和left值。
const DISTANCE_FROM_TEXT = -60;
$inlineTooltip.style.top = $curr.offsetTop + 'px';
// 左边距离行文本元素60px;
$inlineTooltip.style.left = $curr.offsetLeft + DISTANCE_FROM_TEXT + 'px';

其他问题: (1)实现仅在focus时才显示tooltip =>

focus是无法冒泡到父容器的,所以我们只能一个个绑定,但因为不断有删除新增元素,所以一个个文本块绑定focus事件效率并不高。而且退格到头等事件还是得自己添加显示按钮的代码。 我想的是一种方法是直接click事件绑定父元素。如果点击的是p或h3,说明是focus的是文本区域。然后回车和退格事件补充相应代码。 但是还有一个问题是当文本内开始有文字时(监听input事件),tooltip又要消失。 所以无论如何还是需要给p一个个绑定事件(focus和input事件)。

(2)在小屏幕中加号按钮和title旁的边界线是不显示的。这点直接用媒体查询即可=>

 @media only screen and (max-width: 879px) {
    .inlineTooltip {
        opacity: 0 !important;
    }
    .story__title:focus::after {
        opacity: 0 !important;
    }
}

(3)resize之后加号按钮的位置需要跟着动,观察medium可发现,medium网站上加号位置的调整是有一个滞后时间的。

思路:

监听resize,然后在setTimeout回调函数中更新button的位置。 重点是要记录button旁边的文本区域是哪个元素。我想到的方法有:给目标元素增加class,或者是直接用一个变量记录这个元素。我选择用后者实现。

function showInlineTooltip(ele) {
      .....
    
    // 记录当前左边显示加号按钮的元素
    currTooltipLocation = ele;
}
window.onresize = function() {
    if (currTooltipLocation && $inlineTooltip.style.opacity == 1) {
        // update the location of inlineTip
    }
}

(4)输入中文时拼音过程中加号按钮不消失。=>

解决:用isComposing来记录文字是否正在拼写中

$storyTitle.addEventListener('compositionstart', (e) => {
    isComposing = true;
})
$storyTitle.addEventListener('compositionend', (e) => {
    isComposing = false;
})

在input监听时,如果isComposing是true,则什么也不做,直接return。

(5) 内容为iconfont的button鼠标指针有点奇怪,加入cursor:pointer后iconfont部分指针不发生改变。 解决:加入pointer-events: auto;

4 实现每行文字前的按钮点击后出现菜单

步骤:

(1)点击按钮后按钮旋转90度

这里我在js中设定:当菜单展开时添加一个表示展开的class,然后给这个class添加一个旋转效果,当菜单关闭时再添加一个表示关闭状态的class,然后也给这个class添加一个旋转效果。

$inlineTooltipBtn.addEventListener('click', () => {
    // 添加相应类名以添加动画效果
    if ($inlineTooltipBtn.classList.contains('isActive')) {
        $inlineTooltipBtn.classList.remove('isActive');
        $inlineTooltipBtn.classList.add('nonActive');
    } else {
        $inlineTooltipBtn.classList.add('isActive');
        $inlineTooltipBtn.classList.remove('nonActive');
    }
})

相应css

.isActive {
    transition: all .2s;
    transform: rotate(45deg);
}
.nonActive {
    transition: all .2s;
    transform: rotate(-90deg);
}

(2)html和css中设置好会出现的菜单样式(目标效果如下)并添加功能。

image.png

这几个按钮分别是: 插入图片,在unsplash上搜索图片并插入(这个按钮我会去掉)、插入视频地址、插入嵌入式内容地址、插入代码块、插入分隔符。

我决定暂且只做插入图片功能,其他的有额外时间再考虑做。

- 图片按钮和功能

做这里的时候遇到一个问题是发现我的按钮会像透明效果一样,解决方法是给按钮容器添加z-index属性

image.png

点击后出现文件上传窗并上传图片到新建的img元素中=> 首先需要再网页中添加一个input,研究了一下medium网页,发现它是把这个input设置为left: -9999px; top: -9999px;以让它在网页中不可见;我直接设置为了hidden。

$inlineImgbtn.addEventListener('click', () => {
    console.log($currTooltipLocation.offsetTop);
    // 打开文件浏览窗并上传
    $imgInput.click();
    $imgInput.addEventListener('change', (e) => {
        const reader = new FileReader();
        const $newImg = document.createElement('img');
        $newImg.classList.add('story__img');
        $story.insertBefore($newImg, $currTooltipLocation);
        reader.onload = () => {
            $newImg.src = reader.result;
        }
        reader.readAsDataURL(e.target.files[0]);

        // 更新inline tooltip的位置
        // 这里需要稍等一点时间才能真正更新位置,不然会出现在图片一半的位置
        setTimeout(() => {
            //使tooltip呈现关闭状态
            $inlineTooltipBtn.click();
            setInlineToolTipLocation($currTooltipLocation);
        }, 50);
    })
})

分析了下medium,图片元素会有边框,并且hover和点击后的效果不同,点击图片后下方会出现一个可以填写图片说明的文本框,上方会出现一个填alt text的地方。后者我就不做了,我只做边框和填写图片说明的文本框部分。

边框不用多说,但是要注意给图片添加tabindex=0属性,这样它就可以被focus了,填写文本说明部分也是用js插入一个figcaption元素。

中间出现的小问题是:

  • 图片和caption之间自带了空白,将图片改为block可解决这个问题;
  • medium上的效果是当点击了一张图片后,其他的图片的caption如果没有填写的话,caption元素会隐藏,需要再点击相应图片,caption元素才会再次出现。(这个功能感觉不是很必要,暂且去掉)

11月14日更新

  1. 解决标题区域图片插入后问题;=>判断如果是标题区域,则把图片和figcaption插在第一段之前)
  2. 页面刚打开时,标题区域退格后inline tooltip是自动打开的。/ 点击p之后tooltip也是自动打开的。 -> 初始化未设置menu为display:none。
  3. tooltip覆盖问题未完全解决。 => 在tooltip menu的方法里将placeholder进行修改,打开menu则placeholder消失。

5 实现全选文字后出现字符样式tooltip

  1. 实现tooltip基本html和css 实现基本的tooltip在w3c shool直接就有教程,可以参考制作,按钮样式直接用medium上的svg图案。但这里的排版一直有些问题,我最后算是hard code出来的。主要还是对svg特性不够熟悉。

  2. 实现全选文字后弹出tooltip

思路:结合mouseup事件和window.getSelection

一开始写出来的是这样。在每次按下鼠标后监测何时释放鼠标,记录下按下和释放的鼠标坐标,然后为了让tooltip在选中文字的中间,tooltip的左边距离=选中文字左边界的left值+ 选中文字长度的一半 - tooltip长度的一半。

这样写能实现一个最基本的效果,但有一些问题。

$story.addEventListener('mousedown', (e) => {
   const mouseDownPos_x = e.clientX;
   const mouseDownPos_y = e.clientY;
   $storyTitle.addEventListener('mouseup', (e) => {
       if (window.getSelection && window.getSelection().toString().length > 0) {
           console.log(e.target.style.cursor);
           console.log('dis')
           $styleTooltip.style.visibility = 'visible';
           const mouseUpPos_x = e.clientX;
           const mouseUpPos_y = e.clientY;
           const x_small = mouseDownPos_x > mouseUpPos_x ? mouseUpPos_x : mouseDownPos_x;
           const xDiffHalf = mouseDownPos_x > mouseUpPos_x ? (mouseDownPos_x - mouseUpPos_x) / 2 : (mouseUpPos_x - mouseDownPos_x) / 2;
           const widthHalf = $styleTooltip.offsetWidth / 2;
           $styleTooltip.style.left = x_small + xDiffHalf - widthHalf + 'px';
       } else {
           $styleTooltip.style.visibility = 'hidden';
       }
   })
})

问题:

(1)使tooltip居中于选中文字的办法不稳定,有时正确,有时不正确。这里其实应该集合6一起,因为还要实现文字的变动,所以用textrange似乎更能实现效果。这里决定先学习一下

(2)当mouseup时鼠标不是caret时,利用window.getSelection无法判定文字是否被选中,导致tooltip无法出现。但medium网页上即使cursor不是caret,只要能选中文字,tooltip就能正常出现。

** 11月26日更新**

参考了css-tricks.com/how-to-crea… 其实这篇文章思路和我是一样的。例如left计算思路是下图这样: 要求的left = left + a - b,上面我也是这样的思路,但不知为何结果很奇怪

image.png

这次能实现tooltip始终出现在文字中间。 image.png

// 给story部分增加pointerup时间,以实现选择文字后出现style tooltip的目的。
$story.addEventListener('pointerup', (e) => {
    // 重置前面的tooltip(这块后面再改)
    // 在这个过程中如果发生resize的话,还是要继续调整
    $styleTooltip.style.visibility = 'hidden';


    let selection = document.getSelection();
    let text = selection ? selection.toString() : null
    console.log(text)

    if (text && text.trim() != '') {
        $styleTooltip.style.visibility = 'visible';
        let rect = selection.getRangeAt(0).getBoundingClientRect();
        $styleTooltip.style.top = `calc(${rect.top}px - 70px)`;

        // styletooltip中心位置计算方式 
        // 105是tooltip宽度的一半
        $styleTooltip.style.left = `calc(${rect.left}px + calc(${rect.width}px / 2) - 105px)`;
    }
})

不过目前还是有一些问题,例如出现一次tooltip后,有时会出现无法选中的情况。

  1. 加上弹出tooltip时的动画效果

6 实现文字样式变动

一些启发: 一些功能不要孤立地看,一定要提前分析好全部功能,并决定好哪些先做,哪些后做更好。