Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(adapters): caret adapter basic implementation #58

Merged
merged 9 commits into from
Nov 20, 2023
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
Copy link
Member

Choose a reason for hiding this comment

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

Could you move it below the exported members

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There will be ES Lint error: using of method before it declared

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;
};
}
146 changes: 146 additions & 0 deletions packages/dom-adapters/src/caret/utils/useSelectionChange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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>();
Copy link
Member

Choose a reason for hiding this comment

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

Could there be more than one subscriber for an input?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

no


/**
* 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 {
/**
* 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,
Copy link
Member

Choose a reason for hiding this comment

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

Once destroyed, there'll be no way to re-init as createSingletone will always return the same instance

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've exported init method as well. The destroy flow is not designed yet, so it will be implemented once we can understand it

};
});
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