-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(adapters): caret adapter basic implementation (#58)
* 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
Showing
11 changed files
with
453 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
], | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
})); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
150
packages/dom-adapters/src/caret/utils/useSelectionChange.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './EventBus/index.js'; |
Oops, something went wrong.
38cfaa1
There was a problem hiding this comment.
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
Test suite run success
347 tests passing in 20 suites.
Report generated by 🧪jest coverage report action from 38cfaa1