Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export class Paragraph implements BlockTool<ParagraphData, ParagraphConfig> {
public render(): HTMLElement {
const wrapper = document.createElement('div');

wrapper.classList.add('editorjs-paragraph');

wrapper.contentEditable = 'true';
wrapper.style.outline = 'none';
wrapper.style.whiteSpace = 'pre-wrap';
Expand Down
2 changes: 2 additions & 0 deletions packages/dom-adapters/.eslintrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ rules:
- 0
'@typescript-eslint/no-unsafe-argument':
- 0
'jsdoc/require-returns-type':
- 0
env:
browser: true

Expand Down
116 changes: 78 additions & 38 deletions packages/dom-adapters/src/BlockToolAdapter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,14 @@ import {
TextAddedEvent,
TextRemovedEvent
} from '@editorjs/model';
import type { EventBus } from '@editorjs/sdk';
import { type BlockToolAdapter as BlockToolAdapterInterface, type CoreConfig } from '@editorjs/sdk';
import type {
EventBus,
BlockToolAdapter as BlockToolAdapterInterface,
CoreConfig,
BeforeInputUIEvent,
BeforeInputUIEventPayload
} from '@editorjs/sdk';
import { BeforeInputUIEventName } from '@editorjs/sdk';
import type { CaretAdapter } from '../CaretAdapter/index.js';
import type { FormattingAdapter } from '../FormattingAdapter/index.js';
import {
Expand Down Expand Up @@ -63,6 +69,13 @@ export class BlockToolAdapter implements BlockToolAdapterInterface {
*/
#config: Required<CoreConfig>;

/**
* Inputs that bound to the model
*
* @todo handle inputs deletion — remove inputs from the map when they are removed from the DOM
*/
#attachedInputs = new Map<DataKey, HTMLElement>();

/**
* BlockToolAdapter constructor
*
Expand Down Expand Up @@ -90,9 +103,9 @@ export class BlockToolAdapter implements BlockToolAdapterInterface {
this.#formattingAdapter = formattingAdapter;
this.#toolName = toolName;

// eventBus.addEventListener(BeforeInputUIEventName, (event: BeforeInputUIEvent) => {
// console.log('BeforeInputUIEventName', event);
// });
eventBus.addEventListener(`ui:${BeforeInputUIEventName}`, (event: BeforeInputUIEvent) => {
this.#processDelegatedBeforeInput(event);
});
}

/**
Expand All @@ -109,7 +122,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface {

const key = createDataKey(keyRaw);

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

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

Expand All @@ -133,16 +146,64 @@ export class BlockToolAdapter implements BlockToolAdapterInterface {
}
}

/**
* Check current selection and find it across all attached inputs
*
* @returns tuple of data key and input element or null if no focused input is found
*/
#findFocusedInput(): [DataKey, HTMLElement] | null {
const currentInput = Array.from(this.#attachedInputs.entries()).find(([_, input]) => {
/**
* Case 1: Input is a native input — check if it has selection
*/
if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) {
return input.selectionStart !== null && input.selectionEnd !== null;
}

/**
* Case 2: Input is a contenteditable element — check if it has range start container
*/
if (input.isContentEditable) {
const selection = window.getSelection();

if (selection !== null && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);

return input.contains(range.startContainer);
}
}

return false;
});

return currentInput !== undefined ? currentInput : null;
}

/**
* Handles 'beforeinput' event delegated from the blocks host element
*
* @param event - event containig necessary data
*/
#processDelegatedBeforeInput(event: BeforeInputUIEvent): void {
const [dataKey, currentInput] = this.#findFocusedInput() ?? [];

if (currentInput === undefined || dataKey === undefined) {
return;
}

this.#handleBeforeInputEvent(event.detail, currentInput, dataKey);
}

/**
* Handles delete events in native input
*
* @param event - beforeinput event
* @param payload - beforeinput event payload
* @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;
#handleDeleteInNativeInput(payload: BeforeInputUIEventPayload, input: HTMLInputElement | HTMLTextAreaElement, key: DataKey): void {
const inputType = payload.inputType;

/**
* Check that selection exists in current input
Expand Down Expand Up @@ -226,12 +287,12 @@ export class BlockToolAdapter implements BlockToolAdapterInterface {
/**
* Handles delete events in contenteditable element
*
* @param event - beforeinput event
* @param payload - beforeinput event payload
* @param input - input element
* @param key - data key input is attached to
*/
#handleDeleteInContentEditable(event: InputEvent, input: HTMLElement, key: DataKey): void {
const targetRanges = event.getTargetRanges();
#handleDeleteInContentEditable(payload: BeforeInputUIEventPayload, input: HTMLElement, key: DataKey): void {
const { targetRanges } = payload;
const range = targetRanges[0];

