mirror of
https://github.com/penpot/penpot.git
synced 2025-05-18 03:16:50 +02:00
✨ Import text-editor code into the repository
This commit is contained in:
parent
68397edd4d
commit
04a0d867b0
65 changed files with 11112 additions and 7 deletions
104
frontend/text-editor/editor/content/Text.js
Normal file
104
frontend/text-editor/editor/content/Text.js
Normal file
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright (c) KALEIDOS INC
|
||||
*/
|
||||
|
||||
/**
|
||||
* Throws if the passed value is not a valid offset value.
|
||||
*
|
||||
* @param {*} offset
|
||||
* @throws {TypeError}
|
||||
*/
|
||||
function tryOffset(offset) {
|
||||
if (!Number.isInteger(offset) || offset < 0)
|
||||
throw new TypeError("Invalid offset");
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws if the passed value is not a valid string.
|
||||
*
|
||||
* @param {*} str
|
||||
* @throws {TypeError}
|
||||
*/
|
||||
function tryString(str) {
|
||||
if (typeof str !== "string") throw new TypeError("Invalid string");
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts string into a string.
|
||||
*
|
||||
* @param {string} str
|
||||
* @param {number} offset
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function insertInto(str, offset, text) {
|
||||
tryString(str);
|
||||
tryOffset(offset);
|
||||
tryString(text);
|
||||
return str.slice(0, offset) + text + str.slice(offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a part of a string with a string.
|
||||
*
|
||||
* @param {string} str
|
||||
* @param {number} startOffset
|
||||
* @param {number} endOffset
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function replaceWith(str, startOffset, endOffset, text) {
|
||||
tryString(str);
|
||||
tryOffset(startOffset);
|
||||
tryOffset(endOffset);
|
||||
tryString(text);
|
||||
return str.slice(0, startOffset) + text + str.slice(endOffset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes text backward from specified offset.
|
||||
*
|
||||
* @param {string} str
|
||||
* @param {number} offset
|
||||
* @returns {string}
|
||||
*/
|
||||
export function removeBackward(str, offset) {
|
||||
tryString(str);
|
||||
tryOffset(offset);
|
||||
if (offset === 0) {
|
||||
return str;
|
||||
}
|
||||
return str.slice(0, offset - 1) + str.slice(offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes text forward from specified offset.
|
||||
*
|
||||
* @param {string} str
|
||||
* @param {number} offset
|
||||
* @returns {string}
|
||||
*/
|
||||
export function removeForward(str, offset) {
|
||||
tryString(str);
|
||||
tryOffset(offset);
|
||||
return str.slice(0, offset) + str.slice(offset + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a slice of text.
|
||||
*
|
||||
* @param {string} str
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
* @returns {string}
|
||||
*/
|
||||
export function removeSlice(str, start, end) {
|
||||
tryString(str);
|
||||
tryOffset(start);
|
||||
tryOffset(end);
|
||||
return str.slice(0, start) + str.slice(end);
|
||||
}
|
46
frontend/text-editor/editor/content/Text.test.js
Normal file
46
frontend/text-editor/editor/content/Text.test.js
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { describe, test, expect } from 'vitest'
|
||||
import { insertInto, removeBackward, removeForward, replaceWith } from './Text';
|
||||
|
||||
describe("Text", () => {
|
||||
test("* should throw when passed wrong parameters", () => {
|
||||
expect(() => insertInto(Infinity, Infinity, Infinity)).toThrowError('Invalid string');
|
||||
expect(() => insertInto('Hello', Infinity, Infinity)).toThrowError('Invalid offset');
|
||||
expect(() => insertInto('Hello', 0, Infinity)).toThrowError('Invalid string');
|
||||
});
|
||||
|
||||
test("`insertInto` should insert a string into an offset", () => {
|
||||
expect(insertInto("Hell, World!", 4, "o")).toBe("Hello, World!");
|
||||
});
|
||||
|
||||
test("`replaceWith` should replace a string into a string", () => {
|
||||
expect(replaceWith("Hello, Something!", 7, 16, "World")).toBe("Hello, World!");
|
||||
});
|
||||
|
||||
test("`removeBackward` should remove string backward from start (offset 0)", () => {
|
||||
expect(removeBackward("Hello, World!", 0)).toBe("Hello, World!");
|
||||
});
|
||||
|
||||
test("`removeForward` should remove string forward from start (offset 0)", () => {
|
||||
expect(removeForward("Hello, World!", 0)).toBe("ello, World!");
|
||||
});
|
||||
|
||||
test("`removeBackward` should remove string backward from end", () => {
|
||||
expect(removeBackward("Hello, World!", "Hello, World!".length)).toBe(
|
||||
"Hello, World"
|
||||
);
|
||||
});
|
||||
|
||||
test("`removeForward` should remove string forward from end", () => {
|
||||
expect(removeForward("Hello, World!", "Hello, World!".length)).toBe(
|
||||
"Hello, World!"
|
||||
);
|
||||
});
|
||||
|
||||
test("`removeBackward` should remove string backward from offset 6", () => {
|
||||
expect(removeBackward("Hello, World!", 6)).toBe("Hello World!");
|
||||
});
|
||||
|
||||
test("`removeForward` should remove string forward from offset 6", () => {
|
||||
expect(removeForward("Hello, World!", 6)).toBe("Hello,World!");
|
||||
});
|
||||
});
|
78
frontend/text-editor/editor/content/dom/Color.js
Normal file
78
frontend/text-editor/editor/content/dom/Color.js
Normal file
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Canvas used to retrieve colors as CSS hexadecimals.
|
||||
*
|
||||
* @type {OffscreenCanvas|HTMLCanvasElement}
|
||||
*/
|
||||
let canvas = null; // createCanvas(1, 1);
|
||||
|
||||
/**
|
||||
* Context used to retrieve colors as CSS hexadecimals.
|
||||
*
|
||||
* @type {CanvasRenderingContext2D}
|
||||
*/
|
||||
let context = null; // canvas.getContext("2d");
|
||||
|
||||
/**
|
||||
* Returns the canvas context.
|
||||
*
|
||||
* @returns {CanvasRenderingContext2D}
|
||||
*/
|
||||
function getContext() {
|
||||
if (!canvas) {
|
||||
canvas = createCanvas(1, 1);
|
||||
}
|
||||
if (!context) {
|
||||
context = canvas.getContext("2d");
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new canvas element.
|
||||
*
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
* @returns {OffscreenCanvas|HTMLCanvasElement}
|
||||
*/
|
||||
function createCanvas(width, height) {
|
||||
if ("OffscreenCanvas" in globalThis) {
|
||||
return new OffscreenCanvas(width, height);
|
||||
}
|
||||
return document.createElement("canvas");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a byte representation as an hex.
|
||||
*
|
||||
* @param {number} byte
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getByteAsHex(byte) {
|
||||
return byte.toString(16).padStart(2, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a color definition from a fillStyle color.
|
||||
*
|
||||
* @param {string} fillStyle
|
||||
* @returns {[string, number]}
|
||||
*/
|
||||
export function getColor(fillStyle) {
|
||||
const context = getContext();
|
||||
context.fillStyle = fillStyle;
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
const imageData = context.getImageData(0, 0, 1, 1);
|
||||
const [r, g, b, a] = imageData.data;
|
||||
return [`#${getByteAsHex(r)}${getByteAsHex(g)}${getByteAsHex(b)}`, a / 255.0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a fill from a fillStyle color.
|
||||
*
|
||||
* @param {string} fillStyle
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getFills(fillStyle) {
|
||||
const [color, opacity] = getColor(fillStyle);
|
||||
return `[["^ ","~:fill-color","${color}","~:fill-opacity",${opacity}]]`;
|
||||
}
|
102
frontend/text-editor/editor/content/dom/Content.js
Normal file
102
frontend/text-editor/editor/content/dom/Content.js
Normal file
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright (c) KALEIDOS INC
|
||||
*/
|
||||
|
||||
import { createInline } from "./Inline";
|
||||
import {
|
||||
createEmptyParagraph,
|
||||
createParagraph,
|
||||
isLikeParagraph,
|
||||
} from "./Paragraph";
|
||||
import { isDisplayBlock, normalizeStyles } from "./Style";
|
||||
|
||||
/**
|
||||
* Maps any HTML into a valid content DOM element.
|
||||
*
|
||||
* @param {Document} document
|
||||
* @param {HTMLElement} root
|
||||
* @param {CSSStyleDeclaration} [styleDefaults]
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
export function mapContentFragmentFromDocument(document, root, styleDefaults) {
|
||||
const nodeIterator = document.createNodeIterator(
|
||||
root,
|
||||
NodeFilter.SHOW_TEXT
|
||||
);
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
let currentParagraph = null;
|
||||
let currentNode = nodeIterator.nextNode();
|
||||
while (currentNode) {
|
||||
// We cannot call document.defaultView because it is `null`.
|
||||
const currentStyle = normalizeStyles(currentNode, styleDefaults);
|
||||
if (
|
||||
isDisplayBlock(currentNode.parentElement.style) ||
|
||||
isDisplayBlock(currentStyle) ||
|
||||
isLikeParagraph(currentNode.parentElement)
|
||||
) {
|
||||
if (currentParagraph) {
|
||||
fragment.appendChild(currentParagraph);
|
||||
}
|
||||
currentParagraph = createParagraph(undefined, currentStyle);
|
||||
} else {
|
||||
if (currentParagraph === null) {
|
||||
currentParagraph = createParagraph(undefined, currentStyle);
|
||||
}
|
||||
}
|
||||
|
||||
currentParagraph.appendChild(
|
||||
createInline(new Text(currentNode.nodeValue), currentStyle)
|
||||
);
|
||||
|
||||
currentNode = nodeIterator.nextNode();
|
||||
}
|
||||
|
||||
fragment.appendChild(currentParagraph);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps any HTML into a valid content DOM element.
|
||||
*
|
||||
* @param {string} html
|
||||
* @param {CSSStyleDeclaration} [styleDefaults]
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
export function mapContentFragmentFromHTML(html, styleDefaults) {
|
||||
const parser = new DOMParser();
|
||||
const htmlDocument = parser.parseFromString(html, "text/html");
|
||||
return mapContentFragmentFromDocument(
|
||||
htmlDocument,
|
||||
htmlDocument.documentElement,
|
||||
styleDefaults
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a plain text into a valid content DOM element.
|
||||
*
|
||||
* @param {string} string
|
||||
* @param {CSSStyleDeclaration} [styleDefaults]
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
export function mapContentFragmentFromString(string, styleDefaults) {
|
||||
const lines = string.replace(/\r/g, "").split("\n");
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const line of lines) {
|
||||
if (line === "") {
|
||||
fragment.appendChild(createEmptyParagraph(styleDefaults));
|
||||
} else {
|
||||
fragment.appendChild(
|
||||
createParagraph([
|
||||
createInline(new Text(line), styleDefaults)
|
||||
], styleDefaults)
|
||||
);
|
||||
}
|
||||
}
|
||||
return fragment;
|
||||
}
|
91
frontend/text-editor/editor/content/dom/Content.test.js
Normal file
91
frontend/text-editor/editor/content/dom/Content.test.js
Normal file
|
@ -0,0 +1,91 @@
|
|||
import { describe, test, expect } from 'vitest';
|
||||
import { mapContentFragmentFromHTML, mapContentFragmentFromString } from './Content';
|
||||
|
||||
/* @vitest-environment jsdom */
|
||||
describe('Content', () => {
|
||||
test("mapContentFragmentFromHTML should return a valid content for the editor", () => {
|
||||
const inertElement = document.createElement("div");
|
||||
const contentFragment = mapContentFragmentFromHTML(
|
||||
"<div>Hello, World!</div>",
|
||||
inertElement.style
|
||||
);
|
||||
expect(contentFragment).toBeInstanceOf(DocumentFragment);
|
||||
expect(contentFragment.children).toHaveLength(1);
|
||||
expect(contentFragment.firstElementChild).toBeInstanceOf(HTMLDivElement);
|
||||
expect(contentFragment.firstElementChild.firstElementChild).toBeInstanceOf(
|
||||
HTMLSpanElement
|
||||
);
|
||||
expect(contentFragment.firstElementChild.firstElementChild.firstChild).toBeInstanceOf(
|
||||
Text
|
||||
);
|
||||
expect(contentFragment.textContent).toBe("Hello, World!");
|
||||
});
|
||||
|
||||
test("mapContentFragmentFromHTML should return a valid content for the editor (multiple inlines)", () => {
|
||||
const inertElement = document.createElement("div");
|
||||
const contentFragment = mapContentFragmentFromHTML(
|
||||
"<div>Hello,<br/><span> World!</span><br/></div>",
|
||||
inertElement.style
|
||||
);
|
||||
expect(contentFragment).toBeInstanceOf(DocumentFragment);
|
||||
expect(contentFragment.children).toHaveLength(1);
|
||||
expect(contentFragment.firstElementChild).toBeInstanceOf(HTMLDivElement);
|
||||
expect(contentFragment.firstElementChild.children).toHaveLength(2);
|
||||
expect(contentFragment.firstElementChild.firstElementChild).toBeInstanceOf(
|
||||
HTMLSpanElement
|
||||
);
|
||||
expect(contentFragment.firstElementChild.firstElementChild.firstChild).toBeInstanceOf(
|
||||
Text
|
||||
);
|
||||
expect(contentFragment.textContent).toBe("Hello, World!");
|
||||
});
|
||||
|
||||
test("mapContentFragmentFromHTML should return a valid content for the editor (multiple paragraphs)", () => {
|
||||
const paragraphs = [
|
||||
"Lorem ipsum",
|
||||
"Dolor sit amet",
|
||||
"Sed iaculis blandit odio ornare sagittis.",
|
||||
];
|
||||
const inertElement = document.createElement("div");
|
||||
const contentFragment = mapContentFragmentFromHTML(
|
||||
"<div>Lorem ipsum</div><div>Dolor sit amet</div><div><br/></div><div>Sed iaculis blandit odio ornare sagittis.</div>",
|
||||
inertElement.style
|
||||
);
|
||||
expect(contentFragment).toBeInstanceOf(DocumentFragment);
|
||||
expect(contentFragment.children).toHaveLength(3);
|
||||
for (let index = 0; index < contentFragment.children.length; index++) {
|
||||
expect(contentFragment.children.item(index)).toBeInstanceOf(HTMLDivElement);
|
||||
expect(contentFragment.children.item(index).firstElementChild).toBeInstanceOf(
|
||||
HTMLSpanElement
|
||||
);
|
||||
expect(
|
||||
contentFragment.children.item(index).firstElementChild.firstChild
|
||||
).toBeInstanceOf(Text);
|
||||
expect(contentFragment.children.item(index).textContent).toBe(paragraphs[index]);
|
||||
}
|
||||
expect(contentFragment.textContent).toBe("Lorem ipsumDolor sit ametSed iaculis blandit odio ornare sagittis.");
|
||||
});
|
||||
|
||||
test("mapContentFragmentFromString should return a valid content for the editor", () => {
|
||||
const contentFragment = mapContentFragmentFromString(
|
||||
"Hello, \nWorld!"
|
||||
);
|
||||
expect(contentFragment).toBeInstanceOf(DocumentFragment);
|
||||
expect(contentFragment.children).toHaveLength(2);
|
||||
expect(contentFragment.children.item(0)).toBeInstanceOf(HTMLDivElement);
|
||||
expect(contentFragment.children.item(1)).toBeInstanceOf(HTMLDivElement);
|
||||
expect(contentFragment.children.item(0).firstElementChild).toBeInstanceOf(
|
||||
HTMLSpanElement
|
||||
);
|
||||
expect(contentFragment.children.item(0).firstElementChild.firstChild).toBeInstanceOf(
|
||||
Text
|
||||
);
|
||||
expect(contentFragment.children.item(1).firstElementChild).toBeInstanceOf(
|
||||
HTMLSpanElement
|
||||
);
|
||||
expect(
|
||||
contentFragment.children.item(1).firstElementChild.firstChild
|
||||
).toBeInstanceOf(Text);
|
||||
expect(contentFragment.textContent).toBe("Hello, World!");
|
||||
});
|
||||
});
|
98
frontend/text-editor/editor/content/dom/Element.js
Normal file
98
frontend/text-editor/editor/content/dom/Element.js
Normal file
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright (c) KALEIDOS INC
|
||||
*/
|
||||
|
||||
import { setStyles } from "./Style";
|
||||
|
||||
/**
|
||||
* @typedef {Object} CreateElementOptions
|
||||
* @property {Object.<string,*>} [attributes]
|
||||
* @property {Object.<string,*>} [data]
|
||||
* @property {Object.<string,*>|CSSStyleDeclaration} [styles]
|
||||
* @property {Array<[string,?string]>} [allowedStyles]
|
||||
* @property {Array|Node} [children]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a new random id to identify content nodes.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
export function createRandomId() {
|
||||
return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new HTML element.
|
||||
*
|
||||
* @param {string} tag
|
||||
* @param {*} options
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function createElement(tag, options) {
|
||||
const element = document.createElement(tag);
|
||||
if (options?.attributes) {
|
||||
Object.entries(options.attributes).forEach(([name, value]) =>
|
||||
element.setAttribute(name, value)
|
||||
);
|
||||
}
|
||||
if (options?.data) {
|
||||
Object.entries(options.data).forEach(
|
||||
([name, value]) => (element.dataset[name] = value)
|
||||
);
|
||||
}
|
||||
if (options?.styles && options?.allowedStyles) {
|
||||
setStyles(element, options.allowedStyles, options.styles);
|
||||
}
|
||||
if (options?.children) {
|
||||
if (Array.isArray(options.children)) {
|
||||
element.append(...options.children);
|
||||
} else {
|
||||
element.appendChild(options.children);
|
||||
}
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if passed node is an element.
|
||||
*
|
||||
* @param {Node} element
|
||||
* @param {string} nodeName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isElement(element, nodeName) {
|
||||
return (
|
||||
element.nodeType === Node.ELEMENT_NODE &&
|
||||
element.nodeName === nodeName.toUpperCase()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the specified offset is at the start of the element.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @param {number} offset
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isOffsetAtStart(node, offset) {
|
||||
return offset === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the specified offset is at the end of the element.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @param {number} offset
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isOffsetAtEnd(node, offset) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.nodeValue.length === offset;
|
||||
}
|
||||
return true;
|
||||
}
|
108
frontend/text-editor/editor/content/dom/Element.test.js
Normal file
108
frontend/text-editor/editor/content/dom/Element.test.js
Normal file
|
@ -0,0 +1,108 @@
|
|||
import { describe, test, expect } from "vitest";
|
||||
import { createElement, isElement, createRandomId, isOffsetAtStart, isOffsetAtEnd } from "./Element";
|
||||
|
||||
/* @vitest-environment jsdom */
|
||||
describe("Element", () => {
|
||||
test("createRandomId should create a new random id", () => {
|
||||
const randomId = createRandomId();
|
||||
expect(typeof randomId).toBe('string');
|
||||
expect(randomId.length).toBeGreaterThan(0);
|
||||
expect(randomId.length).toBeLessThan(12);
|
||||
});
|
||||
|
||||
test("createElement should create a new element", () => {
|
||||
const element = createElement("div");
|
||||
expect(element.nodeType).toBe(Node.ELEMENT_NODE);
|
||||
expect(element.nodeName).toBe("DIV");
|
||||
});
|
||||
|
||||
test("createElement should create a new element with attributes", () => {
|
||||
const element = createElement("div", {
|
||||
attributes: {
|
||||
"aria-multiline": true,
|
||||
"role": "textbox"
|
||||
}
|
||||
});
|
||||
expect(element.ariaMultiLine).toBe("true");
|
||||
expect(element.role).toBe("textbox");
|
||||
});
|
||||
|
||||
test("createElement should create a new element with data- properties", () => {
|
||||
const element = createElement("div", {
|
||||
data: {
|
||||
itype: "root"
|
||||
}
|
||||
});
|
||||
expect(element.dataset.itype).toBe("root");
|
||||
});
|
||||
|
||||
test("createElement should create a new element with styles from an object", () => {
|
||||
const element = createElement("div", {
|
||||
styles: {
|
||||
"text-decoration": "underline",
|
||||
},
|
||||
allowedStyles: [["text-decoration"]]
|
||||
});
|
||||
expect(element.style.textDecoration).toBe("underline");
|
||||
});
|
||||
|
||||
test("createElement should create a new element with a child", () => {
|
||||
const element = createElement("div", {
|
||||
children: new Text("Hello, World!")
|
||||
});
|
||||
expect(element.textContent).toBe("Hello, World!");
|
||||
});
|
||||
|
||||
test("createElement should create a new element with children", () => {
|
||||
const element = createElement("div", {
|
||||
children: [
|
||||
createElement("div", {
|
||||
children: [
|
||||
createElement("div", {
|
||||
children: new Text("Hello, World!")
|
||||
})
|
||||
]
|
||||
})
|
||||
],
|
||||
});
|
||||
expect(element.textContent).toBe("Hello, World!");
|
||||
expect(element.firstChild.nodeType).toBe(Node.ELEMENT_NODE);
|
||||
expect(element.firstChild.firstChild.nodeType).toBe(Node.ELEMENT_NODE);
|
||||
expect(element.firstChild.firstChild.firstChild.nodeType).toBe(Node.TEXT_NODE);
|
||||
});
|
||||
|
||||
test("isElement returns true if the passed element is the expected element", () => {
|
||||
const br = createElement("br");
|
||||
expect(isElement(br, "br")).toBe(true);
|
||||
const div = createElement("div");
|
||||
expect(isElement(div, "div")).toBe(true);
|
||||
const text = new Text("Hello, World!");
|
||||
expect(isElement(text, "text")).toBe(false);
|
||||
});
|
||||
|
||||
test("isOffsetAtStart should return true when offset is 0", () => {
|
||||
const element = createElement('span', {
|
||||
children: new Text("Hello")
|
||||
})
|
||||
expect(isOffsetAtStart(element, 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("isOffsetAtEnd should return true when offset is the length of the text content", () => {
|
||||
const element = createElement("span", {
|
||||
children: new Text("Hello"),
|
||||
});
|
||||
expect(isOffsetAtEnd(element, 5)).toBe(true);
|
||||
});
|
||||
|
||||
test("isOffsetAtEnd should return true when the node is a Text and offset is the length of the node", () => {
|
||||
const element = new Text("Hello");
|
||||
expect(isOffsetAtEnd(element, 5)).toBe(true);
|
||||
});
|
||||
|
||||
test("isOffsetAtEnd should return true when node is an element", () => {
|
||||
const element = createElement("span", {
|
||||
children: createElement("br"),
|
||||
});
|
||||
expect(isOffsetAtEnd(element, 5)).toBe(true);
|
||||
});
|
||||
});
|
272
frontend/text-editor/editor/content/dom/Inline.js
Normal file
272
frontend/text-editor/editor/content/dom/Inline.js
Normal file
|
@ -0,0 +1,272 @@
|
|||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright (c) KALEIDOS INC
|
||||
*/
|
||||
|
||||
import {
|
||||
createElement,
|
||||
isElement,
|
||||
isOffsetAtStart,
|
||||
isOffsetAtEnd,
|
||||
} from "./Element";
|
||||
import { createLineBreak, isLineBreak } from "./LineBreak";
|
||||
import { setStyles, mergeStyles } from "./Style";
|
||||
import { createRandomId } from "./Element";
|
||||
|
||||
export const TAG = "SPAN";
|
||||
export const TYPE = "inline";
|
||||
export const QUERY = `[data-itype="${TYPE}"]`;
|
||||
export const STYLES = [
|
||||
["--typography-ref-id"],
|
||||
["--typography-ref-file"],
|
||||
["--font-id"],
|
||||
["--font-variant-id"],
|
||||
["--fills"],
|
||||
["font-variant"],
|
||||
["font-family"],
|
||||
["font-size", "px"],
|
||||
["font-weight"],
|
||||
["font-style"],
|
||||
["line-height"],
|
||||
["letter-spacing", "px"],
|
||||
["text-decoration"],
|
||||
["text-transform"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns true if passed node is an inline.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isInline(node) {
|
||||
if (!node) return false;
|
||||
if (!isElement(node, TAG)) return false;
|
||||
if (node.dataset.itype !== TYPE) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the passed node "behaves" like an
|
||||
* inline.
|
||||
*
|
||||
* @param {Node} element
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLikeInline(element) {
|
||||
return element
|
||||
? [
|
||||
"A",
|
||||
"ABBR",
|
||||
"ACRONYM",
|
||||
"B",
|
||||
"BDO",
|
||||
"BIG",
|
||||
"BR",
|
||||
"BUTTON",
|
||||
"CITE",
|
||||
"CODE",
|
||||
"DFN",
|
||||
"EM",
|
||||
"I",
|
||||
"IMG",
|
||||
"INPUT",
|
||||
"KBD",
|
||||
"LABEL",
|
||||
"MAP",
|
||||
"OBJECT",
|
||||
"OUTPUT",
|
||||
"Q",
|
||||
"SAMP",
|
||||
"SCRIPT",
|
||||
"SELECT",
|
||||
"SMALL",
|
||||
"SPAN",
|
||||
"STRONG",
|
||||
"SUB",
|
||||
"SUP",
|
||||
"TEXTAREA",
|
||||
"TIME",
|
||||
"TT",
|
||||
"VAR",
|
||||
].includes(element.nodeName)
|
||||
: false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Inline
|
||||
*
|
||||
* @param {Text|HTMLBRElement} text
|
||||
* @param {Object.<string, *>|CSSStyleDeclaration} styles
|
||||
* @param {Object.<string, *>} [attrs]
|
||||
* @returns {HTMLSpanElement}
|
||||
*/
|
||||
export function createInline(textOrLineBreak, styles, attrs) {
|
||||
if (
|
||||
!(textOrLineBreak instanceof HTMLBRElement) &&
|
||||
!(textOrLineBreak instanceof Text)
|
||||
) {
|
||||
throw new TypeError("Invalid inline child");
|
||||
}
|
||||
if (textOrLineBreak instanceof Text
|
||||
&& textOrLineBreak.nodeValue.length === 0) {
|
||||
console.trace("nodeValue", textOrLineBreak.nodeValue)
|
||||
throw new TypeError("Invalid inline child, cannot be an empty text");
|
||||
}
|
||||
return createElement(TAG, {
|
||||
attributes: { id: createRandomId(), ...attrs },
|
||||
data: { itype: TYPE },
|
||||
styles: styles,
|
||||
allowedStyles: STYLES,
|
||||
children: textOrLineBreak,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new inline from an older inline. This only
|
||||
* merges styles from the older inline to the new inline.
|
||||
*
|
||||
* @param {HTMLSpanElement} inline
|
||||
* @param {Object.<string, *>} textOrLineBreak
|
||||
* @param {Object.<string, *>|CSSStyleDeclaration} styles
|
||||
* @param {Object.<string, *>} [attrs]
|
||||
* @returns {HTMLSpanElement}
|
||||
*/
|
||||
export function createInlineFrom(inline, textOrLineBreak, styles, attrs) {
|
||||
return createInline(
|
||||
textOrLineBreak,
|
||||
mergeStyles(STYLES, inline.style, styles),
|
||||
attrs
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new empty inline.
|
||||
*
|
||||
* @param {Object.<string,*>|CSSStyleDeclaration} styles
|
||||
* @returns {HTMLSpanElement}
|
||||
*/
|
||||
export function createEmptyInline(styles) {
|
||||
return createInline(createLineBreak(), styles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the inline styles.
|
||||
*
|
||||
* @param {HTMLSpanElement} element
|
||||
* @param {Object.<string,*>|CSSStyleDeclaration} styles
|
||||
* @returns {HTMLSpanElement}
|
||||
*/
|
||||
export function setInlineStyles(element, styles) {
|
||||
return setStyles(element, STYLES, styles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the closest inline from a node.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
export function getInline(node) {
|
||||
if (!node) return null; // FIXME: Should throw?
|
||||
if (isInline(node)) return node;
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const inline = node?.parentElement;
|
||||
if (!inline) return null;
|
||||
if (!isInline(inline)) return null;
|
||||
return inline;
|
||||
}
|
||||
return node.closest(QUERY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if we are at the start offset
|
||||
* of an inline.
|
||||
*
|
||||
* NOTE: Only the first inline returns this as true
|
||||
*
|
||||
* @param {TextNode|HTMLBRElement} node
|
||||
* @param {number} offset
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isInlineStart(node, offset) {
|
||||
const inline = getInline(node);
|
||||
if (!inline) return false;
|
||||
return isOffsetAtStart(inline, offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if we are at the end offset
|
||||
* of an inline.
|
||||
*
|
||||
* @param {TextNode|HTMLBRElement} node
|
||||
* @param {number} offset
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isInlineEnd(node, offset) {
|
||||
const inline = getInline(node);
|
||||
if (!inline) return false;
|
||||
return isOffsetAtEnd(inline.firstChild, offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits an inline.
|
||||
*
|
||||
* @param {HTMLSpanElement} inline
|
||||
* @param {number} offset
|
||||
*/
|
||||
export function splitInline(inline, offset) {
|
||||
const textNode = inline.firstChild;
|
||||
const style = inline.style;
|
||||
const newTextNode = textNode.splitText(offset);
|
||||
return createInline(newTextNode, style);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the inlines of a paragraph starting at
|
||||
* the specified inline.
|
||||
*
|
||||
* @param {HTMLSpanElement} startInline
|
||||
* @returns {Array<HTMLSpanElement>}
|
||||
*/
|
||||
export function getInlinesFrom(startInline) {
|
||||
const inlines = [];
|
||||
let currentInline = startInline;
|
||||
let index = 0;
|
||||
while (currentInline) {
|
||||
if (index > 0) inlines.push(currentInline);
|
||||
currentInline = currentInline.nextElementSibling;
|
||||
index++;
|
||||
}
|
||||
return inlines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the length of an inline.
|
||||
*
|
||||
* @param {HTMLElement} inline
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getInlineLength(inline) {
|
||||
if (!isInline(inline)) throw new Error("Invalid inline");
|
||||
if (isLineBreak(inline.firstChild)) return 0;
|
||||
return inline.firstChild.nodeValue.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges two inlines.
|
||||
*
|
||||
* @param {HTMLSpanElement} a
|
||||
* @param {HTMLSpanElement} b
|
||||
* @returns {HTMLSpanElement}
|
||||
*/
|
||||
export function mergeInlines(a, b) {
|
||||
a.append(...b.childNodes);
|
||||
b.remove();
|
||||
// We need to normalize Text nodes.
|
||||
a.normalize();
|
||||
return a;
|
||||
}
|
111
frontend/text-editor/editor/content/dom/Inline.test.js
Normal file
111
frontend/text-editor/editor/content/dom/Inline.test.js
Normal file
|
@ -0,0 +1,111 @@
|
|||
import { describe, test, expect } from "vitest";
|
||||
import { createEmptyInline, createInline, getInline, getInlineLength, isInline, isInlineEnd, isInlineStart, isLikeInline, splitInline, TAG, TYPE } from "./Inline";
|
||||
import { createLineBreak } from "./LineBreak";
|
||||
|
||||
/* @vitest-environment jsdom */
|
||||
describe("Inline", () => {
|
||||
test("createInline should throw when passed an invalid child", () => {
|
||||
expect(() => createInline("Hello, World!")).toThrowError(
|
||||
"Invalid inline child"
|
||||
);
|
||||
});
|
||||
|
||||
test("createInline creates a new inline element with a <br> inside", () => {
|
||||
const inline = createInline(createLineBreak());
|
||||
expect(inline).toBeInstanceOf(HTMLSpanElement);
|
||||
expect(inline.dataset.itype).toBe(TYPE);
|
||||
expect(inline.nodeName).toBe(TAG);
|
||||
expect(inline.textContent).toBe("");
|
||||
expect(inline.firstChild).toBeInstanceOf(HTMLBRElement);
|
||||
});
|
||||
|
||||
test("createInline creates a new inline element with a text inside", () => {
|
||||
const inline = createInline(new Text("Hello, World!"));
|
||||
expect(inline).toBeInstanceOf(HTMLSpanElement);
|
||||
expect(inline.dataset.itype).toBe(TYPE);
|
||||
expect(inline.nodeName).toBe(TAG);
|
||||
expect(inline.textContent).toBe("Hello, World!");
|
||||
expect(inline.firstChild).toBeInstanceOf(Text);
|
||||
});
|
||||
|
||||
test("createEmptyInline creates a new empty inline element with a <br> inside", () => {
|
||||
const emptyInline = createEmptyInline();
|
||||
expect(emptyInline).toBeInstanceOf(HTMLSpanElement);
|
||||
expect(emptyInline.dataset.itype).toBe(TYPE);
|
||||
expect(emptyInline.nodeName).toBe(TAG);
|
||||
expect(emptyInline.textContent).toBe("");
|
||||
expect(emptyInline.firstChild).toBeInstanceOf(HTMLBRElement);
|
||||
});
|
||||
|
||||
test("isInline should return true on elements that are inlines", () => {
|
||||
const inline = createInline(new Text("Hello, World!"));
|
||||
expect(isInline(inline)).toBe(true);
|
||||
const a = document.createElement("a");
|
||||
expect(isInline(a)).toBe(false);
|
||||
const b = null;
|
||||
expect(isInline(b)).toBe(false);
|
||||
const c = document.createElement('span');
|
||||
expect(isInline(c)).toBe(false);
|
||||
});
|
||||
|
||||
test("isLikeInline should return true on elements that have inline behavior by default", () => {
|
||||
expect(isLikeInline(Infinity)).toBe(false);
|
||||
expect(isLikeInline(null)).toBe(false);
|
||||
expect(isLikeInline(document.createElement("A"))).toBe(true);
|
||||
});
|
||||
|
||||
// FIXME: Should throw?
|
||||
test("isInlineStart returns false when passed node is not an inline", () => {
|
||||
const inline = document.createElement("div");
|
||||
expect(isInlineStart(inline, 0)).toBe(false);
|
||||
expect(isInlineStart(inline, "Hello, World!".length)).toBe(false);
|
||||
});
|
||||
|
||||
test("isInlineStart returns if we're at the start of an inline", () => {
|
||||
const inline = createInline(new Text("Hello, World!"));
|
||||
expect(isInlineStart(inline, 0)).toBe(true);
|
||||
expect(isInlineStart(inline, "Hello, World!".length)).toBe(false);
|
||||
});
|
||||
|
||||
// FIXME: Should throw?
|
||||
test("isInlineEnd returns false when passed node is not an inline", () => {
|
||||
const inline = document.createElement("div");
|
||||
expect(isInlineEnd(inline, 0)).toBe(false);
|
||||
expect(isInlineEnd(inline, "Hello, World!".length)).toBe(false);
|
||||
});
|
||||
|
||||
test("isInlineEnd returns if we're in the end of an inline", () => {
|
||||
const inline = createInline(new Text("Hello, World!"));
|
||||
expect(isInlineEnd(inline, 0)).toBe(false);
|
||||
expect(isInlineEnd(inline, "Hello, World!".length)).toBe(true);
|
||||
});
|
||||
|
||||
test("getInline ", () => {
|
||||
expect(getInline(null)).toBe(null);
|
||||
})
|
||||
|
||||
test("getInlineLength throws when the passed node is not an inline", () => {
|
||||
const inline = document.createElement('div');
|
||||
expect(() => getInlineLength(inline)).toThrowError('Invalid inline');
|
||||
});
|
||||
|
||||
test("getInlineLength returns the length of the inline content", () => {
|
||||
const inline = createInline(new Text("Hello, World!"));
|
||||
expect(getInlineLength(inline)).toBe(13);
|
||||
});
|
||||
|
||||
test("getInlineLength should return 0 when the inline content is a <br>", () => {
|
||||
const emptyInline = createEmptyInline();
|
||||
expect(getInlineLength(emptyInline)).toBe(0);
|
||||
});
|
||||
|
||||
test("splitInline returns a new inline from the splitted inline", () => {
|
||||
const inline = createInline(new Text("Hello, World!"));
|
||||
const newInline = splitInline(inline, 5);
|
||||
expect(newInline).toBeInstanceOf(HTMLSpanElement);
|
||||
expect(newInline.firstChild).toBeInstanceOf(Text);
|
||||
expect(newInline.textContent).toBe(", World!");
|
||||
expect(newInline.dataset.itype).toBe(TYPE);
|
||||
expect(newInline.nodeName).toBe(TAG);
|
||||
});
|
||||
});
|
28
frontend/text-editor/editor/content/dom/LineBreak.js
Normal file
28
frontend/text-editor/editor/content/dom/LineBreak.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright (c) KALEIDOS INC
|
||||
*/
|
||||
|
||||
export const TAG = "BR";
|
||||
|
||||
/**
|
||||
* Creates a new line break.
|
||||
*
|
||||
* @returns {HTMLBRElement}
|
||||
*/
|
||||
export function createLineBreak() {
|
||||
return document.createElement(TAG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the passed node is a line break.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLineBreak(node) {
|
||||
return node && node.nodeType === Node.ELEMENT_NODE && node.nodeName === TAG;
|
||||
}
|
11
frontend/text-editor/editor/content/dom/LineBreak.test.js
Normal file
11
frontend/text-editor/editor/content/dom/LineBreak.test.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { createLineBreak } from './LineBreak';
|
||||
|
||||
/* @vitest-environment jsdom */
|
||||
describe('LineBreak', () => {
|
||||
test("createLineBreak should return a <br> element", () => {
|
||||
const br = createLineBreak();
|
||||
expect(br.nodeType).toBe(Node.ELEMENT_NODE);
|
||||
expect(br.nodeName).toBe('BR');
|
||||
})
|
||||
});
|
259
frontend/text-editor/editor/content/dom/Paragraph.js
Normal file
259
frontend/text-editor/editor/content/dom/Paragraph.js
Normal file
|
@ -0,0 +1,259 @@
|
|||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright (c) KALEIDOS INC
|
||||
*/
|
||||
|
||||
import {
|
||||
createElement,
|
||||
isElement,
|
||||
isOffsetAtStart,
|
||||
isOffsetAtEnd,
|
||||
} from "./Element";
|
||||
import {
|
||||
isInline,
|
||||
isLikeInline,
|
||||
getInline,
|
||||
getInlinesFrom,
|
||||
createInline,
|
||||
createEmptyInline,
|
||||
isInlineEnd,
|
||||
splitInline,
|
||||
} from "./Inline";
|
||||
import { createLineBreak, isLineBreak } from "./LineBreak";
|
||||
import { setStyles } from "./Style";
|
||||
import { createRandomId } from "./Element";
|
||||
import { isEmptyTextNode, isTextNode } from './TextNode';
|
||||
|
||||
export const TAG = "DIV";
|
||||
export const TYPE = "paragraph";
|
||||
export const QUERY = `[data-itype="${TYPE}"]`;
|
||||
export const STYLES = [
|
||||
["--typography-ref-id"],
|
||||
["--typography-ref-file"],
|
||||
["--font-id"],
|
||||
["--font-variant-id"],
|
||||
["--fills"],
|
||||
["font-variant"],
|
||||
["font-family"],
|
||||
["font-size", "px"],
|
||||
["font-weight"],
|
||||
["font-style"],
|
||||
["line-height"],
|
||||
["letter-spacing", "px"],
|
||||
["text-decoration"],
|
||||
["text-transform"],
|
||||
["text-align"],
|
||||
["direction"]
|
||||
];
|
||||
|
||||
/**
|
||||
* FIXME: This is a fix for Chrome that removes the
|
||||
* current inline when the last character is deleted
|
||||
* in `insertCompositionText`.
|
||||
*
|
||||
* @param {*} node
|
||||
*/
|
||||
export function fixParagraph(node) {
|
||||
if (!isParagraph(node) || !isLineBreak(node.firstChild)) {
|
||||
return;
|
||||
}
|
||||
const br = createLineBreak();
|
||||
node.replaceChildren(
|
||||
createInline(br)
|
||||
);
|
||||
return br;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the passed node behaves like a paragraph.
|
||||
*
|
||||
* NOTE: This is mainly used in paste operations. Every element node
|
||||
* it's going to be treated as paragraph it
|
||||
*
|
||||
* @param {Node} element
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isLikeParagraph(element) {
|
||||
return !isLikeInline(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if we have an empty paragraph.
|
||||
*
|
||||
* @param {Node} element
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isEmptyParagraph(element) {
|
||||
if (!isParagraph(element)) throw new TypeError("Invalid paragraph");
|
||||
const inline = element.firstChild;
|
||||
if (!isInline(inline)) throw new TypeError("Invalid inline");
|
||||
return isLineBreak(inline.firstChild);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if passed node is a paragraph.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isParagraph(node) {
|
||||
if (!node) return false;
|
||||
if (!isElement(node, TAG)) return false;
|
||||
if (node.dataset.itype !== TYPE) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new paragraph.
|
||||
*
|
||||
* @param {Array<HTMLDivElement>} inlines
|
||||
* @param {Object.<string, *>|CSSStyleDeclaration} styles
|
||||
* @param {Object.<string, *>} [attrs]
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
export function createParagraph(inlines, styles, attrs) {
|
||||
if (inlines && (!Array.isArray(inlines) || !inlines.every(isInline)))
|
||||
throw new TypeError("Invalid paragraph children");
|
||||
return createElement(TAG, {
|
||||
attributes: { id: createRandomId(), ...attrs },
|
||||
data: { itype: TYPE },
|
||||
styles: styles,
|
||||
allowedStyles: STYLES,
|
||||
children: inlines,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new empty paragraph
|
||||
*
|
||||
* @param {Object.<string, *>} styles
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
export function createEmptyParagraph(styles) {
|
||||
return createParagraph([
|
||||
createEmptyInline(styles)
|
||||
], styles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the paragraph styles.
|
||||
*
|
||||
* @param {HTMLDivElement} element
|
||||
* @param {Object.<string,*>|CSSStyleDeclaration} styles
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
export function setParagraphStyles(element, styles) {
|
||||
return setStyles(element, STYLES, styles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the closest paragraph from a node.
|
||||
*
|
||||
* @param {Text|HTMLBRElement} node
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
export function getParagraph(node) {
|
||||
if (!node) return null;
|
||||
if (isParagraph(node)) return node;
|
||||
if (node.nodeType === Node.TEXT_NODE
|
||||
|| isLineBreak(node)) {
|
||||
const paragraph = node?.parentElement?.parentElement;
|
||||
if (!paragraph) {
|
||||
return null;
|
||||
}
|
||||
if (!isParagraph(paragraph)) {
|
||||
return null;
|
||||
}
|
||||
return paragraph;
|
||||
}
|
||||
return node.closest(QUERY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the specified node and offset represents
|
||||
* the start of the paragraph.
|
||||
*
|
||||
* @param {Text|HTMLBRElement} node
|
||||
* @param {number} offset
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isParagraphStart(node, offset) {
|
||||
const paragraph = getParagraph(node);
|
||||
if (!paragraph) throw new Error("Can't find the paragraph");
|
||||
const inline = getInline(node);
|
||||
if (!inline) throw new Error("Can't find the inline");
|
||||
return (
|
||||
paragraph.firstElementChild === inline &&
|
||||
isOffsetAtStart(inline.firstChild, offset)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the specified node and offset represents
|
||||
* the end of the paragraph.
|
||||
*
|
||||
* @param {Text|HTMLBRElement} node
|
||||
* @param {number} offset
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isParagraphEnd(node, offset) {
|
||||
const paragraph = getParagraph(node);
|
||||
if (!paragraph) throw new Error("Cannot find the paragraph");
|
||||
const inline = getInline(node);
|
||||
if (!inline) throw new Error("Cannot find the inline");
|
||||
return (
|
||||
paragraph.lastElementChild === inline &&
|
||||
isOffsetAtEnd(inline.firstChild, offset)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a paragraph.
|
||||
*
|
||||
* @param {HTMLDivElement} paragraph
|
||||
* @param {HTMLSpanElement} inline
|
||||
* @param {number} offset
|
||||
*/
|
||||
export function splitParagraph(paragraph, inline, offset) {
|
||||
const style = paragraph.style;
|
||||
if (isInlineEnd(inline, offset)) {
|
||||
const newParagraph = createParagraph(getInlinesFrom(inline), style);
|
||||
return newParagraph;
|
||||
}
|
||||
const newInline = splitInline(inline, offset);
|
||||
const newParagraph = createParagraph([newInline], style);
|
||||
return newParagraph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a paragraph at a specified child node index
|
||||
*
|
||||
* @param {HTMLDivElement} paragraph
|
||||
* @param {number} startIndex
|
||||
*/
|
||||
export function splitParagraphAtNode(paragraph, startIndex) {
|
||||
const style = paragraph.style;
|
||||
const newParagraph = createParagraph(null, style);
|
||||
const newInlines = [];
|
||||
for (let index = startIndex; index < paragraph.children.length; index++) {
|
||||
newInlines.push(paragraph.children.item(index));
|
||||
}
|
||||
newParagraph.append(...newInlines);
|
||||
return newParagraph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges two paragraphs.
|
||||
*
|
||||
* @param {HTMLDivElement} a
|
||||
* @param {HTMLDivElement} b
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
export function mergeParagraphs(a, b) {
|
||||
a.append(...b.children);
|
||||
b.remove();
|
||||
return a;
|
||||
}
|
172
frontend/text-editor/editor/content/dom/Paragraph.test.js
Normal file
172
frontend/text-editor/editor/content/dom/Paragraph.test.js
Normal file
|
@ -0,0 +1,172 @@
|
|||
import { describe, test, expect } from "vitest";
|
||||
import {
|
||||
createEmptyParagraph,
|
||||
createParagraph,
|
||||
getParagraph,
|
||||
isLikeParagraph,
|
||||
isParagraph,
|
||||
isParagraphStart,
|
||||
isParagraphEnd,
|
||||
TAG,
|
||||
TYPE,
|
||||
splitParagraph,
|
||||
splitParagraphAtNode,
|
||||
isEmptyParagraph,
|
||||
} from "./Paragraph";
|
||||
import { createInline, isInline } from "./Inline";
|
||||
|
||||
/* @vitest-environment jsdom */
|
||||
describe("Paragraph", () => {
|
||||
test("createParagraph should throw when passed invalid children", () => {
|
||||
expect(() => createParagraph([
|
||||
"Whatever"
|
||||
])).toThrowError("Invalid paragraph children");
|
||||
});
|
||||
|
||||
test("createEmptyParagraph should create a new empty paragraph", () => {
|
||||
const emptyParagraph = createEmptyParagraph();
|
||||
expect(emptyParagraph).toBeInstanceOf(HTMLDivElement);
|
||||
expect(emptyParagraph.nodeName).toBe(TAG);
|
||||
expect(emptyParagraph.dataset.itype).toBe(TYPE);
|
||||
expect(isInline(emptyParagraph.firstChild)).toBe(true);
|
||||
});
|
||||
|
||||
test("isParagraph should return true when the passed node is a paragraph", () => {
|
||||
expect(isParagraph(null)).toBe(false);
|
||||
expect(isParagraph(document.createElement('div'))).toBe(false);
|
||||
expect(isParagraph(document.createElement('h1'))).toBe(false);
|
||||
expect(isParagraph(createEmptyParagraph())).toBe(true);
|
||||
expect(isParagraph(createParagraph([
|
||||
createInline(new Text('Hello, World!'))
|
||||
]))).toBe(true);
|
||||
});
|
||||
|
||||
test("isLikeParagraph should return true when node looks like a paragraph", () => {
|
||||
const p = document.createElement('p');
|
||||
expect(isLikeParagraph(p)).toBe(true);
|
||||
const div = document.createElement('div');
|
||||
expect(isLikeParagraph(div)).toBe(true);
|
||||
const h1 = document.createElement('h1');
|
||||
expect(isLikeParagraph(h1)).toBe(true);
|
||||
const h2 = document.createElement('h2');
|
||||
expect(isLikeParagraph(h2)).toBe(true);
|
||||
const h3 = document.createElement('h3');
|
||||
expect(isLikeParagraph(h3)).toBe(true);
|
||||
const h4 = document.createElement('h4');
|
||||
expect(isLikeParagraph(h4)).toBe(true);
|
||||
const h5 = document.createElement('h5');
|
||||
expect(isLikeParagraph(h5)).toBe(true);
|
||||
const h6 = document.createElement('h6');
|
||||
expect(isLikeParagraph(h6)).toBe(true);
|
||||
});
|
||||
|
||||
test("getParagraph should return the closest paragraph of the passed node", () => {
|
||||
const text = new Text("Hello, World!");
|
||||
const inline = createInline(text);
|
||||
const paragraph = createParagraph([inline]);
|
||||
expect(getParagraph(text)).toBe(paragraph);
|
||||
});
|
||||
|
||||
test("getParagraph should return null if there aren't closer paragraph nodes", () => {
|
||||
const text = new Text("Hello, World!");
|
||||
const whatever = document.createElement('div');
|
||||
whatever.appendChild(text);
|
||||
expect(getParagraph(text)).toBe(null);
|
||||
});
|
||||
|
||||
test("isParagraphStart should return true on an empty paragraph", () => {
|
||||
const paragraph = createEmptyParagraph();
|
||||
expect(isParagraphStart(paragraph.firstChild.firstChild, 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("isParagraphStart should return true on a paragraph", () => {
|
||||
const paragraph = createParagraph([
|
||||
createInline(new Text("Hello, World!"))
|
||||
]);
|
||||
expect(isParagraphStart(paragraph.firstChild.firstChild, 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("isParagraphEnd should return true on an empty paragraph", () => {
|
||||
const paragraph = createEmptyParagraph();
|
||||
expect(isParagraphEnd(paragraph.firstChild.firstChild, 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("isParagraphEnd should return true on a paragraph", () => {
|
||||
const paragraph = createParagraph([
|
||||
createInline(new Text("Hello, World!")),
|
||||
]);
|
||||
expect(isParagraphEnd(paragraph.firstChild.firstChild, 13)).toBe(true);
|
||||
});
|
||||
|
||||
test("splitParagraph should split a paragraph", () => {
|
||||
const inline = createInline(new Text("Hello, World!"));
|
||||
const paragraph = createParagraph([inline]);
|
||||
const newParagraph = splitParagraph(paragraph, inline, 6);
|
||||
expect(newParagraph).toBeInstanceOf(HTMLDivElement);
|
||||
expect(newParagraph.nodeName).toBe(TAG);
|
||||
expect(newParagraph.dataset.itype).toBe(TYPE);
|
||||
expect(newParagraph.firstElementChild.textContent).toBe(" World!");
|
||||
});
|
||||
|
||||
test("splitParagraphAtNode should split a paragraph at a specified node", () => {
|
||||
const helloInline = createInline(new Text("Hello, "));
|
||||
const worldInline = createInline(new Text("World"));
|
||||
const exclInline = createInline(new Text("!"));
|
||||
const paragraph = createParagraph([helloInline, worldInline, exclInline]);
|
||||
const newParagraph = splitParagraphAtNode(paragraph, 1);
|
||||
expect(newParagraph).toBeInstanceOf(HTMLDivElement);
|
||||
expect(newParagraph.nodeName).toBe(TAG);
|
||||
expect(newParagraph.dataset.itype).toBe(TYPE);
|
||||
expect(newParagraph.children.length).toBe(2);
|
||||
expect(newParagraph.textContent).toBe("World!");
|
||||
});
|
||||
|
||||
test("isLikeParagraph should return true if the element it's not an inline element", () => {
|
||||
const span = document.createElement("span");
|
||||
const a = document.createElement("a");
|
||||
const br = document.createElement("br");
|
||||
const i = document.createElement("span");
|
||||
const u = document.createElement("span");
|
||||
const div = document.createElement("div");
|
||||
const blockquote = document.createElement("blockquote");
|
||||
const table = document.createElement("table");
|
||||
expect(isLikeParagraph(span)).toBe(false);
|
||||
expect(isLikeParagraph(a)).toBe(false);
|
||||
expect(isLikeParagraph(br)).toBe(false);
|
||||
expect(isLikeParagraph(i)).toBe(false);
|
||||
expect(isLikeParagraph(u)).toBe(false);
|
||||
expect(isLikeParagraph(div)).toBe(true);
|
||||
expect(isLikeParagraph(blockquote)).toBe(true);
|
||||
expect(isLikeParagraph(table)).toBe(true);
|
||||
});
|
||||
|
||||
test("isEmptyParagraph should return true if the paragraph is empty", () => {
|
||||
expect(() => {
|
||||
isEmptyParagraph(document.createElement("svg"));
|
||||
}).toThrowError("Invalid paragraph");
|
||||
expect(() => {
|
||||
const paragraph = document.createElement("div");
|
||||
paragraph.dataset.itype = "paragraph";
|
||||
paragraph.appendChild(document.createElement("svg"));
|
||||
isEmptyParagraph(paragraph);
|
||||
}).toThrowError("Invalid inline");
|
||||
|
||||
const lineBreak = document.createElement("br");
|
||||
const emptyInline = document.createElement("span");
|
||||
emptyInline.dataset.itype = "inline";
|
||||
emptyInline.appendChild(lineBreak);
|
||||
const emptyParagraph = document.createElement("div");
|
||||
emptyParagraph.dataset.itype = "paragraph";
|
||||
emptyParagraph.appendChild(emptyInline);
|
||||
expect(isEmptyParagraph(emptyParagraph)).toBe(true);
|
||||
|
||||
const nonEmptyInline = document.createElement("span");
|
||||
nonEmptyInline.dataset.itype = "inline";
|
||||
nonEmptyInline.appendChild(new Text('Not empty!'));
|
||||
const nonEmptyParagraph = document.createElement("div");
|
||||
nonEmptyParagraph.dataset.itype = "paragraph";
|
||||
nonEmptyParagraph.appendChild(nonEmptyInline);
|
||||
expect(isEmptyParagraph(nonEmptyParagraph)).toBe(false);
|
||||
|
||||
});
|
||||
});
|
71
frontend/text-editor/editor/content/dom/Root.js
Normal file
71
frontend/text-editor/editor/content/dom/Root.js
Normal file
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright (c) KALEIDOS INC
|
||||
*/
|
||||
|
||||
import { createElement, isElement } from "./Element";
|
||||
import { createEmptyParagraph, isParagraph } from "./Paragraph";
|
||||
import { setStyles } from "./Style";
|
||||
import { createRandomId } from "./Element";
|
||||
|
||||
export const TAG = "DIV";
|
||||
export const TYPE = "root";
|
||||
export const QUERY = `[data-itype="${TYPE}"]`;
|
||||
export const STYLES = [["--vertical-align"]];
|
||||
|
||||
/**
|
||||
* Returns true if passed node is a root.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isRoot(node) {
|
||||
if (!node) return false;
|
||||
if (!isElement(node, TAG)) return false;
|
||||
if (node.dataset.itype !== TYPE) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new root element
|
||||
*
|
||||
* @param {Array<HTMLDivElement>} paragraphs
|
||||
* @param {Object.<string, *>|CSSStyleDeclaration} styles,
|
||||
* @param {Object.<string, *>} [attrs]
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
export function createRoot(paragraphs, styles, attrs) {
|
||||
if (!Array.isArray(paragraphs) || !paragraphs.every(isParagraph))
|
||||
throw new TypeError("Invalid root children");
|
||||
|
||||
return createElement(TAG, {
|
||||
attributes: { id: createRandomId(), ...attrs },
|
||||
data: { itype: TYPE },
|
||||
styles: styles,
|
||||
allowedStyles: STYLES,
|
||||
children: paragraphs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new empty root element
|
||||
*
|
||||
* @param {Object.<string,*>|CSSStyleDeclaration} styles
|
||||
*/
|
||||
export function createEmptyRoot(styles) {
|
||||
return createRoot([createEmptyParagraph(styles)], styles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the root styles.
|
||||
*
|
||||
* @param {HTMLDivElement} element
|
||||
* @param {Object.<string,*>|CSSStyleDeclaration} styles
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
export function setRootStyles(element, styles) {
|
||||
return setStyles(element, STYLES, styles);
|
||||
}
|
33
frontend/text-editor/editor/content/dom/Root.test.js
Normal file
33
frontend/text-editor/editor/content/dom/Root.test.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { describe, test, expect } from "vitest";
|
||||
import { createEmptyRoot, createRoot, setRootStyles, TAG, TYPE } from './Root'
|
||||
|
||||
/* @vitest-environment jsdom */
|
||||
describe("Root", () => {
|
||||
test("createRoot should throw when passed invalid children", () => {
|
||||
expect(() => createRoot(["Whatever"])).toThrowError(
|
||||
"Invalid root children"
|
||||
);
|
||||
});
|
||||
|
||||
test("createEmptyRoot should create a new root with an empty paragraph", () => {
|
||||
const emptyRoot = createEmptyRoot();
|
||||
expect(emptyRoot).toBeInstanceOf(HTMLDivElement);
|
||||
expect(emptyRoot.nodeName).toBe(TAG);
|
||||
expect(emptyRoot.dataset.itype).toBe(TYPE);
|
||||
expect(emptyRoot.firstChild).toBeInstanceOf(HTMLDivElement);
|
||||
expect(emptyRoot.firstChild.firstChild).toBeInstanceOf(HTMLSpanElement);
|
||||
expect(emptyRoot.firstChild.firstChild.firstChild).toBeInstanceOf(HTMLBRElement);
|
||||
});
|
||||
|
||||
test("setRootStyles should apply only the styles of root to the root", () => {
|
||||
const emptyRoot = createEmptyRoot();
|
||||
setRootStyles(emptyRoot, {
|
||||
["--vertical-align"]: "top",
|
||||
["font-size"]: "25px"
|
||||
});
|
||||
expect(emptyRoot.style.getPropertyValue("--vertical-align")).toBe("top");
|
||||
// We expect this style to be empty because we don't apply it
|
||||
// to the root.
|
||||
expect(emptyRoot.style.getPropertyValue("font-size")).toBe("");
|
||||
})
|
||||
});
|
329
frontend/text-editor/editor/content/dom/Style.js
Normal file
329
frontend/text-editor/editor/content/dom/Style.js
Normal file
|
@ -0,0 +1,329 @@
|
|||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright (c) KALEIDOS INC
|
||||
*/
|
||||
|
||||
import { getFills } from "./Color";
|
||||
|
||||
const DEFAULT_FONT_SIZE = "16px";
|
||||
const DEFAULT_LINE_HEIGHT = "1.2";
|
||||
|
||||
/**
|
||||
* Merges two style declarations. `source` -> `target`.
|
||||
*
|
||||
* @param {CSSStyleDeclaration} target
|
||||
* @param {CSSStyleDeclaration} source
|
||||
* @returns {CSSStyleDeclaration}
|
||||
*/
|
||||
export function mergeStyleDeclarations(target, source) {
|
||||
// This is better but it doesn't work in JSDOM
|
||||
// for (const styleName of source) {
|
||||
for (let index = 0; index < source.length; index++) {
|
||||
const styleName = source.item(index);
|
||||
target.setProperty(styleName, source.getPropertyValue(styleName));
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the properties of a style declaration.
|
||||
*
|
||||
* @param {CSSStyleDeclaration} styleDeclaration
|
||||
* @returns {CSSStyleDeclaration}
|
||||
*/
|
||||
function resetStyleDeclaration(styleDeclaration) {
|
||||
for (let index = 0; index < styleDeclaration.length; index++) {
|
||||
const styleName = styleDeclaration.item(index);
|
||||
styleDeclaration.removeProperty(styleName);
|
||||
}
|
||||
return styleDeclaration
|
||||
}
|
||||
|
||||
/**
|
||||
* An inert element that only keeps the style
|
||||
* declaration used for merging other styleDeclarations.
|
||||
*
|
||||
* @type {HTMLDivElement|null}
|
||||
*/
|
||||
let inertElement = null
|
||||
|
||||
/**
|
||||
* Resets the style declaration of the inert
|
||||
* element.
|
||||
*/
|
||||
function resetInertElement() {
|
||||
if (!inertElement) throw new Error('Invalid inert element');
|
||||
resetStyleDeclaration(inertElement.style);
|
||||
return inertElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an instance of a <div> element used
|
||||
* to keep style declarations.
|
||||
*
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
function getInertElement() {
|
||||
if (!inertElement) {
|
||||
inertElement = document.createElement("div");
|
||||
return inertElement;
|
||||
}
|
||||
resetInertElement();
|
||||
return inertElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the styles of an element the same way `window.getComputedStyle` does.
|
||||
*
|
||||
* @param {Element} element
|
||||
* @returns {CSSStyleDeclaration}
|
||||
*/
|
||||
export function getComputedStyle(element) {
|
||||
const inertElement = getInertElement();
|
||||
let currentElement = element;
|
||||
while (currentElement) {
|
||||
// This is better but it doesn't work in JSDOM.
|
||||
// for (const styleName of currentElement.style) {
|
||||
for (let index = 0; index < currentElement.style.length; index++) {
|
||||
const styleName = currentElement.style.item(index);
|
||||
const currentValue = inertElement.style.getPropertyValue(styleName);
|
||||
if (currentValue) {
|
||||
const priority = currentElement.style.getPropertyPriority(styleName);
|
||||
if (priority === "important") {
|
||||
const newValue = currentElement.style.getPropertyValue(styleName);
|
||||
inertElement.style.setProperty(styleName, newValue);
|
||||
}
|
||||
} else {
|
||||
inertElement.style.setProperty(
|
||||
styleName,
|
||||
currentElement.style.getPropertyValue(styleName)
|
||||
);
|
||||
}
|
||||
}
|
||||
currentElement = currentElement.parentElement;
|
||||
}
|
||||
return inertElement.style;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes style declaration.
|
||||
*
|
||||
* TODO: I think that this also needs to remove some "conflicting"
|
||||
* CSS properties like `font-family` or some CSS variables.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @param {CSSStyleDeclaration} styleDefaults
|
||||
* @returns {CSSStyleDeclaration}
|
||||
*/
|
||||
export function normalizeStyles(node, styleDefaults) {
|
||||
const styleDeclaration = mergeStyleDeclarations(
|
||||
styleDefaults,
|
||||
getComputedStyle(node.parentElement)
|
||||
);
|
||||
// If there's a color property, we should convert it to
|
||||
// a --fills CSS variable property.
|
||||
const fills = styleDeclaration.getPropertyValue("--fills");
|
||||
const color = styleDeclaration.getPropertyValue("color");
|
||||
if (color && !fills) {
|
||||
styleDeclaration.removeProperty("color");
|
||||
styleDeclaration.setProperty("--fills", getFills(color));
|
||||
}
|
||||
// If there's a font-family property and not a --font-id, then
|
||||
// we remove the font-family because it will not work.
|
||||
const fontFamily = styleDeclaration.getPropertyValue("font-family");
|
||||
const fontId = styleDeclaration.getPropertyPriority("--font-id");
|
||||
if (fontFamily && !fontId) {
|
||||
styleDeclaration.removeProperty("font-family");
|
||||
}
|
||||
|
||||
const fontSize = styleDeclaration.getPropertyValue("font-size");
|
||||
if (!fontSize || fontSize === "0px") {
|
||||
styleDeclaration.setProperty("font-size", DEFAULT_FONT_SIZE);
|
||||
}
|
||||
|
||||
const lineHeight = styleDeclaration.getPropertyValue("line-height");
|
||||
if (!lineHeight || lineHeight === "") {
|
||||
styleDeclaration.setProperty("line-height", DEFAULT_LINE_HEIGHT);
|
||||
}
|
||||
return styleDeclaration
|
||||
}
|
||||
/**
|
||||
* Sets a single style property value of an element.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @param {string} styleName
|
||||
* @param {*} styleValue
|
||||
* @param {string} [styleUnit]
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function setStyle(element, styleName, styleValue, styleUnit) {
|
||||
if (
|
||||
styleName.startsWith("--") &&
|
||||
typeof styleValue !== "string" &&
|
||||
typeof styleValue !== "number"
|
||||
) {
|
||||
if (styleName === "--fills" && styleValue === null) debugger;
|
||||
element.style.setProperty(styleName, JSON.stringify(styleValue));
|
||||
} else {
|
||||
element.style.setProperty(styleName, styleValue + (styleUnit ?? ""));
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a style from a declaration.
|
||||
*
|
||||
* @param {CSSStyleDeclaration} style
|
||||
* @param {string} styleName
|
||||
* @param {string|undefined} [styleUnit]
|
||||
* @returns {*}
|
||||
*/
|
||||
export function getStyleFromDeclaration(style, styleName, styleUnit) {
|
||||
if (styleName.startsWith("--")) {
|
||||
return style.getPropertyValue(styleName);
|
||||
}
|
||||
const styleValue = style.getPropertyValue(styleName);
|
||||
if (styleValue.endsWith(styleUnit)) {
|
||||
return styleValue.slice(0, -styleUnit.length);
|
||||
}
|
||||
return styleValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a style.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @param {string} styleName
|
||||
* @param {string|undefined} [styleUnit]
|
||||
* @returns {*}
|
||||
*/
|
||||
export function getStyle(element, styleName, styleUnit) {
|
||||
return getStyleFromDeclaration(element.style, styleName, styleUnit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the styles of an element using an object and a list of
|
||||
* allowed styles.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @param {Array<[string,?string]>} allowedStyles
|
||||
* @param {Object.<string, *>} styleObject
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function setStylesFromObject(element, allowedStyles, styleObject) {
|
||||
for (const [styleName, styleUnit] of allowedStyles) {
|
||||
if (!(styleName in styleObject)) {
|
||||
continue;
|
||||
}
|
||||
const styleValue = styleObject[styleName];
|
||||
if (styleValue) {
|
||||
setStyle(element, styleName, styleValue, styleUnit);
|
||||
}
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the styles of an element using a CSS Style Declaration and a list
|
||||
* of allowed styles.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @param {Array<[string,?string]>} allowedStyles
|
||||
* @param {CSSStyleDeclaration} styleDeclaration
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function setStylesFromDeclaration(
|
||||
element,
|
||||
allowedStyles,
|
||||
styleDeclaration
|
||||
) {
|
||||
for (const [styleName, styleUnit] of allowedStyles) {
|
||||
const styleValue = getStyleFromDeclaration(styleDeclaration, styleName, styleUnit);
|
||||
if (styleValue) {
|
||||
setStyle(element, styleName, styleValue, styleUnit);
|
||||
}
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the styles of an element using an Object or a CSS Style Declaration and
|
||||
* a list of allowed styles.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @param {Array<[string,?string]} allowedStyles
|
||||
* @param {Object.<string,*>|CSSStyleDeclaration} styleObjectOrDeclaration
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function setStyles(element, allowedStyles, styleObjectOrDeclaration) {
|
||||
if (styleObjectOrDeclaration instanceof CSSStyleDeclaration) {
|
||||
return setStylesFromDeclaration(
|
||||
element,
|
||||
allowedStyles,
|
||||
styleObjectOrDeclaration
|
||||
);
|
||||
}
|
||||
return setStylesFromObject(element, allowedStyles, styleObjectOrDeclaration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the styles of an element using a list of allowed styles.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @param {Array<[string,?string]} allowedStyles
|
||||
* @returns {Object.<string, *>}
|
||||
*/
|
||||
export function getStyles(element, allowedStyles) {
|
||||
const styleObject = {};
|
||||
for (const [styleName, styleUnit] of allowedStyles) {
|
||||
const styleValue = getStyle(element, styleName, styleUnit);
|
||||
if (styleValue) {
|
||||
styleObject[styleName] = styleValue;
|
||||
}
|
||||
}
|
||||
return styleObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a series of merged styles.
|
||||
*
|
||||
* @param {Array<[string,?string]} allowedStyles
|
||||
* @param {CSSStyleDeclaration} styleDeclaration
|
||||
* @param {Object.<string,*>} newStyles
|
||||
* @returns {Object.<string,*>}
|
||||
*/
|
||||
export function mergeStyles(allowedStyles, styleDeclaration, newStyles) {
|
||||
const mergedStyles = {};
|
||||
for (const [styleName, styleUnit] of allowedStyles) {
|
||||
if (styleName in newStyles) {
|
||||
mergedStyles[styleName] = newStyles[styleName];
|
||||
} else {
|
||||
mergedStyles[styleName] = getStyleFromDeclaration(styleDeclaration, styleName, styleUnit);
|
||||
}
|
||||
}
|
||||
return mergedStyles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the specified style declaration has a display block.
|
||||
*
|
||||
* @param {CSSStyleDeclaration} style
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isDisplayBlock(style) {
|
||||
return style.display === "block";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the specified style declaration has a display inline
|
||||
* or inline-block.
|
||||
*
|
||||
* @param {CSSStyleDeclaration} style
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isDisplayInline(style) {
|
||||
return style.display === "inline" || style.display === "inline-block";
|
||||
}
|
76
frontend/text-editor/editor/content/dom/Style.test.js
Normal file
76
frontend/text-editor/editor/content/dom/Style.test.js
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { describe, test, expect, vi } from "vitest";
|
||||
import { getStyles, isDisplayBlock, isDisplayInline, setStyle, setStyles } from "./Style";
|
||||
|
||||
/* @vitest-environment jsdom */
|
||||
describe("Style", () => {
|
||||
test("setStyle should apply a style to an element", () => {
|
||||
const element = document.createElement("div");
|
||||
setStyle(element, "display", "none");
|
||||
expect(element.style.display).toBe("none");
|
||||
});
|
||||
|
||||
test("setStyles should apply multiple styles to an element using an Object", () => {
|
||||
const element = document.createElement("div");
|
||||
setStyles(element, [["display"]], {
|
||||
"text-decoration": "none",
|
||||
"font-size": "32px",
|
||||
display: "none",
|
||||
});
|
||||
expect(element.style.display).toBe("none");
|
||||
expect(element.style.fontSize).toBe("");
|
||||
expect(element.style.textDecoration).toBe("");
|
||||
});
|
||||
|
||||
test("setStyles should apply multiple styles to an element using a CSSStyleDeclaration", () => {
|
||||
const a = document.createElement("div");
|
||||
setStyles(a, [["display"]], {
|
||||
display: "none",
|
||||
});
|
||||
expect(a.style.display).toBe("none");
|
||||
expect(a.style.fontSize).toBe("");
|
||||
expect(a.style.textDecoration).toBe("");
|
||||
|
||||
const b = document.createElement("div");
|
||||
setStyles(b, [["display"]], a.style);
|
||||
expect(b.style.display).toBe("none");
|
||||
expect(b.style.fontSize).toBe("");
|
||||
expect(b.style.textDecoration).toBe("");
|
||||
});
|
||||
|
||||
test("getStyles should retrieve a list of allowed styles", () => {
|
||||
const element = document.createElement("div");
|
||||
element.style.display = 'block';
|
||||
element.style.textDecoration = 'underline';
|
||||
element.style.fontSize = '32px';
|
||||
const textDecorationStyles = getStyles(element, [["text-decoration"]]);
|
||||
expect(textDecorationStyles).toStrictEqual({
|
||||
"text-decoration": "underline"
|
||||
});
|
||||
const displayStyles = getStyles(element, [["display"]]);
|
||||
expect(displayStyles).toStrictEqual({
|
||||
"display": "block",
|
||||
});
|
||||
const fontSizeStyles = getStyles(element, [["font-size", "px"]]);
|
||||
expect(fontSizeStyles).toStrictEqual({
|
||||
"font-size": "32",
|
||||
});
|
||||
});
|
||||
|
||||
test("isDisplayBlock should return true if display is 'block'", () => {
|
||||
const div = document.createElement("div");
|
||||
div.style.display = "block";
|
||||
expect(isDisplayBlock(div.style)).toBe(true);
|
||||
const span = document.createElement("span");
|
||||
span.style.display = "inline";
|
||||
expect(isDisplayBlock(span)).toBe(false);
|
||||
});
|
||||
|
||||
test("isDisplayInline should return true if display is 'inline'", () => {
|
||||
const span = document.createElement("span");
|
||||
span.style.display = "inline";
|
||||
expect(isDisplayInline(span.style)).toBe(true);
|
||||
const div = document.createElement("div");
|
||||
div.style.display = "block";
|
||||
expect(isDisplayInline(div)).toBe(false);
|
||||
});
|
||||
});
|
64
frontend/text-editor/editor/content/dom/TextNode.js
Normal file
64
frontend/text-editor/editor/content/dom/TextNode.js
Normal file
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright (c) KALEIDOS INC
|
||||
*/
|
||||
|
||||
import { isInline } from "./Inline";
|
||||
import { isLineBreak } from "./LineBreak";
|
||||
import { isParagraph } from "./Paragraph";
|
||||
import { isRoot } from "./Root";
|
||||
|
||||
/**
|
||||
* Returns true if the node is "like"
|
||||
* text, this means that it is a Text
|
||||
* node or a <br> element.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isTextNode(node) {
|
||||
if (!node) throw new TypeError("Invalid text node");
|
||||
return node.nodeType === Node.TEXT_NODE
|
||||
|| isLineBreak(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the text node is empty.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isEmptyTextNode(node) {
|
||||
return node.nodeType === Node.TEXT_NODE
|
||||
&& node.nodeValue === "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the content length of the
|
||||
* node.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getTextNodeLength(node) {
|
||||
if (!node) throw new TypeError("Invalid text node");
|
||||
if (isLineBreak(node)) return 0;
|
||||
return node.nodeValue.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the closest text node.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @returns {Node}
|
||||
*/
|
||||
export function getClosestTextNode(node) {
|
||||
if (isTextNode(node)) return node;
|
||||
if (isInline(node)) return node.firstChild;
|
||||
if (isParagraph(node)) return node.firstChild.firstChild;
|
||||
if (isRoot(node)) return node.firstChild.firstChild.firstChild;
|
||||
throw new Error("Cannot find a text node");
|
||||
}
|
26
frontend/text-editor/editor/content/dom/TextNode.test.js
Normal file
26
frontend/text-editor/editor/content/dom/TextNode.test.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { describe, test, expect } from 'vitest';
|
||||
import { isTextNode, getTextNodeLength } from './TextNode';
|
||||
import { createLineBreak } from './LineBreak';
|
||||
|
||||
/* @vitest-environment jsdom */
|
||||
describe("TextNode", () => {
|
||||
test("isTextNode should return true when the passed node is a Text", () => {
|
||||
expect(isTextNode(new Text("Hello, World!"))).toBe(true);
|
||||
expect(isTextNode(Infinity)).toBe(false);
|
||||
expect(isTextNode(true)).toBe(false);
|
||||
expect(isTextNode("hola")).toBe(false);
|
||||
expect(isTextNode({})).toBe(false);
|
||||
expect(isTextNode([])).toBe(false);
|
||||
expect(() => isTextNode(undefined)).toThrowError('Invalid text node');
|
||||
expect(() => isTextNode(null)).toThrowError('Invalid text node');
|
||||
expect(() => isTextNode(0)).toThrowError('Invalid text node');
|
||||
});
|
||||
|
||||
test("getTextNodeLength should return the length of the text node or 0 if it is a <br>", () => {
|
||||
expect(getTextNodeLength(new Text("Hello, World!"))).toBe(13);
|
||||
expect(getTextNodeLength(createLineBreak())).toBe(0);
|
||||
expect(() => getTextNodeLength(undefined)).toThrowError('Invalid text node');
|
||||
expect(() => getTextNodeLength(null)).toThrowError('Invalid text node');
|
||||
expect(() => getTextNodeLength(0)).toThrowError('Invalid text node');
|
||||
});
|
||||
});
|
250
frontend/text-editor/editor/content/dom/TextNodeIterator.js
Normal file
250
frontend/text-editor/editor/content/dom/TextNodeIterator.js
Normal file
|
@ -0,0 +1,250 @@
|
|||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright (c) KALEIDOS INC
|
||||
*/
|
||||
|
||||
/**
|
||||
* Iterator direction.
|
||||
*
|
||||
* @enum {number}
|
||||
*/
|
||||
export const TextNodeIteratorDirection = {
|
||||
FORWARD: 1,
|
||||
BACKWARD: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* TextNodeIterator
|
||||
*/
|
||||
export class TextNodeIterator {
|
||||
/**
|
||||
* Returns if a specific node is a text node.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isTextNode(node) {
|
||||
return (
|
||||
node.nodeType === Node.TEXT_NODE ||
|
||||
(node.nodeType === Node.ELEMENT_NODE && node.nodeName === "BR")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if a specific node is a container node.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isContainerNode(node) {
|
||||
return node.nodeType === Node.ELEMENT_NODE && node.nodeName !== "BR";
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a node from an initial node and down the tree.
|
||||
*
|
||||
* @param {Node} startNode
|
||||
* @param {Node} rootNode
|
||||
* @param {Set<Node>} skipNodes
|
||||
* @param {number} direction
|
||||
* @returns {Node}
|
||||
*/
|
||||
static findDown(
|
||||
startNode,
|
||||
rootNode,
|
||||
skipNodes = new Set(),
|
||||
direction = TextNodeIteratorDirection.FORWARD
|
||||
) {
|
||||
if (startNode === rootNode) {
|
||||
return TextNodeIterator.findDown(
|
||||
direction === TextNodeIteratorDirection.FORWARD
|
||||
? startNode.firstChild
|
||||
: startNode.lastChild,
|
||||
rootNode,
|
||||
skipNodes,
|
||||
direction
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: This should not use the SafeGuard
|
||||
// module.
|
||||
let safeGuard = Date.now();
|
||||
let currentNode = startNode;
|
||||
while (currentNode) {
|
||||
if (Date.now() - safeGuard >= 1000) {
|
||||
throw new Error("Iteration timeout");
|
||||
}
|
||||
if (skipNodes.has(currentNode)) {
|
||||
currentNode =
|
||||
direction === TextNodeIteratorDirection.FORWARD
|
||||
? currentNode.nextSibling
|
||||
: currentNode.previousSibling;
|
||||
continue;
|
||||
}
|
||||
if (TextNodeIterator.isTextNode(currentNode)) {
|
||||
return currentNode;
|
||||
} else if (TextNodeIterator.isContainerNode(currentNode)) {
|
||||
return TextNodeIterator.findDown(
|
||||
direction === TextNodeIteratorDirection.FORWARD
|
||||
? currentNode.firstChild
|
||||
: currentNode.lastChild,
|
||||
rootNode,
|
||||
skipNodes,
|
||||
direction
|
||||
);
|
||||
}
|
||||
currentNode =
|
||||
direction === TextNodeIteratorDirection.FORWARD
|
||||
? currentNode.nextSibling
|
||||
: currentNode.previousSibling;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a node from an initial node and up the tree.
|
||||
*
|
||||
* @param {Node} startNode
|
||||
* @param {Node} rootNode
|
||||
* @param {Set} backTrack
|
||||
* @param {number} direction
|
||||
* @returns {Node}
|
||||
*/
|
||||
static findUp(
|
||||
startNode,
|
||||
rootNode,
|
||||
backTrack = new Set(),
|
||||
direction = TextNodeIteratorDirection.FORWARD
|
||||
) {
|
||||
backTrack.add(startNode);
|
||||
if (TextNodeIterator.isTextNode(startNode)) {
|
||||
return TextNodeIterator.findUp(
|
||||
startNode.parentNode,
|
||||
rootNode,
|
||||
backTrack,
|
||||
direction
|
||||
);
|
||||
} else if (TextNodeIterator.isContainerNode(startNode)) {
|
||||
const found = TextNodeIterator.findDown(
|
||||
startNode,
|
||||
rootNode,
|
||||
backTrack,
|
||||
direction
|
||||
);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
if (startNode !== rootNode) {
|
||||
return TextNodeIterator.findUp(
|
||||
startNode.parentNode,
|
||||
rootNode,
|
||||
backTrack,
|
||||
direction
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the root text node.
|
||||
*
|
||||
* @type {HTMLElement}
|
||||
*/
|
||||
#rootNode = null;
|
||||
|
||||
/**
|
||||
* This is the current text node.
|
||||
*
|
||||
* @type {Text|null}
|
||||
*/
|
||||
#currentNode = null;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param {HTMLElement} rootNode
|
||||
*/
|
||||
constructor(rootNode) {
|
||||
if (!(rootNode instanceof HTMLElement)) {
|
||||
throw new TypeError("Invalid root node");
|
||||
}
|
||||
this.#rootNode = rootNode;
|
||||
this.#currentNode = TextNodeIterator.findDown(rootNode, rootNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current node we're into.
|
||||
*
|
||||
* @type {TextNode|HTMLBRElement}
|
||||
*/
|
||||
get currentNode() {
|
||||
return this.#currentNode;
|
||||
}
|
||||
|
||||
set currentNode(newCurrentNode) {
|
||||
const isContained =
|
||||
(newCurrentNode.compareDocumentPosition(this.#rootNode) &
|
||||
Node.DOCUMENT_POSITION_CONTAINS) ===
|
||||
Node.DOCUMENT_POSITION_CONTAINS;
|
||||
if (
|
||||
!(newCurrentNode instanceof Node) ||
|
||||
!TextNodeIterator.isTextNode(newCurrentNode) ||
|
||||
!isContained
|
||||
) {
|
||||
throw new TypeError("Invalid new current node");
|
||||
}
|
||||
this.#currentNode = newCurrentNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next Text node or <br> element or null if there are.
|
||||
*
|
||||
* @returns {Text|HTMLBRElement}
|
||||
*/
|
||||
nextNode() {
|
||||
if (!this.#currentNode) return null;
|
||||
|
||||
const nextNode = TextNodeIterator.findUp(
|
||||
this.#currentNode,
|
||||
this.#rootNode,
|
||||
new Set(),
|
||||
TextNodeIteratorDirection.FORWARD
|
||||
);
|
||||
|
||||
if (!nextNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.#currentNode = nextNode;
|
||||
return this.#currentNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous Text node or <br> element or null.
|
||||
*
|
||||
* @returns {Text|HTMLBRElement}
|
||||
*/
|
||||
previousNode() {
|
||||
if (!this.#currentNode) return null;
|
||||
|
||||
const previousNode = TextNodeIterator.findUp(
|
||||
this.#currentNode,
|
||||
this.#rootNode,
|
||||
new Set(),
|
||||
TextNodeIteratorDirection.BACKWARD
|
||||
);
|
||||
|
||||
if (!previousNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.#currentNode = previousNode;
|
||||
return this.#currentNode;
|
||||
}
|
||||
}
|
||||
|
||||
export default TextNodeIterator;
|
|
@ -0,0 +1,70 @@
|
|||
import { describe, test, expect } from "vitest";
|
||||
import TextNodeIterator from "./TextNodeIterator";
|
||||
import { createInline } from "./Inline";
|
||||
import { createParagraph } from "./Paragraph";
|
||||
import { createRoot } from "./Root";
|
||||
import { createLineBreak } from "./LineBreak";
|
||||
|
||||
/* @vitest-environment jsdom */
|
||||
describe("TextNodeIterator", () => {
|
||||
test("Create a new TextNodeIterator with an invalid root should throw", () => {
|
||||
expect(() => new TextNodeIterator(null)).toThrowError("Invalid root node");
|
||||
expect(() => new TextNodeIterator(Infinity)).toThrowError(
|
||||
"Invalid root node"
|
||||
);
|
||||
expect(() => new TextNodeIterator(1)).toThrowError("Invalid root node");
|
||||
expect(() => new TextNodeIterator("hola")).toThrowError(
|
||||
"Invalid root node"
|
||||
);
|
||||
});
|
||||
|
||||
test("Create a new TextNodeIterator and iterate only over text nodes", () => {
|
||||
const rootNode = createRoot([
|
||||
createParagraph([
|
||||
createInline(new Text("Hello, ")),
|
||||
createInline(new Text("World!")),
|
||||
createInline(new Text("Whatever")),
|
||||
]),
|
||||
createParagraph([createInline(createLineBreak())]),
|
||||
createParagraph([createInline(new Text("This is a ")), createInline(new Text("test"))]),
|
||||
createParagraph([createInline(new Text("Hi!"))]),
|
||||
]);
|
||||
|
||||
const textNodeIterator = new TextNodeIterator(rootNode);
|
||||
expect(textNodeIterator.currentNode.nodeValue).toBe("Hello, ");
|
||||
textNodeIterator.nextNode();
|
||||
expect(textNodeIterator.currentNode.nodeValue).toBe("World!");
|
||||
textNodeIterator.nextNode();
|
||||
expect(textNodeIterator.currentNode.nodeValue).toBe("Whatever");
|
||||
textNodeIterator.nextNode();
|
||||
expect(textNodeIterator.currentNode.nodeType).toBe(Node.ELEMENT_NODE);
|
||||
expect(textNodeIterator.currentNode.nodeName).toBe("BR");
|
||||
textNodeIterator.nextNode();
|
||||
expect(textNodeIterator.currentNode.nodeValue).toBe("This is a ");
|
||||
textNodeIterator.nextNode();
|
||||
expect(textNodeIterator.currentNode.nodeValue).toBe("test");
|
||||
textNodeIterator.nextNode();
|
||||
expect(textNodeIterator.currentNode.nodeValue).toBe("Hi!");
|
||||
textNodeIterator.previousNode();
|
||||
expect(textNodeIterator.currentNode.nodeValue).toBe("test");
|
||||
textNodeIterator.previousNode();
|
||||
expect(textNodeIterator.currentNode.nodeValue).toBe("This is a ");
|
||||
textNodeIterator.previousNode();
|
||||
expect(textNodeIterator.currentNode.nodeType).toBe(Node.ELEMENT_NODE);
|
||||
expect(textNodeIterator.currentNode.nodeName).toBe("BR");
|
||||
textNodeIterator.previousNode();
|
||||
expect(textNodeIterator.currentNode.nodeValue).toBe("Whatever");
|
||||
textNodeIterator.previousNode();
|
||||
expect(textNodeIterator.currentNode.nodeValue).toBe("World!");
|
||||
textNodeIterator.previousNode();
|
||||
expect(textNodeIterator.currentNode.nodeValue).toBe("Hello, ");
|
||||
textNodeIterator.nextNode();
|
||||
expect(textNodeIterator.currentNode.nodeValue).toBe("World!");
|
||||
textNodeIterator.previousNode();
|
||||
expect(textNodeIterator.currentNode.nodeValue).toBe("Hello, ");
|
||||
textNodeIterator.nextNode();
|
||||
expect(textNodeIterator.currentNode.nodeValue).toBe("World!");
|
||||
textNodeIterator.nextNode();
|
||||
expect(textNodeIterator.currentNode.nodeValue).toBe("Whatever");
|
||||
});
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue