Import text-editor code into the repository

This commit is contained in:
Andrey Antukh 2024-11-19 17:05:30 +01:00
parent 68397edd4d
commit 04a0d867b0
65 changed files with 11112 additions and 7 deletions

View 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);
}

View 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!");
});
});

View 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}]]`;
}

View 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;
}

View 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!");
});
});

View 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;
}

View 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);
});
});

View 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;
}

View 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);
});
});

View 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;
}

View 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');
})
});

View 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;
}

View 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);
});
});

View 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);
}

View 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("");
})
});

View 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";
}

View 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);
});
});

View 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");
}

View 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');
});
});

View 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;

View file

@ -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");
});
});