blog

リッチテキストエディタの簡単な実装

このプロジェクトを実装する前に、たくさんの情報を調べました。ひとつはiframe +経由ですが、これは基本的な理解で諦めました。もう一つは+です。 多くの記事でこのA......

Mar 1, 2020 · 11 min. read
シェア

まず、最終結果を見てみましょう。

このプロジェクトを実行する前に、多くの情報を調べました。ひとつは、iframe + designModeによるもので、これは基本的な理解の後にあきらめました。もう一つは、contenteditable + execCommandです。

多くの記事で execCommand APIを使った機能が紹介されていましたが、 MDN ドキュメントを確認したところ、このAPIは非推奨となっており、検討した結果、このプロジェクトを実装する他の方法がないか試してみたくなり、最終的にこのAPIexecCommand)を使うことを諦めました。その代わりに、Rangeが提供するeditメソッドを使って、このシンプルなリッチテキストエディタを実装しました。

プロジェクトディレクトリ

editor プロジェクトのルート・ディレクトリ
公開静的リソース・ディレクトリ
  └─index.html html 最終的にパッケージ化されたjsとcssがマウントされるテンプレートファイル。
 
プロジェクトの開発ディレクトリ
│ ├─scss sass リソース
│ └─index.scss
│ │
アプリケーション・ディレクトリ
│ ├─config.ts 設定ファイル
│ ├─要素.ts 一般的なDOM操作メソッドのカプセル化
│ ├─editor-dom.ts リッチテキストエディタを作成し、リッスンする要素ノードをキャッシュする
│ └─選択.ts 最も重要なステップは、エディター・イベント全体のインタラクションが行われる
│ │
│ └─index.ts エントリーファイル
│
パッケージ.json
├─tsconfig.json
├─postcss.config.js
ウェブパック.confing.js

コアAPI

  1. contentEditable: このAPIの目的は、DIVを編集可能にすることです。
  2. Range:それは、次の操作を実行するように、カーソルの情報を取るために使用することができます。

準備

メイン記事

ステップ1:設定項目ファイルを作成します:

confing.ts

