使用 contenteditable 构建 To-Do List

发布于 大漠

大多数情况之下,Web 中构建 ToDo 列表都是采用 JavaScript 脚本(或 ReactVue这样的 JavaScript 框架)来构建。今天在 Codepen 上看到 @Adir 使用纯 CSS 实现了一个简单版本的 ToDo 列表效果。刚看到这个效果的时候,感到很神奇,纯CSS如何实现这样的效果?为了一探究竟,查看源码之后才发现,实现该效果主要是依赖 HTML 元素的 contenteditable 属性。那么今天就和大家一起聊聊 contenteditable 属性怎么实现 ToDo 列表效果。

纯 CSS 实现的 ToDo 列表

先来看纯 CSS 实现的 ToDo 列表的效果

在上面示例中,尝试着在洋红色框中按下鼠标左键,进入可编辑状态。当输入完(或不输入)内容按下回车键(Enter)就会自动创建另一个列表项(List)。同样的,当你用删除键删除列表项的文本内容时就会删除该列表项。效果如下:

从开发者工具中可以发现,每按一次回车键(Eenter)就会新增一个div元素:

看上去还不错,但在不同的浏览器中渲染还是有所差异的:

contenteditable 属性

contenteditableHTML 元素的属性,它是一个通用属性。将contenteditable属性应用于任何 HTML 元素。它的表现就像 <input><textarea> 一样,你可以编辑它。

这对于为你的用户创造了一个无缝的流畅的编辑体验是非常酷的。他们可以简单地点击元素,并立即对文本进行更新。相比而言,contenteditable 的编辑体验比直接使用标准的 <input><textarea> 来做更佳:

contenteditable 属性是一个枚举式属性,接受三个属性值:inherittruefalse

  • inherit :表明该元素继承了其父元素的可编辑状态,它是缺失值默认值(和无效值默认值)
  • true :表明该元素可编辑
  • false :表明该元素不可编辑

我们可以像下面这样使用 contenteditable

<p contenteditable="true">Element is editable</p>
<p contenteditable="false">Element is NOT editable</p>
<p contenteditable="inherit">Element inherits its parent's editable status</p>

如果一个 HTML 元素的 contenteditable 属性被设置为 true 值,或者它的 contenteditable 属性被设置为 inherit 值,并且如果它的最近的祖先 HTML 元素的 contenteditable 属性被设置为 inherit 以外的值,其属性被设置为 true值,或者如果它和它的祖先都将其 contenteditable 设置为 inherit 值,但 Document 启用了 designMode,那么 UA(客户端)必须将该元素视为可编辑的。

否则,要么该 HTML 元素的 contenteditable 属性被设置为 false 值,要么其 contenteditable 值被设置为 inherit 值,而其最近的祖先 HTML 元素的 contenteditable 属性被设置为 inherit 以外的值,其属性被设置为 false,要么其所有祖先的 contenteditable 值被设置为 inherit 值,而 Document 禁用 designMode,那么 UA(客户端)必须将该元素视为不可编辑的。

另外,HTML 元素都有 contentEditable 属性和 isContentEditable属性,我们可以通过JavaScript脚本可以获取元素的contentEditableisContentEditable属性的值。如果:

  • 如果元素显式设置了contenteditable属性的值为true,元素的contentEditable属性返回字符串值trueisContentEditable属性返回的值为true
  • 如果元素显式设置了contenteditable属性的值为false,元素的contentEditable属性返回字符串值falseisContentEditable属性返回的值为false
  • 如果元素显式设置了contenteditable属性的值为inherit,元素的contentEditable属性返回字符串值inheritisContentEditable属性返回的值为false
  • 如果元素未显式设置contenteditable属性,元素的contentEditable属性返回字符串值inheritisContentEditable属性返回的值为false

比如:

<!-- HTML -->
<p contenteditable="true">Element is editable</p>
<p contenteditable="false">Element is NOT editable</p>
<p contenteditable="inherit">Element inherits its parent's editable status</p>
<p>There is no element that explicitly sets the contenteditable attribute.</p>

const pElements = document.querySelectorAll("p");

pElements.forEach((p, index) => {
    console.log(`p[${index}](contentEditable):`, p.contentEditable);
    console.log(`p[${index}](isContentEditable):`, p.isContentEditable);
});

如果一个元素是可编辑的而它的父元素不是,或者如果一个元素是可编辑的而它没有父元素,那么这个元素就是一个编辑主机(Editing Host)。可编辑元素可以被嵌套。用户代理必须使编辑主机成为焦点。一个编辑主机可以包含不可编辑的部分。一个编辑主机可以包含不可编辑的部分,这些部分包含进一步的编辑主机。

