Skip to content

Commit

Permalink
Merge pull request #738 from streamich/peritext-dom-events
Browse files Browse the repository at this point in the history
Add Peritext DOM event handlers to the main line
  • Loading branch information
streamich authored Oct 31, 2024
2 parents d772cf8 + b3554aa commit 4d723e7
Show file tree
Hide file tree
Showing 9 changed files with 583 additions and 0 deletions.
12 changes: 12 additions & 0 deletions src/json-crdt-peritext-ui/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const enum Char {
ZeroLengthSpace = '\uFEFF',
}

export const enum ElementAttr {
InlineOffset = '__jsonjoy.com',
}

export enum CssClass {
Editor = 'jsonjoy-peritext-editor',
Inline = 'jsonjoy-peritext-inline',
}
226 changes: 226 additions & 0 deletions src/json-crdt-peritext-ui/dom/InputController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import type {Peritext} from '../../json-crdt-extensions/peritext';
import type {PeritextEventTarget} from '../events/PeritextEventTarget';
import type {TypedEventTarget} from '../events/TypedEventTarget';
import type {UiLifeCycles} from './types';

export interface InputControllerEventSourceMap {
beforeinput: HTMLElementEventMap['beforeinput'];
keydown: HTMLElementEventMap['keydown'];
}

export type InputControllerEventSource = TypedEventTarget<InputControllerEventSourceMap>;

const unit = (event: KeyboardEvent): '' | 'word' | 'line' =>
event.metaKey ? 'line' : event.altKey || event.ctrlKey ? 'word' : '';

export interface InputControllerOpts {
source: InputControllerEventSource;
txt: Peritext;
et: PeritextEventTarget;
}

/**
* Processes incoming DOM "input" events (such as "beforeinput", "input",
* "keydown", etc.) and translates them into Peritext events.
*/
export class InputController implements UiLifeCycles {
protected readonly source: InputControllerEventSource;
protected readonly txt: Peritext;
public readonly et: PeritextEventTarget;

public constructor(options: InputControllerOpts) {
this.source = options.source;
this.txt = options.txt;
this.et = options.et;
}

public start(): void {
this.source.addEventListener('beforeinput', this.onBeforeInput);
this.source.addEventListener('keydown', this.onKeyDown);
}

public stop(): void {
this.source.removeEventListener('beforeinput', this.onBeforeInput);
this.source.removeEventListener('keydown', this.onKeyDown);
}

private onBeforeInput = (event: InputEvent): void => {
// TODO: prevent default more selectively?
event.preventDefault();
const editor = this.txt.editor;
const et = this.et;
const inputType = event.inputType;
switch (inputType) {
case 'insertParagraph': {
// editor.saved.insMarker('p');
// editor.cursor.move(1);
// this.et.change(event);
break;
}
case 'insertFromComposition':
case 'insertFromDrop':
case 'insertFromPaste':
case 'insertFromYank':
case 'insertReplacementText': // insert or replace existing text by means of a spell checker, auto-correct, writing suggestions or similar
case 'insertText': {
// insert typed plain
if (typeof event.data === 'string') {
et.insert(event.data);
} else {
const item = event.dataTransfer ? event.dataTransfer.items[0] : null;
if (item) {
item.getAsString((text) => {
et.insert(text);
});
}
}
break;
}
case 'deleteContentBackward': // delete the content directly before the caret position and this intention is not covered by another inputType or delete the selection with the selection collapsing to its start after the deletion
case 'deleteContent': {
// delete the selection without specifying the direction of the deletion and this intention is not covered by another inputType
et.delete(-1, 'char');
break;
}
case 'deleteContentForward': {
// delete the content directly after the caret position and this intention is not covered by another inputType or delete the selection with the selection collapsing to its end after the deletion
et.delete(1, 'char');
break;
}
case 'deleteWordBackward': {
// delete a word directly before the caret position
et.delete(-1, 'word');
break;
}
case 'deleteWordForward': {
// delete a word directly after the caret position
et.delete(1, 'word');
break;
}
case 'deleteSoftLineBackward': {
// delete from the caret to the nearest visual line break before the caret position
et.delete(-1, 'line');
break;
}
case 'deleteSoftLineForward': {
// delete from the caret to the nearest visual line break after the caret position
et.delete(1, 'line');
break;
}
case 'deleteEntireSoftLine': // delete from the nearest visual line break before the caret position to the nearest visual line break after the caret position
case 'deleteHardLineBackward': // delete from the caret to the nearest beginning of a block element or br element before the caret position
case 'deleteHardLineForward': {
// delete from the caret to the nearest end of a block element or br element after the caret position
et.delete(-1, 'word');
break;
}
// case 'insertLineBreak': { // insert a line break
// }
// case 'insertParagraph': { // insert a paragraph break
// }
// case 'insertOrderedList': { // insert a numbered list
// }
// case 'insertUnorderedList': { // insert a bulleted list
// }
// case 'insertHorizontalRule': { // insert a horizontal rule
// }
// case 'insertFromYank': { // replace the current selection with content stored in a kill buffer
// }
// case 'insertFromDrop': { // insert content by means of drop
// }
// case 'insertFromPaste': { // paste content from clipboard or paste image from client provided image library
// }
// case 'insertFromPasteAsQuotation': { // paste content from the clipboard as a quotation
// }
// case 'insertTranspose': { // transpose the last two grapheme cluster. that were entered
// }
// case 'insertCompositionText': { // replace the current composition string
// }
// case 'insertLink': { // insert a link
// }
// case 'deleteByDrag': { // remove content from the DOM by means of drag
// }
// case 'deleteByCut': { // remove the current selection as part of a cut
// }
// case 'historyUndo': { // undo the last editing action
// }
// case 'historyRedo': { // to redo the last undone editing action
// }
// case 'formatBold': { // initiate bold text
// }
// case 'formatItalic': { // initiate italic text
// }
// case 'formatUnderline': { // initiate underline text
// }
// case 'formatStrikeThrough': { // initiate stricken through text
// }
// case 'formatSuperscript': { // initiate superscript text
// }
// case 'formatSubscript': { // initiate subscript text
// }
// case 'formatJustifyFull': { // make the current selection fully justified
// }
// case 'formatJustifyCenter': { // center align the current selection
// }
// case 'formatJustifyRight': { // right align the current selection
// }
// case 'formatJustifyLeft': { // left align the current selection
// }
// case 'formatIndent': { // indent the current selection
// }
// case 'formatOutdent': { // outdent the current selection
// }
// case 'formatRemove': { // remove all formatting from the current selection
// }
// case 'formatSetBlockTextDirection': { // set the text block direction
// }
// case 'formatSetInlineTextDirection': { // set the text inline direction
// }
// case 'formatBackColor': { // change the background color
// }
// case 'formatFontColor': { // change the font color
// }
// case 'formatFontName': { // change the font name
// }
}
};

private onKeyDown = (event: KeyboardEvent): void => {
const key = event.key;
const et = this.et;
switch (key) {
case 'ArrowLeft':
case 'ArrowRight': {
const direction = key === 'ArrowLeft' ? -1 : 1;
event.preventDefault();
if (event.shiftKey) et.move(direction, unit(event) || 'char', 'focus');
else if (event.metaKey) et.move(direction, 'line');
else if (event.altKey || event.ctrlKey) et.move(direction, 'word');
else et.move(direction);
break;
}
case 'Backspace':
case 'Delete': {
const direction = key === 'Delete' ? 1 : -1;
const deleteUnit = unit(event);
if (deleteUnit) {
event.preventDefault();
et.delete(direction, deleteUnit);
}
break;
}
case 'Home':
case 'End':
event.preventDefault();
const direction = key === 'End' ? 1 : -1;
const edge = event.shiftKey ? 'focus' : 'both';
return this.et.move(direction, 'line', edge);
case 'a':
if (event.metaKey || event.ctrlKey) {
event.preventDefault();
this.et.cursor({unit: 'all'});
return;
}
}
};
}
35 changes: 35 additions & 0 deletions src/json-crdt-peritext-ui/dom/KeyController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type {UiLifeCycles} from './types';

