Skip to content

Commit 6fbd035

Browse files
authored
feat(ui, dom-adapters): delegated beforeinput (#113)
* delegated before input * lint * lint * lint
1 parent e875b7e commit 6fbd035

File tree

8 files changed

+147
-42
lines changed

8 files changed

+147
-42
lines changed

packages/core/src/tools/internal/block-tools/paragraph/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export class Paragraph implements BlockTool<ParagraphData, ParagraphConfig> {
5050
public render(): HTMLElement {
5151
const wrapper = document.createElement('div');
5252

53+
wrapper.classList.add('editorjs-paragraph');
54+
5355
wrapper.contentEditable = 'true';
5456
wrapper.style.outline = 'none';
5557
wrapper.style.whiteSpace = 'pre-wrap';

packages/dom-adapters/.eslintrc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ rules:
2727
- 0
2828
'@typescript-eslint/no-unsafe-argument':
2929
- 0
30+
'jsdoc/require-returns-type':
31+
- 0
3032
env:
3133
browser: true
3234

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

Lines changed: 78 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,14 @@ import {
1212
TextAddedEvent,
1313
TextRemovedEvent
1414
} from '@editorjs/model';
15-
import type { EventBus } from '@editorjs/sdk';
16-
import { type BlockToolAdapter as BlockToolAdapterInterface, type CoreConfig } from '@editorjs/sdk';
15+
import type {
16+
EventBus,
17+
BlockToolAdapter as BlockToolAdapterInterface,
18+
CoreConfig,
19+
BeforeInputUIEvent,
20+
BeforeInputUIEventPayload
21+
} from '@editorjs/sdk';
22+
import { BeforeInputUIEventName } from '@editorjs/sdk';
1723
import type { CaretAdapter } from '../CaretAdapter/index.js';
1824
import type { FormattingAdapter } from '../FormattingAdapter/index.js';
1925
import {
@@ -63,6 +69,13 @@ export class BlockToolAdapter implements BlockToolAdapterInterface {
6369
*/
6470
#config: Required<CoreConfig>;
6571

72+
/**
73+
* Inputs that bound to the model
74+
*
75+
* @todo handle inputs deletion — remove inputs from the map when they are removed from the DOM
76+
*/
77+
#attachedInputs = new Map<DataKey, HTMLElement>();
78+
6679
/**
6780
* BlockToolAdapter constructor
6881
*
@@ -90,9 +103,9 @@ export class BlockToolAdapter implements BlockToolAdapterInterface {
90103
this.#formattingAdapter = formattingAdapter;
91104
this.#toolName = toolName;
92105

93-
// eventBus.addEventListener(BeforeInputUIEventName, (event: BeforeInputUIEvent) => {
94-
// console.log('BeforeInputUIEventName', event);
95-
// });
106+
eventBus.addEventListener(`ui:${BeforeInputUIEventName}`, (event: BeforeInputUIEvent) => {
107+
this.#processDelegatedBeforeInput(event);
108+
});
96109
}
97110

98111
/**
@@ -109,7 +122,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface {
109122

110123
const key = createDataKey(keyRaw);
111124

112-
input.addEventListener('beforeinput', event => this.#handleBeforeInputEvent(event, input, key));
125+
this.#attachedInputs.set(key, input);
113126

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

@@ -133,16 +146,64 @@ export class BlockToolAdapter implements BlockToolAdapterInterface {
133146
}
134147
}
135148

149+
/**
150+
* Check current selection and find it across all attached inputs
151+
*
152+
* @returns tuple of data key and input element or null if no focused input is found
153+
*/
154+
#findFocusedInput(): [DataKey, HTMLElement] | null {
155+
const currentInput = Array.from(this.#attachedInputs.entries()).find(([_, input]) => {
156+
/**
157+
* Case 1: Input is a native input — check if it has selection
158+
*/
159+
if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) {
160+
return input.selectionStart !== null && input.selectionEnd !== null;
161+
}
162+
163+
/**
164+
* Case 2: Input is a contenteditable element — check if it has range start container
165+
*/
166+
if (input.isContentEditable) {
167+
const selection = window.getSelection();
168+
169+
if (selection !== null && selection.rangeCount > 0) {
170+
const range = selection.getRangeAt(0);
171+
172+
return input.contains(range.startContainer);
173+
}
174+
}
175+
176+
return false;
177+
});
178+
179+
return currentInput !== undefined ? currentInput : null;
180+
}
181+
182+
/**
183+
* Handles 'beforeinput' event delegated from the blocks host element
184+
*
185+
* @param event - event containig necessary data
186+
*/
187+
#processDelegatedBeforeInput(event: BeforeInputUIEvent): void {
188+
const [dataKey, currentInput] = this.#findFocusedInput() ?? [];
189+
190+
if (currentInput === undefined || dataKey === undefined) {
191+
return;
192+
}
193+
194+
this.#handleBeforeInputEvent(event.detail, currentInput, dataKey);
195+
}
196+
136197
/**
137198
* Handles delete events in native input
138199
*
139-
* @param event - beforeinput event
200+
* @param payload - beforeinput event payload
140201
* @param input - input element
141202
* @param key - data key input is attached to
142203
* @private
143204
*/
144-
#handleDeleteInNativeInput(event: InputEvent, input: HTMLInputElement | HTMLTextAreaElement, key: DataKey): void {
145-
const inputType = event.inputType as InputType;
205+
#handleDeleteInNativeInput(payload: BeforeInputUIEventPayload, input: HTMLInputElement | HTMLTextAreaElement, key: DataKey): void {
206+
const inputType = payload.inputType;
146207

147208
/**
148209
* Check that selection exists in current input
@@ -226,12 +287,12 @@ export class BlockToolAdapter implements BlockToolAdapterInterface {
226287
/**
227288
* Handles delete events in contenteditable element
228289
*
229-
* @param event - beforeinput event
290+
* @param payload - beforeinput event payload
230291
* @param input - input element
231292
* @param key - data key input is attached to
232293
*/
233-
#handleDeleteInContentEditable(event: InputEvent, input: HTMLElement, key: DataKey): void {
234-
const targetRanges = event.getTargetRanges();
294+
#handleDeleteInContentEditable(payload: BeforeInputUIEventPayload, input: HTMLElement, key: DataKey): void {
295+
const { targetRanges } = payload;
235296
const range = targetRanges[0];
236297

237298
const start: number = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset);
@@ -245,23 +306,18 @@ export class BlockToolAdapter implements BlockToolAdapterInterface {
245306
*
246307
* We prevent beforeinput event of any type to handle it manually via model update
247308
*
248-
* @param event - beforeinput event
309+
* @param payload - payload of input event
249310
* @param input - input element
250311
* @param key - data key input is attached to
251312
*/
252-
#handleBeforeInputEvent(event: InputEvent, input: HTMLElement, key: DataKey): void {
253-
/**
254-
* We prevent all events to handle them manually via model update
255-
*/
256-
event.preventDefault();
313+
#handleBeforeInputEvent(payload: BeforeInputUIEventPayload, input: HTMLElement, key: DataKey): void {
314+
const { data, inputType, targetRanges } = payload;
257315

258316
const isInputNative = isNativeInput(input);
259-
const inputType = event.inputType as InputType;
260317
let start: number;
261318
let end: number;
262319

263320
if (isInputNative === false) {
264-
const targetRanges = event.getTargetRanges();
265321
const range = targetRanges[0];
266322

267323
start = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset);
@@ -281,20 +337,6 @@ export class BlockToolAdapter implements BlockToolAdapterInterface {
281337
this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end);
282338
}
283339

