From 568c0b145ae54253ec68b7e5b341b511a83402c0 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Sat, 18 Nov 2023 00:02:41 +0000 Subject: [PATCH 1/9] feat(adapters): caret adapter basic implementation --- .vscode/settings.json | 6 +- .../dom-adapters/src/caret/CaretAdapter.ts | 140 ++++++++++++++++++ .../src/caret/utils/absoluteOffset.ts | 57 +++++++ .../src/caret/utils/singletone.ts | 25 ++++ .../src/caret/utils/useSelectionChange.ts | 51 +++++++ packages/playground/src/App.vue | 13 +- packages/playground/src/components/Input.vue | 68 +++++++++ packages/playground/src/components/index.ts | 4 +- 8 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 packages/dom-adapters/src/caret/CaretAdapter.ts create mode 100644 packages/dom-adapters/src/caret/utils/absoluteOffset.ts create mode 100644 packages/dom-adapters/src/caret/utils/singletone.ts create mode 100644 packages/dom-adapters/src/caret/utils/useSelectionChange.ts create mode 100644 packages/playground/src/components/Input.vue diff --git a/.vscode/settings.json b/.vscode/settings.json index aab3e8dc..b5ca0d39 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,10 @@ { "eslint.workingDirectories": [ + "./packages/dom-adapters", "./packages/model", - "./packages/playground" + "./packages/playground", + ], + "cSpell.words": [ + "Singletone" ] } diff --git a/packages/dom-adapters/src/caret/CaretAdapter.ts b/packages/dom-adapters/src/caret/CaretAdapter.ts new file mode 100644 index 00000000..eb8ace05 --- /dev/null +++ b/packages/dom-adapters/src/caret/CaretAdapter.ts @@ -0,0 +1,140 @@ +import type { EditorJSModel } from '@editorjs/model'; +import { useSelectionChange, type Subscriber } from './utils/useSelectionChange.js'; +import { getAbsoluteRangeOffset } from './utils/absoluteOffset.js'; + +/** + * Caret index is a tuple of start and end offset of a caret + */ +export type CaretIndex = [number, number]; + +/** + * 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: CaretIndex = [0, 0]; + + /** + * Input element + */ + #input: null | HTMLElement = null; + + /** + * Method for subscribing on document selection change + */ + #onSelectionChange: (callback: Subscriber) => void; + + /** + * Method for unsubscribing from document selection change + */ + #offSelectionChange: (callback: Subscriber) => void; + + /** + * Callback that will be called on document selection change + * Stored as a class property to be able to unsubscribe from document selection change + * + * @param selection - changed document selection + */ + #onDocumentSelectionChange = (selection: Selection | null): void => { + if (!this.#isSelectionRelatedToInput(selection)) { + return; + } + + this.#updateIndex(selection); + }; + + /** + * @param model - EditorJSModel instance + * @param blockIndex - index of a block that contains input + */ + constructor(private readonly model: EditorJSModel, private readonly blockIndex: number) { + super(); + + 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.#onDocumentSelectionChange); + } + + /** + * Unsubscribes from input caret change + */ + public detachInput(): void { + this.#offSelectionChange(this.#onDocumentSelectionChange); + } + + /** + * Checks if selection is related to input + * + * @param selection - changed document selection + */ + #isSelectionRelatedToInput(selection: Selection | null): boolean { + if (!selection) { + return false; + } + + const range = selection.getRangeAt(0); + + return this.#input?.contains(range.startContainer) ?? false; + } + + /** + * Returns absolute caret index related to input + */ + public get index(): CaretIndex { + return this.#index; + } + + /** + * 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, + }, + })); + } +} diff --git a/packages/dom-adapters/src/caret/utils/absoluteOffset.ts b/packages/dom-adapters/src/caret/utils/absoluteOffset.ts new file mode 100644 index 00000000..18d83a09 --- /dev/null +++ b/packages/dom-adapters/src/caret/utils/absoluteOffset.ts @@ -0,0 +1,57 @@ +/** + * Returns true if node is a line break + * + *
+ *
+ *
+ *
+ *
+ * + * @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; +} diff --git a/packages/dom-adapters/src/caret/utils/singletone.ts b/packages/dom-adapters/src/caret/utils/singletone.ts new file mode 100644 index 00000000..0f562828 --- /dev/null +++ b/packages/dom-adapters/src/caret/utils/singletone.ts @@ -0,0 +1,25 @@ +/** + * Creates a singletone factory. + * + * @example + * const useFoo = createSingletone(() => { + * const foo = 'bar'; + * + * return { + * foo, + * }; + * }); + * + * @param factory - factory function that will be called only once + */ +export function createSingletone(factory: () => T): () => T { + let instance: T | null = null; + + return () => { + if (instance === null) { + instance = factory(); + } + + return instance; + }; +} diff --git a/packages/dom-adapters/src/caret/utils/useSelectionChange.ts b/packages/dom-adapters/src/caret/utils/useSelectionChange.ts new file mode 100644 index 00000000..27fa45a9 --- /dev/null +++ b/packages/dom-adapters/src/caret/utils/useSelectionChange.ts @@ -0,0 +1,51 @@ +import { createSingletone } from './singletone.js'; + +/** + * Singletone 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 + */ + (selection: Selection | null): void; +} + +/** + * const should contain a function that will return on and off methods. + */ +export const useSelectionChange = createSingletone(() => { + const subscribers = new Set(); + + document.addEventListener('selectionchange', () => { + const selection = document.getSelection(); + + subscribers.forEach((callback) => { + callback(selection); + }); + }); + + /** + * Subscribe on "selection change" event. + * + * @param callback - callback that will be called on "selection change" event + */ + function on(callback: Subscriber): void { + subscribers.add(callback); + } + + /** + * Unsubscribe from "selection change" event. + * + * @param callback - callback that was passed to "on" method + */ + function off(callback: Subscriber): void { + subscribers.delete(callback); + } + + return { + on, + off, + }; +}); diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index 3c78d29f..2555e1c9 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -1,11 +1,11 @@