json-joy
Version:
Collection of libraries for building collaborative editing apps.
209 lines (208 loc) • 7.37 kB
JavaScript
import { getCursorPosition, unit } from '../util';
import { ElementAttr } from '../constants';
import { throttle } from '../../../util/throttle';
import { ValueSyncStore } from '../../../util/events/sync-store';
/**
* Controller for handling text selection and cursor movements. Listens to
* naive browser events and translates them into Peritext events.
*/
export class CursorController {
opts;
caretId;
_cursor;
constructor(opts) {
this.opts = opts;
this.caretId = 'jsonjoy.com-peritext-caret-' + opts.et.id;
this._cursor = throttle(opts.et.cursor.bind(opts.et), 25);
}
/** The position where user started to scrub the text. */
selAnchor = -1;
/**
* String position at coordinate, or -1, if unknown.
*/
posAtPoint(x, y) {
const res = getCursorPosition(x, y);
if (res) {
let node = res[0];
const offset = res[1];
for (let i = 0; i < 5 && node; i++) {
const inline = node[ElementAttr.InlineOffset];
const pos = inline?.pos?.();
if (typeof pos === 'number')
return pos + offset;
node = node.parentNode;
}
}
return -1;
}
/** -------------------------------------------------- {@link UiLifeCycles} */
start() {
const el = this.opts.source;
el.addEventListener('mousedown', this.onMouseDown);
el.addEventListener('keydown', this.onKeyDown);
el.addEventListener('focus', this.onFocus);
el.addEventListener('blur', this.onBlur);
document.addEventListener('mousemove', this.onMouseMove);
document.addEventListener('mouseup', this.onMouseUp);
return () => {
el.removeEventListener('mousedown', this.onMouseDown);
el.removeEventListener('keydown', this.onKeyDown);
el.removeEventListener('focus', this.onFocus);
el.removeEventListener('blur', this.onBlur);
document.removeEventListener('mousemove', this.onMouseMove);
document.removeEventListener('mouseup', this.onMouseUp);
this._cursor[1](); // Stop throttling loop.
};
}
focus = new ValueSyncStore(false);
onFocus = () => {
this.focus.next(true);
};
onBlur = () => {
this.focus.next(false);
};
x = 0;
y = 0;
mouseDown = new ValueSyncStore(false);
onMouseDown = (ev) => {
if (!this.focus.value && this.opts.txt.editor.hasCursor())
return;
const { clientX, clientY } = ev;
this.x = clientX;
this.y = clientY;
const et = this.opts.et;
switch (ev.detail) {
case 1: {
this.mouseDown.next(false);
const at = this.posAtPoint(clientX, clientY);
if (at === -1)
return;
this.selAnchor = at;
const pressed = this.opts.keys.pressed;
if (pressed.has('Shift')) {
ev.preventDefault();
et.move([
['start', 'word', -1],
['end', 'word', 1],
], [at]);
}
else if (pressed.has('Alt')) {
ev.preventDefault();
et.cursor({ at: [at], add: true });
}
else {
this.mouseDown.next(true);
ev.preventDefault();
et.cursor({ at: [at] });
}
break;
}
case 2:
this.mouseDown.next(false);
ev.preventDefault();
et.move([
['start', 'word', -1],
['end', 'word', 1],
]);
break;
case 3:
this.mouseDown.next(false);
ev.preventDefault();
et.move([
['start', 'word', -1],
['end', 'word', 1],
]);
break;
case 4:
this.mouseDown.next(false);
ev.preventDefault();
et.move([
['start', 'line', -1],
['end', 'line', 1],
]);
break;
case 5:
this.mouseDown.next(false);
ev.preventDefault();
et.move([
['start', 'block', -1],
['end', 'block', 1],
]);
break;
case 6:
this.mouseDown.next(false);
ev.preventDefault();
et.move([
['start', 'all', -1],
['end', 'all', 1],
]);
break;
}
};
onMouseMove = (ev) => {
if (!this.mouseDown.value)
return;
const at = this.selAnchor;
if (at < 0)
return;
const { clientX, clientY } = ev;
const to = this.posAtPoint(clientX, clientY);
if (to < 0)
return;
ev.preventDefault();
const mouseHasNotMoved = clientX === this.x && clientY === this.y;
if (mouseHasNotMoved)
return;
this.x = clientX;
this.y = clientY;
this._cursor[0]({ move: [['focus', to]] });
};
onMouseUp = (ev) => {
this.mouseDown.next(false);
};
onKeyDown = (event) => {
const key = event.key;
if (event.isComposing || key === 'Dead')
return;
const et = this.opts.et;
switch (key) {
case 'ArrowUp':
case 'ArrowDown': {
event.preventDefault();
et.move('focus', 'vert', key === 'ArrowUp' ? -1 : 1, !event.shiftKey);
break;
}
case 'ArrowLeft':
case 'ArrowRight': {
const direction = key === 'ArrowLeft' ? -1 : 1;
event.preventDefault();
if (event.metaKey)
et.move('focus', 'line', direction, !event.shiftKey);
else if (event.altKey && event.ctrlKey)
et.move('focus', 'point', direction, !event.shiftKey);
else if (event.altKey || event.ctrlKey)
et.move('focus', 'word', direction, !event.shiftKey);
else
et.move('focus', unit(event) || 'char', direction, !event.shiftKey);
break;
}
case 'Home':
case 'End': {
event.preventDefault();
const direction = key === 'End' ? 1 : -1;
et.move('focus', 'line', direction, !event.shiftKey);
return;
}
case 'a':
if (event.metaKey || event.ctrlKey) {
event.preventDefault();
et.cursor({ at: [0, 0], move: [['end', 'all', 1]] });
return;
}
}
};
/** ----------------------------------------------------- {@link Printable} */
toString(tab) {
return `cursor { focus: ${this.focus.value}, x: ${this.x}, y: ${this.y}, mouseDown: ${this.mouseDown.value} }`;
}
}