Skip to content

Commit

Permalink
CaretAdapter reimplementation (#69)
Browse files Browse the repository at this point in the history
* Implmenet global CaretAdapter

* Handle native inputs

* Pass input type to Input component props

* Use class to represent index

* Fix lint in dom-adapters

* fix linter

* Fix infinite cycle

* Fix text range is not passed with caret update event

* Fix safari problems & backwards selection

* Add some docs

* Add tests

* Fix playground build
  • Loading branch information
gohabereg authored Aug 28, 2024
1 parent 58671e2 commit 1c40976
Show file tree
Hide file tree
Showing 44 changed files with 1,343 additions and 448 deletions.
96 changes: 65 additions & 31 deletions packages/dom-adapters/src/BlockToolAdapter/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
import type { DataKey, EditorJSModel, ModelEvents } from '@editorjs/model';
import { type DataKey, type EditorJSModel, IndexBuilder, type ModelEvents } from '@editorjs/model';
import {
composeDataIndex,
EventAction,
EventType,
TextAddedEvent,
TextRemovedEvent
} from '@editorjs/model';
import { InputType } from './types/InputType.js';
import { CaretAdapter } from '../caret/CaretAdapter.js';
import {
getAbsoluteRangeOffset,
getBoundaryPointByAbsoluteOffset,
isNonTextInput
} from '../utils/index.js';
import type { CaretAdapter } from '../CaretAdapter/index.js';

import { isNativeInput } from '@editorjs/dom';

Expand All @@ -32,15 +31,24 @@ export class BlockToolAdapter {
*/
#blockIndex: number;

/**
* Caret adapter instance
*
* @private
*/
#caretAdapter: CaretAdapter;

/**
* BlockToolAdapter constructor
*
* @param model - EditorJSModel instance
* @param caretAdapter - CaretAdapter instance
* @param blockIndex - index of the block that this adapter is connected to
*/
constructor(model: EditorJSModel, blockIndex: number) {
constructor(model: EditorJSModel, caretAdapter: CaretAdapter, blockIndex: number) {
this.#model = model;
this.#blockIndex = blockIndex;
this.#caretAdapter = caretAdapter;
}

/**
Expand All @@ -55,11 +63,15 @@ export class BlockToolAdapter {
throw new Error('Cannot attach non-text input');
}

const caretAdapter = new CaretAdapter(input, this.#model, this.#blockIndex, key);

input.addEventListener('beforeinput', event => this.#handleBeforeInputEvent(event, input, key));

this.#model.addEventListener(EventType.Changed, (event: ModelEvents) => this.#handleModelUpdate(event, input, key, caretAdapter));
this.#model.addEventListener(EventType.Changed, (event: ModelEvents) => this.#handleModelUpdate(event, input, key));

const builder = new IndexBuilder();

builder.addBlockIndex(this.#blockIndex).addDataKey(key);

this.#caretAdapter.attachInput(input, builder.build());
}

/**
Expand Down Expand Up @@ -141,14 +153,12 @@ export class BlockToolAdapter {
let start: number;
let end: number;

if (!isInputNative) {
if (isInputNative === false) {
const targetRanges = event.getTargetRanges();
const range = targetRanges[0];

start = getAbsoluteRangeOffset(input, range.startContainer,
range.startOffset);
end = getAbsoluteRangeOffset(input, range.endContainer,
range.endOffset);
start = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset);
end = getAbsoluteRangeOffset(input, range.endContainer, range.endOffset);
} else {
const currentElement = input as HTMLInputElement | HTMLTextAreaElement;

Expand Down Expand Up @@ -204,8 +214,8 @@ export class BlockToolAdapter {
case InputType.DeleteSoftLineForward:
case InputType.DeleteWordBackward:
case InputType.DeleteWordForward: {
if (isInputNative) {
this.#handleDeleteInNativeInput(event, input, key);
if (isInputNative === true) {
this.#handleDeleteInNativeInput(event, input as HTMLInputElement | HTMLTextAreaElement, key);
} else {
this.#handleDeleteInContentEditable(event, input, key);
}
Expand All @@ -228,7 +238,11 @@ export class BlockToolAdapter {
return;
}

const [, dataKey, blockIndex] = event.detail.index;
const { textRange, dataKey, blockIndex } = event.detail.index;

if (textRange === undefined) {
return;
}

/**
* Event is not related to the attached block
Expand All @@ -240,32 +254,41 @@ export class BlockToolAdapter {
/**
* Event is not related to the attached data key
*/
if (dataKey !== composeDataIndex(key)) {
if (dataKey !== key) {
return;
}

const currentElement = input;
const [ [start, end] ] = event.detail.index;
const [start, end] = textRange;

const action = event.detail.action;

const builder = new IndexBuilder();

builder.from(event.detail.index);

switch (action) {
case EventAction.Added: {
const text = event.detail.data as string;
const prevValue = currentElement.value;

currentElement.value = prevValue.slice(0, start) + text + prevValue.slice(end - 1);

currentElement.setSelectionRange(start + text.length, start + text.length);
builder.addTextRange([start + text.length, start + text.length]);

break;
}
case EventAction.Removed: {
currentElement.value = currentElement.value.slice(0, start) +
currentElement.value.slice(end);
currentElement.setSelectionRange(start, start);

builder.addTextRange([start, start]);

break;
}
}

this.#caretAdapter.updateIndex(builder.build());
};

/**
Expand All @@ -274,42 +297,52 @@ export class BlockToolAdapter {
* @param event - model update event
* @param input - input element
* @param key - data key input is attached to
* @param caretAdapter - caret adapter instance
*/
#handleModelUpdateForContentEditableElement(event: ModelEvents, input: HTMLElement, key: DataKey, caretAdapter: CaretAdapter): void {
#handleModelUpdateForContentEditableElement(event: ModelEvents, input: HTMLElement, key: DataKey): void {
if (!(event instanceof TextAddedEvent) && !(event instanceof TextRemovedEvent)) {
return;
}

const [rangeIndex, dataIndex, blockIndex] = event.detail.index;
const { textRange, dataKey, blockIndex } = event.detail.index;

if (blockIndex !== this.#blockIndex) {
return;
}

if (dataIndex !== composeDataIndex(key)) {
/**
* Event is not related to the attached data key
*/
if (dataKey !== key) {
return;
}

if (textRange === undefined) {
return;
}

const action = event.detail.action;

const start = rangeIndex[0];
const end = rangeIndex[1];
const start = textRange[0];
const end = textRange[1];

const [startNode, startOffset] = getBoundaryPointByAbsoluteOffset(input, start);
const [endNode, endOffset] = getBoundaryPointByAbsoluteOffset(input, end);
const range = new Range();

range.setStart(startNode, startOffset);

const builder = new IndexBuilder();

builder.addDataKey(key).addBlockIndex(this.#blockIndex);

switch (action) {
case EventAction.Added: {
const text = event.detail.data as string;
const textNode = document.createTextNode(text);

range.insertNode(textNode);

caretAdapter.updateIndex([start + text.length, start + text.length]);
builder.addTextRange([start + text.length, start + text.length]);

break;
}
Expand All @@ -318,13 +351,15 @@ export class BlockToolAdapter {

range.deleteContents();

caretAdapter.updateIndex([start, start]);
builder.addTextRange([start, start]);

break;
}
}

input.normalize();

this.#caretAdapter.updateIndex(builder.build());
};

/**
Expand All @@ -333,15 +368,14 @@ export class BlockToolAdapter {
* @param event - model update event
* @param input - attched input element
* @param key - data key input is attached to
* @param caretAdapter - caret adapter instance
*/
#handleModelUpdate(event: ModelEvents, input: HTMLElement, key: DataKey, caretAdapter: CaretAdapter): void {
#handleModelUpdate(event: ModelEvents, input: HTMLElement, key: DataKey): void {
const isInputNative = isNativeInput(input);

if (isInputNative) {
if (isInputNative === true) {
this.#handleModelUpdateForNativeInput(event, input as HTMLInputElement | HTMLTextAreaElement, key);
} else {
this.#handleModelUpdateForContentEditableElement(event, input, key, caretAdapter);
this.#handleModelUpdateForContentEditableElement(event, input, key);
}
};
}
Loading

0 comments on commit 1c40976

Please sign in to comment.