const start: number = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset);
Expand All @@ -245,23 +306,18 @@ export class BlockToolAdapter implements BlockToolAdapterInterface {
*
* We prevent beforeinput event of any type to handle it manually via model update
*
* @param event - beforeinput event
* @param payload - payload of input event
* @param input - input element
* @param key - data key input is attached to
*/
#handleBeforeInputEvent(event: InputEvent, input: HTMLElement, key: DataKey): void {
/**
* We prevent all events to handle them manually via model update
*/
event.preventDefault();
#handleBeforeInputEvent(payload: BeforeInputUIEventPayload, input: HTMLElement, key: DataKey): void {
const { data, inputType, targetRanges } = payload;

const isInputNative = isNativeInput(input);
const inputType = event.inputType as InputType;
let start: number;
let end: number;

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

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

let data: string;

/**
* 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
*/
if (isInputNative) {
data = event.data ?? '';
} else {
data = event.dataTransfer!.getData('text/plain');
}

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

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

const data = event.data as string;

this.#model.insertText(this.#config.userId, this.#blockIndex, key, data, start);
break;
}
Expand All @@ -331,9 +371,9 @@ export class BlockToolAdapter implements BlockToolAdapterInterface {
case InputType.DeleteWordBackward:
case InputType.DeleteWordForward: {
if (isInputNative === true) {
this.#handleDeleteInNativeInput(event, input as HTMLInputElement | HTMLTextAreaElement, key);
this.#handleDeleteInNativeInput(payload, input as HTMLInputElement | HTMLTextAreaElement, key);
} else {
this.#handleDeleteInContentEditable(event, input, key);
this.#handleDeleteInContentEditable(payload, input, key);
}
break;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface BeforeInputUIEventPayload {
* This may be an empty string if the change doesn't insert text
* (for example, when deleting characters).
*/
data: string | null;
data: string;

/**
* Same as 'beforeinput' event's inputType
Expand All @@ -26,6 +26,11 @@ export interface BeforeInputUIEventPayload {
* Same as 'beforeinput' event's isComposing
*/
isComposing: boolean;

/**
* Objects that will be affected by a change to the DOM if the input event is not canceled.
*/
targetRanges: StaticRange[];
}

/**
Expand Down
12 changes: 12 additions & 0 deletions packages/ui/src/Blocks/Blocks.module.pcss
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
.blocks {
outline: none;
display: grid;
grid-template-columns: 1fr;
}

/**
* Zero-width space wrapper that will prevent Safari from deleting blocks if there is no content in host
*/
.host-holder {
line-height: 0;
width: 0;
height: 0;
user-select: none;
}
43 changes: 41 additions & 2 deletions packages/ui/src/Blocks/Blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@editorjs/sdk';
import type { EventBus } from '@editorjs/sdk';
import Style from './Blocks.module.pcss';
import { isNativeInput } from '@editorjs/dom';

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

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

blocksHolder.contentEditable = 'false';
blocksHolder.contentEditable = 'true';

/**
* Workaround Safari behavior when it deletes blocks if there is no content in them
* E.g. when you delete all content in the only block, it deletes the block
*/
this.#addHostHolder(blocksHolder);

blocksHolder.addEventListener('beforeinput', (e) => {
e.preventDefault();

const isInputNative = isNativeInput(e.target as HTMLElement);

let data: string;

/**
* 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
*/
if (isInputNative) {
data = e.data ?? '';
} else {
data = e.dataTransfer?.getData('text/plain') ?? e.data ?? '';
}

this.#eventBus.dispatchEvent(new BeforeInputUIEvent({
data: e.data,
data,
inputType: e.inputType,
isComposing: e.isComposing,
targetRanges: e.getTargetRanges(),
}));
});

Expand All @@ -100,6 +125,20 @@ export class BlocksUI implements EditorjsPlugin {
return blocksHolder;
}

/**
* Adds host holder that will prevent Safari from deleting blocks if there is no content host
* @param blocksHolder - blocks holder element
*/
#addHostHolder(blocksHolder: HTMLElement): void {
const zeroWidthSpaceWrapper = document.createElement('span');
const zeroWidthSpace = document.createTextNode('\u200B');

zeroWidthSpaceWrapper.classList.add(Style['host-holder']);
zeroWidthSpaceWrapper.appendChild(zeroWidthSpace);

blocksHolder.appendChild(zeroWidthSpaceWrapper);
}

/**
* Renders block's content on the page
* @param blockElement - block HTML element to add to the page
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/Toolbox/Toolbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export class ToolboxUI implements EditorjsPlugin {
this.#eventBus.addEventListener(`core:${CoreEventType.ToolLoaded}`, (event: ToolLoadedCoreEvent) => {
const { tool } = event.detail;

if ('isBlock' in tool && tool.isBlock()) {
if (tool?.isBlock?.() === true) {
this.addTool(tool);
}
});
Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/main.module.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,8 @@

width: 100%;
}

.paragraph {
min-height: 1em;
}