284-
let data: string;
285-
286-
/**
287-
* For native inputs data for those events comes from event.data property
288-
* while for contenteditable elements it's stored in event.dataTransfer
289-
*
290-
* @see https://www.w3.org/TR/input-events-2/#overview
291-
*/
292-
if (isInputNative) {
293-
data = event.data ?? '';
294-
} else {
295-
data = event.dataTransfer!.getData('text/plain');
296-
}
297-
298340
this.#model.insertText(this.#config.userId, this.#blockIndex, key, data, start);
299341

300342
break;
@@ -312,8 +354,6 @@ export class BlockToolAdapter implements BlockToolAdapterInterface {
312354
this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end);
313355
}
314356

315-
const data = event.data as string;
316-
317357
this.#model.insertText(this.#config.userId, this.#blockIndex, key, data, start);
318358
break;
319359
}
@@ -331,9 +371,9 @@ export class BlockToolAdapter implements BlockToolAdapterInterface {
331371
case InputType.DeleteWordBackward:
332372
case InputType.DeleteWordForward: {
333373
if (isInputNative === true) {
334-
this.#handleDeleteInNativeInput(event, input as HTMLInputElement | HTMLTextAreaElement, key);
374+
this.#handleDeleteInNativeInput(payload, input as HTMLInputElement | HTMLTextAreaElement, key);
335375
} else {
336-
this.#handleDeleteInContentEditable(event, input, key);
376+
this.#handleDeleteInContentEditable(payload, input, key);
337377
}
338378
break;
339379
}

packages/sdk/src/entities/EventBus/events/ui/BeforeInputUIEvent.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export interface BeforeInputUIEventPayload {
1515
* This may be an empty string if the change doesn't insert text
1616
* (for example, when deleting characters).
1717
*/
18-
data: string | null;
18+
data: string;
1919

2020
/**
2121
* Same as 'beforeinput' event's inputType
@@ -26,6 +26,11 @@ export interface BeforeInputUIEventPayload {
2626
* Same as 'beforeinput' event's isComposing
2727
*/
2828
isComposing: boolean;
29+
30+
/**
31+
* Objects that will be affected by a change to the DOM if the input event is not canceled.
32+
*/
33+
targetRanges: StaticRange[];
2934
}
3035

3136
/**
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
11
.blocks {
22
outline: none;
3+
display: grid;
4+
grid-template-columns: 1fr;
5+
}
6+
7+
/**
8+
* Zero-width space wrapper that will prevent Safari from deleting blocks if there is no content in host
9+
*/
10+
.host-holder {
11+
line-height: 0;
12+
width: 0;
13+
height: 0;
14+
user-select: none;
315
}

packages/ui/src/Blocks/Blocks.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from '@editorjs/sdk';
1010
import type { EventBus } from '@editorjs/sdk';
1111
import Style from './Blocks.module.pcss';
12+
import { isNativeInput } from '@editorjs/dom';
1213

1314
/**
1415
* Editor's main UI renderer for HTML environment
@@ -85,13 +86,37 @@ export class BlocksUI implements EditorjsPlugin {
8586

8687
blocksHolder.classList.add(Style['blocks']);
8788

88-
blocksHolder.contentEditable = 'false';
89+
blocksHolder.contentEditable = 'true';
90+
91+
/**
92+
* Workaround Safari behavior when it deletes blocks if there is no content in them
93+
* E.g. when you delete all content in the only block, it deletes the block
94+
*/
95+
this.#addHostHolder(blocksHolder);
8996

9097
blocksHolder.addEventListener('beforeinput', (e) => {
98+
e.preventDefault();
99+
100+
const isInputNative = isNativeInput(e.target as HTMLElement);
101+
102+
let data: string;
103+
104+
/**
105+
* For native inputs data for those events comes from event.data property
106+
* while for contenteditable elements it's stored in event.dataTransfer
107+
* @see https://www.w3.org/TR/input-events-2/#overview
108+
*/
109+
if (isInputNative) {
110+
data = e.data ?? '';
111+
} else {
112+
data = e.dataTransfer?.getData('text/plain') ?? e.data ?? '';
113+
}
114+
91115
this.#eventBus.dispatchEvent(new BeforeInputUIEvent({
92-
data: e.data,
116+
data,
93117
inputType: e.inputType,
94118
isComposing: e.isComposing,
119+
targetRanges: e.getTargetRanges(),
95120
}));
96121
});
97122

