/**
* A (not yet documentd) collection of low-level utitity functions
* related to <em>node views</em>, client-side <em>views</em> of
* server-side XML <em>nodes</em>. Node views
* are contained in the {@link DocumentView}.
* <p>A node view is represented on the client side by an HTML element
* (mainly <code>div</code>; possibly having descendant HTML elements)
* having the following internal structure/attributes:
* <ul>
* <li>Text view:
*
* <pre><span data-t="" id=UID contenteditable>TEXT<span></pre>
*
* <p>No generated content.
*
* <li>Comment view:
*
* <pre>data-wc=UID
* [ + data-bc=UID ]
* + data-c="" id=UID contenteditable
* + TEXT
* [ + data-ac=UID ]</pre>
*
* <p><strong>But</strong> when styled:
*
* <pre>data-c="" id=UID contenteditable
* + TEXT</pre>
*
* <pre>data-rc="" id=UID
* + REPLACED_CONTENT</pre>
*
* <li>Processing-instruction view:
*
* <pre>data-wp=UID
* [ + data-bc=UID ]
* + data-p="" id=UID contenteditable
* + TEXT
* [ + data-ap=UID ]</pre>
*
* BUT when styled:
*
* <pre>data-p="" id=UID contenteditable
* + TEXT</pre>
*
* <pre>data-rp="" id=UID
* + REPLACED_CONTENT</pre>
*
* <li>Element view:
*
* <pre>data-e="" id=UID
* [ + data-be=UID ]
* + CHILDREN
* [ + data-ae=UID ]</pre>
*
* <p><strong>Also</strong> when styled:
*
* <pre>data-re="" id=UID
* [ + data-be=UID ]
* + REPLACED_CONTENT
* [ + data-ae=UID ]</pre>
*
* <pre>data-we=UID
* [ + data-be=UID ]
* + data-e="" id=UID
* + CHILDREN
* [ + data-ae=UID ]</pre>
*
* <pre>data-we=UID
* [ + data-be=UID ]
* + data-re="" id=UID
* + REPLACED_CONTENT
* [ + data-ae=UID ]</pre>
*
* <li>Table view specificities: see description in
* <code>com.xmlmind.xmleditsrv.webview.TableRendering.java</code>.
*
* <li>The root view must have an "<code>xxe-re</code>" class if editable
* and "<code>xxe-ro</code>" otherwise.
*
* <li>Collapsers must have a <code>collapsed</code> attribute which is
* added/removed dynamically.
*
* <p>In the case of the tree view,
* the collapser is <code><xxe-collapser></code>.
* More expectations in <code>TreeCollapser.js</code>.
*
* <p>In the case of the styled view, the collapser is
* <code><xxe-collapser2></code>.
* See <code>StyledCollapser.js</code>.
*
* <p><code><xxe-collapser2></code> for a collapsible
* styled element view may be found not only
* in the content before/content after of this
* element view, but also inside its <strong>descendant</strong>
* element views (e.g. content before in the caption of
* a collapsible table or the title of a collapsible section).
*
* <p>A collapsible styled element has a
* <code>data-collapsible="collapsed;notCollapsibleHead;notCollapsibleFoot"</code>
* attribute.
* </ul>
*/
export class NodeView {
// -----------------------------------------------------------------------
// Access by uid
// -----------------------------------------------------------------------
static content(root, uid, reportError=true) {
let content = root.getElementById(uid);
if (content === null && reportError) {
console.error(`NodeView.content: INTERNAL ERROR: \
cannot find node view "${uid}"`);
}
return content;
}
static textualContent(root, uid, reportError=true) {
let content = NodeView.content(root, uid, reportError);
if (content !== null &&
(content.hasAttribute("data-t") ||
content.hasAttribute("data-c") ||
content.hasAttribute("data-p"))) {
return content;
}
return null;
}
static view(root, uid, reportError=true) {
let content = NodeView.content(root, uid, reportError);
if (content !== null) {
return NodeView.contentToView(content, uid);
}
return null;
}
static contentToView(content, uid) {
assertOrError(NodeView.isContent(content));
let parent = content.parentElement;
if (parent !== null &&
(parent.getAttribute("data-we") === uid ||
parent.getAttribute("data-wp") === uid ||
parent.getAttribute("data-wc") === uid)) {
content = parent;
}
// Otherwise the view is equal to its content.
return content;
}
// -----------------------------------------------------------------------
// Categorization of view parts
// -----------------------------------------------------------------------
static isContent(node) {
return (node !== null &&
node.nodeType === Node.ELEMENT_NODE &&
node.id && /*accelerator*/
(node.hasAttribute("data-t") ||
node.hasAttribute("data-e") ||
node.hasAttribute("data-c") ||
node.hasAttribute("data-p") ||
node.hasAttribute("data-re") ||
node.hasAttribute("data-rc") ||
node.hasAttribute("data-rp")));
}
static isTextualContent(node) {
return (node !== null &&
node.nodeType === Node.ELEMENT_NODE &&
node.id && /*accelerator*/
(node.hasAttribute("data-t") ||
node.hasAttribute("data-c") ||
node.hasAttribute("data-p")));
}
static isReplacedContent(node) {
return (node !== null &&
node.nodeType === Node.ELEMENT_NODE &&
node.id && /*accelerator*/
(node.hasAttribute("data-re") ||
node.hasAttribute("data-rc") ||
node.hasAttribute("data-rp")));
}
static isContentBefore(node, uid) {
return (node !== null &&
node.nodeType === Node.ELEMENT_NODE &&
(node.getAttribute("data-be") === uid ||
node.getAttribute("data-bp") === uid ||
node.getAttribute("data-bc") === uid));
}
static isContentAfter(node, uid) {
return (node !== null &&
node.nodeType === Node.ELEMENT_NODE &&
(node.getAttribute("data-ae") === uid ||
node.getAttribute("data-ap") === uid ||
node.getAttribute("data-ac") === uid));
}
static isContentWrapper(node) {
return (node !== null &&
node.nodeType === Node.ELEMENT_NODE &&
(node.hasAttribute("data-we") ||
node.hasAttribute("data-wp") ||
node.hasAttribute("data-wc")));
}
static isGeneratedContent(node) {
return (node !== null &&
node.nodeType === Node.ELEMENT_NODE &&
(node.hasAttribute("data-be") ||
node.hasAttribute("data-bp") ||
node.hasAttribute("data-bc") ||
node.hasAttribute("data-ae") ||
node.hasAttribute("data-ap") ||
node.hasAttribute("data-ac") ||
node.hasAttribute("data-re") ||
node.hasAttribute("data-rp") ||
node.hasAttribute("data-rc")));
}
static isActualContent(node) {
return (node !== null &&
node.nodeType === Node.ELEMENT_NODE &&
node.id && /*accelerator*/
(node.hasAttribute("data-t") ||
node.hasAttribute("data-e") ||
node.hasAttribute("data-p") ||
node.hasAttribute("data-c")));
}
// -----------------------------------------------------------------------
// View functions
// -----------------------------------------------------------------------
static getUID(view) {
if (view !== null &&
view.nodeType === Node.ELEMENT_NODE) {
// Do not assume that it's a content just because it has an ID.
if (view.id && NodeView.isContent(view)) {
// Here we assume that specified view is also the content.
return view.id;
} else {
// A wrapper, when it exists, is always the view.
for (const attrName of ["data-we", "data-wp", "data-wc"]) {
let uid = view.getAttribute(attrName);
if (uid !== null) {
return uid;
}
}
}
}
return null;
}
static getContent(view) {
if (view !== null &&
view.nodeType === Node.ELEMENT_NODE) {
if (view.id && NodeView.isContent(view)) {
return view;
} else {
for (const attrName of ["data-we", "data-wp", "data-wc"]) {
let uid = view.getAttribute(attrName);
if (uid !== null) {
let child = view.firstChild;
while (child !== null) {
if (child.id === uid) {
return child;
}
child = child.nextSibling;
}
}
}
}
}
return null;
}
static getTextualContent(view) {
let content = NodeView.getContent(view);
if (content !== null &&
(content.hasAttribute("data-t") ||
content.hasAttribute("data-c") ||
content.hasAttribute("data-p"))) {
return content;
}
return null;
}
static getReplacedContent(view) {
let content = NodeView.getContent(view);
if (content !== null &&
(content.hasAttribute("data-re") ||
content.hasAttribute("data-rc") ||
content.hasAttribute("data-rp"))) {
return content;
}
return null;
}
static isView(node) {
if (node !== null &&
node.nodeType === Node.ELEMENT_NODE) {
if (NodeView.isContentWrapper(node)) {
// A wrapper, when it exists, is always the view.
return true;
} else {
let uid = node.id;
if (uid && NodeView.isContent(node)) {
// Content. Is it also the view?
return (NodeView.contentToView(node, uid) === node);
}
}
}
return false;
}
static uidIfElementView(node) {
if (node !== null &&
node.nodeType === Node.ELEMENT_NODE) {
let uid = node.getAttribute("data-we");
if (uid !== null) {
// An element wrapper, when it exists, is always the element
// view.
return uid;
}
uid = node.id;
if (uid &&
(node.hasAttribute("data-e") || node.hasAttribute("data-re"))) {
// Element content.
let parent = node.parentElement;
if (parent === null ||
parent.getAttribute("data-we") !== uid) {
// No wrapper, hence the element content is also the
// element view.
return uid;
}
}
}
return null;
}
static getNextViewSibling(view) {
assertOrError(NodeView.isView(view));
let node = view.nextSibling;
while (node !== null) {
if (NodeView.isView(node)) {
return node;
}
node = node.nextSibling;
}
return null;
}
static getPreviousViewSibling(view) {
assertOrError(NodeView.isView(view));
let node = view.previousSibling;
while (node !== null) {
if (NodeView.isView(node)) {
return node;
}
node = node.previousSibling;
}
return null;
}
static getParentView(view) {
assertOrError(NodeView.isView(view));
return NodeView.lookupView(view.parentElement);
}
// -----------------------------------------------------------------------
// Lookup (HTML ancestors) functions
// -----------------------------------------------------------------------
// ----------
// lookupView
// ----------
static lookupView(node) {
let elem = node;
if (node !== null &&
node.nodeType !== Node.ELEMENT_NODE) {
elem = node.parentElement;
}
while (elem !== null) {
let uid = elem.id;
if (uid) {
if (NodeView.isContent(elem)) {
// Content. Is it also the view?
return NodeView.contentToView(elem, uid);
}
} else {
if (NodeView.isContentWrapper(elem)) {
// A wrapper, when it exists, is always the view.
return elem;
}
}
elem = elem.parentElement;
}
return null;
}
// --------------
// lookupViewPart
// --------------
static lookupViewPart(node) {
while (node !== null) {
if (node.nodeType === Node.ELEMENT_NODE &&
(NodeView.isContentWrapper(node) ||
NodeView.isGeneratedContent(node) ||
NodeView.isActualContent(node))) {
return node;
}
node = node.parentElement;
}
return null;
}
// --------------------
// lookupTextualContent
// --------------------
static lookupTextualContent(node) {
while (node !== null) {
if (NodeView.isTextualContent(node)) {
return node;
}
node = node.parentElement;
}
return null;
}
// -----------------------------------------------------------------------
// Find (inside an HTML node tree) functions
// -----------------------------------------------------------------------
// ------------------
// findTextualContent
// ------------------
static findTextualContent(tree) {
if (NodeView.isTextualContent(tree)) {
return tree;
}
let child = tree.firstChild;
while (child !== null) {
if (child.nodeType === Node.ELEMENT_NODE &&
!NodeView.isGeneratedContent(child)) {
let found = NodeView.findTextualContent(child);
if (found !== null) {
return found;
}
}
child = child.nextSibling;
}
return null;
}
// -------------------------
// pickNearestTextualContent
// -------------------------
static pickNearestTextualContent(tree, clientX, clientY) {
let found = [null, Number.MAX_SAFE_INTEGER];
NodeView.doPickNearestTextualContent(tree, clientX, clientY, found);
return (found[0] === null)? null : found[0];
}
static doPickNearestTextualContent(tree, clientX, clientY, found) {
if (NodeView.isTextualContent(tree)) {
const rects = tree.getClientRects();
const rectCount = rects.length;
for (let i = 0; i < rectCount; ++i) {
const rect = rects[i];
if (rect !== null &&
rect.height > 0 && // rect.width=0 OK.
clientY >= rect.top && clientY < rect.bottom) {
let distance = 0;
if (clientX < rect.left) {
distance = rect.left - clientX;
} else if (clientX >= rect.right) {
distance = clientX - rect.right;
}
if (distance < found[1]) {
found[0] = tree;
found[1] = distance;
}
}
}
}
let child = tree.firstChild;
while (child !== null) {
if (child.nodeType === Node.ELEMENT_NODE &&
!NodeView.isGeneratedContent(child)) {
NodeView.doPickNearestTextualContent(child, clientX, clientY,
found);
}
child = child.nextSibling;
}
}
// ---------------------
// collectTextualContent
// ---------------------
static collectTextualContent(view1, offset1, view2, offset2, collected) {
collected.length = 0;
if (view1 === view2) {
let textContent1 = NodeView.getTextualContent(view1);
if (textContent1 !== null) {
collected.push(textContent1);
}
return (offset1 > offset2);
}
// ---
let reversed = false;
if (view1.compareDocumentPosition(view2) &
Node.DOCUMENT_POSITION_PRECEDING) {
// view2 before view1.
reversed = true;
}
if (reversed) {
let tmp = view1; view1 = view2; view2 = tmp;
// offset1, offset2 not used below.
}
let start = NodeView.getTextualContent(view1);
if (start === null) {
// For example a TextNode's view having replaced content.
start = view1;
}
let end = NodeView.getTextualContent(view2);
if (end === null) {
end = view2;
}
let range = new Range();
range.setStart(start, 0);
range.setEnd(end, 0);
NodeView.doCollectTextualContent(range.commonAncestorContainer,
start, end, [false], collected);
return reversed;
}
static doCollectTextualContent(tree, start, end, collecting, collected) {
if (collecting[0]) {
if (NodeView.isTextualContent(tree)) {
collected.push(tree);
}
if (tree === end) {
return true; // Done.
}
} else {
if (tree === start) {
collecting[0] = true;
if (NodeView.isTextualContent(tree)) {
collected.push(tree);
}
if (tree === end) {
return true; // Done.
}
}
}
if (!NodeView.isGeneratedContent(tree)) {
let child = tree.firstChild;
while (child !== null) {
if (child.nodeType === Node.ELEMENT_NODE &&
NodeView.doCollectTextualContent(child, start, end,
collecting, collected)) {
return true;
}
child = child.nextSibling;
}
}
return false;
}
// -----------------------------------------------------------------------
// A text node view (having no replaced content; contenteditable=true)
// is supposed to contain a single HTML text node.
//
// This is not always the case. Why?
//
// 1) Contenteditable tend to "damage" its text node.
// Ideally, you should find a single text node. In practice
// we may end up with none, several or even inserted elements
// like <br />.
//
// 2) CaretPlaceholder.js inserts <a class="xxe-caret"/> caret placeholder
// when the document view looses the keyboard focus.
//
// 3) TextHighlight.js renders the text selection by inserting
// <mark class="xxe-text-sel">SELECTED TEXT</mark>.
// -----------------------------------------------------------------------
// --------------------
// normalizeTextContent
// --------------------
static normalizeTextualContent(textContent) {
assertOrError(NodeView.isTextualContent(textContent));
let child = textContent.firstChild;
if (child === null) {
child = document.createTextNode("");
textContent.appendChild(child);
// Done.
return;
}
if (child.nextSibling === null &&
child.nodeType === Node.TEXT_NODE) {
// Nothing to do.
return;
}
let needNormalize = 0;
while (child !== null) {
let next = child.nextSibling;
switch (child.nodeType) {
case Node.TEXT_NODE:
++needNormalize;
break;
case Node.ELEMENT_NODE:
{
let inserted = null;
let node = child.firstChild;
if (node !== null) {
if (node.nextSibling === null &&
node.nodeType === Node.TEXT_NODE) {
// Contains a single text node. Use it as is.
child.removeChild(node);
inserted = node;
} else {
let text = child.textContent;
if (text !== null && text.length > 0) {
// Contains some text. Create new text node.
inserted = document.createTextNode(text);
}
}
}
if (inserted !== null) {
textContent.insertBefore(inserted, /*before*/ child);
++needNormalize;
}
textContent.removeChild(child);
}
break;
}
child = next;
}
if (needNormalize > 1) {
textContent.normalize();
}
}
// --------------------------
// textualContentToCharOffset
// --------------------------
static textualContentToCharOffset(textContent, offset) {
assertOrError(NodeView.isTextualContent(textContent));
let result = [null, -1];
let node = textContent.firstChild;
if (node === null) {
node = document.createTextNode("");
textContent.appendChild(node);
result[0] = node;
result[1] = 0;
} else {
result[1] = 0;
if (!NodeView.findCharOffset(textContent, offset, result)) {
if (result[0] !== null && result[1] === offset) {
// Offset is just after result[0], the last text node
// of textContent.
// Convert to relative offset.
result[1] = result[0].length;
} else {
// Not found.
result[0] = null;
result[1] = -1;
}
}
}
return result;
}
static findCharOffset(tree, offset, result) {
let node = tree.firstChild;
while (node !== null) {
switch (node.nodeType) {
case Node.TEXT_NODE:
{
result[0] = node;
let start = result[1];
result[1] += node.length;
if (offset < result[1]) {
// Convert to relative offset.
result[1] = offset - start;
return true;
}
}
break;
case Node.ELEMENT_NODE:
if (NodeView.findCharOffset(node, offset, result)) {
return true;
}
break;
}
node = node.nextSibling;
}
return false;
}
// --------------------------
// charToTextualContentOffset
// --------------------------
static charToTextualContentOffset(pos) {
let done = false;
const node = pos[0];
const offset = pos[1];
pos[0] = null;
pos[1] = -1;
let textContent = NodeView.lookupTextualContent(node);
if (textContent !== null) {
let child = textContent.firstChild;
if (child === null) {
// Empty text node view.
pos[0] = textContent;
pos[1] = 0;
done = true;
} else {
if (child.nextSibling === null &&
child.nodeType === Node.TEXT_NODE) {
// textContent just contains a single text node.
pos[0] = textContent;
pos[1] = offset;
done = true;
} else {
let startOffset = [0];
if (NodeView.findNodeStartOffset(textContent, node,
startOffset)) {
pos[0] = textContent;
pos[1] = startOffset[0] + offset;
done = true;
}
}
}
}
return done;
}
static findNodeStartOffset(tree, searchedNode, result) {
let node = tree.firstChild;
while (node !== null) {
if (node === searchedNode) {
return true;
}
switch (node.nodeType) {
case Node.TEXT_NODE:
result[0] += node.length;
break;
case Node.ELEMENT_NODE:
if (NodeView.findNodeStartOffset(node, searchedNode, result)) {
return true;
}
break;
}
node = node.nextSibling;
}
return false;
}
// -----------------------------------------------------------------------
// Expand/collapse views
// -----------------------------------------------------------------------
// ----------------
// expandViewBranch
// ----------------
static expandViewBranch(node, docView) {
let elem = node;
if (node !== null &&
node.nodeType !== Node.ELEMENT_NODE) {
elem = node.parentElement;
}
while (elem !== null && elem !== docView) {
// <xxe-collapser> are found only inside the content before or
// content after of the element view.
let uid = elem.id;
if (uid && NodeView.isContent(elem)) {
// Replaced or actual content.
NodeView.expandCollapsers(elem, uid);
} else {
// Wrapper?
uid = null;
for (const attrName of ["data-we", "data-wp", "data-wc"]) {
uid = elem.getAttribute(attrName);
if (uid !== null) {
NodeView.expandCollapsers(elem, uid);
break;
}
}
}
// <xxe-collapser2> are found inside the content before or
// content after of the element view AND ITS DESCENDANT VIEWS.
if (uid && elem.hasAttribute("data-collapsible")) {
// A collapsible styled element view.
NodeView.expandCollapsers2(elem, uid);
}
elem = elem.parentElement;
}
}
static expandCollapsers(elem, uid) {
let child = elem.firstChild;
while (child !== null) {
if (child.nodeType === Node.ELEMENT_NODE &&
(NodeView.isContentBefore(child, uid) ||
NodeView.isContentAfter(child, uid))) {
NodeView.doExpandCollapsers(child);
}
child = child.nextSibling;
}
}
static doExpandCollapsers(elem) {
let child = elem.firstChild;
while (child !== null) {
if (child.nodeType === Node.ELEMENT_NODE) {
if ("xxe-collapser" === child.localName) {
if (child.collapsed) {
child.collapsed = false;
}
} else {
NodeView.doExpandCollapsers(child);
}
}
child = child.nextSibling;
}
}
static expandCollapsers2(elem, uid) {
let collapsers = elem.querySelectorAll(`xxe-collapser2[for="${uid}"]`);
const count = collapsers.length;
if (count > 0) {
for (let i = 0; i < count; ++i) {
let collapser = collapsers[i];
if (collapser.collapsed) {
collapser.collapsed = false;
}
}
}
}
// -----------------
// lookupVisibleView
// -----------------
static lookupVisibleView(node, docView) {
let elem = node;
if (node !== null &&
node.nodeType !== Node.ELEMENT_NODE) {
elem = node.parentElement;
}
let visibleView = null;
while (elem !== null && elem !== docView) {
let view = null;
let uid = elem.id;
if (uid) {
if (NodeView.isContent(elem)) {
// Content. Is it also the view?
view = NodeView.contentToView(elem, uid);
}
} else {
if (NodeView.isContentWrapper(elem)) {
// A wrapper, when it exists, is always the view.
view = elem;
}
}
if (view !== null) {
let display =
window.getComputedStyle(view).getPropertyValue("display");
if (display) {
if (display === "none") {
// May be an ancestor view will be visible?
visibleView = null;
} else {
if (visibleView === null) {
// Remember "deepest" visible view.
visibleView = view;
}
}
}
}
elem = elem.parentElement;
}
return visibleView;
}
}