Skip to content

Commit

Permalink
feat(ui): placeholders updated (#2758)
Browse files Browse the repository at this point in the history
* data-empty mark

* Update CHANGELOG.md

* lint

* tests added

* Update DataEmpty.cy.ts

* lint

* fix tests

* Update Placeholders.cy.ts

* upd paragraph

* rm redundant test

* lint fix

* disable test for firefox
  • Loading branch information
neSpecc authored Jul 4, 2024
1 parent fb3089c commit 9c1e2e5
Show file tree
Hide file tree
Showing 20 changed files with 329 additions and 42 deletions.
3 changes: 3 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
`Improvement`*Types*`BlockToolConstructorOptions` type improved, `block` and `config` are not optional anymore
- `Improvement` - The Plus button and Block Tunes toggler are now better aligned with large line-height blocks, such as Headings
- `Improvement` — Creating links on Android devices: now the mobile keyboard will have an "Enter" key for accepting the inserted link.
- `Improvement` — Placeholders will stay visible on inputs focus.
- `New` — Editor.js now supports contenteditable placeholders out of the box. Just add `data-placeholder` or `data-placeholder-active` attribute to make it work. The first one will work like native placeholder while the second one will show placeholder only when block is current.
- `Improvement` — Now Paragraph placeholder will be shown for the current paragraph, not the only first one.

### 2.29.1

Expand Down
3 changes: 3 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@
*/
// defaultBlock: 'paragraph',

placeholder: 'Write something or press / to select a tool',
autofocus: true,

/**
* Initial Editor data
*/
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"@editorjs/code": "^2.7.0",
"@editorjs/delimiter": "^1.2.0",
"@editorjs/header": "^2.7.0",
"@editorjs/paragraph": "^2.11.4",
"@editorjs/paragraph": "^2.11.6",
"@editorjs/simple-image": "^1.4.1",
"@types/node": "^18.15.11",
"chai-subset": "^1.6.0",
Expand Down
27 changes: 19 additions & 8 deletions src/components/block/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from '../../../types';

import { SavedData } from '../../../types/data-formats';
import $ from '../dom';
import $, { toggleEmptyMark } from '../dom';
import * as _ from '../utils';
import ApiModules from '../modules/api';
import BlockAPI from './api';
Expand Down Expand Up @@ -183,11 +183,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
*/
private unavailableTunesData: { [name: string]: BlockTuneData } = {};

/**
* Editor`s API module
*/
private readonly api: ApiModules;

/**
* Focused input index
*
Expand Down Expand Up @@ -223,7 +218,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
id = _.generateBlockId(),
data,
tool,
api,
readOnly,
tunesData,
}: BlockConstructorOptions, eventBus?: EventsDispatcher<EditorEventMap>) {
Expand All @@ -232,7 +226,6 @@ export default class Block extends EventsDispatcher<BlockEvents> {
this.id = id;
this.settings = tool.settings;
this.config = tool.settings.config || {};
this.api = api;
this.editorEventBus = eventBus || null;
this.blockAPI = new BlockAPI(this);

Expand Down Expand Up @@ -262,6 +255,12 @@ export default class Block extends EventsDispatcher<BlockEvents> {
* so we need to track focus events to update current input and clear cache.
*/
this.addInputEvents();

/**
* We mark inputs with [data-empty] attribute
* It can be useful for developers, for example for correct placeholder behavior
*/
this.toggleInputsEmptyMark();
});
}

Expand Down Expand Up @@ -938,6 +937,11 @@ export default class Block extends EventsDispatcher<BlockEvents> {
*/
this.updateCurrentInput();

/**
* We mark inputs with 'data-empty' attribute, so new inputs should be marked as well
*/
this.toggleInputsEmptyMark();

this.call(BlockToolAPI.UPDATED);

/**
Expand Down Expand Up @@ -1000,4 +1004,11 @@ export default class Block extends EventsDispatcher<BlockEvents> {
private dropInputsCache(): void {
this.cachedInputs = [];
}

/**
* Mark inputs with 'data-empty' attribute with the empty state
*/
private toggleInputsEmptyMark(): void {
this.inputs.forEach(toggleEmptyMark);
}
}
10 changes: 10 additions & 0 deletions src/components/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,3 +662,13 @@ export function calculateBaseline(element: Element): number {

return baselineY;
}

