Skip to content

Commit

Permalink
[BlockToolAdapter] Improve native inputs handling (#78)
Browse files Browse the repository at this point in the history
* Improve native inputs handling

* Fix types issue

* Update packages/dom-adapters/src/utils/findHardLineBoundary.ts

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

* Add comments, return white-space: pre; for contenteditable

---------

Co-authored-by: Peter <[email protected]>
  • Loading branch information
gohabereg and neSpecc authored Aug 28, 2024
1 parent 317034b commit dada291
Show file tree
Hide file tree
Showing 9 changed files with 196 additions and 34 deletions.
105 changes: 87 additions & 18 deletions packages/dom-adapters/src/BlockToolAdapter/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import { type DataKey, type EditorJSModel, IndexBuilder, type ModelEvents } from '@editorjs/model';
import { isNativeInput } from '@editorjs/dom';
import {
type EditorJSModel,
type DataKey,
createDataKey,
EventAction,
EventType,
IndexBuilder,
type ModelEvents,
TextAddedEvent,
TextRemovedEvent
} from '@editorjs/model';
import { InputType } from './types/InputType.js';
import type { CaretAdapter } from '../CaretAdapter/index.js';
import {
findNextHardLineBoundary,
findNextWordBoundary, findPreviousHardLineBoundary,
findPreviousWordBoundary,
getAbsoluteRangeOffset,
getBoundaryPointByAbsoluteOffset,
isNonTextInput
} from '../utils/index.js';
import type { CaretAdapter } from '../CaretAdapter/index.js';

import { isNativeInput } from '@editorjs/dom';
import { InputType } from './types/InputType.js';

/**
* BlockToolAdapter is using inside Block tools to connect browser DOM elements to the model
Expand Down Expand Up @@ -99,8 +104,14 @@ export class BlockToolAdapter {
let end = input.selectionEnd;

/**
* @todo Handle all possible deletion events
* If selection is not collapsed, just remove selected text
*/
if (start !== end) {
this.#model.removeText(this.#blockIndex, key, start, end);

return;
}

switch (inputType) {
case InputType.DeleteContentForward: {
/**
Expand All @@ -109,13 +120,55 @@ export class BlockToolAdapter {
end = end !== input.value.length ? end + 1 : end;
break;
}
default: {
case InputType.DeleteContentBackward: {
/**
* If start is already 0, then there is nothing to delete
*/
start = start !== 0 ? start - 1 : start;

break;
}

case InputType.DeleteWordBackward: {
start = findPreviousWordBoundary(input.value, start);

break;
}

case InputType.DeleteWordForward: {
end = findNextWordBoundary(input.value, start);

break;
}

case InputType.DeleteHardLineBackward: {
start = findPreviousHardLineBoundary(input.value, start);

break;
}
case InputType.DeleteHardLineForward: {
end = findNextHardLineBoundary(input.value, start);

break;
}

case InputType.DeleteSoftLineBackward:
case InputType.DeleteSoftLineForward:
case InputType.DeleteEntireSoftLine:
/**
* @todo Think of how to find soft line boundaries
*/

case InputType.DeleteByDrag:
case InputType.DeleteByCut:
case InputType.DeleteContent:

default:
/**
* do nothing, use start and end from user selection
*/
}

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

Expand Down Expand Up @@ -171,17 +224,25 @@ export class BlockToolAdapter {

switch (inputType) {
case InputType.InsertReplacementText:
case InputType.InsertFromDrop:
case InputType.InsertFromPaste: {
this.#model.removeText(this.#blockIndex, key, start, end);
if (start !== end) {
this.#model.removeText(this.#blockIndex, key, start, end);
}

let data: string;

/**
* DataTransfer object is guaranteed to be not null for these types of event for contenteditable elements
*
* However, it is not guaranteed for INPUT and TEXTAREA elements, so @todo handle this case
* For native inputs data for those events comes from event.data property
* while for contenteditable elements it's stored in event.dataTransfer
*
* @see https://www.w3.org/TR/input-events-2/#overview
*/
const data = event.dataTransfer!.getData('text/plain');
if (isInputNative) {
data = event.data ?? '';
} else {
data = event.dataTransfer!.getData('text/plain');
}

this.#model.insertText(this.#blockIndex, key, data, start);

Expand Down Expand Up @@ -215,6 +276,7 @@ export class BlockToolAdapter {
case InputType.DeleteHardLineForward:
case InputType.DeleteSoftLineBackward:
case InputType.DeleteSoftLineForward:
case InputType.DeleteEntireSoftLine:
case InputType.DeleteWordBackward:
case InputType.DeleteWordForward: {
if (isInputNative === true) {
Expand All @@ -225,6 +287,13 @@ export class BlockToolAdapter {
break;
}

case InputType.InsertLineBreak:
/**
* @todo Think if we need to keep that or not
*/
if (isInputNative === true) {
this.#model.insertText(this.#blockIndex, key, '\n', start);
}
default:
}
};
Expand Down Expand Up @@ -266,32 +335,32 @@ export class BlockToolAdapter {

const action = event.detail.action;

const builder = new IndexBuilder();
const caretIndexBuilder = new IndexBuilder();

builder.from(event.detail.index);
caretIndexBuilder.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.value = prevValue.slice(0, start) + text + prevValue.slice(start);

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

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

builder.addTextRange([start, start]);
caretIndexBuilder.addTextRange([start, start]);

break;
}
}

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

/**
Expand Down
39 changes: 39 additions & 0 deletions packages/dom-adapters/src/utils/findHardLineBoundary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Finds nearest next carriage return symbol from passed position
*
* @param text - string to search in
* @param position - search starting position
*/
export function findNextHardLineBoundary(text: string, position: number): number {
const nextLineBoundary = /\n/g;

nextLineBoundary.lastIndex = position;

const match = nextLineBoundary.exec(text);

return match ? match.index : text.length;
}

/**
* Finds nearest previous caret symbol before passed position
*
* @param text - sting to search in
* @param position - search finish position
*/
export function findPreviousHardLineBoundary(text: string, position: number): number {
const previousLineBoundary = /\n/g;

let match = previousLineBoundary.exec(text);

while (match) {
const newMatch = previousLineBoundary.exec(text);

if (!newMatch || newMatch.index >= position) {
break;
}

match = newMatch;
}

return match && match.index < position ? match.index : 0;
}
62 changes: 62 additions & 0 deletions packages/dom-adapters/src/utils/findWordBoundary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const APOSTROPHE_AND_CURLY_QUOTES = "['\u2018\u2019]";
const PUNCTUATION = '.,!?:;"\\(\\){}\\[\\]<>@*~\\/\\-#$&|^%+=';
const WHITESPACE = '\\s';

const WHITESPACE_AND_PUNCTUATION = `[${WHITESPACE}${PUNCTUATION}]`;

/**
* Finds the nearest next word boundary from the passed position
*
* @param text - string to search in
* @param position - search starting position
*/
export function findNextWordBoundary(text: string, position: number): number {
const nextWordBoundary = new RegExp(
/**
* Match whitespace or punctuation
* or an apostrophe or curly quotes followed by a whitespace character or punctuation
*/
`(${WHITESPACE_AND_PUNCTUATION}|${APOSTROPHE_AND_CURLY_QUOTES}(?=${WHITESPACE_AND_PUNCTUATION}))`,
'g'
);

/**
* Start searching from the next character to allow word deletion with one non-word character before the word
*/
nextWordBoundary.lastIndex = position + 1;

const match = nextWordBoundary.exec(text);

return match ? match.index : text.length;
}

/**
* Finds the nearest previous word boundary before the passed position
*
* @param text - string to search in
* @param position - search finish position
*/
export function findPreviousWordBoundary(text: string, position: number): number {
const previousWordBoundary = new RegExp(
/**
* Match whitespace or punctuation,
* or an apostrophe or curly quotes preceded by whitespace or punctuation
*/
`(${WHITESPACE_AND_PUNCTUATION}|(?<=${WHITESPACE_AND_PUNCTUATION})${APOSTROPHE_AND_CURLY_QUOTES})`,
'g'
);

let match = previousWordBoundary.exec(text);

while (match) {
const newMatch = previousWordBoundary.exec(text);

if (!newMatch || newMatch.index >= position) {
break;
}

match = newMatch;
}

return match && match.index < position ? match.index : 0;
}
2 changes: 2 additions & 0 deletions packages/dom-adapters/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ export * from './getAbsoluteRangeOffset.js';
export * from './getRelativeIndex.js';
export * from './isNonTextInput.js';
export * from './useSelectionChange.js';
export * from './findWordBoundary.js';
export * from './findHardLineBoundary.js';
9 changes: 0 additions & 9 deletions packages/model/src/CaretManagement/Caret/Caret.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,6 @@ describe('Caret', () => {
}));
});

it('should not update index if it is the same', () => {
const caret = new Caret(new Index());
const index = new Index();

caret.update(index);

expect(caret.index).not.toBe(index);
});

it('should serialize to JSON', () => {
const index = new Index();
const caret = new Caret(index);
Expand Down
4 changes: 0 additions & 4 deletions packages/model/src/CaretManagement/Caret/Caret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,6 @@ export class Caret extends EventBus {
* @param index - new caret index
*/
public update(index: Index): void {
if (this.#index?.serialize() === index.serialize()) {
return;
}

this.#index = index;

this.dispatchEvent(new CaretUpdatedEvent(this));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export class ParentInlineNode extends EventBus implements InlineNode {

const builder = new IndexBuilder();

builder.addTextRange([index, index + text.length]);
builder.addTextRange([index, index]);

this.dispatchEvent(new TextAddedEvent(builder.build(), text));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ describe('ParentInlineNode', () => {
expect(event).toBeInstanceOf(TextAddedEvent);
expect(event).toHaveProperty('detail', expect.objectContaining({
action: EventAction.Added,
index: expect.objectContaining({ textRange: [index, index + newText.length] }),
index: expect.objectContaining({ textRange: [index, index] }),
data: newText,
}));
});
Expand Down
5 changes: 4 additions & 1 deletion packages/playground/src/components/Input.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ onMounted(() => {
ref="input"
:contenteditable="type === 'contenteditable' ? true : undefined"
type="text"
:class="$style.input"
:class="{ [$style.input]: true, [$style.contenteditable]: type === 'contenteditable' }"
:value="type !== 'contenteditable' ? value : undefined"
v-html="type === 'contenteditable' ? value : undefined"
/>
Expand All @@ -50,6 +50,7 @@ onMounted(() => {

<style module>
.input {
max-width: 300px;
width: 100%;
box-sizing: border-box;
padding: 8px 14px;
Expand All @@ -61,7 +62,9 @@ onMounted(() => {
outline: none;
font-family: inherit;
}
.contenteditable {
white-space: pre;
}
</style>

1 comment on commit dada291

@github-actions
Copy link

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

St.
Category Percentage Covered / Total
🟢 Statements 100% 742/742
🟢 Branches 99.49% 197/198
🟢 Functions 99.44% 179/180
🟢 Lines 100% 716/716

Test suite run success

389 tests passing in 24 suites.

Report generated by 🧪jest coverage report action from dada291

Please sign in to comment.