import { isTextNode } from 'mobiledoc-kit/utils/dom-utils';
import assert from 'mobiledoc-kit/utils/assert';
import {
HIGH_SURROGATE_RANGE,
LOW_SURROGATE_RANGE
} from 'mobiledoc-kit/models/marker';
import { containsNode } from 'mobiledoc-kit/utils/dom-utils';
import { findOffsetInNode } from 'mobiledoc-kit/utils/selection-utils';
import { DIRECTION } from 'mobiledoc-kit/utils/key';
import Range from './range';
const { FORWARD, BACKWARD } = DIRECTION;
// generated via http://xregexp.com/ to cover chars that \w misses
// (new XRegExp('\\p{Alphabetic}|[0-9]|_|:')).toString()
const WORD_CHAR_REGEX = /[A-Za-zªµºÀ-ÖØ-öø-ˁˆ-ˑˠ-ˤˬˮͅͰ-ʹͶͷͺ-ͽͿΆΈ-ΊΌΎ-ΡΣ-ϵϷ-ҁҊ-ԯԱ-Ֆՙա-ևְ-ׇֽֿׁׂׅׄא-תװ-ײؐ-ؚؠ-ٗٙ-ٟٮ-ۓە-ۜۡ-ۭۨ-ۯۺ-ۼۿܐ-ܿݍ-ޱߊ-ߪߴߵߺࠀ-ࠗࠚ-ࠬࡀ-ࡘࢠ-ࢴࣣ-ࣰࣩ-ऻऽ-ौॎ-ॐॕ-ॣॱ-ঃঅ-ঌএঐও-নপ-রলশ-হঽ-ৄেৈোৌৎৗড়ঢ়য়-ৣৰৱਁ-ਃਅ-ਊਏਐਓ-ਨਪ-ਰਲਲ਼ਵਸ਼ਸਹਾ-ੂੇੈੋੌੑਖ਼-ੜਫ਼ੰ-ੵઁ-ઃઅ-ઍએ-ઑઓ-નપ-રલળવ-હઽ-ૅે-ૉોૌૐૠ-ૣૹଁ-ଃଅ-ଌଏଐଓ-ନପ-ରଲଳଵ-ହଽ-ୄେୈୋୌୖୗଡ଼ଢ଼ୟ-ୣୱஂஃஅ-ஊஎ-ஐஒ-கஙசஜஞடணதந-பம-ஹா-ூெ-ைொ-ௌௐௗఀ-ఃఅ-ఌఎ-ఐఒ-నప-హఽ-ౄె-ైొ-ౌౕౖౘ-ౚౠ-ౣಁ-ಃಅ-ಌಎ-ಐಒ-ನಪ-ಳವ-ಹಽ-ೄೆ-ೈೊ-ೌೕೖೞೠ-ೣೱೲഁ-ഃഅ-ഌഎ-ഐഒ-ഺഽ-ൄെ-ൈൊ-ൌൎൗൟ-ൣൺ-ൿංඃඅ-ඖක-නඳ-රලව-ෆා-ුූෘ-ෟෲෳก-ฺเ-ๆํກຂຄງຈຊຍດ-ທນ-ຟມ-ຣລວສຫອ-ູົ-ຽເ-ໄໆໍໜ-ໟༀཀ-ཇཉ-ཬཱ-ཱྀྈ-ྗྙ-ྼက-ံးျ-ဿၐ-ၢၥ-ၨၮ-ႆႎႜႝႠ-ჅჇჍა-ჺჼ-ቈቊ-ቍቐ-ቖቘቚ-ቝበ-ኈኊ-ኍነ-ኰኲ-ኵኸ-ኾዀዂ-ዅወ-ዖዘ-ጐጒ-ጕጘ-ፚ፟ᎀ-ᎏᎠ-Ᏽᏸ-ᏽᐁ-ᙬᙯ-ᙿᚁ-ᚚᚠ-ᛪᛮ-ᛸᜀ-ᜌᜎ-ᜓᜠ-ᜳᝀ-ᝓᝠ-ᝬᝮ-ᝰᝲᝳក-ឳា-ៈៗៜᠠ-ᡷᢀ-ᢪᢰ-ᣵᤀ-ᤞᤠ-ᤫᤰ-ᤸᥐ-ᥭᥰ-ᥴᦀ-ᦫᦰ-ᧉᨀ-ᨛᨠ-ᩞᩡ-ᩴᪧᬀ-ᬳᬵ-ᭃᭅ-ᭋᮀ-ᮩᮬ-ᮯᮺ-ᯥᯧ-ᯱᰀ-ᰵᱍ-ᱏᱚ-ᱽᳩ-ᳬᳮ-ᳳᳵᳶᴀ-ᶿᷧ-ᷴḀ-ἕἘ-Ἕἠ-ὅὈ-Ὅὐ-ὗὙὛὝὟ-ώᾀ-ᾴᾶ-ᾼιῂ-ῄῆ-ῌῐ-ΐῖ-Ίῠ-Ῥῲ-ῴῶ-ῼⁱⁿₐ-ₜℂℇℊ-ℓℕℙ-ℝℤΩℨK-ℭℯ-ℹℼ-ℿⅅ-ⅉⅎⅠ-ↈⒶ-ⓩⰀ-Ⱞⰰ-ⱞⱠ-ⳤⳫ-ⳮⳲⳳⴀ-ⴥⴧⴭⴰ-ⵧⵯⶀ-ⶖⶠ-ⶦⶨ-ⶮⶰ-ⶶⶸ-ⶾⷀ-ⷆⷈ-ⷎⷐ-ⷖⷘ-ⷞⷠ-ⷿⸯ々-〇〡-〩〱-〵〸-〼ぁ-ゖゝ-ゟァ-ヺー-ヿㄅ-ㄭㄱ-ㆎㆠ-ㆺㇰ-ㇿ㐀-䶵一-鿕ꀀ-ꒌꓐ-ꓽꔀ-ꘌꘐ-ꘟꘪꘫꙀ-ꙮꙴ-ꙻꙿ-ꛯꜗ-ꜟꜢ-ꞈꞋ-ꞭꞰ-ꞷꟷ-ꠁꠃ-ꠅꠇ-ꠊꠌ-ꠧꡀ-ꡳꢀ-ꣃꣲ-ꣷꣻꣽꤊ-ꤪꤰ-ꥒꥠ-ꥼꦀ-ꦲꦴ-ꦿꧏꧠ-ꧤꧦ-ꧯꧺ-ꧾꨀ-ꨶꩀ-ꩍꩠ-ꩶꩺꩾ-ꪾꫀꫂꫛ-ꫝꫠ-ꫯꫲ-ꫵꬁ-ꬆꬉ-ꬎꬑ-ꬖꬠ-ꬦꬨ-ꬮꬰ-ꭚꭜ-ꭥꭰ-ꯪ가-힣ힰ-ퟆퟋ-ퟻ豈-舘並-龎ff-stﬓ-ﬗיִ-ﬨשׁ-זּטּ-לּמּנּסּףּפּצּ-ﮱﯓ-ﴽﵐ-ﶏﶒ-ﷇﷰ-ﷻﹰ-ﹴﹶ-ﻼA-Za-zヲ-하-ᅦᅧ-ᅬᅭ-ᅲᅳ-ᅵ]|[0-9]|_|:/;
function findParentSectionFromNode(renderTree, node) {
let renderNode = renderTree.findRenderNodeFromElement(
node,
(renderNode) => renderNode.postNode.isSection
);
return renderNode && renderNode.postNode;
}
function findOffsetInMarkerable(markerable, node, offset) {
let offsetInSection = 0;
let marker = markerable.markers.head;
while (marker) {
let markerNode = marker.renderNode.element;
if (markerNode === node) {
return offsetInSection + offset;
} else if (marker.isAtom) {
if (marker.renderNode.headTextNode === node) {
return offsetInSection;
} else if (marker.renderNode.tailTextNode === node) {
return offsetInSection + 1;
}
}
offsetInSection += marker.length;
marker = marker.next;
}
return offsetInSection;
}
function findOffsetInSection(section, node, offset) {
if (section.isMarkerable) {
return findOffsetInMarkerable(section, node, offset);
} else {
assert('findOffsetInSection must be called with markerable or card section',
section.isCardSection);
let wrapperNode = section.renderNode.element;
let endTextNode = wrapperNode.lastChild;
if (node === endTextNode) {
return 1;
}
return 0;
}
}
let Position, BlankPosition;
Position = class Position {
/**
* A position is a logical location (zero-width, or "collapsed") in a post,
* typically between two characters in a section.
* Two positions (a head and a tail) make up a {@link Range}.
* @constructor
*/
constructor(section, offset=0, isBlank=false) {
if (!isBlank) {
assert('Position must have a section that is addressable by the cursor',
(section && section.isLeafSection));
assert('Position must have numeric offset',
(typeof offset === 'number'));
}
this.section = section;
this.offset = offset;
this.isBlank = isBlank;
}
/**
* @param {integer} x x-position in current viewport
* @param {integer} y y-position in current viewport
* @param {Editor} editor
* @return {Position|null}
*/
static atPoint(x, y, editor) {
let { _renderTree, element: rootElement } = editor;
let elementFromPoint = document.elementFromPoint(x, y);
if (!containsNode(rootElement, elementFromPoint)) {
return;
}
let { node, offset } = findOffsetInNode(elementFromPoint, {left: x, top: y});
return Position.fromNode(_renderTree, node, offset);
}
static blankPosition() {
return new BlankPosition();
}
/**
* Returns a range from this position to the given tail. If no explicit
* tail is given this returns a collapsed range focused on this position.
* @param {Position} [tail=this] The ending position
* @return {Range}
* @public
*/
toRange(tail=this, direction=null) {
return new Range(this, tail, direction);
}
get leafSectionIndex() {
let post = this.section.post;
let leafSectionIndex;
post.walkAllLeafSections((section, index) => {
if (section === this.section) {
leafSectionIndex = index;
}
});
return leafSectionIndex;
}
get isMarkerable() {
return this.section && this.section.isMarkerable;
}
/**
* Returns the marker at this position, in the backward direction
* (i.e., the marker to the left of the cursor if the cursor is on a marker boundary and text is left-to-right)
* @return {Marker|undefined}
*/
get marker() {
return this.isMarkerable && this.markerPosition.marker;
}
/**
* Returns the marker in `direction` from this position.
* If the position is in the middle of a marker, the direction is irrelevant.
* Otherwise, if the position is at a boundary between two markers, returns the
* marker to the left if `direction` === BACKWARD and the marker to the right
* if `direction` === FORWARD (assuming left-to-right text direction).
* @param {Direction}
* @return {Marker|undefined}
*/
markerIn(direction) {
if (!this.isMarkerable) { return; }
let { marker, offsetInMarker } = this;
if (!marker) { return; }
if (offsetInMarker > 0 && offsetInMarker < marker.length) {
return marker;
} else if (offsetInMarker === 0) {
return direction === BACKWARD ? marker : marker.prev;
} else if (offsetInMarker === marker.length) {
return direction === FORWARD ? marker.next : marker;
}
}
get offsetInMarker() {
return this.markerPosition.offset;
}
isEqual(position) {
return this.section === position.section &&
this.offset === position.offset;
}
/**
* @return {Boolean} If this position is at the head of the post
*/
isHeadOfPost() {
return this.move(BACKWARD).isEqual(this);
}
/**
* @return {Boolean} If this position is at the tail of the post
*/
isTailOfPost() {
return this.move(FORWARD).isEqual(this);
}
/**
* @return {Boolean} If this position is at the head of its section
*/
isHead() {
return this.isEqual(this.section.headPosition());
}
/**
* @return {Boolean} If this position is at the tail of its section
*/
isTail() {
return this.isEqual(this.section.tailPosition());
}
/**
* Move the position 1 unit in `direction`.
*
* @param {Number} units to move. > 0 moves right, < 0 moves left
* @return {Position} Return a new position one unit in the given
* direction. If the position is moving left and at the beginning of the post,
* the same position will be returned. Same if the position is moving right and
* at the end of the post.
*/
move(units) {
assert('Must pass integer to Position#move', typeof units === 'number');
if (units < 0) {
return this.moveLeft().move(++units);
} else if (units > 0) {
return this.moveRight().move(--units);
} else {
return this;
}
}
/**
* @param {Number} direction (FORWARD or BACKWARD)
* @return {Position} The result of moving 1 "word" unit in `direction`
*/
moveWord(direction) {
let isPostBoundary = direction === BACKWARD ? this.isHeadOfPost() : this.isTailOfPost();
if (isPostBoundary) {
return this;
}
if (!this.isMarkerable) {
return this.move(direction);
}
let pos = this;
// Helper fn to check if the pos is at the `dir` boundary of its section
let isBoundary = (pos, dir) => {
return dir === BACKWARD ? pos.isHead() : pos.isTail();
};
// Get the char at this position (looking forward/right)
let getChar = (pos) => {
let { marker, offsetInMarker } = pos;
return marker.charAt(offsetInMarker);
};
// Get the char in `dir` at this position
let peekChar = (pos, dir) => {
return dir === BACKWARD ? getChar(pos.move(BACKWARD)) : getChar(pos);
};
// Whether there is an atom in `dir` from this position
let isAtom = (pos, dir) => {
// Special case when position is at end, the marker associated with it is
// the marker to its left. Normally `pos#marker` is the marker to the right of the pos's offset.
if (dir === BACKWARD && pos.isTail() && pos.marker.isAtom) {
return true;
}
return dir === BACKWARD ? pos.move(BACKWARD).marker.isAtom : pos.marker.isAtom;
};
if (isBoundary(pos, direction)) {
// extend movement into prev/next section
return pos.move(direction).moveWord(direction);
}
let seekWord = (pos) => {
return !isBoundary(pos, direction) &&
!isAtom(pos, direction) &&
!WORD_CHAR_REGEX.test(peekChar(pos, direction));
};
// move(dir) while we are seeking the first word char
while (seekWord(pos)) {
pos = pos.move(direction);
}
if (isAtom(pos, direction)) {
return pos.move(direction);
}
let seekBoundary = (pos) => {
return !isBoundary(pos, direction) &&
!isAtom(pos, direction) &&
WORD_CHAR_REGEX.test(peekChar(pos, direction));
};
// move(dir) while we are seeking the first boundary position
while (seekBoundary(pos)) {
pos = pos.move(direction);
}
return pos;
}
/**
* The position to the left of this position.
* If this position is the post's headPosition it returns itself.
* @return {Position}
* @private
*/
moveLeft() {
if (this.isHead()) {
let prev = this.section.previousLeafSection();
return prev ? prev.tailPosition() : this;
} else {
let offset = this.offset - 1;
if (this.isMarkerable && this.marker) {
let code = this.marker.value.charCodeAt(offset);
if (code >= LOW_SURROGATE_RANGE[0] && code <= LOW_SURROGATE_RANGE[1]) {
offset = offset - 1;
}
}
return new Position(this.section, offset);
}
}
/**
* The position to the right of this position.
* If this position is the post's tailPosition it returns itself.
* @return {Position}
* @private
*/
moveRight() {
if (this.isTail()) {
let next = this.section.nextLeafSection();
return next ? next.headPosition() : this;
} else {
let offset = this.offset + 1;
if (this.isMarkerable && this.marker) {
let code = this.marker.value.charCodeAt(offset - 1);
if (code >= HIGH_SURROGATE_RANGE[0] && code <= HIGH_SURROGATE_RANGE[1]) {
offset = offset + 1;
}
}
return new Position(this.section, offset);
}
}
static fromNode(renderTree, node, offset) {
if (isTextNode(node)) {
return Position.fromTextNode(renderTree, node, offset);
} else {
return Position.fromElementNode(renderTree, node, offset);
}
}
static fromTextNode(renderTree, textNode, offsetInNode) {
const renderNode = renderTree.getElementRenderNode(textNode);
let section, offsetInSection;
if (renderNode) {
const marker = renderNode.postNode;
section = marker.section;
assert(`Could not find parent section for mapped text node "${textNode.textContent}"`,
!!section);
offsetInSection = section.offsetOfMarker(marker, offsetInNode);
} else {
// all text nodes should be rendered by markers except:
// * text nodes inside cards
// * text nodes created by the browser during text input
// both of these should have rendered parent sections, though
section = findParentSectionFromNode(renderTree, textNode);
assert(`Could not find parent section for un-mapped text node "${textNode.textContent}"`,
!!section);
offsetInSection = findOffsetInSection(section, textNode, offsetInNode);
}
return new Position(section, offsetInSection);
}
static fromElementNode(renderTree, elementNode, offset) {
let position;
// The browser may change the reported selection to equal the editor's root
// element if the user clicks an element that is immediately removed,
// which can happen when clicking to remove a card.
if (elementNode === renderTree.rootElement) {
let post = renderTree.rootNode.postNode;
position = offset === 0 ? post.headPosition() : post.tailPosition();
} else {
let section = findParentSectionFromNode(renderTree, elementNode);
assert('Could not find parent section from element node', !!section);
if (section.isCardSection) {
// Selections in cards are usually made on a text node
// containing a ‌ on one side or the other of the card but
// some scenarios (Firefox) will result in selecting the
// card's wrapper div. If the offset is 2 we've selected
// the final zwnj and should consider the cursor at the
// end of the card (offset 1). Otherwise, the cursor is at
// the start of the card
position = offset < 2 ? section.headPosition() : section.tailPosition();
} else {
// In Firefox it is possible for the cursor to be on an atom's wrapper
// element. (In Chrome/Safari, the browser corrects this to be on
// one of the text nodes surrounding the wrapper).
// This code corrects for when the browser reports the cursor position
// to be on the wrapper element itself
let renderNode = renderTree.getElementRenderNode(elementNode);
let postNode = renderNode && renderNode.postNode;
if (postNode && postNode.isAtom) {
let sectionOffset = section.offsetOfMarker(postNode);
if (offset > 1) {
// we are on the tail side of the atom
sectionOffset += postNode.length;
}
position = new Position(section, sectionOffset);
} else if (offset >= elementNode.childNodes.length) {
// This is to deal with how Firefox handles triple-click selections.
// See https://stackoverflow.com/a/21234837/1269194 for an
// explanation.
position = section.tailPosition();
} else {
// The offset is 0 if the cursor is on a non-atom-wrapper element node
// (e.g., a <br> tag in a blank markup section)
position = section.headPosition();
}
}
}
return position;
}
/**
* @private
*/
get markerPosition() {
assert('Cannot get markerPosition without a section', !!this.section);
assert('cannot get markerPosition of a non-markerable', !!this.section.isMarkerable);
return this.section.markerPositionAtOffset(this.offset);
}
};
BlankPosition = class BlankPosition extends Position {
constructor() {
super(null, 0, true);
}
isEqual(other) {
return other && other.isBlank;
}
toRange() { return Range.blankRange(); }
get leafSectionIndex() { assert('must implement get leafSectionIndex', false); }
get isMarkerable() { return false; }
get marker() { return false; }
isHeadOfPost() { return false; }
isTailOfPost() { return false; }
isHead() { return false; }
isTail() { return false; }
move() { return this; }
moveWord() { return this; }
get markerPosition() { return {}; }
};
export default Position;