2019-05-08 12:31:43 +00:00
|
|
|
/*
|
|
|
|
Copyright 2019 New Vector Ltd
|
2019-05-22 14:16:32 +00:00
|
|
|
Copyright 2019 The Matrix.org Foundation C.I.C.
|
2019-05-08 12:31:43 +00:00
|
|
|
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
you may not use this file except in compliance with the License.
|
|
|
|
You may obtain a copy of the License at
|
|
|
|
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
See the License for the specific language governing permissions and
|
|
|
|
limitations under the License.
|
|
|
|
*/
|
|
|
|
|
2020-07-20 15:33:53 +00:00
|
|
|
import {Part} from "./parts";
|
2020-07-15 08:45:45 +00:00
|
|
|
import EditorModel from "./model";
|
|
|
|
|
2020-07-20 15:33:53 +00:00
|
|
|
export function needsCaretNodeBefore(part: Part, prevPart: Part) {
|
2019-06-18 16:53:55 +00:00
|
|
|
const isFirst = !prevPart || prevPart.type === "newline";
|
|
|
|
return !part.canEdit && (isFirst || !prevPart.canEdit);
|
|
|
|
}
|
|
|
|
|
2020-07-20 15:33:53 +00:00
|
|
|
export function needsCaretNodeAfter(part: Part, isLastOfLine: boolean) {
|
2019-06-18 16:53:55 +00:00
|
|
|
return !part.canEdit && isLastOfLine;
|
|
|
|
}
|
|
|
|
|
2020-07-15 08:45:45 +00:00
|
|
|
function insertAfter(node: HTMLElement, nodeToInsert: HTMLElement) {
|
2019-06-18 16:53:55 +00:00
|
|
|
const next = node.nextSibling;
|
|
|
|
if (next) {
|
|
|
|
node.parentElement.insertBefore(nodeToInsert, next);
|
|
|
|
} else {
|
|
|
|
node.parentElement.appendChild(nodeToInsert);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-06-21 14:37:29 +00:00
|
|
|
// Use a BOM marker for caret nodes.
|
|
|
|
// On a first test, they seem to be filtered out when copying text out of the editor,
|
|
|
|
// but this could be platform dependent.
|
|
|
|
// As a precautionary measure, I chose the character that slate also uses.
|
|
|
|
export const CARET_NODE_CHAR = "\ufeff";
|
2019-06-20 12:44:18 +00:00
|
|
|
// a caret node is a node that allows the caret to be placed
|
2019-06-18 16:53:55 +00:00
|
|
|
// where otherwise it wouldn't be possible
|
|
|
|
// (e.g. next to a pill span without adjacent text node)
|
|
|
|
function createCaretNode() {
|
|
|
|
const span = document.createElement("span");
|
2019-06-21 09:21:38 +00:00
|
|
|
span.className = "caretNode";
|
2019-06-21 14:37:29 +00:00
|
|
|
span.appendChild(document.createTextNode(CARET_NODE_CHAR));
|
2019-06-18 16:53:55 +00:00
|
|
|
return span;
|
|
|
|
}
|
|
|
|
|
2020-07-15 08:45:45 +00:00
|
|
|
function updateCaretNode(node: HTMLElement) {
|
2019-06-20 12:44:18 +00:00
|
|
|
// ensure the caret node contains only a zero-width space
|
2019-06-21 14:37:29 +00:00
|
|
|
if (node.textContent !== CARET_NODE_CHAR) {
|
|
|
|
node.textContent = CARET_NODE_CHAR;
|
2019-06-20 12:44:18 +00:00
|
|
|
}
|
2019-06-18 16:53:55 +00:00
|
|
|
}
|
|
|
|
|
2020-07-15 08:45:45 +00:00
|
|
|
export function isCaretNode(node: HTMLElement) {
|
2019-06-21 09:21:38 +00:00
|
|
|
return node && node.tagName === "SPAN" && node.className === "caretNode";
|
2019-06-18 16:53:55 +00:00
|
|
|
}
|
|
|
|
|
2020-07-15 08:45:45 +00:00
|
|
|
function removeNextSiblings(node: ChildNode) {
|
2019-06-18 16:53:55 +00:00
|
|
|
if (!node) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
node = node.nextSibling;
|
|
|
|
while (node) {
|
|
|
|
const removeNode = node;
|
|
|
|
node = node.nextSibling;
|
|
|
|
removeNode.remove();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-07-15 08:45:45 +00:00
|
|
|
function removeChildren(parent: HTMLElement) {
|
2019-06-18 16:53:55 +00:00
|
|
|
const firstChild = parent.firstChild;
|
|
|
|
if (firstChild) {
|
|
|
|
removeNextSiblings(firstChild);
|
|
|
|
firstChild.remove();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-07-20 15:33:53 +00:00
|
|
|
function reconcileLine(lineContainer: ChildNode, parts: Part[]) {
|
2019-06-18 16:53:55 +00:00
|
|
|
let currentNode;
|
|
|
|
let prevPart;
|
|
|
|
const lastPart = parts[parts.length - 1];
|
|
|
|
|
|
|
|
for (const part of parts) {
|
|
|
|
const isFirst = !prevPart;
|
|
|
|
currentNode = isFirst ? lineContainer.firstChild : currentNode.nextSibling;
|
|
|
|
|
|
|
|
if (needsCaretNodeBefore(part, prevPart)) {
|
|
|
|
if (isCaretNode(currentNode)) {
|
|
|
|
updateCaretNode(currentNode);
|
2019-06-20 12:44:18 +00:00
|
|
|
currentNode = currentNode.nextSibling;
|
2019-06-18 16:53:55 +00:00
|
|
|
} else {
|
|
|
|
lineContainer.insertBefore(createCaretNode(), currentNode);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// remove nodes until matching current part
|
|
|
|
while (currentNode && !part.canUpdateDOMNode(currentNode)) {
|
|
|
|
const nextNode = currentNode.nextSibling;
|
|
|
|
lineContainer.removeChild(currentNode);
|
|
|
|
currentNode = nextNode;
|
|
|
|
}
|
|
|
|
// update or insert node for current part
|
|
|
|
if (currentNode && part) {
|
|
|
|
part.updateDOMNode(currentNode);
|
|
|
|
} else if (part) {
|
|
|
|
currentNode = part.toDOMNode();
|
|
|
|
// hooks up nextSibling for next iteration
|
|
|
|
lineContainer.appendChild(currentNode);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (needsCaretNodeAfter(part, part === lastPart)) {
|
|
|
|
if (isCaretNode(currentNode.nextSibling)) {
|
|
|
|
currentNode = currentNode.nextSibling;
|
|
|
|
updateCaretNode(currentNode);
|
|
|
|
} else {
|
|
|
|
const caretNode = createCaretNode();
|
|
|
|
insertAfter(currentNode, caretNode);
|
|
|
|
currentNode = caretNode;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
prevPart = part;
|
|
|
|
}
|
|
|
|
|
|
|
|
removeNextSiblings(currentNode);
|
|
|
|
}
|
|
|
|
|
|
|
|
function reconcileEmptyLine(lineContainer) {
|
|
|
|
// empty div needs to have a BR in it to give it height
|
|
|
|
let foundBR = false;
|
|
|
|
let partNode = lineContainer.firstChild;
|
|
|
|
while (partNode) {
|
|
|
|
const nextNode = partNode.nextSibling;
|
|
|
|
if (!foundBR && partNode.tagName === "BR") {
|
|
|
|
foundBR = true;
|
|
|
|
} else {
|
|
|
|
partNode.remove();
|
|
|
|
}
|
|
|
|
partNode = nextNode;
|
|
|
|
}
|
|
|
|
if (!foundBR) {
|
|
|
|
lineContainer.appendChild(document.createElement("br"));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-07-15 08:45:45 +00:00
|
|
|
export function renderModel(editor: HTMLDivElement, model: EditorModel) {
|
|
|
|
const lines = model.parts.reduce((linesArr, part) => {
|
2019-05-13 14:21:57 +00:00
|
|
|
if (part.type === "newline") {
|
2020-07-15 08:45:45 +00:00
|
|
|
linesArr.push([]);
|
2019-05-13 14:21:57 +00:00
|
|
|
} else {
|
2020-07-15 08:45:45 +00:00
|
|
|
const lastLine = linesArr[linesArr.length - 1];
|
2019-05-13 14:21:57 +00:00
|
|
|
lastLine.push(part);
|
2019-05-08 12:31:43 +00:00
|
|
|
}
|
2020-07-15 08:45:45 +00:00
|
|
|
return linesArr;
|
2019-05-13 14:21:57 +00:00
|
|
|
}, [[]]);
|
|
|
|
lines.forEach((parts, i) => {
|
2019-06-18 16:53:55 +00:00
|
|
|
// find first (and remove anything else) div without className
|
|
|
|
// (as browsers insert these in contenteditable) line container
|
2020-07-23 08:25:01 +00:00
|
|
|
let lineContainer = editor.childNodes[i];
|
|
|
|
while (lineContainer && ((<Element>lineContainer).tagName !== "DIV" || !!(<Element>lineContainer).className)) {
|
2019-05-13 14:21:57 +00:00
|
|
|
editor.removeChild(lineContainer);
|
2020-07-23 08:25:01 +00:00
|
|
|
lineContainer = editor.childNodes[i];
|
2019-05-08 12:31:43 +00:00
|
|
|
}
|
2019-05-13 14:21:57 +00:00
|
|
|
if (!lineContainer) {
|
|
|
|
lineContainer = document.createElement("div");
|
|
|
|
editor.appendChild(lineContainer);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (parts.length) {
|
2019-06-18 16:53:55 +00:00
|
|
|
reconcileLine(lineContainer, parts);
|
2019-05-13 14:21:57 +00:00
|
|
|
} else {
|
2019-06-18 16:53:55 +00:00
|
|
|
reconcileEmptyLine(lineContainer);
|
2019-05-13 14:21:57 +00:00
|
|
|
}
|
|
|
|
});
|
2019-06-18 16:53:55 +00:00
|
|
|
if (lines.length) {
|
2019-08-01 09:28:40 +00:00
|
|
|
removeNextSiblings(editor.children[lines.length - 1]);
|
2019-06-18 16:53:55 +00:00
|
|
|
} else {
|
|
|
|
removeChildren(editor);
|
|
|
|
}
|
2019-05-08 12:31:43 +00:00
|
|
|
}
|