json-joy
Version:
Collection of libraries for building collaborative editing apps.
237 lines (236 loc) • 8.22 kB
JavaScript
import { Point } from './Point';
import { Anchor } from './constants';
import { updateNum } from '../../../json-hash/hash';
/**
* A range is a pair of points that represent a selection in the text. A range
* can be collapsed to a single point, then it is called a *marker*
* (if it is stored in the text), or *caret* (if it is a cursor position).
*/
export class Range {
rga;
start;
end;
/**
* Creates a range from two points. The points are ordered so that the
* start point is before or equal to the end point.
*
* @param rga Peritext context.
* @param p1 Some point.
* @param p2 Another point.
* @returns Range with points in correct order.
*/
static from(rga, p1, p2) {
return p1.cmpSpatial(p2) > 0 ? new Range(rga, p2, p1) : new Range(rga, p1, p2);
}
/**
* A convenience method for creating a range from a view position and a length.
* The `start` argument specifies the position between characters, where
* the range should start. The `size` argument specifies the number of
* characters in the range. If `size` is zero or not specified, the range
* will be collapsed to a single point.
*
* When the range is collapsed, the anchor position is set to "after" the
* character. When the range is expanded, the anchor positions are set to
* "before" for the start point and "after" for the end point.
*
* The `size` argument can be negative, in which case the range is selected
* backwards.
*
* @param rga Peritext context.
* @param start Position in the text between characters.
* @param size Length of the range. Can be negative, in which case the range
* is selected backwards.
* @returns A range from the given position with the given length.
*/
static at(rga, start, size = 0) {
const length = rga.length();
if (!size) {
if (start > length)
start = length;
const startId = !start ? rga.id : rga.find(start - 1) || rga.id;
const point = new Point(rga, startId, Anchor.After);
return new Range(rga, point, point.clone());
}
if (size < 0) {
size = -size;
start -= size;
}
if (start < 0) {
size += start;
start = 0;
if (size < 0)
return Range.at(rga, start, 0);
}
if (start >= length) {
start = length;
size = 0;
}
if (start + size > length)
size = length - start;
const startId = rga.find(start) || rga.id;
const endId = rga.find(start + size - 1) || startId;
const startEndpoint = new Point(rga, startId, Anchor.Before);
const endEndpoint = new Point(rga, endId, Anchor.After);
return new Range(rga, startEndpoint, endEndpoint);
}
/**
* @param rga Peritext context.
* @param start Start point of the range, must be before or equal to end.
* @param end End point of the range, must be after or equal to start.
*/
constructor(rga, start, end) {
this.rga = rga;
this.start = start;
this.end = end;
}
/**
* Clones the range.
*
* @returns A new range with the same start and end points.
*/
range() {
return new Range(this.rga, this.start.clone(), this.end.clone());
}
cmp(range) {
return this.start.cmp(range.start) || this.end.cmp(range.end);
}
cmpSpatial(range) {
return this.start.cmpSpatial(range.start) || this.end.cmpSpatial(range.end);
}
/**
* Determines if the range is collapsed to a single point. Handles special
* cases where the range is collapsed, but the points are not equal, for
* example, when the characters between the points are invisible.
*
* @returns True if the range is collapsed to a single point.
*/
isCollapsed() {
const { start, end } = this;
if (start.cmpSpatial(end) === 0)
return true;
const start2 = start.clone();
const end2 = end.clone();
start2.refAfter();
end2.refAfter();
return start2.cmp(end2) === 0;
}
contains(range) {
return this.start.cmpSpatial(range.start) <= 0 && this.end.cmpSpatial(range.end) >= 0;
}
containsPoint(point) {
return this.start.cmpSpatial(point) <= 0 && this.end.cmpSpatial(point) >= 0;
}
// ---------------------------------------------------------------- mutations
set(start, end = start) {
this.start = start;
this.end = end === start ? end.clone() : end;
}
setRange(range) {
this.set(range.start, range.end);
}
setAt(start, length = 0) {
const range = Range.at(this.rga, start, length);
this.setRange(range);
}
setAfter(id) {
const point = new Point(this.rga, id, Anchor.After);
this.set(point);
}
/**
* Collapse the range to the start point and sets the anchor position to be
* "after" the character.
*/
collapseToStart() {
const start = this.start.clone();
start.refAfter();
const end = start.clone();
this.set(start, end);
}
/**
* Collapse the range to the end point and sets the anchor position to be
* "before" the character.
*/
collapseToEnd() {
const end = this.end.clone();
end.refAfter();
const start = this.end.clone();
this.set(start, end);
}
/**
* Expand range left and right to contain all invisible space: (1) tombstones,
* (2) anchors of non-deleted adjacent chunks.
*/
expand() {
this.start.refAfter();
this.end.refBefore();
}
/**
* The reverse of {@link expand}. Shrink the range's start and end points to
* still contain the same visible text, but narrow the range in the CRDT-space
* as much as possible.
*/
shrink() {
this.start.refBefore();
this.end.refAfter();
}
// -------------------------------------------------- View coordinate methods
/**
* Returns the range in the view coordinates as a position and length.
*
* @returns The range as a view position and length.
*/
view() {
const start = this.start.viewPos();
const end = this.end.viewPos();
return [start, end - start];
}
/**
* @returns The length of the range in view coordinates.
*/
length() {
return this.end.viewPos() - this.start.viewPos();
}
/**
* Returns plain text view of the range. Concatenates all text chunks in the
* range ignoring tombstones and returns the result.
*
* @returns The text content of the range.
*/
text() {
const isCaret = this.isCollapsed();
if (isCaret)
return '';
const { start, end } = this;
const rga = this.rga;
const startId = start.anchor === Anchor.Before ? start.id : start.nextId();
const endId = end.anchor === Anchor.After ? end.id : end.prevId();
if (!startId || !endId)
return '';
let result = '';
rga.range0(start.chunk(), startId, endId, (chunk, from, length) => {
const data = chunk.data;
if (data)
result += data.slice(from, from + length);
});
return result;
}
// ----------------------------------------------------------------- Stateful
refresh() {
let state = this.start.refresh();
state = updateNum(state, this.end.refresh());
return state;
}
// ---------------------------------------------------------------- Printable
toStringName() {
return 'Range';
}
toString(tab = '', lite = true) {
const name = this.toStringName();
const start = this.start.toString(tab, lite);
const end = this.end.toString(tab, lite);
let text = this.text();
if (text.length > 16)
text = text.slice(0, 16) + '...';
return `${name} ${JSON.stringify(text)} ${start} ↔ ${end}`;
}
}