Skip to content

Commit

Permalink
feat(adapters): caret adapter basic implementation (#58)
Browse files Browse the repository at this point in the history
* feat(adapters): caret adapter basic implementation

* fix naming

* use caret idx type from model

* few fixed after review

* improve naming

* rm unused change

* few fixes

* make initial index is null instead of [0,0]

* throw error on multiple subscription
  • Loading branch information
neSpecc authored Nov 20, 2023
1 parent 536bf8c commit 38cfaa1
Show file tree
Hide file tree
Showing 11 changed files with 453 additions and 8 deletions.
5 changes: 3 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"eslint.workingDirectories": [
"./packages/dom-adapters",
"./packages/model",
"./packages/playground"
]
"./packages/playground",
],
}
126 changes: 126 additions & 0 deletions packages/dom-adapters/src/caret/CaretAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import type { EditorJSModel, TextRange } from '@editorjs/model';
import { useSelectionChange, type Subscriber, type InputWithCaret } from './utils/useSelectionChange.js';
import { getAbsoluteRangeOffset } from './utils/absoluteOffset.js';

/**
* Caret adapter watches input caret change and passes it to the model
*
* - subscribe on input caret change
* - compose caret index by selection position related to start of input
* - pass caret position to model — model.updateCaret()
* - subscribe on model's ‘caret change’ event (index) => {}), by filtering events only related with current input and set caret position
*
* @todo add support for native inputs
* @todo debug problem when document "selectionchange" is not fired on Enter press
* @todo debug problem when offset at the end of line and at the beginning of the next line is the same
*/
export class CaretAdapter extends EventTarget {
/**
* Index stores start and end offset of a caret depending on a root of input
*/
#index: TextRange | null = null;

/**
* Input element
*/
#input: null | InputWithCaret = null;

/**
* EditorJSModel instance
*/
#model: EditorJSModel;

/**
* Index of a block that contains input
*/
#blockIndex: number;

/**
* Method for subscribing on document selection change
*/
#onSelectionChange: (input: InputWithCaret, callback: Subscriber['callback'], context: Subscriber['context']) => void;

/**
* Method for unsubscribing from document selection change
*/
#offSelectionChange: (input: InputWithCaret) => void;

/**
* @param model - EditorJSModel instance
* @param blockIndex - index of a block that contains input
*/
constructor(model: EditorJSModel, blockIndex: number) {
super();

this.#model = model;
this.#blockIndex = blockIndex;

const { on, off } = useSelectionChange();

this.#onSelectionChange = on;
this.#offSelectionChange = off;
}

/**
* - Subscribes on input caret change
* - Composes caret index by selection position related to start of input
*
* @param input - input to watch caret change
* @param _dataKey - key of data property in block's data that contains input's value
*/
public attachInput(input: HTMLElement, _dataKey: string): void {
this.#input = input;

this.#onSelectionChange(this.#input, this.#onInputSelectionChange, this);
}

/**
* Unsubscribes from input caret change
*/
public detachInput(): void {
if (!this.#input) {
return;
}

this.#offSelectionChange(this.#input);
this.#input = null;
}

/**
* Callback that will be called on document selection change
*
* @param selection - changed document selection
*/
#onInputSelectionChange(selection: Selection | null): void {
this.#updateIndex(selection);
};

/**
* Updates caret index
*
* @param selection - changed document selection
*/
#updateIndex(selection: Selection | null): void {
const range = selection?.getRangeAt(0);

if (!range || !this.#input) {
return;
}

this.#index = [
getAbsoluteRangeOffset(this.#input, range.startContainer, range.startOffset),
getAbsoluteRangeOffset(this.#input, range.endContainer, range.endOffset),
];

/**
* @todo
*/
// this.#model.updateCaret(this.blockIndex, this.#index);

this.dispatchEvent(new CustomEvent('change', {
detail: {
index: this.#index,
},
}));
}
}
57 changes: 57 additions & 0 deletions packages/dom-adapters/src/caret/utils/absoluteOffset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Returns true if node is a line break
*
* <div>
* <div>
* <br>
* </div>
* </div>
*
* @param node - node to check
*/
function isLineBreak(node: Node): boolean {
return node.textContent?.length === 0 && node.nodeType === Node.ELEMENT_NODE && (node as Element).querySelector('br') !== null;
}

