Skip to content

Commit

Permalink
feat: improve BlockToolAdapter (#68)
Browse files Browse the repository at this point in the history
* feat: improve `BlockToolAdapter`

[skip ci]

* fix: allow three type input

* feat: update code

* fix: example

* fix: correctly delete backward

* fix: input

* fix: improve

* fix: improve

* fix: abstract

* fix: special case for input delete

* fix: special case for command delete

* fix: insert into the middle

* revert: change

* Update packages/dom-adapters/src/BlockToolAdapter/index.ts

Co-authored-by: Peter Savchenko <[email protected]>

* fix: abstract function

* fix: add comment

* fix: remove mode field

* fix: remove one case that might not happen

* backspace and delete handling in native inputs

* added handler for non-text inputs

* lint fix

* lint fix in dom adapters

* rm unwanted change

* add root linter

* get rid of anonymous functions

* fix native input style

* added jsdoc

* typo

* lint fix

---------

Co-authored-by: Peter Savchenko <[email protected]>
Co-authored-by: e11sy <[email protected]>
  • Loading branch information
3 people authored Aug 26, 2024
1 parent 27adb2e commit 58671e2
Show file tree
Hide file tree
Showing 11 changed files with 273 additions and 49 deletions.
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,9 @@
"packageManager": "[email protected]",
"workspaces": [
"packages/*"
]
],
"scripts": {
"lint": "yarn workspaces foreach -A run lint",
"lint:fix": "yarn workspaces foreach -A run lint --fix"
}
}
1 change: 1 addition & 0 deletions packages/dom-adapters/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"typescript": "^5.2.2"
},
"dependencies": {
"@editorjs/dom": "^1.0.0",
"@editorjs/model": "workspace:^"
}
}
196 changes: 167 additions & 29 deletions packages/dom-adapters/src/BlockToolAdapter/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import type { DataKey, EditorJSModel, ModelEvents } from '@editorjs/model';
import { EventType, EventAction, composeDataIndex } from '@editorjs/model';
import { InputType } from './types/InputType.js';
import {
composeDataIndex,
EventAction,
EventType,
TextAddedEvent,
TextRemovedEvent
} from '@editorjs/model';
import { InputType } from './types/InputType.js';
import { CaretAdapter } from '../caret/CaretAdapter.js';
import { getAbsoluteRangeOffset, getBoundaryPointByAbsoluteOffset } from '../utils/index.js';
import {
getAbsoluteRangeOffset,
getBoundaryPointByAbsoluteOffset,
isNonTextInput
} from '../utils/index.js';

enum NativeInput {
Textarea = 'TEXTAREA',
Input = 'INPUT',
}
import { isNativeInput } from '@editorjs/dom';

/**
* BlockToolAdapter is using inside Block tools to connect browser DOM elements to the model
Expand Down Expand Up @@ -48,13 +51,8 @@ export class BlockToolAdapter {
* @param input - input element
*/
public attachInput(key: DataKey, input: HTMLElement): void {
const inputTag = input.tagName as NativeInput;

/**
* @todo Filter non-text-editable inputs
*/
if (![NativeInput.Textarea, NativeInput.Input].includes(inputTag) && !input.isContentEditable) {
throw new Error('BlockToolAdapter: input should be either INPUT, TEXTAREA or contenteditable element');
if (input instanceof HTMLInputElement && isNonTextInput(input)) {
throw new Error('Cannot attach non-text input');
}

const caretAdapter = new CaretAdapter(input, this.#model, this.#blockIndex, key);
Expand All @@ -64,6 +62,65 @@ export class BlockToolAdapter {
this.#model.addEventListener(EventType.Changed, (event: ModelEvents) => this.#handleModelUpdate(event, input, key, caretAdapter));
}

/**
* Handles delete events in native input
*
* @param event - beforeinput event
* @param input - input element
* @param key - data key input is attached to
* @private
*/
#handleDeleteInNativeInput(event: InputEvent, input: HTMLInputElement | HTMLTextAreaElement, key: DataKey): void {
const inputType = event.inputType as InputType;

/**
* Check that selection exists in current input
*/
if (input.selectionStart === null || input.selectionEnd === null) {
return;
}

let start = input.selectionStart;
let end = input.selectionEnd;

/**
* @todo Handle all possible deletion events
*/
switch (inputType) {
case InputType.DeleteContentForward: {
/**
* If selection end is already after the last element, then there is nothing to delete
*/
end = end !== input.value.length ? end + 1 : end;
break;
}
default: {
/**
* If start is already 0, then there is nothing to delete
*/
start = start !== 0 ? start - 1 : start;
}
}
this.#model.removeText(this.#blockIndex, key, start, end);
};

/**
* Handles delete events in contenteditable element
*
* @param event - beforeinput event
* @param input - input element
* @param key - data key input is attached to
*/
#handleDeleteInContentEditable(event: InputEvent, input: HTMLElement, key: DataKey): void {
const targetRanges = event.getTargetRanges();
const range = targetRanges[0];

const start: number = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset);
const end: number = getAbsoluteRangeOffset(input, range.endContainer, range.endOffset);

this.#model.removeText(this.#blockIndex, key, start, end);
};

