/**
* The document view.
*/
class DocumentView extends HTMLElement {
constructor() {
super();
this._xmlEditor = null;
this._button2PastesText = false;
this._contenteditableState = null;
this._markManager = null;
this._bindings = null;
this._cmdToBinding = null;
this._appEventToBinding = null;
this._controller = null;
this._magicX = -1;
this._addColumnResizers = false;
// Scratch variables.
this._syncArgs = [/*uid*/ null, /*text*/ null, /*offset*/ -1];
this._domTree = document; // Could have been shadowRoot.
this._styles = null;
this._view = null;
}
// -----------------------------------------------------------------------
// Custom element
// -----------------------------------------------------------------------
connectedCallback() {
if (this.firstChild === null) {
this._styles = document.createElement("style");
let head = document.querySelector("head");
if (head !== null) {
head.appendChild(this._styles);
}
// tabindex="0" makes this element focusable and tab-able.
// Needed because some XML documents may not contain text at all.
this.setAttribute("tabindex", "0");
}
// Otherwise, already connected.
}
disconnectedCallback() {
let styles = this._styles;
this._styles = null;
if (styles !== null && styles.parentNode !== null) {
styles.parentNode.removeChild(styles);
}
}
// -----------------------------------------------------------------------
// API
// -----------------------------------------------------------------------
get xmlEditor() {
return this._xmlEditor;
}
/**
* Get/Set the <code>xmlEditor</code> property of document view:
* the {@link XMLEditor} containing this document view.
*
* @type {XMLEditor}
*/
set xmlEditor(editor) {
assertOrError(editor !== null);
this._xmlEditor = editor;
this._button2PastesText = editor.button2PastesText;
const clipboardIntegration = editor.clipboardIntegration;
clipboardIntegration.getLevel()
.then((level) => {
if (level === ClipboardIntegration.CAN_READ_WRITE) {
clipboardIntegration.autoUpdatePrivateClipboard(this);
}
});
// No need to catch error. getLevel always returns a value.
}
get button2PastesText() {
return this._button2PastesText;
}
/**
* Get/Set the <code>button2PastesText</code> property of document view:
* same as {@link XMLEditor#button2PastesText}.
*
* @type {boolean}
*/
set button2PastesText(pastes) {
this._button2PastesText = pastes;
}
setView(settings) {
this._styles.textContent = "";
this.innerHTML = "";
this._contenteditableState = null;
if (this._markManager !== null) {
this._markManager.dispose();
this._markManager = null;
}
if (this._controller !== null) {
this._controller.dispose();
this._controller = null;
this._bindings = null;
this._cmdToBinding = null;
this._appEventToBinding = null;
}
this._magicX = -1; // View coordinates: -1 means unset.
this._addColumnResizers = false;
// ---
if (settings !== null) {
this._styles.textContent = settings.styles? settings.styles : "";
this.innerHTML = settings.view? settings.view : "";
this._contenteditableState = new ContenteditableState();
this._markManager =
new MarkManager(this, settings.marks? settings.marks : null);
this._bindings =
Bindings.compile(settings.bindings? settings.bindings : null);
this._cmdToBinding = null;
this._appEventToBinding = null;
this._controller = new Controller(this, this._bindings);
// ---
this._addColumnResizers =
(this.getAppEventBinding("resize-table-column") !== null &&
this.canEdit());
this.processNewViews(this.firstChild); // Root view.
}
}
processNewViews(tree) {
if (this._addColumnResizers) {
// Unlike what is implemented in the desktop app (which is more
// efficient), a table view is rebuilt each time any of there are
// changes in its cells, rows, row groups, column, column groups.
// See WebViewFactory.java#treeChanged.
ColumnResizer.addResizers(tree);
}
}
/**
* Get the <code>viewContainer</code> property of this document view:
* the descendant element of this document view which contains all
* the node views.
*
* @type {Element}
*/
get viewContainer() {
return this;
}
get magicX() {
return this._magicX;
}
set magicX(x) {
// magicX is stored in view coordinates, not in window coordinates.
this._magicX = x;
}
clientToView(clientX, clientY) {
let coords = [clientX, clientY];
this.convertClientToView(coords);
return coords;
}
convertClientToView(coords) {
const docView = this.viewContainer;
// coords and docViewRect are both relative to the viewport ("client
// rects").
const docViewRect = docView.getBoundingClientRect();
coords[0] = coords[0] - docViewRect.left + docView.scrollLeft;
coords[1] = coords[1] - docViewRect.top + docView.scrollTop;
}
viewToClient(viewX, viewY) {
let coords = [viewX, viewY];
this.converViewToClient(coords);
return coords;
}
converViewToClient(coords) {
const docView = this.viewContainer;
// docViewRect is relative to the viewport ("client rects").
const docViewRect = docView.getBoundingClientRect();
coords[0] = coords[0] - docView.scrollLeft + docViewRect.left;
coords[1] = coords[1] - docView.scrollTop + docViewRect.top;
}
get contenteditableState() {
return this._contenteditableState;
}
// -----------------------------------------------------------------------
// onDocumentViewChanged (invoked by XMLEditor.onDocumentViewChanged)
// -----------------------------------------------------------------------
onDocumentViewChanged(event) {
this._markManager.changingDocumentView();
let redrawMarks = false;
for (let change of event.changes) {
switch (change.changeType) {
case CHANGE_INSERT_TEXT:
case CHANGE_REPLACE_TEXT:
case CHANGE_DELETE_TEXT:
this.changeText(change);
break;
case CHANGE_REPLACE_VIEW:
// Replacing a view by a new version of itself may have caused
// some marks to disappear.
// Note that, anyway, a DocumentViewChangedEvent is followed
// by a DocumentMarksChangedEvent.
redrawMarks = true;
//FALLTHROUGH
case CHANGE_REMOVE_VIEW:
this.replaceView(change);
break;
case CHANGE_INSERT_VIEW:
this.insertView(change);
break;
case CHANGE_PATCH_TEXT:
this.patchText(change);
break;
case CHANGE_UPDATE_CUSTOM_PART:
this.updateCustomPart(change);
break;
}
}
this._markManager.documentViewChanged(redrawMarks);
}
patchText(change) {
let element = this._domTree.getElementById(change.id);
if (element !== null) {
element.textContent = change.text;
}
}
updateCustomPart(change) {
let element = this._domTree.getElementById(change.id);
if (element !== null) {
let name = change.invoke[0];
let args = (change.invoke.length > 1)?
change.invoke.slice(1) : null;
element[name].apply(/*this*/ element, args);
}
}
changeText(change) {
let textContent = this.getNodeViewTextualContent(change.uid);
if (textContent === null) {
// Internal error already reported.
return;
}
NodeView.normalizeTextualContent(textContent);
let chars = textContent.firstChild;
let offset = change.offset;
if (offset >= 0 && offset <= chars.length) {
switch (change.changeType) {
case CHANGE_INSERT_TEXT:
chars.insertData(offset, change.insertedText);
break;
case CHANGE_DELETE_TEXT:
chars.deleteData(offset, change.deletedCount);
break;
case CHANGE_REPLACE_TEXT:
chars.replaceData(offset, change.deletedCount,
change.insertedText);
break;
}
} else {
console.error(`DocumentView.changeText: INTERNAL ERROR: \
${offset}, invalid offset in "${chars.data}" (length=${chars.length})`);
}
}
getNodeViewContent(uid, reportError=true) {
return NodeView.content(this._domTree, uid, reportError);
}
getNodeViewTextualContent(uid, reportError=true) {
return NodeView.textualContent(this._domTree, uid, reportError);
}
getNodeView(uid, reportError=true) {
return NodeView.view(this._domTree, uid, reportError);
}
// --------------------------------
// replaceView
// --------------------------------
replaceView(change) {
const viewUID = change.uid;
let [replacedElem, oldView] = this.getAnchorView(viewUID);
if (oldView === null) {
// Internal error already reported.
return;
}
let parent = replacedElem.parentNode;
if (parent === null) {
console.error(`DocumentView.replaceView: INTERNAL ERROR: \
node view "${viewUID}" has no parent`);
return;
}
if (change.view) {
if (change.styles !== null) {
this.updateStyles(change.styles);
}
// Why not simply use insertAdjacentHTML?
// --------------------------------------
// When using insertAdjacentHTML, the browser may automatically
// wrap the inserted view in an element to keep the DOM
// consistent. For example, it may wrap a td in a tr when the td
// is inserted directly into a tbody.
let newView = DOMUtil.createElementFromHTML(change.view);
let replacementElem = newView;
// Does newView needs to be wrapped in a child wrapper?
let parentView = NodeView.getParentView(oldView);
if (parentView !== null) {
let parentUID = NodeView.getUID(parentView);
// parentContent is either the content element of parentView
// or an automatically generated child group belonging to
// parentView.
let parentContent = replacedElem.parentNode;
replacementElem =
DocumentView.wrapChildView(parentContent, parentUID, newView);
}
replacedElem.parentNode.insertBefore(/*new*/ replacementElem,
/*ref*/ replacedElem);
// ---
if (!change.deep) {
DocumentView.processSlot(oldView, newView, viewUID);
}
// ---
this.processNewViews(replacementElem);
}
// Otherwise, just a CHANGE_REMOVE_VIEW.
parent.removeChild(replacedElem);
}
updateStyles(rules) {
const ruleCount = rules.length;
if (ruleCount > 0) {
let stylesheet = this._styles.sheet;
if (rules[0].startsWith("#xxe---")) {
let index = -1;
const cssRules = stylesheet.cssRules;
const cssRuleCount = cssRules.length;
for (let i = 0; i < cssRuleCount; ++i) {
if (cssRules[i].cssText.startsWith("#xxe---")) {
index = i;
break;
}
}
if (index >= 0) {
for (let i = cssRuleCount-1; i >= index; --i) {
stylesheet.deleteRule(i);
}
}
}
for (let rule of rules) {
stylesheet.insertRule(rule, stylesheet.cssRules.length);
}
}
}
getAnchorView(viewUID) {
let anchorView = this.getNodeView(viewUID);
if (anchorView === null) {
// Internal error already reported.
return [null, null];
}
let anchorElem = anchorView;
let parentView = NodeView.getParentView(anchorView);
if (parentView !== null) {
let parentUID = NodeView.getUID(parentView);
let wrapper = anchorView.parentElement;
while (wrapper !== null &&
wrapper.getAttribute("data-cw") === parentUID) {
anchorElem = wrapper;
wrapper = wrapper.parentElement;
}
}
return [anchorElem, anchorView];
}
static wrapChildView(parentContent, parentUID, newView) {
let wrapper = newView;
const viewTag = newView.localName;
const parentTag = parentContent.localName;
if (parentTag === "table") {
if (viewTag !== "tbody") {
if (viewTag !== "tr") {
if (viewTag !== "td") {
wrapper = DocumentView.doWrapChildView(
wrapper, "td", parentUID);
}
wrapper = DocumentView.doWrapChildView(
wrapper, "tr", parentUID);
}
wrapper = DocumentView.doWrapChildView(
wrapper, "tbody", parentUID);
}
} else if (parentTag === "tbody") {
if (viewTag !== "tr") {
if (viewTag !== "td") {
wrapper = DocumentView.doWrapChildView(
wrapper, "td", parentUID);
}
wrapper = DocumentView.doWrapChildView(
wrapper, "tr", parentUID);
}
} else if (parentTag === "tr") {
if (viewTag !== "td") {
wrapper = DocumentView.doWrapChildView(
newView, "td", parentUID);
}
}
return wrapper;
}
static doWrapChildView(view, wrapperTag, parentUID) {
let wrapper = document.createElement(wrapperTag);
wrapper.setAttribute("data-cw", parentUID);
wrapper.appendChild(view);
return wrapper;
}
static processSlot(oldView, newView, viewUID) {
// Replace <slot/> by the (non generated content) child nodes
// of oldView.
let oldViewContent = NodeView.getContent(oldView);
let newViewContent = NodeView.getContent(newView);
let slot = newViewContent.getElementsByTagName("slot").item(0);
if (slot !== null) {
if (!NodeView.isReplacedContent(newViewContent)) {
let child = oldViewContent.firstChild;
while (child !== null) {
let nextChild = child.nextSibling;
// Do not move generated content (if any) from
// oldViewContent to newViewContent.
if (!NodeView.isContentBefore(child, viewUID) &&
!NodeView.isContentAfter(child, viewUID)) {
oldViewContent.removeChild(child);
newViewContent.insertBefore(/*new*/ child,
/*ref*/slot);
}
child = nextChild;
}
}
// Otherwise replaced content, should not happen, but just
// in case, remove <slot/>.
newViewContent.removeChild(slot);
}
}
// --------------------------------
// insertView
// --------------------------------
insertView(change) {
let [parentContent, beforeSibling] =
this.getInsertPosition(change.parentUID, change.beforeUID,
change.afterUID, change.viewTag);
if (parentContent === null) {
// Internal error already reported.
return;
}
if (change.styles !== null) {
this.updateStyles(change.styles);
}
let newView = DOMUtil.createElementFromHTML(change.view);
// Does newView needs to be wrapped in a child wrapper?
let insertedElem =
DocumentView.wrapChildView(parentContent, change.parentUID, newView);
if (beforeSibling !== null) {
beforeSibling.parentNode.insertBefore(/*new*/ insertedElem,
/*ref*/ beforeSibling);
} else {
// Insert new view at the end of parent content ---
//
// - If lastChild is generated content after,
// insert before this last child.
// - If lastChild is null (empty parent content)
// or if lastChild is generated content before
// or if lastChild is another child view,
// insert at end.
beforeSibling = parentContent.lastChild;
if (beforeSibling !== null &&
NodeView.isContentAfter(beforeSibling, change.parentUID)) {
beforeSibling.parentNode.insertBefore(insertedElem,
beforeSibling);
} else {
parentContent.appendChild(insertedElem);
}
}
// ---
this.processNewViews(insertedElem);
}
getInsertPosition(parentUID, beforeUID, afterUID, viewTag) {
let parentContent = this.getNodeViewContent(parentUID);
if (parentContent === null) {
// Internal error already reported.
return [null, null];
}
let beforeSibling = null;
let beforeView = null;
if (beforeUID !== null) {
[beforeSibling, beforeView] = this.getAnchorView(beforeUID);
if (beforeSibling === null) {
// Internal error already reported.
return [null, null];
}
}
let tableChildGroup =
parentContent.querySelector(`table[data-cg='${parentUID}']`);
if (tableChildGroup === null) {
// Not a table view. Simplest and most common case.
return [parentContent, beforeSibling];
}
// Table view ---
let headerChildGroup =
parentContent.querySelector(`header[data-cg='${parentUID}']`);
if (headerChildGroup === null) {
console.error(`DocumentView.getInsertionParent: \
INTERNAL ERROR: \
cannot find the <header> child group inside table view '${parentUID}'`);
return [null, null];
}
let tbodyChildGroup =
tableChildGroup.querySelector(`tbody[data-cg='${parentUID}']`);
if (tbodyChildGroup === null) {
console.error(`DocumentView.getInsertionParent: \
INTERNAL ERROR: \
cannot find the <tbody> child group inside <table data-cg='${parentUID}'>`);
return [null, null];
}
let afterSibling = null;
let afterView = null;
if (afterUID !== null) {
[afterSibling, afterView] = this.getAnchorView(afterUID);
if (afterSibling === null) {
// Internal error already reported.
return [null, null];
}
}
if (viewTag === "nav" ||
viewTag === "tbody[header]" || viewTag === "tbody" ||
viewTag === "tr" || viewTag === "td") {
// The child view is "typed", therefore it goes into a "typed"
// child group. ---
let childGroup;
if (viewTag === "nav") {
childGroup = headerChildGroup;
} else if (viewTag === "tbody[header]") {
childGroup = tableChildGroup;
if (beforeSibling !== null &&
beforeSibling.parentNode !== tableChildGroup) {
// Before a tr or a "bottom caption". Not before another
// tbody. Hence, last table-header-group of the table.
beforeSibling = tbodyChildGroup;
}
} else if (viewTag === "tbody") {
childGroup = tableChildGroup;
} else {
// tr, td ---
if (afterSibling !== null &&
afterSibling.localName === "tbody" &&
afterSibling.getAttribute("role") !== "header") {
// This tr follows a tbody. Invalid. Will be wrapped in a
// tbody.
childGroup = tableChildGroup;
} else {
// * afterSibling===null: does not follow anything:
// first child view of the table.
// OR
// * does not follow a tbody: either first tr of the table
// or this tr follows another tr found in tbodyChildGroup.
childGroup = tbodyChildGroup;
}
}
if (beforeSibling !== null &&
beforeSibling.parentNode !== childGroup) {
// Insert new view at the end of childGroup.
//
// For example, the view of last colspec is before the view of
// first row. Yet, the view of last colspec is contained in a
// header child group and the view of first row is contained
// in a tbody child group.
beforeSibling = null;
}
return [childGroup, beforeSibling];
}
// The child view is not "typed", e.g. div or span. For example, a
// caption. ---
let whichParent = null;
let whichBefore = null;
if (beforeSibling === null) {
if (afterSibling === null) {
// Very first and only child view. ---
// For example, caption added to an empty table.
// ------------
// QUESTIONABLE: here we consider it to be a "top caption".
// Could also be a "middle caption" or a "bottom caption".
// ------------
whichParent = parentContent;
whichBefore = headerChildGroup;
} else {
// New last child view. ---
// Simple example: a "bottom caption" added after last "bottom
// caption".
//
// Example: a caption added after last column
// view. Hence whichBefore should be empty table child group.
// But here we'll consider this caption to be a "bottom caption"
// and not a "middle caption".
whichParent = parentContent;
whichBefore = DocumentView.ensureAnchorIsChild(
parentContent, afterSibling).nextElementSibling;
if (whichBefore === tableChildGroup) {
whichBefore = null;
}
}
} else {
// Here we do have a beforeSibling. ---
if (afterSibling === null) {
// New first child view. ---
// Simple example: a "top caption" added before first "top
// caption".
//
// Example: a "top caption" added before first column
// view (hence whichBefore=header child group).
//
// Example : a caption added before first row view
// view. Hence whichBefore should be the table child group.
// But here we'll consider this caption to be a "top caption"
// and not a "middle caption".
whichParent = parentContent;
whichBefore = DocumentView.ensureAnchorIsChild(parentContent,
beforeSibling);
if (whichBefore === tableChildGroup) {
whichBefore = headerChildGroup;
}
} else {
// Child view between 2 other child views. ---
if (beforeSibling.parentNode === afterSibling.parentNode) {
// Simple example: a caption added between 2 captions.
//
// Example: a paragraph (to be wrapped as a row)
// added between 2 rows.
whichParent = beforeSibling.parentNode;
whichBefore = beforeSibling;
} else {
// Example: a "top caption" added after another "top
// caption" and before first column view
// (hence whichBefore=header child group).
//
// Example: a "middle caption" added after last column view
// and before first row view
// (hence whichBefore=table child group).
whichParent = parentContent;
whichBefore =
DocumentView.ensureAnchorIsChild(parentContent,
beforeSibling);
}
}
}
return [whichParent, whichBefore];
}
static ensureAnchorIsChild(parentContent, anchor) {
if (anchor !== null && anchor.parentNode !== parentContent) {
// For example, the view of caption is before the view of
// first row but the view of of first row is contained in
// a table child group, and not directly inside
// parentContent.
let ancestor = anchor.parentNode;
while (ancestor !== null) {
if (ancestor === parentContent) {
break;
}
anchor = ancestor;
ancestor = ancestor.parentNode;
}
}
return anchor;
}
// -----------------------------------------------------------------------
// onDocumentMarksChanged (invoked by XMLEditor.onDocumentMarksChanged)
// -----------------------------------------------------------------------
onDocumentMarksChanged(event) {
this._markManager.documentMarksChanged(event);
}
// -----------------------------------------------------------------------
// saveStateChanged (invoked by XMLEditor.onSaveStateChanged)
// -----------------------------------------------------------------------
saveStateChanged() {
if (!this._xmlEditor.saveNeeded) {
this._contenteditableState.resetAutoSync();
}
}
// -----------------------------------------------------------------------
// Used to implement local commands
// -----------------------------------------------------------------------
/**
* Get the <code>bindings</code> property of this document view:
* an array of {@link Binding}s currently used for the document
* being edited; <code>null</code> if no document is currently being edited.
*
* @type {array}
*/
get bindings() {
return this._bindings;
}
/**
* Returns the {@link Binding} which associates the
* <i>application event</i> having specified name
* (e.g. <code>"resize-image"</code>, <code>"rescale-image"</code>)
* to a command.
* Returns <code>null</code> if no document is currently being edited or
* if there is no such binding.
*/
getAppEventBinding(name) {
if (this._appEventToBinding === null && this._bindings !== null) {
this._appEventToBinding = {};
for (let binding of this._bindings) {
let userInput = binding.userInput;
if (userInput instanceof AppEvent) {
this._appEventToBinding[userInput.name] = binding;
}
}
}
if (this._appEventToBinding === null) {
// No opened document.
return null;
} else {
let binding = this._appEventToBinding[name];
return !binding? null : binding;
}
}
/**
* Returns the {@link Binding} used to invoke specified command.
* Returns <code>null</code> if no document is currently being edited or
* if there is no such binding.
*/
getBindingForCommand(cmdName, cmdParams) {
if (this._cmdToBinding === null && this._bindings !== null) {
this._cmdToBinding = {};
for (let binding of this._bindings) {
let key = DocumentView.cmdToBindingKey(binding.commandName,
binding.commandParams);
this._cmdToBinding[key] = binding;
}
}
if (this._cmdToBinding === null) {
// No opened document.
return null;
} else {
let key = DocumentView.cmdToBindingKey(cmdName, cmdParams);
let binding = this._cmdToBinding[key];
return !binding? null : binding;
}
}
static cmdToBindingKey(cmdName, cmdParams) {
let key = cmdName;
if (cmdParams !== null && (cmdParams = cmdParams.trim()).length > 0) {
key += " " + cmdParams;
}
return key;
}
/**
* Get the <code>namespacePrefixes</code> property of this document view:
* a (possibly empty) array containing namespace prefix/URI pairs;
* <code>null</code> if no document is currently being edited.
* <p>The default namespace (having an empty prefix), if any, is always the
* last pair of returned array.
*
* @type {array}
*/
get namespacePrefixes() {
return this._xmlEditor.namespacePrefixes;
}
/**
* Equivalent to {@link XMLEditor#showStatus
* this.xmlEditor.showStatus(text, autoErase)}.
*/
showStatus(text, autoErase=true) {
this._xmlEditor.showStatus(text, autoErase);
}
/**
* A cover for {@link XMLEditor#getCommandState}.
*/
getCommandState(cmdName, cmdParam) {
return this._xmlEditor.getCommandState(cmdName, cmdParam);
}
/**
* Get the <code>dot</code> property of this document view:
* the text node view containing <tt>dot</tt> if any;
* <code>null</code> otherwise.
*
* @type {Element}
*/
get dot() {
return ((this._contenteditableState === null)?
null : this._contenteditableState.dot);
}
/**
* Get the <code>dotOffset</code> property of this document view:
* the character offset of <tt>dot</tt> if any; -1 otherwise.
*
* @type {number}
*/
get dotOffset() {
return ((this._contenteditableState === null)?
-1 : this._contenteditableState.dotOffset);
}
/**
* Get the <code>mark</code> property of this document view:
* the text node view containing <tt>mark</tt> if any;
* <code>null</code> otherwise.
*
* @type {Element}
*/
get mark() {
return (this._markManager === null)? null : this._markManager.mark;
}
/**
* Get the <code>markOffset</code> property of this document view:
* the character offset of <tt>mark</tt> if any; -1 otherwise.
*
* @type {number}
*/
get markOffset() {
return (this._markManager === null)? -1 : this._markManager.markOffset;
}
/**
* Get the <code>selected</code> property of this document view:
* the <tt>selected</tt> node view if any; <code>null</code> otherwise.
*
* @type {Element}
*/
get selected() {
return (this._markManager === null)? null : this._markManager.selected;
}
/**
* Get the <code>selected2</code> property of this document view:
* the <tt>selected2</tt> node view if any; <code>null</code> otherwise.
*
* @type {Element}
*/
get selected2() {
return (this._markManager === null)? null : this._markManager.selected2;
}
/**
* Tests whether there is a text selection.
*/
hasTextSelection() {
const dot = this.dot;
const mark = this.mark;
return (dot !== null && mark !== null &&
(mark !== dot || this.markOffset !== this.dotOffset));
}
/**
* Returns text selection if any; <code>null</code> otherwise.
*/
getSelectedText() {
if (!this.hasTextSelection()) {
return null;
}
let sel = null;
const highlights = this.getElementsByClassName("xxe-text-sel");
const count = highlights.length;
for (let i = 0; i < count; ++i) {
let highlight = highlights.item(i);
let span = highlight.textContent;
if (span !== null && span.length > 0) {
if (sel === null) {
sel = span;
} else {
sel += span;
}
}
}
return sel;
}
/**
* Ensures that this document view has the keyboard focus and
* that the caret is blinking (if the dot mark exists).
*/
requestFocus() {
if (this._markManager !== null) {
this._markManager.redrawDot();
}
}
/**
* Scrolls the selection, if any, to center it in this document view.
*
* @param {boolean} [expand=false] expand - if <code>true</code>,
* when needed to, expand the views of the ancestors of the nodes
* comprising the selection to really make the selection visible.
*/
makeSelectionVisible(expand=false) {
if (this._markManager !== null) {
this._markManager.makeSelectionVisible(expand);
}
}
/**
* Tests whether there is a node selection.
*/
hasNodeSelection() {
return (this.selected !== null);
}
/**
* Select the node having specified view.
* <p>Automatically invokes {@link DocumentView#ensureDotIsInside}.
*
* @param view - specifies the node to be selected by its view.
* @param {boolean} [show=false] show - if <code>true</code>,
* when needed to, expand the views of the ancestors of specified node
* to really make the node visible.
* @returns {Promise} A Promise containing <code>true</code>
* if the selection has been successfully changed;
* <code>false</code> otherwise (even when an error occurs).
*/
selectNode(view, show=false) {
let uid = NodeView.getUID(view);
assertOrError(uid !== null);
let marks = { mark: "", selected2: "", selected: uid };
if (show) {
marks.reason = "showing";
}
this.ensureDotIsInside(view, marks);
return this.sendSetMarks(marks);
}
/**
* If needed to, adds <code>dot</code, <code>dotOffset</code to
* specified <code>marks</code object to ensure that the caret is
* inside specified <code>view</code>.
* <p>Does nothing if this is not possible for specified view.
*/
ensureDotIsInside(view, marks) {
let dot = this.dot;
if (dot === null || !view.contains(dot)) {
let text = NodeView.findTextualContent(view);
if (text !== null) {
// A content is guaranteed to have an ID.
marks.dot = text.id;
marks.dotOffset = 0;
}
}
}
/**
* Select the node range specified using the view of first node
* of the range and the view of last node of the range.
* <p>Specified views must have a common parent view to make sure
* that corresponding nodes are siblings.
* <p>Automatically invokes {@link DocumentView#ensureDotIsInside}.
*
* @param view1 - specifies the first node of the range.
* @param view2 - specifies the last node of the range.
* @returns {Promise} A Promise containing <code>true</code>
* if the selection has been successfully changed;
* <code>false</code> otherwise (even when an error occurs).
*/
selectNodeRange(view1, view2) {
let uid1 = NodeView.getUID(view1);
assertOrError(uid1 !== null);
let uid2 = NodeView.getUID(view2);
assertOrError(uid2 !== null);
assertOrError(view1.parentNode === view2.parentNode);
if (view1 === view2) {
return this.selectNode(view1);
}
let marks = { mark: "", selected: uid1, selected2: uid2 };
this.ensureDotIsInside(view2, marks);
return this.sendSetMarks(marks);
}
/**
* Find the text node view and the text offset corresponding
* to specified coordinates.
*
* @param {MouseEvent} mouseEvent - specifies the coordinates
* where the search begins.
* @param {array} [pickedText=null] - if not <code>null</code>,
* the result is stored there.
* @returns {array} An array containing two items. First item is
* a text node view or <code>null</code> (if the search has failed).
* Second item is a text offset or -1 (if the search has failed).
* The returned array is <tt>pickedText</tt> if specified and
* a newly created array otherwise.
*/
pickTextualView(mouseEvent, pickedText=null) {
let picked;
if (pickedText !== null) {
picked = pickedText;
picked[0] = null;
picked[1] = -1;
} else {
picked = [null, -1];
}
if (DocumentView.pickTextualContent(mouseEvent, picked)) {
let textContent = picked[0];
picked[0] = NodeView.contentToView(textContent, textContent.id);
}
return picked;
}
static pickTextualContent(mouseEvent, picked) {
let part = NodeView.lookupViewPart(mouseEvent.target);
if (part !== null && NodeView.isActualContent(part)) {
const clientX = mouseEvent.clientX;
const clientY = mouseEvent.clientY;
/*BROWSER BUG: Chrome caretPositionFromPoint and
a text a line ending with a space char.
================================================================
Why not start by simply:
---
if (DocumentView.caretPositionFromPoint(clientX, clientY,
picked) &&
NodeView.charToTextualContentOffset(picked)) {
return true;
}
---
1) Chrome tends to find a caret pos/text where there is no text.
(XXE Desktop does not behave this way.)
2) When a text node at the end of a text line ends
with a space char and the user clicks at its right, Chrome
finds that the caret pos is before the space char
and not after it. (Moreover this space char is not displayed.)
Therefore, we'll use caretPositionFromPoint only where
we are sure that there is some text.
*/
let textContent =
NodeView.pickNearestTextualContent(part, clientX, clientY);
if (textContent !== null) {
let chars = textContent.textContent;
if (chars === null || chars.length === 0) {
// Empty text content.
picked[0] = textContent;
picked[1] = 0;
return true;
}
const rects = textContent.getClientRects();
const lastRect = rects.length-1;
for (let i = 0; i <= lastRect; ++i) {
const rect = rects[i];
if (rect !== null &&
rect.height > 0 && // rect.width=0 OK.
clientY >= rect.top && clientY < rect.bottom) {
if (clientX <= rect.left) {
if (i === 0) {
picked[0] = textContent;
picked[1] = 0;
return true;
} else {
if (DocumentView.caretPositionFromPoint(
rect.left, clientY, picked)) {
let done =
NodeView.charToTextualContentOffset(
picked);
return done;
}
}
} else if (clientX >= rect.right) {
if (i === lastRect) {
picked[0] = textContent;
picked[1] = chars.length;
return true;
} else {
if (DocumentView.caretPositionFromPoint(
rect.right, clientY, picked)) {
let done =
NodeView.charToTextualContentOffset(
picked);
return done;
}
}
} else {
if (DocumentView.caretPositionFromPoint(
clientX, clientY, picked)) {
let done =
NodeView.charToTextualContentOffset(
picked);
return done;
}
}
}
}
}
}
return false;
}
static caretPositionFromPoint(clientX, clientY, pos) {
// pos[0] will be set to null, a text node found inside textContent or
// textContent itself (with pos[1]=0).
// That's why NodeView.charToTextualContentOffset must be used after
// this method to obtain a textContent or null.
if (document.caretPositionFromPoint) { // Firefox
let caretPos = document.caretPositionFromPoint(clientX, clientY);
if (caretPos && caretPos.offsetNode !== null) {
switch (caretPos.offsetNode.nodeType) {
case Node.TEXT_NODE:
pos[0] = caretPos.offsetNode;
pos[1] = caretPos.offset;
return true;
case Node.ELEMENT_NODE:
if (NodeView.isTextualContent(caretPos.offsetNode)) {
let child = caretPos.offsetNode.firstChild;
if (child === null) {
// Empty textContent.
pos[0] = caretPos.offsetNode;
pos[1] = 0;
return true;
} else if (child.nodeType === Node.TEXT_NODE &&
child.nextSibling === null &&
child.length === 0) {
// textContent containing an empty text node.
// (not detected by
// document.caretPositionFromPoint?)
pos[0] = child;
pos[1] = 0;
return true;
}
}
break;
}
}
} else if (document.caretRangeFromPoint) { // Chrome
let range = document.caretRangeFromPoint(clientX, clientY);
if (range && range.startContainer !== null) {
switch (range.startContainer.nodeType) {
case Node.TEXT_NODE:
pos[0] = range.startContainer;
pos[1] = range.startOffset;
return true;
case Node.ELEMENT_NODE:
if (NodeView.isTextualContent(range.startContainer)) {
let child = range.startContainer.firstChild;
if (child === null) {
// Empty textContent.
pos[0] = range.startContainer;
pos[1] = 0;
return true;
} else if (child.nodeType === Node.TEXT_NODE &&
child.nextSibling === null &&
child.length === 0) {
// textContent containing an empty text node.
// (not detected by document.caretRangeFromPoint?)
pos[0] = child;
pos[1] = 0;
return true;
}
}
break;
}
}
}
return false;
}
/**
* Tests whether specified mouse click is contained
* in text or node selection (if any).
*/
selectionContains(mouseEvent) {
return ((this._markManager === null)? false :
this._markManager.selectionContains(mouseEvent.clientX,
mouseEvent.clientY));
}
/**
* Change node marks on the server side.
*
* @param {object} marks - an object containing one or more of the
* following keys: <ul>
* <li><code>dot</code>: <i>UID</i> or <b>""</b>,
* dotOffset: <i>integer</i>,
* <li><code>mark</code>: <i>UID</i> or <b>""</b>,
* markOffset: <i>integer</i>,
* <li><code>selected</code>: <i>UID</i> or <b>""</b>,
* <li><code>selected2</code>: <i>UID</i> or <b>""</b>.
* <li><code>reason</code>: <b>"dragging"</b>, <b>"showing"</b> or
* <code>null</code>.
* <p><b>"dragging"</b>: if a quick sequence of change marks
* requests is being sent to the server.
* <p><b>"showing"</b>: if the goal is to show the selection to the user,
* in which case, the MarkManager may have to expand some collapsed
* node views after receiving the marks changed event.
* </li>
* </ul>
* <p>Use <b>""</b> to clear a mark, <i>UID</i> to set it.
* Value <code>null</code> is ignored.
* @returns {Promise} A Promise containing <code>true</code>
* if specified marks were successfully changed;
* <code>false</code> otherwise (even when an error occurs).
* <p>Note that {@link DocumentMarksChangedEvent}s and
* {@link EditingContextChangedEvent}s are received <em>before</em>
* this Promise is fulfilled.
*/
sendSetMarks(marks) {
return this.sync()
.then((synced) => {
if (synced) {
return this.doSendSetMarks(marks);
} else {
return false;
}
});
}
doSendSetMarks(marks) {
const dragging = (marks.reason === "dragging");
if (!dragging) {
this.disableController(true);
}
return this._xmlEditor.sendRequest("setMarks", marks)
.then((result) => {
if (!dragging) {
this.disableController(false);
}
return result;
})
.catch((error) => {
if (!dragging) {
this.disableController(false);
}
console.error(`Request "setMarks" marks=${marks} \
has failed: ${error}`);
return false;
});
}
disableController(disable) {
if (this._controller !== null) {
this._controller.disabled = disable;
}
}
/**
* Copy a string in to clipboard on the server side.
*
* @param {string} copied - String to be copied.
* @returns {Promise} A Promise containing <code>true</code>
* if the server-side clipboard has been successfully updated;
* <code>false</code> otherwise (even when an error occurs).
*/
sendSetClipboard(copied) {
return this.sync()
.then((synced) => {
if (synced) {
return this.doSendSetClipboard(copied);
} else {
return false;
}
});
}
doSendSetClipboard(copied) {
this.disableController(true);
return this._xmlEditor.sendRequest("setClipboard", copied)
.then((result) => {
this.disableController(false);
return result;
})
.catch((error) => {
this.disableController(false);
console.error(`Request "setClipboard" copied="${copied}" \
has failed: ${error}`);
return false;
});
}
/**
* When {@link DocumentView#button2PastesText} is <code>true</code>,
* update the (emulated) primary selection by copying selected text to it.
*/
updatePrimarySelection() {
if (this._button2PastesText) {
let copied = this.getSelectedText();
if (copied) {
// CANNOT BE INVOKED VERY OFTEN, for example, during the text
// selection done using a mouse drag. If this is nevertheless
// the case, then the following code must be "throttled" using
// setTimeout(XXX, 100).
this.sendSetPrimarySelection(copied);
}
}
}
/**
* Copy a string as the primary selection on the server side.
*
* @param {string} copied - String to be copied.
* @returns {Promise} A Promise containing <code>true</code>
* if the server-side primary selection has been successfully updated;
* <code>false</code> otherwise (even when an error occurs).
*/
sendSetPrimarySelection(copied) {
return this.sync()
.then((synced) => {
if (synced) {
return this.doSendSetPrimarySelection(copied);
} else {
return false;
}
});
}
doSendSetPrimarySelection(copied) {
this.disableController(true);
return this._xmlEditor.sendRequest("setPrimarySelection", copied)
.then((result) => {
this.disableController(false);
return result;
})
.catch((error) => {
this.disableController(false);
console.error(`Request "setPrimarySelection" \
copied="${copied}" has failed: ${error}`);
return false;
});
}
/**
* Tests wether this document view is editable.
* Returns <code>false</code> either because no document is currently opened
* or opened document is marked read-only or the peer document view
* configured as not editable.
*/
canEdit() {
let rootView = this.firstChild;
if (rootView !== null && rootView.nodeType === Node.ELEMENT_NODE &&
rootView.classList.contains("xxe-re")) {
// Normal, editable, root element.
return true;
} else {
return false;
}
}
/**
* Tests wether specified command can be executed or
* actually executes specified command, whether
* this command is remote or local.
*
* @param {number} mode - specifies how this command is to be executed.
* @param {string} commandName - the command name.
* @param {string} commandParams - parameterizes the command.
* May be <code>null</code>.
* @param {UIEvent} [event=null] - the keyboard or event
* which triggered this command. May be <code>null</code>.
* @returns {Promise} A Promise containing:
* <ul>
* <li>If <code>mode</code> is <code>EXECUTE_TEST</code>,
* <code>true</code> if the command can be executed,
* <code>false</code> otherwise.
* <li>In the other modes, a {@link CommandResult}.
* <li><code>null</code> if this command has not been executed or
* if an error of any kind occurs.
* <ul>
* @see Command#execute
*/
executeCommand(mode, commandName, commandParams, event=null) {
let command = this.getCommand(commandName);
return command.execute(mode, this, commandParams, event);
}
/**
* Return the instance of command having specified name.
* <p>If specified command is not a local command, a new instance of
* {@link RemoteCommand} is returned.
*/
getCommand(commandName) {
let command = ALL_LOCAL_COMMANDS[commandName];
if (!command) {
command = new RemoteCommand(commandName);
}
return command;
}
/**
* Tests wether specified <em>remote</em> command can be executed or
* actually executes specified <em>remote</em> command.
*
* @param {number} mode - specifies how this command is to be executed.
* @param {string} commandName - the command name.
* @param {string} commandParams - parameterizes the command.
* May be <code>null</code>.
* @returns {Promise} A Promise containing:
* <ul>
* <li>If <code>mode</code> is <code>EXECUTE_TEST</code>,
* <code>true</code> if the command can be executed,
* <code>false</code> otherwise.
* <li>In the other modes, a {@link CommandResult}.
* <li><code>null</code> if this command has not been executed
* (for example, an unknown command) or if an error of any kind occurs.
* <ul>
* @see RemoteCommand
*/
sendExecuteCommand(mode, commandName, commandParams) {
return this.sync()
.then((synced) => {
if (synced) {
return this.doSendExecuteCommand(mode, commandName,
commandParams);
} else {
return null;
}
});
}
doSendExecuteCommand(mode, commandName, commandParams) {
// Why this?
// In the sequence Ctrl-V to paste "foo" then press "?", if
// "executeCommand" takes too much time, we could end up with "foo^?"
// (where "^" is the caret) instead of expected "foo?^"
this.disableController(true);
return this._xmlEditor.sendRequest("executeCommand",
mode, commandName, commandParams)
.then((result) => {
this.disableController(false);
if (mode === EXECUTE_TEST) {
return result;
} else {
return CommandResult.deserialize(result);
}
})
.catch((error) => {
this.disableController(false);
console.error(`Request "executeCommand" \
mode=${mode} commandName="${commandName}" commandParams="${commandParams}" \
has failed: ${error}`);
return null; // Not CommandResult.FAILED.
});
}
/**
* Update server-side text node content and dot to match
* the state of currently used (focused) contenteditable.
*
* @returns {Promise} A Promise containing <code>true</code> if sync
* was successful; <code>false</code> otherwise (an internal error
* which should not happen).
*/
sync() {
const cs = this._contenteditableState;
if (cs !== null && cs.syncing(this._syncArgs)) {
// When syncing, the dot mark is not updated by a
// DocumentMarksChangedEvent. So update this here.
assertOrError(this._markManager.dot === cs.dot);
this._markManager.dotOffset = cs.dotOffset;
return this._xmlEditor.sendRequest("sync", ...this._syncArgs)
.catch((error) => {
console.error(`Request "sync" args=${this._syncArgs} \
has failed: ${error}`);
return false;
});
} else {
return Promise.resolve(true);
}
}
}
window.customElements.define("xxe-document-view", DocumentView);