まず、最終結果を見てみましょう。
このプロジェクトを実行する前に、多くの情報を調べました。ひとつは、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
- contentEditable: このAPIの目的は、DIVを編集可能にすることです。
- 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;">​</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 = "​";
} 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>
- :