/**
* Handles beforeinput event from user input and updates model data
*
Expand All @@ -79,12 +136,25 @@ export class BlockToolAdapter {
*/
event.preventDefault();

const isInputNative = isNativeInput(input);
const inputType = event.inputType as InputType;
const targetRanges = event.getTargetRanges();
const range = targetRanges[0];

const start = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset);
const end = getAbsoluteRangeOffset(input, range.endContainer, range.endOffset);
let start: number;
let end: number;

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

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

start = currentElement.selectionStart as number;
end = currentElement.selectionEnd as number;
}

switch (inputType) {
case InputType.InsertReplacementText:
Expand All @@ -110,7 +180,8 @@ export class BlockToolAdapter {
*/
case InputType.InsertCompositionText: {
/**
* If start and end aren't equal, it means that user selected some text and replaced it with new one
* If start and end aren't equal,
* it means that user selected some text and replaced it with new one
*/
if (start !== end) {
this.#model.removeText(this.#blockIndex, key, start, end);
Expand All @@ -133,29 +204,31 @@ export class BlockToolAdapter {
case InputType.DeleteSoftLineForward:
case InputType.DeleteWordBackward:
case InputType.DeleteWordForward: {
this.#model.removeText(this.#blockIndex, key, start, end);

if (isInputNative) {
this.#handleDeleteInNativeInput(event, input, key);
} else {
this.#handleDeleteInContentEditable(event, input, key);
}
break;
}

default:
}
}
};

/**
* Handles model update events and updates DOM
* Handles model update events for native inputs and updates DOM
*
* @param event - model update event
* @param input - attched input element
* @param input - 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 {
#handleModelUpdateForNativeInput(event: ModelEvents, input: HTMLInputElement | HTMLTextAreaElement, key: DataKey): void {
if (!(event instanceof TextAddedEvent) && !(event instanceof TextRemovedEvent)) {
return;
}

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

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

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

const action = event.detail.action;

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);
break;
}
case EventAction.Removed: {
currentElement.value = currentElement.value.slice(0, start) +
currentElement.value.slice(end);
currentElement.setSelectionRange(start, start);
break;
}
}
};

/**
* Handles model update events for contenteditable elements and updates DOM
*
* @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 {
if (!(event instanceof TextAddedEvent) && !(event instanceof TextRemovedEvent)) {
return;
}

const [rangeIndex, dataIndex, blockIndex] = event.detail.index;

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

if (dataIndex !== composeDataIndex(key)) {
return;
}
Expand Down Expand Up @@ -205,5 +325,23 @@ export class BlockToolAdapter {
}

input.normalize();
}
};

/**
* Handles model update events and updates DOM
*
* @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 {
const isInputNative = isNativeInput(input);

if (isInputNative) {
this.#handleModelUpdateForNativeInput(event, input as HTMLInputElement | HTMLTextAreaElement, key);
} else {
this.#handleModelUpdateForContentEditableElement(event, input, key, caretAdapter);
}
};
}
33 changes: 24 additions & 9 deletions packages/dom-adapters/src/caret/CaretAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ import type {
CaretManagerEvents
} from '@editorjs/model';
import { useSelectionChange, type Subscriber, type InputWithCaret } from './utils/useSelectionChange.js';
import { getAbsoluteRangeOffset, getBoundaryPointByAbsoluteOffset } from '../utils/index.js';
import {
getAbsoluteRangeOffset,
getBoundaryPointByAbsoluteOffset
} from '../utils/index.js';
import { EventType } from '@editorjs/model';
import { isNativeInput } from '@editorjs/dom';

/**
* Caret adapter watches input caret change and passes it to the model
Expand Down Expand Up @@ -187,17 +191,28 @@ export class CaretAdapter extends EventTarget {
return;
}

const start = getBoundaryPointByAbsoluteOffset(this.#input!, textIndex[0]);
const end = getBoundaryPointByAbsoluteOffset(this.#input!, textIndex[1]);
const input: HTMLElement = this.#input!;

const selection = document.getSelection()!;
const range = new Range();
/**
* For native input, we cannot set caret position programmatically
* because there is no enough information to get `start`
* and `end` points in this case.
*/
if (isNativeInput(input)) {
return;
} else {
const start = getBoundaryPointByAbsoluteOffset(input, textIndex[0]);
const end = getBoundaryPointByAbsoluteOffset(input, textIndex[1]);

range.setStart(...start);
range.setEnd(...end);
const selection = document.getSelection()!;
const range = new Range();

selection.removeAllRanges();
range.setStart(...start);
range.setEnd(...end);

selection.addRange(range);
selection.removeAllRanges();

selection.addRange(range);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const useSelectionChange = createSingleton(() => {
* @param input - input to check
*/
function isSelectionRelatedToInput(selection: Selection | null, input: InputWithCaret): boolean {
if (!selection) {
if (selection === null) {
return false;
}

Expand Down
1 change: 1 addition & 0 deletions packages/dom-adapters/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './getAbsoluteRangeOffset.js';
export * from './getRelativeIndex.js';
export * from './isNonTextInput.js';
25 changes: 25 additions & 0 deletions packages/dom-adapters/src/utils/isNonTextInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
enum TextInputType {
Text = 'text',
Tel = 'tel',
Search = 'search',
Url = 'url',
Password = 'password',
}

const TEXT_INPUT_SET = new Set([
TextInputType.Text,
TextInputType.Tel,
TextInputType.Search,
TextInputType.Url,
TextInputType.Password,
]) as ReadonlySet<string>;

/**
* Checks if a given HTML input element is not a text input.
*
* @param {HTMLElement} element - The HTML element to check.
* @returns {boolean} - Returns true if the element is not a text input, false otherwise.
*/
export function isNonTextInput(element: HTMLInputElement): boolean {
return !TEXT_INPUT_SET.has(element.type);
}
4 changes: 2 additions & 2 deletions packages/model/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
"types": "dist/index.d.ts",
"type": "module",
"scripts": {
"build": "tsc --build tsconfig.build.json",
"dev": "yarn build --watch",
"build": "tsc --project tsconfig.build.json",
"dev": "tsc --project tsconfig.build.json --watch",
"lint": "eslint ./src",
"lint:ci": "yarn lint --max-warnings 0",
"lint:fix": "yarn lint --fix",
Expand Down
1 change: 1 addition & 0 deletions packages/playground/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ model.addEventListener(EventType.Changed, () => {
<div :class="$style.playground">
<Input
:model="model"
type="input"
/>
<pre>{{ serialized }}</pre>
</div>
Expand Down
Loading

0 comments on commit 58671e2

Please sign in to comment.