/**
* Toggles the [data-empty] attribute on element depending on its emptiness
* Used to mark empty inputs with a special attribute for placeholders feature
*
* @param element - The element to toggle the [data-empty] attribute on
*/
export function toggleEmptyMark(element: HTMLElement): void {
element.dataset.empty = Dom.isEmpty(element) ? 'true' : 'false';
}
8 changes: 4 additions & 4 deletions src/components/modules/blockManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@ export default class BlockManager extends Module {
* If first Block was removed, insert new Initial Block and set focus on it`s first input
*/
if (!this.blocks.length) {
this.currentBlockIndex = -1;
this.unsetCurrentBlock();

if (addLastBlock) {
this.insert();
Expand Down Expand Up @@ -591,7 +591,7 @@ export default class BlockManager extends Module {
this._blocks.remove(index);
}

this.currentBlockIndex = -1;
this.unsetCurrentBlock();
this.insert();
this.currentBlock.firstInput.focus();
}
Expand Down Expand Up @@ -873,7 +873,7 @@ export default class BlockManager extends Module {
* Sets current Block Index -1 which means unknown
* and clear highlights
*/
public dropPointer(): void {
public unsetCurrentBlock(): void {
this.currentBlockIndex = -1;
}

Expand All @@ -895,7 +895,7 @@ export default class BlockManager extends Module {

await queue.completed;

this.dropPointer();
this.unsetCurrentBlock();

if (needToAddDefaultBlock) {
this.insert();
Expand Down
38 changes: 34 additions & 4 deletions src/components/modules/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* @type {UI}
*/
import Module from '../__module';
import $ from '../dom';
import $, { toggleEmptyMark } from '../dom';
import * as _ from '../utils';

import Selection from '../selection';
Expand Down Expand Up @@ -380,6 +380,12 @@ export default class UI extends Module<UINodes> {
* Start watching 'block-hovered' events that is used by Toolbar for moving
*/
this.watchBlockHoveredEvents();

/**
* We have custom logic for providing placeholders for contenteditable elements.
* To make it work, we need to have data-empty mark on empty inputs.
*/
this.enableInputsEmptyMark();
}


Expand Down Expand Up @@ -498,7 +504,7 @@ export default class UI extends Module<UINodes> {
/**
* Remove all highlights and remove caret
*/
this.Editor.BlockManager.dropPointer();
this.Editor.BlockManager.unsetCurrentBlock();

/**
* Close Toolbar
Expand Down Expand Up @@ -645,12 +651,12 @@ export default class UI extends Module<UINodes> {

if (!clickedInsideOfEditor) {
/**
* Clear highlights and pointer on BlockManager
* Clear pointer on BlockManager
*
* Current page might contain several instances
* Click between instances MUST clear focus, pointers and close toolbars
*/
this.Editor.BlockManager.dropPointer();
this.Editor.BlockManager.unsetCurrentBlock();
this.Editor.Toolbar.close();
}

Expand Down Expand Up @@ -874,4 +880,28 @@ export default class UI extends Module<UINodes> {

this.Editor.InlineToolbar.tryToShow(true);
}

/**
* Editor.js provides and ability to show placeholders for empty contenteditable elements
*
* This method watches for input and focus events and toggles 'data-empty' attribute
* to workaroud the case, when inputs contains only <br>s and has no visible content
* Then, CSS could rely on this attribute to show placeholders
*/
private enableInputsEmptyMark(): void {
/**
* Toggle data-empty attribute on input depending on its emptiness
*
* @param event - input or focus event
*/
function handleInputOrFocusChange(event: Event): void {
const input = event.target as HTMLElement;

toggleEmptyMark(input);
}

this.readOnlyMutableListeners.on(this.nodes.wrapper, 'input', handleInputOrFocusChange);
this.readOnlyMutableListeners.on(this.nodes.wrapper, 'focusin', handleInputOrFocusChange);
this.readOnlyMutableListeners.on(this.nodes.wrapper, 'focusout', handleInputOrFocusChange);
}
}
7 changes: 7 additions & 0 deletions src/components/utils/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
export function isMutationBelongsToElement(mutationRecord: MutationRecord, element: Element): boolean {
const { type, target, addedNodes, removedNodes } = mutationRecord;

/**
* Skip own technical mutations, for example, data-empty attribute changes
*/
if (mutationRecord.type === 'attributes' && mutationRecord.attributeName === 'data-empty') {
return false;
}

/**
* Covers all types of mutations happened to the element or it's descendants with the only one exception - removing/adding the element itself;
*/
Expand Down
1 change: 1 addition & 0 deletions src/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@
@import './input.css';
@import './popover.css';
@import './popover-inline.css';
@import './placeholders.css';

45 changes: 45 additions & 0 deletions src/styles/placeholders.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@

/**
* We support two types of placeholders for contenteditable:
*
* 1. Regular-like placeholders. Will be visible when element is empty.
-- Best choice for rare-used blocks like Headings.
* 2. Current-block placeholders. Will be visible when element is empty and the block is focused.
-- Best choice for common-used blocks like Paragraphs.
*/
:root {
--placeholder {
pointer-events: none;
color: var(--grayText);
cursor: text;
}
}

.codex-editor {
/**
* Use [data-placeholder="..."] to always show a placeholder on empty contenteditable.
*/
[data-placeholder]:empty,
[data-placeholder][data-empty="true"] {
&::before {
@apply --placeholder;

content: attr(data-placeholder);
}
}

/**
* Use [data-placeholder-active="..."] to show a placeholder on empty contenteditable in current block.
*/
[data-placeholder-active]:empty,
[data-placeholder-active][data-empty="true"] {
/* Paragraph tool shows the placeholder for the first block, event it is not focused, so we need to prepare styles for it */
&::before {
@apply --placeholder;
}

&:focus::before {
content: attr(data-placeholder-active);
}
}
}
15 changes: 15 additions & 0 deletions test/cypress/support/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,18 @@ Cypress.Commands.add('keydown', {

return cy.wrap(subject);
});

/**
* Extract content of pseudo element
*
* @example cy.get('element').getPseudoElementContent('::before').should('eq', 'my-test-string')
*/
Cypress.Commands.add('getPseudoElementContent', {
prevSubject: true,
}, (subject, pseudoElement: 'string') => {
const win = subject[0].ownerDocument.defaultView;
const computedStyle = win.getComputedStyle(subject[0], pseudoElement);
const content = computedStyle.getPropertyValue('content');

return content.replace(/['"]/g, ''); // Remove quotes around the content
});
7 changes: 7 additions & 0 deletions test/cypress/support/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ declare global {
* @param keyCode - key code to dispatch
*/
keydown(keyCode: number): Chainable<Subject>;

/**
* Extract content of pseudo element
*
* @example cy.get('element').getPseudoElementContent('::before').should('eq', 'my-test-string')
*/
getPseudoElementContent(pseudoElement: string): Chainable<string>;
}

interface ApplicationWindow {
Expand Down
8 changes: 5 additions & 3 deletions test/cypress/support/utils/createEditorWithTextBlocks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { EditorConfig } from '../../../../types/index';
import Chainable = Cypress.Chainable;
import type EditorJS from '../../../../types/index';

Expand All @@ -6,9 +7,10 @@ import type EditorJS from '../../../../types/index';
* Creates Editor instance with list of Paragraph blocks of passed texts
*
* @param textBlocks - list of texts for Paragraph blocks
* @param editorConfig - config to pass to the editor
*/
export function createEditorWithTextBlocks(textBlocks: string[]): Chainable<EditorJS> {
return cy.createEditor({
export function createEditorWithTextBlocks(textBlocks: string[], editorConfig?: Omit<EditorConfig, 'data'>): Chainable<EditorJS> {
return cy.createEditor(Object.assign(editorConfig || {}, {
data: {
blocks: textBlocks.map((text) => ({
type: 'paragraph',
Expand All @@ -17,5 +19,5 @@ export function createEditorWithTextBlocks(textBlocks: string[]): Chainable<Edit
},
})),
},
});
}));
}
4 changes: 2 additions & 2 deletions test/cypress/tests/modules/InlineToolbar.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe('Inline Toolbar', () => {

const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();

expect($toolbar.offset().left).to.be.closeTo(rect.left, 1);
});
});
Expand Down Expand Up @@ -67,7 +67,7 @@ describe('Inline Toolbar', () => {
cy.get('@blockWrapper')
.then(($blockWrapper) => {
const blockWrapperRect = $blockWrapper.get(0).getBoundingClientRect();

/**
* Toolbar should be aligned with right side of text column
*/
Expand Down
2 changes: 1 addition & 1 deletion test/cypress/tests/tools/ToolsFactory.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe('ToolsFactory', (): void => {
prop1: 'prop1',
prop2: 'prop2',
};
}
},
} as any
);
});
Expand Down
2 changes: 1 addition & 1 deletion test/cypress/tests/ui/BlockTunes.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ describe('BlockTunes', function () {
icon: 'Icon',
title: 'Tune',
// eslint-disable-next-line @typescript-eslint/no-empty-function
onActivate: () => {}
onActivate: () => {},
};
}

Expand Down
Loading

0 comments on commit 9c1e2e5

Please sign in to comment.