downthemall/uikit/lib/selection.ts
2019-08-20 16:41:37 +02:00

319 lines
7.3 KiB
TypeScript

"use strict";
// License: MIT
import { EventEmitter } from "./events";
export class SelectionRange {
public start: number;
public end: number;
constructor(start: number, end: number) {
this.start = start;
this.end = end;
if (this.start > this.end) {
throw new Error(`Invalid range ${this.start} - ${this.end}`);
}
Object.freeze(this);
}
get first() {
return this.start;
}
*[Symbol.iterator]() {
for (let i = this.start; i <= this.end; ++i) {
yield i;
}
}
get length() {
return this.end - this.start + 1;
}
contains(idx: number, border?: number) {
if (border) {
const rv = this.start <= idx && idx <= this.end;
if (rv) {
return rv;
}
idx -= border;
}
return this.start <= idx && idx <= this.end;
}
}
export class TableSelection extends EventEmitter {
public ranges: SelectionRange[];
constructor() {
super();
this.ranges = [];
Object.freeze(this);
}
get empty() {
return this.ranges.length === 0;
}
get first() {
return this.ranges[0].first;
}
*[Symbol.iterator]() {
for (const r of Array.from(this.ranges)) {
yield *r;
}
}
_findContainingRange(idx: number, border?: number) {
if (!this.ranges.length) {
return null;
}
let low = 0;
let high = this.ranges.length - 1;
while (low <= high) {
const mid = ((high + low) / 2) | 0;
const r = this.ranges[mid];
if (r.contains(idx, border)) {
return {range: r, pos: mid};
}
if (r.end < idx) {
low = mid + 1;
}
else {
high = mid - 1;
}
}
return null;
}
_findInsertionPoint(range: SelectionRange) {
const end = this.ranges.length - 1;
let low = 0;
let high = end;
while (low <= high) {
const mid = ((high + low) / 2) | 0;
const r = this.ranges[mid];
if (range.start < r.start) {
if (mid >= end) {
return mid;
}
const next = this.ranges[mid + 1];
if (next.start > range.end) {
return mid;
}
}
if (r.end > range.start) {
high = mid - 1;
}
else {
low = mid + 1;
}
}
return this.ranges.length;
}
_sanity(sidx: number, eidx: number) {
if (!isFinite(sidx) || !isFinite(eidx) || sidx < 0 || eidx < sidx) {
throw new Error(`Invalid Range ${sidx} - ${eidx}`);
}
}
contains(idx: number) {
return !!this._findContainingRange(idx);
}
_add(sidx: number, eidx: number) {
const rs = this._findContainingRange(sidx, 1);
let re;
if (rs && rs.range.contains(eidx)) {
re = rs;
}
else {
re = this._findContainingRange(eidx, -1);
}
if (rs && re) {
// Already present
if (rs.pos === re.pos) {
// ... and one range
return false;
}
// Need to merge
const rn = new SelectionRange(rs.range.start, re.range.end);
this.ranges.splice(rs.pos, re.pos - rs.pos + 1, rn);
return true;
}
if (rs) {
// extend
const rn = new SelectionRange(rs.range.start, eidx);
let {pos} = rs;
for (; pos < this.ranges.length; ++pos) {
if (this.ranges[pos].start > rn.end) {
break;
}
}
this.ranges.splice(rs.pos, pos - rs.pos, rn);
return true;
}
if (re) {
// extend
const rn = new SelectionRange(sidx, re.range.end);
let {pos} = re;
for (; pos >= 0; --pos) {
if (this.ranges[pos].end < rn.start) {
break;
}
}
this.ranges.splice(pos + 1, re.pos - pos, rn);
return true;
}
const rn = new SelectionRange(sidx, eidx);
const ip = this._findInsertionPoint(rn);
let pos = ip;
for (; pos < this.ranges.length; ++pos) {
if (this.ranges[pos].start > rn.end) {
break;
}
}
this.ranges.splice(ip, pos - ip, rn);
return true;
}
_delete(sidx: number, eidx: number) {
// Let's just add the entire range (which will merge stuff for us) and then
// remove it again
this._add(sidx, eidx);
const cr = this._findContainingRange(sidx);
if (!cr) {
return false;
}
const {pos, range} = cr;
this.ranges.splice(pos, 1);
if (range.start === sidx && range.end === eidx) {
// Entire range affected, shortcut
return true;
}
if (range.start < sidx && range.end > eidx) {
// Need to re-add head and tail
this.ranges.splice(pos, 0,
new SelectionRange(range.start, sidx - 1),
new SelectionRange(eidx + 1, range.end));
return true;
}
if (range.start < sidx) {
// Need to re-add only head
this.ranges.splice(pos, 0,
new SelectionRange(range.start, sidx - 1));
return true;
}
// Need to re-add only tail
this.ranges.splice(pos, 0,
new SelectionRange(eidx + 1, range.end));
return true;
}
add(sidx: number, eidx?: number) {
if (typeof eidx === "undefined") {
eidx = sidx;
}
this._sanity(sidx, eidx);
if (this._add(sidx, eidx)) {
this.emit("selection-added", new SelectionRange(sidx, eidx));
}
}
delete(sidx: number, eidx?: number) {
if (typeof eidx === "undefined") {
eidx = sidx;
}
this._sanity(sidx, eidx);
if (this._delete(sidx, eidx)) {
this.emit("selection-deleted", new SelectionRange(sidx, eidx));
}
}
toggle(sidx: number, eidx?: number) {
if (typeof eidx === "undefined") {
eidx = sidx;
}
if (!isFinite(eidx)) {
eidx = sidx;
}
this._sanity(sidx, eidx);
if (sidx === eidx) {
// just toggle directly
if (this.contains(sidx)) {
this.delete(sidx);
}
else {
this.add(sidx);
}
return;
}
const range = new SelectionRange(sidx, eidx);
const ranges = this.ranges.
filter(r => {
return range.contains(r.start) || range.contains(r.end);
}).
map(r => {
return [r.start, r.end];
});
const changed = new TableSelection();
changed._add(sidx, eidx);
this._add(sidx, eidx);
for (const [s, e] of ranges) {
this._delete(s, e);
}
this.emit("selection-toggled", changed);
}
offset(pos: number, offset: number) {
if (offset === 0) {
return;
}
const newSelection = new TableSelection();
for (const r of this.ranges) {
if (pos > r.end) {
newSelection._add(r.start, r.end);
continue;
}
if (pos <= r.end && pos <= r.start) {
if (pos > r.start + offset) {
const sidx = Math.max(pos, r.start + offset);
const eidx = Math.max(pos, r.end + offset);
newSelection._add(sidx, eidx);
}
else {
newSelection._add(r.start + offset, r.end + offset);
}
continue;
}
if (pos - 1 >= r.start) {
newSelection._add(r.start, pos - 1);
}
if (offset >= 0) {
newSelection._add(pos + offset, r.end + offset);
continue;
}
if (r.end + offset < r.start) {
continue;
}
newSelection._add(pos, r.end + offset);
}
this.ranges.length = 0;
this.ranges.push.apply(this.ranges, newSelection.ranges);
}
clear() {
this.ranges.length = 0;
this.emit("selection-cleared", this);
}
replace(sidx: number, eidx?: number) {
this.clear();
this.add(sidx, eidx);
}
}