@@ -100,6 +125,20 @@ export class BlocksUI implements EditorjsPlugin {
100125
return blocksHolder;
101126
}
102127

128+
/**
129+
* Adds host holder that will prevent Safari from deleting blocks if there is no content host
130+
* @param blocksHolder - blocks holder element
131+
*/
132+
#addHostHolder(blocksHolder: HTMLElement): void {
133+
const zeroWidthSpaceWrapper = document.createElement('span');
134+
const zeroWidthSpace = document.createTextNode('\u200B');
135+
136+
zeroWidthSpaceWrapper.classList.add(Style['host-holder']);
137+
zeroWidthSpaceWrapper.appendChild(zeroWidthSpace);
138+
139+
blocksHolder.appendChild(zeroWidthSpaceWrapper);
140+
}
141+
103142
/**
104143
* Renders block's content on the page
105144
* @param blockElement - block HTML element to add to the page

packages/ui/src/Toolbox/Toolbox.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export class ToolboxUI implements EditorjsPlugin {
5555
this.#eventBus.addEventListener(`core:${CoreEventType.ToolLoaded}`, (event: ToolLoadedCoreEvent) => {
5656
const { tool } = event.detail;
5757

58-
if ('isBlock' in tool && tool.isBlock()) {
58+
if (tool?.isBlock?.() === true) {
5959
this.addTool(tool);
6060
}
6161
});

packages/ui/src/main.module.pcss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,8 @@
88

99
width: 100%;
1010
}
11+
12+
.paragraph {
13+
min-height: 1em;
14+
}
15+

0 commit comments

Comments
 (0)