/**
* Returns absolute caret offset from caret position in container to the start of the parent node (input)
*
* @param parent - parent node containing the range
* @param initialNode - exact node containing the caret
* @param initialOffset - caret offset in the initial node
*/
export function getAbsoluteRangeOffset(parent: Node, initialNode: Node, initialOffset: number): number {
let node = initialNode;
let offset = initialOffset;

if (!parent.contains(node)) {
throw new Error('Range is not contained by the parent node');
}

while (node !== parent) {
const childNodes = Array.from(node.parentNode!.childNodes);
const index = childNodes.indexOf(node as ChildNode);

/**
* Iterate over left siblings and compute offset
*/
offset = childNodes.slice(0, index)
.reduce((acc, child) => {
/**
* Support for line breaks
*/
if (isLineBreak(child)) {
return acc + 1;
}

/**
* Compute offset with text length of left siblings
*/
return acc + child.textContent!.length;
}, offset);

node = node.parentNode!;
}

return offset;
}
25 changes: 25 additions & 0 deletions packages/dom-adapters/src/caret/utils/singleton.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Creates a singleton factory.
*
* @example
* const useFoo = createSingleton(() => {
* const foo = 'bar';
*
* return {
* foo,
* };
* });
*
* @param factory - factory function that will be called only once
*/
export function createSingleton<T>(factory: () => T): () => T {
let instance: T | null = null;

return () => {
if (instance === null) {
instance = factory();
}

return instance;
};
}
150 changes: 150 additions & 0 deletions packages/dom-adapters/src/caret/utils/useSelectionChange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { createSingleton } from './singleton.js';

/**
* Singleton that watches for document "selection change" event and delegates the provided callbacks to subscribers.
*/
export interface Subscriber {
/**
* Callback that will be called on "selection change" event.
*
* @param selection - current document selection
*/
callback: (selection: Selection | null) => void;

/**
* Used to save context of the callback.
*/
context: unknown;
}

/**
* Node that contains a selection.
*
* Now supports only contenteditable elements.
*
* @todo add support for native inputs
*/
export type InputWithCaret = HTMLElement;


/**
* Utility composable that watches for document "selection change" event and delegates the provided callbacks to subscribers.
*/
export const useSelectionChange = createSingleton(() => {
/**
* Used to iterate over all inputs and check if selection is related to them.
*/
const inputsWatched: InputWithCaret[] = [];

/**
* WeakMap that stores subscribers for each input.
*/
const subscribers = new WeakMap<InputWithCaret, Subscriber>();

/**
* Checks if selection is related to input
*
* @param selection - changed document selection
* @param input - input to check
*/
function isSelectionRelatedToInput(selection: Selection | null, input: InputWithCaret): boolean {
if (!selection) {
return false;
}

const range = selection.getRangeAt(0);

/**
* @todo think of cross-block selection
*/
return range.intersectsNode(input);
}

/**
* Handler for document "selection change" event.
*/
function onDocumentSelectionChanged(): void {
const selection = document.getSelection();

/**
* Iterate over all subscribers WeakMap and call their callbacks.
*/
inputsWatched.forEach((input) => {
const subscriber = subscribers.get(input);

if (subscriber && isSelectionRelatedToInput(selection, input)) {
subscriber.callback.call(subscriber.context, selection);
}
});
}

/**
* Subscribe on "selection change" event.
*
* @param input - input to watch caret change
* @param callback - callback that will be called on "selection change" event
* @param context - context of the callback
*/
function on(input: InputWithCaret, callback: Subscriber['callback'], context: Subscriber['context']): void {
if (subscribers.has(input)) {
throw new Error('Input is already subscribed to "selection change" event.');
}

/**
* Add input to the list of watched inputs.
*/
inputsWatched.push(input);

/**
* Store subscription
*/
subscribers.set(input, {
callback,
context,
});
}

/**
* Unsubscribe from "selection change" event.
*
* @param input - input to remove subscription
*/
function off(input: InputWithCaret): void {
subscribers.delete(input);
inputsWatched.splice(inputsWatched.indexOf(input), 1);
}

/**
* Initialize document selection change watcher.
*/
function init(): void {
/**
* We use single for document "selection change" event and delegate the provided callbacks to subscribers.
*/
document.addEventListener('selectionchange', onDocumentSelectionChanged);
}

/**
* Destroy document selection change watcher.
*/
function destroy(): void {
document.removeEventListener('selectionchange', onDocumentSelectionChanged);

inputsWatched.forEach((input) => {
const subscriber = subscribers.get(input);

if (subscriber) {
off(input);
}
});
}

init();

return {
on,
off,
init,
destroy,
};
});
1 change: 1 addition & 0 deletions packages/model/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './entities/index.js';
export * from './utils/index.js';
export * from './EditorJSModel.js';
8 changes: 8 additions & 0 deletions packages/model/src/utils/EventBus/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export * from './EventBus.js';
export * from './events/index.js';
export type * from './types/EventAction.js';
export type * from './types/EventMap.js';
export type * from './types/EventPayloadBase.js';
export type * from './types/EventTarget.js';
export type * from './types/EventType.js';
export type * from './types/indexing.js';
1 change: 1 addition & 0 deletions packages/model/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './EventBus/index.js';
Loading

1 comment on commit 38cfaa1

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage report for ./packages/model

St.
Category Percentage Covered / Total
🟢 Statements 100% 589/589
🟢 Branches 99.27% 136/137
🟢 Functions 99.33% 149/150
🟢 Lines 100% 564/564

Test suite run success

347 tests passing in 20 suites.

Report generated by 🧪jest coverage report action from 38cfaa1

Please sign in to comment.