/**
* Keeps track of all pressed down keys.
*/
export class KeyController implements UiLifeCycles {
/**
* All currently pressed keys.
*/
public readonly pressed = new Set<string>();

public start(): void {
document.addEventListener('keydown', this.onKeyDown);
document.addEventListener('keyup', this.onKeyUp);
document.addEventListener('focus', this.onFocus);
}

public stop(): void {
document.removeEventListener('keydown', this.onKeyDown);
document.removeEventListener('keyup', this.onKeyUp);
document.removeEventListener('focus', this.onFocus);
}

private onKeyDown = (event: KeyboardEvent): void => {
this.pressed.add(event.key);
};

private onKeyUp = (event: KeyboardEvent): void => {
this.pressed.delete(event.key);
};

private onFocus = (): void => {
this.pressed.clear();
};
}
2 changes: 2 additions & 0 deletions src/json-crdt-peritext-ui/dom/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Controllers in this folder subscribe to native DOM events and translate them
into Peritext events.
40 changes: 40 additions & 0 deletions src/json-crdt-peritext-ui/dom/RichTextController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type {PeritextEventTarget} from '../events/PeritextEventTarget';
import type {UiLifeCycles} from './types';
import type {Peritext} from '../../json-crdt-extensions/peritext';

export interface RichTextControllerOpts {
source: HTMLElement;
txt: Peritext;
et: PeritextEventTarget;
}

export class RichTextController implements UiLifeCycles {
public constructor(public readonly opts: RichTextControllerOpts) {}

/** -------------------------------------------------- {@link UiLifeCycles} */

public start(): void {
const el = this.opts.source;
el.addEventListener('keydown', this.onKeyDown);
}

public stop(): void {
const el = this.opts.source;
el.removeEventListener('keydown', this.onKeyDown);
}

private onKeyDown = (event: KeyboardEvent): void => {
const key = event.key;
const et = this.opts.et;
if (key === 'b' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
et.inline({type: 'b'});
return;
}
if (key === 'i' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
et.inline({type: 'i'});
return;
}
};
}
Loading

0 comments on commit 4d723e7

Please sign in to comment.