当一个编辑主机有焦点时,它必须有一个指定当前编辑位置的光标。它也可以有一个选择。

设置了contenteditable也可以像<input>元素一样,支持input监听事件。设置contenteditable属性的元素的内容发生变化时,input事件就会被触发:

const pElements = document.querySelectorAll("p");

pElements.forEach((p, index) => {
    p.addEventListener("input", (etv) => {
        alert(etv.target.textContent);
    });
});

制作一个可编辑的 HTML 元素

在 HTML 中制作一个可编辑的元素并不困难。只需要将 HTML 元素的 contenteditable 属性设置为 true 即可。比如说,在你想要编辑的元素上添加 contenteditabletrue

<div contenteditable="true">Edit Text</div>

如果你希望用户在一个段落中只更新一个或两个字,那么你可以使用<p>元素。但是,如果你要为用户提供一个添加长篇内容区域,那么 div 可能更合适,因为浏览器处理字符返回的方式不同。

例如,Chrome、Safari和Edge浏览器在每个断行处注入 <div>,Firefox浏览器在每个断行处注入 <div> <br/></div>

但有的时候 Safari 断行处注入的是<font><span></span></font>。但未常见,大多情景注入的是<div>

如果我们在<p>设置contenteditable="true",变成一个可编辑的段落元素,断行处注入的div元素,那么div是无效的子元素,可能会导致一些意想不到的,不受欢迎的渲染怪癖。

除了编辑内容之外,有的时候希望能够暂时保存修改,并恢复原状(或撤销)修改过的内容。这就需要使用到 JavaScript 脚本。我们通过一个简单的示例来演示该效果。

<!-- HTML -->
<div class="container">
    <h3>Contenteditable Element</h3>
    <div contenteditable="true" id="contenteditable">This is the editable content.</div>
    <div class="actions">
        <button class="btn btn--undo" id="undo">Undo Change</button>
        <button class="btn btn--save" id="save">Save Content</button>
    </div>
</div>

添加所需要的 JavaScript 脚本:

const getById = (id_string) => {
    return document.getElementById(di_strinng)
}

const insertAfter = (newEl, refEl) => {
    refEl.parentNode.insertBefore(newEl, refEl.nextSibling)
}

const editElement = getById('contenteditable')
const undoBtn = getById('undo')
const saveBtn = getById('save')

let originalContent = editElement.innerHTML
let updatedContent = ''

// 如果用户刷新了页面,这些声明将确保一切回到初始状态
undoBtn.disabled = true
saveBtn.disabled = true

// 创建一个 redo 按钮
const redoBtn = document.createElement('button')
const redoLabel = document.createTextNode('Redo')
redoBtn.id = 'redo'
redoBtn.className = 'btn btn--redo'
redoBtn.hidden = true
redoBtn.appendChild(redoLabel)
insertAfter(redoBtn, undo)

// 如果内容已被改变,启用 save 按钮
editElement.addEventListener('keypress', () => {
    if (editElement.innerHTML !== originalContent) {
        saveBtn.disabled = false
    }
})

// 点击 save 按钮,将更新的内容保存到 updatedContent 变量中
saveBtn.addEventListener('click', () => {
    updatedContent = getById('contenteditable').innerHTML

    if (updatedContent !== originalContent) {
        // 如果你不喜欢你写的东西,想回到初始状态,显示撤销按钮
        undoBtn.disabled = false
    }
})

// 如果点击 undo 按钮,将 contenteditable区域的innerHTML恢复到原来的内容
// 然后添加一个 redo 按钮,使编辑过的内容恢复原状
undoBtn.addEventListener('click', () => {
    editElement.innerHTML = originalContent
    undoBtn.disabled = true
    redoBtn.hidden = false
})

// 点击 redo 按钮,将 contenteditable区域的innerHTML回到撤销前状态
redoBtn.addEventListener('click', () => {
    editElement.innerHTML = updatedContent
    redoBtn.hidden = true
    undoBtn.disabled = false
    undoBtn.focus()
})

整个效果如下:

正如上面的示例所示,我们可以在实际业务中开发中使用 contenteditable 属性的功能,它要比使用 <input><textarea> 会更为灵活。比如像 Twitter 发消息的组件,就使用了 contenteditable 特性:

还可以使用 contenteditable 构建富文本编辑器。这允许你的用户阅读内容并随心所欲地编辑。

contenteditable 元素的占位符

