UNPKG

json-joy

Version:

Collection of libraries for building collaborative editing apps.

546 lines (545 loc) 18.4 kB
import { compare, printTs, equal, tick, containsId } from '../../../json-crdt-patch/clock'; import { Anchor } from './constants'; import { ChunkSlice } from '../util/ChunkSlice'; import { hashId, updateId } from '../../../json-crdt/hash'; import { Position } from '../constants'; /** * A "point" in a rich-text Peritext document. It is a combination of a * character ID and an anchor. Anchor specifies the side of the character to * which the point is attached. For example, a point with an anchor "before" .▢ * points just before the character, while a point with an anchor "after" ▢. * points just after the character. Points attached to string characters are * referred to as *relative* points, while points attached to the beginning or * end of the string are referred to as *absolute* points. * * The *absolute* points are reference the string itself, by using the string's * ID as the character ID. The *absolute (abs) start* references the very start * of the string, before the first character, and even before any deleted * characters. The *absolute (abs) end* references the very end of the string, * after the last character, and even after any deleted characters at the end * of the string. */ export class Point { rga; id; anchor; constructor(rga, id, anchor) { this.rga = rga; this.id = id; this.anchor = anchor; } /** * Overwrites the internal state of this point with the state of the given * point. * * @param point Point to copy. */ set(point) { this.id = point.id; this.anchor = point.anchor; } /** * Creates a copy of this point. * * @returns Returns a new point with the same ID and anchor as this point. */ clone() { return new Point(this.rga, this.id, this.anchor); } copy(mutate) { const copy = this.clone(); mutate(copy); return copy; } /** * Compares two points by their character IDs and anchors. First, the character * IDs are compared. If they are equal, the anchors are compared. The anchor * "before" is considered less than the anchor "after". * * @param other The other point to compare to. * @returns Returns 0 if the two points are equal, -1 if this point is less * than the other point, and 1 if this point is greater than the other * point. */ cmp(other) { const cmp = compare(this.id, other.id); if (cmp !== 0) return cmp; return (this.anchor - other.anchor); } /** * Compares two points by their spatial (view) location in the string. Takes * into account not only the character position in the view, but also handles * deleted characters, attachment anchors, and absolute points. * * @param other The other point to compare to. * @returns Returns 0 if the two points are equal, negative if this point is * less than the other point, and positive if this point is greater * than the other point. */ cmpSpatial(other) { const thisId = this.id; const otherId = other.id; if (this.isAbs()) { const isStart = this.anchor === Anchor.After; return isStart ? (other.isAbsStart() ? 0 : -1) : other.isAbsEnd() ? 0 : 1; } else if (other.isAbs()) { const isStart = other.anchor === Anchor.After; return isStart ? (this.isAbsStart() ? 0 : 1) : this.isAbsEnd() ? 0 : -1; } const cmp0 = compare(thisId, otherId); if (!cmp0) return this.anchor - other.anchor; const cmp1 = this.pos() - other.pos(); if (cmp1) return cmp1; let chunk = this.chunk(); if (!chunk) return cmp0; if (containsId(chunk.id, chunk.span, otherId)) return thisId.time - otherId.time; const rga = this.rga; chunk = rga.next(chunk); while (chunk) { if (containsId(chunk.id, chunk.span, otherId)) return -1; chunk = rga.next(chunk); } return 1; } _chunk; /** * @returns Returns the chunk that contains the character referenced by the * point, or `undefined` if the chunk is not found. */ chunk() { let chunk = this._chunk; const id = this.id; if (chunk) { const chunkId = chunk.id; const chunkIdTime = chunkId.time; const idTime = id.time; if (id.sid === chunkId.sid && idTime >= chunkIdTime && idTime < chunkIdTime + chunk.span) return chunk; } this._chunk = chunk = this.rga.findById(this.id); return chunk; } /** * @returns Returns position of the character referenced by the point. */ pos() { const chunk = this.chunk(); if (!chunk) return this.isAbsEnd() ? Position.AbsEnd : Position.AbsStart; const pos = this.rga.pos(chunk); if (chunk.del) return pos; return pos + this.id.time - chunk.id.time; } /** * @returns Returns the view position of the point, as if it is a caret in * the text pointing between characters (0 is before the first * character, 1 is after the first character, etc.). */ viewPos() { const pos = this.pos(); const isAbs = equal(this.rga.id, this.id); if (isAbs) return this.anchor === Anchor.After ? 0 : this.rga.length(); return this.anchor === Anchor.Before ? pos : pos + 1; } /** * @returns Returns `true` if the point is at the very start of the string, i.e. * there are no visible characters before it. */ isStart() { const chunk = this.chunk(); if (!chunk) return true; if (!chunk.del && chunk.id.time < this.id.time) return false; const l = chunk.l; return l ? !l.len : true; } /** * Goes to the next visible character in the string. The `move` parameter * specifies how many characters to move the cursor by. If the cursor reaches * the end of the string, it will return `undefined`. * * @param skip How many characters to move by. * @returns Next visible ID in string. */ nextId(skip = 1) { if (this.isAbsEnd()) return; let remaining = skip; const { id, rga } = this; let chunk; if (this.isAbsStart()) { chunk = rga.first(); while (chunk && chunk.del) chunk = rga.next(chunk); if (!chunk) return; const span = chunk.span; if (remaining <= span) return tick(chunk.id, remaining - 1); remaining -= span; chunk = rga.next(chunk); } else { chunk = this.chunk(); if (!chunk) return undefined; if (!chunk.del) { const offset = id.time - chunk.id.time; const span = chunk.span; if (offset + remaining < span) return tick(id, remaining); else remaining -= span - offset - 1; } chunk = rga.next(chunk); } let lastVisibleChunk; while (chunk && remaining >= 0) { if (chunk.del) { chunk = rga.next(chunk); continue; } lastVisibleChunk = chunk; const span = chunk.span; if (remaining <= span) return remaining > 1 ? tick(chunk.id, remaining - 1) : chunk.id; remaining -= span; chunk = rga.next(chunk); } if (remaining > 0) return; return lastVisibleChunk ? tick(lastVisibleChunk.id, lastVisibleChunk.span - 1) : undefined; } /** * @returns ID of the character that is `move` characters before the * character referenced by the point, or `undefined` if there is no * such character. */ prevId(skip = 1) { if (this.isAbsStart()) return; let remaining = skip; const { id, rga } = this; let chunk = this.chunk(); if (!chunk) return rga.id; if (!chunk.del) { const offset = id.time - chunk.id.time; if (offset >= remaining) return tick(id, -remaining); remaining -= offset; } chunk = rga.prev(chunk); while (chunk) { if (chunk.del) { chunk = rga.prev(chunk); continue; } const span = chunk.span; if (remaining <= span) { return tick(chunk.id, span - remaining); } remaining -= span; chunk = rga.prev(chunk); } return; } /** * Returns one character to the left of the point, or `undefined` if there * is no such character. Skips any deleted characters. Handles absolute points. * * @returns A character slice to the left of the point. */ leftChar() { const rga = this.rga; if (this.isAbsEnd()) { const res = rga.findChunk(rga.length() - 1); if (!res) return; return new ChunkSlice(res[0], res[1], 1); } const tmp = this.clone(); tmp.refAfter(); if (tmp.isAbsStart()) return; const chunk = tmp.chunk(); if (!chunk || chunk.del) return; const off = tmp.id.time - chunk.id.time; return new ChunkSlice(chunk, off, 1); } /** * Returns one character to the right of the point, or `undefined` if there * is no such character. Skips any deleted characters. Handles absolute points. * * @returns A character slice to the right of the point. */ rightChar() { const rga = this.rga; if (this.isAbsStart()) { const res = rga.findChunk(0); if (!res) return; return new ChunkSlice(res[0], res[1], 1); } const tmp = this.clone(); tmp.refBefore(); if (tmp.isAbsEnd()) return; const chunk = tmp.chunk(); if (!chunk || chunk.del) return; const off = tmp.id.time - chunk.id.time; return new ChunkSlice(chunk, off, 1); } char() { return this.anchor === Anchor.Before ? this.rightChar() : this.leftChar(); } /** * Checks if the point is an absolute point. An absolute point is a point that * references the string itself, rather than a character in the string. It can * be either the very start or the very end of the string. * * @returns Returns `true` if the point is an absolute point. */ isAbs() { return equal(this.id, this.rga.id); } /** * @returns Returns `true` if the point is an absolute point and is anchored * before the first character in the string. */ isAbsStart() { return this.isAbs() && this.anchor === Anchor.After; } /** * @returns Returns `true` if the point is an absolute point and is anchored * after the last character in the string. */ isAbsEnd() { return this.isAbs() && this.anchor === Anchor.Before; } /** * @returns Returns `true` if the point is exactly the relative start, i.e. * it is attached to the first visible character in the string and * anchored "before". */ isRelStart() { if (this.anchor !== Anchor.Before) return false; const id = this.rga.find(0); return !!id && equal(this.id, id); } /** * @returns Returns `true` if the point is exactly the relative end, i.e. it * is attached to the last visible character in the string and * anchored "after". */ isRelEnd() { if (this.anchor !== Anchor.After) return false; const rga = this.rga; const length = rga.length(); if (length === 0) return false; const id = rga.find(length - 1); return !!id && equal(this.id, id); } /** * Sets the point to the absolute start of the string. */ refAbsStart() { this.id = this.rga.id; this.anchor = Anchor.After; } /** * Sets the point to the absolute end of the string. */ refAbsEnd() { this.id = this.rga.id; this.anchor = Anchor.Before; } /** * Sets the point to the relative start of the string. */ refStart() { this.refAbsStart(); this.refBefore(); return this; } /** * Sets the point to the relative end of the string. */ refEnd() { this.refAbsEnd(); this.refAfter(); return this; } /** * Modifies the location of the point, such that the view location remains * the same, but ensures that it is anchored before a character. Skips any * deleted characters (chunks), attaching the point to the next visible * character. */ refBefore() { const chunk = this.chunk(); if (!chunk) { if (this.isAbsStart()) { const id = this.rga.find(0); if (id) { this.id = id; this.anchor = Anchor.Before; return; } } this.refAbsEnd(); return; } if (!chunk.del && this.anchor === Anchor.Before) return; this.anchor = Anchor.Before; this.id = this.nextId() || this.rga.id; } /** * Modifies the location of the point, such that the view location remains * the same, but ensures that it is anchored after a character. Skips any * deleted characters (chunks), attaching the point to the next visible * character. */ refAfter() { const chunk = this.chunk(); if (!chunk) { if (this.isAbsEnd()) { const rga = this.rga; const length = rga.length(); if (length !== 0) { const id = rga.find(length - 1); if (id) { this.id = id; this.anchor = Anchor.After; return; } } } this.refAbsStart(); return; } if (!chunk.del && this.anchor === Anchor.After) return; this.anchor = Anchor.After; this.id = this.prevId() || this.rga.id; } /** * Modifies the location of the point, such that the spatial location remains * the same and tries to preserve anchor location, but ensures that the point * references a visible (not deleted) character. */ refVisible() { if (this.anchor === Anchor.Before) this.refBefore(); else this.refAfter(); } /** * Moves point past given number of visible characters. Accepts positive * and negative distances. * * @param length How many characters to move by. Positive number moves the * point to the right, negative number moves the point to the left. * @returns Returns `true` if the absolute end of the string is reached. */ step(length) { if (!length) return this.isAbs(); const anchor = this.anchor; if (anchor !== Anchor.After) this.refAfter(); if (length > 0) { const nextId = this.nextId(length); if (!nextId) { this.refAbsEnd(); return true; } else { this.id = nextId; if (anchor !== Anchor.After) this.refBefore(); } } else { const prevId = this.prevId(-length); if (!prevId) { this.refAbsStart(); return true; } else { this.id = prevId; if (anchor !== Anchor.After) this.refBefore(); } } return false; } /** * Moves the to the next point, which does not necessarily result in a visible * character skip. * * @param length How many points to move by. * @returns Returns `true` if the absolute end of the string is reached. */ halfstep(length) { this.refVisible(); const isOdd = !!(length % 2); if (isOdd) { if (length > 0) { length--; if (this.anchor === Anchor.After) this.refBefore(); else if (this.isAbs()) return true; else this.anchor = Anchor.After; } else { length++; if (this.anchor === Anchor.Before) this.refAfter(); else if (this.isAbs()) return true; else this.anchor = Anchor.Before; } } return this.step(length / 2); } key() { return hashId(this.id) + (this.anchor ? 0 : 1); } // ----------------------------------------------------------------- Stateful refresh() { return updateId(this.anchor, this.id); } // ---------------------------------------------------------------- Printable toStringName() { return 'Point'; } toString(tab = '', lite) { const name = lite ? '' : this.toStringName() + ' '; const pos = this.pos(); const id = printTs(this.id); let char = this.char()?.view(); char = typeof char === 'string' ? JSON.stringify(char) : '▢'; const anchor = this.anchor === Anchor.Before ? '.' + char : char + '.'; return `${name}{ ${pos === Position.AbsEnd ? '∞' : pos}, ${id}, ${anchor} }`; } }