//  
export default {
 menu: ["bold", "head", "fontColor"],
 head: [
 ["h1", "H1"],
 ["h2", "H2"],
 ["h3", "H3"],
 ["h4", "H4"],
 ["h5", "H5"],
 ["h6", "H6"],
 ["p", " ],
 ],
 color: [ "#d8d8d8", "#5FB878", "#4cb4e7", "#ffc09f", "#a3a380", "#4cb4e7", "#ffc09f", "#ffee93", "#e2dbbe", "#a3a380"]
};

第二段階:共通のDOM操作メソッドをカプセル化し、後の統一的な管理を容易にします。

element.ts

class element {
 // 現在のインスタンスのElement要素の例
 private el: any;
 constructor(...arg: string[]) {
 if (arg[0]) {
 this.create(arg[0]);
 }
 }
 // 現在の要素インスタンス
 get element() {
 return this.el;
 }
 get style() {
 return this.el.style;
 }
 get class() {
 return this.el.classList;
 }
 // 現在のクラスの要素インスタンスを変更する
 set(el: Element) {
 this.el = el;
 return this;
 }
 // 現在のクラスの要素インスタンスを作成によって変更する
 create(name: string) {
 this.set(document.createElement(name));
 return this;
 }
 // 属性属性を一括設定する
 setAttributes(attributes: { [propName: string]: string | boolean | number }) {
 Object.entries(attributes).forEach(([name, value]) => {
 value ? this.el.setAttribute(name, value) : this.el.removeAttribute(name);
 });
 return this;
 }
 // スタイルを一括設定する
 styles(options: { [propName: string]: string } | [string, string][]) {
 if (typeof options === "object") {
 options = Object.entries(options);
 }
 options.forEach(([name, value]) => {
 this.el.style[name] = value;
 });
 return this;
 }
 dataset(name?: string, value?: string) {
 if (!value) {
 if (name) {
 return this.el.dataset[name];
 }
 return this.el.dataset;
 }
 this.el.dataset[name as string] = value;
 return this;
 }
 //  /textContentを設定する
 textContent(value?: string) {
 if (value != undefined) {
 this.el.textContent = value;
 return this;
 }
 return this.el.textContent;
 }
 append(element: HTMLElement) {
 this.el.appendChild(element);
 return this;
 }
 // イベントをリッスンする関数をキャッシュする
 protected fns: { [protName: string]: Function } = {};
 // イベント・リスニング
 listen(type: string, callback: Function, name: string) {
 this.fns[`${type}-${name}`] = callback;
 this.el.addEventListener(type, callback, false);
 return this;
 }
 // イベントリスニングをキャンセルする
 unListen(type: string, name: string) {
 let key = `${type}-${name}`;
 this.el.removeEventListener(type, this.fns[key]);
 delete this.fns[key];
 return this;
 }
 // HTML文字列をDOMノードに変換する
 static strToNode(str: string): HTMLElement {
 let div = document.createElement("div");
 div.innerHTML = str;
 return div.firstElementChild as HTMLElement;
 }
}
export default element;

ステップ3: リッチテキストエディタを作成し、リスニングが必要な要素ノードをキャッシュします。

editor-dom.ts

// 初期化、リッチテキストエディタのDOM情報を生成する
import config from "./config";
import element from "./element";
const menu = config.menu;
interface headerTags {
 0: element;
 [propName: string]: element;
}
// エディター入力ボックスセクションを初期化する
function createContent() {
 return new element("div")
 .setAttributes({
 class: "content",
 contenteditable: true,
 })
 .append(element.strToNode("<p><br></p>"));
}
// メニューバーのタイトル
function createHead() {
 let head: headerTags = {
 0: new element("span").textContent("H"),
 };
 config.head.forEach(([tag, txt]) => {
 head[tag] = new element(tag).textContent(txt);
 });
 return head;
}
// メニューバーのフォントの色
function createFontColor() {
 let colors: headerTags = {
 0: new element("span"),
 };
 config.color.forEach((color) => {
 colors[color] = new element("span")
 .styles({ backgroundColor: color })
 .dataset("color", color);
 });
 return colors;
}
// レンダーエディター、重要なDOMノードキャッシュ、呼び出しインターフェース
class EditorDoms {
 // 太字のノード
 protected bold: headerTags;
 
 // タイトルノード
 protected head: headerTags;
 
 // フォントカラーノード
 protected fontColor: headerTags;
 
 // 入力ボックスのノード
 protected content: element;
 constructor() {
 // エディター入力ボックスセクション
 this.content = createContent();
 this.bold = { 0: new element("span").textContent("B") };
 this.head = createHead();
 this.fontColor = createFontColor();
 }
 // 入力ボックスの要素インスタンスを返す
 get inputBox() {
 return this.content;
 }
 get boldSpan() {
 return this.bold[0];
 }
 get headSpan() {
 return this.head[0];
 }
 get fontColorSpan() {
 return this.fontColor[0];
 }
 getHead() {
 let { 0: o, ...head } = this.head;
 return head;
 }
 getFontColor() {
 let { 0: o, ...colors } = this.fontColor;
 return colors;
 }
 // エディタエディタをレンダリングし、エディタ全体のルートノードを返す外部呼び出しの場合
 render() {}
 // メニューバーを太字にする
 private renderBold() {}
 // タイトルメニューバーをレンダリングする
 private renderHead() {}
 // レンダリング fontColor メニューバー
 private renderColor() {}
}
export default EditorDoms;

ステップ4: 最も重要なステップです。

selection.ts

// 選択範囲を取得する
import { debounce } from "lodash";
import element from "./element";
import EditorDoms from "./editor-dom";
import { target } from "../../webpack.config";
export default class selection {
 public range: Range;
 
 // カーソル・ノードのテキストは太字か?
 private selBold: boolean = false;
 private h: string[] = ["h1", "h2", "h3", "h4", "h5", "h6"];
 // ルート要素がタイトルかどうか
 private selHeadline: boolean = false;
 constructor(public doms: EditorDoms) {
 // selectionchangeイベントをドキュメントにバインドする
 this.selectionchange();
 this.bindBold();
 this.bindHead();
 this.bindFontColor();
 // 入力ボックスのフォーカス
 this.doms.inputBox.element.focus();
 // this.resetRange();
 this.range = (document.getSelection() as Selection).getRangeAt(0);
 this.rangeCache = this.range.cloneRange();
 }
 get bold() {
 return this.selBold;
 }
 // メニューバーの値を変更することで、メニューバーのスタイルを動的に更新する。
 set bold(value: boolean) {
 this.selBold = value;
 if (value) {
 this.doms.boldSpan.class.add("selected");
 } else {
 this.doms.boldSpan.class.remove("selected");
 }
 }
 get headline() {
 return this.selHeadline;
 }
 // メニューバーの値を変更することで、メニューバーのスタイルを動的に更新する。
 set headline(value: boolean) {
 this.selHeadline = value;
 if (value) {
 this.doms.headSpan.class.add("selected");
 } else {
 this.doms.headSpan.class.remove("selected");
 }
 }
 // メニューバーの値を変更することで、メニューバーのスタイルを動的に更新する。
 set fontColor(value: string) {
 this.doms.fontColorSpan.setAttributes({
 style: value ? `background-color: ${value}` : "",
 });
 }
 // 範囲オブジェクトをキャッシュする
 resetRange() {
 this.range = (document.getSelection() as Selection).getRangeAt(0);
 return this;
 }
 // selectionchangeイベントをバインドして、カーソルが変わった後にRangeオブジェクトを再キャッシュし、現在のカーソルがある親ノードのスタイルに基づいてメニューバーを更新する。
 selectionchange() {
 document.addEventListener(
 "selectionchange",
 debounce((e) => {
 if (
 e.srcElement &&
 e.srcElement.activeElement == this.doms.inputBox.element
 ) {
 this.resetRange();
 // 現在のカーソルノードのルートノードを取得する
 let path = this.path();
 let root: HTMLElement;
 let parent: HTMLElement;
 switch (path.length) {
 case 0:
 this.bold = false;
 this.headline = false;
 this.fontColor = "";
 break;
 case 1:
 root = path[0];
 this.bold = false;
 this.headline = root.nodeName != "P";
 this.fontColor = "";
 break;
 case 2:
 default:
 parent = path[1];
 root = path.pop();
 if (parent.nodeName == "SPAN") {
 let style = this.getStyle(parent);
 style.forEach(([k, v]) => {
 switch (k) {
 case "font-weight":
 this.bold = v == "bold";
 break;
 case "color":
 this.fontColor = v;
 break;
 }
 });
 } else {
 this.bold = false;
 this.fontColor = "";
 }
 this.headline = this.h.includes(root.nodeName.toLowerCase());
 break;
 }
 }
 }, 350)
 );
 }
 // クリックイベントを太字ボタンにバインドする
 bindBold() {
 this.doms.boldSpan.listen(
 "click",
 () => {
 let parent = this.range.startContainer.parentElement as HTMLElement;
 // spanタイプの親ノードが存在する
 if (parent.nodeName == "SPAN") {
 let isBold = parent.style.fontWeight == "bold";
 let style = this.getStyle(parent);
 //  
 if (this.range.collapsed) {
 if (isBold) {
 style = style.filter((row) => row[0] != "font-weight");
 this.updateParentSpanStyle(parent, style);
 this.bold = false;
 } else {
 style.push(["font-weight", "bold"]);
 parent.setAttribute(
 "style",
 style.map((row) => row.join(":")).join(";") + ";"
 );
 this.bold = true;
 }
 }
 //  
 else {
 if (isBold) {
 this.bold = false;
 } else {
 this.bold = true;
 }
 }
 }
 // スパンの親がない
 else {
 let span: HTMLElement;
 if (this.range.collapsed) {
 // 非選択時、カーソルに空のスパンを挿入する
 span = element.strToNode(
 '<span style="font-weight: bold;">&#8203;</span>'
 );
 this.range.insertNode(span);
 let p = this.range.commonAncestorContainer;
 if (p.parentElement === this.doms.inputBox.element) {
 let last = p.lastChild;
 if (last && last.nodeName == "BR") {
 p.removeChild(last);
 }
 }
 } else {
 // 選択、選択内容をスパン内容に設定する
 span = element.strToNode(
 '<span style="font-weight: bold;"></span>'
 );
 span.appendChild(this.range.extractContents());
 this.range.insertNode(span);
 }
 this.range.selectNode(span);
 this.bold = true;
 }
 },
 "check"
 );
 }
 // クリックイベントをタイトルにバインドする
 bindHead() {
 let headcall = (e: EventTarget) => {
 let root = this.path().pop();
 let newRoot = document.createElement(e.target.nodeName);
 newRoot.innerHTML = root.innerHTML;
 this.doms.inputBox.replace(root, newRoot);
 this.headline = e.target.nodeName != "P";
 this.hideBox(e.target.parentElement);
 };
 Object.values(this.doms.getHead()).forEach((head) => {
 head.listen("click", headcall, "check");
 });
 }
 // クリックイベントにフォントの色をバインドする
 bindFontColor() {
 let colorcall = (e: EventTarget) => {
 let color = e.target.dataset.color;
 let parent = this.range.startContainer.parentElement as HTMLElement;
 // 存在スパン親
 if (parent.nodeName == "SPAN") {
 let style = this.getStyle(parent).filter((row) => row[0] != "color");
 style.push(["color", color]);
 this.updateParentSpanStyle(parent, style);
 }
 // スパンの親がない
 else {
 let span = element.strToNode(`<span style="color: ${color};"></span>`);
 if (this.range.collapsed) {
 span.innerHTML = "&#8203;";
 } else {
 span.appendChild(this.range.extractContents());
 }
 this.range.insertNode(span);
 }
 this.fontColor = color;
 this.hideBox(e.target.parentElement);
 };
 Object.values(this.doms.getFontColor()).forEach((fontColor) => {
 fontColor.listen("click", colorcall, "check");
 });
 }
 // 現在のカーソルのルートノードへのパスを取得する
 protected path() {
 let path = [];
 let el: any = this.range.startContainer;
 while (!this.isInputBox(el)) {
 path.push(el);
 el = el.parentElement;
 }
 return path;
 }
 private isInputBox(el: Node) {
 return this.doms.inputBox.element === el;
 }
 // 親 span の style 属性の値を更新する
 updateParentSpanStyle(parent: HTMLElement, style: string[][]) {
 // 親スパンもスタイル属性値を持つ
 if (style.length) {
 parent.setAttribute(
 "style",
 style.map((row) => row.join(":")).join(";") + ";"
 );
 }
 // 親スパンがスタイル属性値を持たなくなった
 else {
 (parent.parentNode as HTMLElement).replaceChild(
 document.createTextNode(parent.innerText),
 parent
 );
 }
 }
 // 要素のstyle属性を配列に変換する
 getStyle(target: HTMLElement): string[][] {
 let styleItmes = (target.getAttribute("style") || "")
 .split(";")
 .filter((item) => item);
 let style: string[][] = [];
 if (styleItmes) {
 style = styleItmes.map((item) => {
 let [k, v] = item.split(":");
 return [k.trim(), v.trim()];
 });
 }
 return style;
 }
 // フローティング・パネルをクリックしたときに、タイトルとフォント・カラーが非表示にならない場合の処理
 hideBox(box: HTMLElement) {
 box.style.display = "none";
 requestAnimationFrame(function () {
 box.removeAttribute("style");
 });
 }
}

ステップ5:これが最後のステップであり、プロジェクト全体の入り口です。

index.ts

import "./scss/index.scss";
import EditorDoms from "./ts/editor-dom";
import Selection from "./ts/selection";
let parentTag = document.querySelector("#demo");
let editor = new EditorDoms();
if (parentTag) {
 parentTag.appendChild(editor.render());
} else {
 throw new Error("未定義エディタの親コンテナ");
}
new Selection(editor);

結論

  • 問題:
    • シンプルなリッチテキストの実装が、非常に重要な問題が解決策が見つかりませんでしたが、それは、入力ボックスのカーソルの設定でクリックした後、メニューバーでは、編集方法の範囲は、ここで失敗しました!
  • 経験
    • div[contenteditable=true] <div><br></div> div[contenteditable=true] マウスを戻したときのデフォルトの挿入は 、ですが、カスタマイズしたい場合は、例えば、初期化時に希望のdomノードを挿入するだけです:<p class="custom-class"><br></p>
  • :

Read next

関数型プログラミング

つの関数が組み合わされ、オリジナルと同じ順序で実行された場合。結果は同じです。 lodash/fpモジュール関数が優先され、データは遅れます。 1つの引数を渡された関数は、残りの引数を待つ新しい関数を返します。 ポイントフリー : データ処理プロセスをデータに依存しない複合操作として定義することができます。データを表現するもの...

Mar 1, 2020 · 7 min read