<input>元素可以使用placeholder给文本框提供一个占位符,展示提示文本信息。但 contenteditable 元素并没有 placeholder属性,不过我们可以借助 CSS 伪类选择器 :empty伪类元素::before(或::aftercontent 来实现类似的效果:

[contenteditable]:empty::before {
    content: attr(data-placeholder);
    color: rgb(0 0 0 / 0.6);
}

除了使用纯 CSS 技术之外,还可以使用一点 JavaScript 脚本,比如说可编辑元素获取焦点时(focus事件)时设置可编辑元素的内容为空,当可编辑元素失去焦点(blur事件)设置可编辑元素的内容为用户输入的内容或占位符文本:

const editableElement = document.getElementById("contenteditable");

// 获取占位符文本(data-placeholder)
const placeholder = editableElement.getAttribute("data-placeholder");

// 如果可编辑元素内容是空的,就使用占位符文本
editableElement.innerHTML === "" && (editableElement.innerHTML = placeholder);

editableElement.addEventListener("focus", function (e) {
    const value = e.target.innerHTML;
    value === placeholder && (e.target.innerHTML = "");
});

editableElement.addEventListener("blur", function (e) {
    const value = e.target.innerHTML;
    value === "" && (e.target.innerHTML = placeholder);
});

contenteditable 构建ToDo列表的缺陷

常见的ToDo列表组件交互功能相对而言是比较丰富的,比如列表项的增加,删除,完成和未完成等:

但仅使用 contenteditable 属性构建的ToDo列表只能实现列表的增加和删除,但不能很好的处理完成和未完成状态。因为设置contenteditable的元素在按下回车时可以新增了个子元素,但我们并不能新增其他的HTML元素,比如:

<div>
    <input id="item" type="checkbox">
    <label for="item">Item</label>
</div>

而不使用 contenteditable 我们又无法输入内容,只能是纯展示类的ToDo列表,比如下面这个效果:

或者借助 CSS 计数器,能让更有感知的完成已完成和未完成的任务以及两种任务的状态切换:

不过,CSS 计数器 用于 contenteditable 的元素上是无效的:

[contenteditable] {
    counter-reset: item;
}

[contenteditable] div {
    counter-increment: item;
}

h3::after {
    content: counter(item);
}

在可编辑的元素中按下回车键,键入新的内容,CSS的counter()并不会根据新增的子元素做统计:

你也可以尝试着自己在上面示例中可编辑元素内按回车输入内容,效果如下:

如果你对 CSS 计数器相关的知识感兴趣的话,可以阅读 《奇妙的CSS计数器世界》一文,该文分三个部分:Part1Part2Part3

基于JS框架构建的可编辑 UI 组件

前面示例也展示了 JavaScript 操作可编辑元素,即操作了设置contenteditable的元素。而当下开发Web主要是基于 React、Vue 或 Svelte 框架。基于这几个框架,可以使用相对应的库来构建可编辑的UI(contenteditable),目前主要有:

React Contenteditable

import React, { useState, useRef } from "react";
import ContentEditable from "react-contenteditable";

export default () => {
    const editableRef = useRef();
    const [editableText, setEditableText] = useState("Edit me.");

    return (
        <ContentEditable
            innerRef={editableRef}
            tagName="p"
            html={editableText}
            onPaste={(e) => {
                e.preventDefault();
                const text = e.clipboardData.getData("text");
                document.execCommand("insertText", false, text);
            }}
            onChange={(e) => {
                const html = e.target.value;
                setEditableText(html);
            }}
        />
    );
};

有关于这方面更详细的介绍,可以阅读@markos_kon 的《Using the contenteditable attribute》一文 或 @Tania 的 《Using Content Editable Elements in JavaScript》一文。

Vue Contenteditable

<template>
    <contenteditable tag="div" :contenteditable="isEditable" v-model="message" :noNL="true" :noHTML="true" @returned="enterPressed" />
</template>
 
<script>
    export default {
        data() {
            return {
                isEditable: true,
                message: "hello"
            }
        },
        methods : {
            enterPressed(){
                alert('Enter Pressed');
            }
        }
    }
</script>

小结

在HTML中,任何元素都可以被编辑。通过使用一些JavaScript事件处理程序,您可以将您的网页转换为完整且快速的富文本编辑器。为了使元素可编辑,你所要做的就是在 HTML 标签上设置 contenteditable 属性,它几乎支持所有的HTML元素。文章中就是使用该特性来构建了个ToDo列表的效果。虽然它有一定的缺陷性,但这个创意是有意思的。不过,我们可以使用contenteditable特性构建一个用户体验更好的富文本编辑器和类似Twitter发推的UI组件。