Unique ids in blocks with nanoid (#1667)

* feat: Add unique ids for each block

* fix: Improve code based on code review

* feat(block ids): Use nanoid library for block id generation

* Remove unused files

* Add tests

* Fix lint & test

* fix: Remove unnecessary id generation, use nanoid(10) to shorten the id, add changelog and some documentation

Also improved some documentation along the lines and fixed linting

* Update copy-paste.spec.ts

* fix id generation, add api method

* Update blocks.spec.ts

* update tests

Co-authored-by: cobb <kebincheng@yeah.net>
Co-authored-by: George Berezhnoy <gohabereg@users.noreply.github.com>
Co-authored-by: Georgy Berezhnoy <gohabereg@gmail.com>
Co-authored-by: Peter Savchenko <specc.dev@gmail.com>
This commit is contained in:
Jean-Gab 2021-04-27 09:33:00 -04:00 committed by GitHub
parent ef0c7d76a7
commit de364175eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 333 additions and 18 deletions

View file

@ -1,5 +1,8 @@
# Changelog
### 2.21.0
- `New` - Blocks now have a unique ID [#873](https://github.com/codex-team/editor.js/issues/873)
### 2.20.2
- `Fix` — Append default Tunes if user tunes are provided for Block Tool [#1640](https://github.com/codex-team/editor.js/issues/1640)

View file

@ -197,6 +197,7 @@
data: {
blocks: [
{
id: "zcKCF1S7X8",
type: "header",
data: {
text: "Editor.js",
@ -205,12 +206,14 @@
},
{
type : 'paragraph',
id: "b6ji-DvaKb",
data : {
text : 'Hey. Meet the new Editor. On this page you can see it in action — try to edit this text. Source code of the page contains the example of connection and configuration.'
}
},
{
type: "header",
id: "7ItVl5biRo",
data: {
text: "Key features",
level: 3
@ -218,6 +221,7 @@
},
{
type : 'list',
id: "SSBSguGvP7",
data : {
items : [
{
@ -238,6 +242,7 @@
},
{
type: "header",
id: "QZFox1m_ul",
data: {
text: "What does it mean «block-styled editor»",
level: 3
@ -245,18 +250,21 @@
},
{
type : 'paragraph',
id: "bwnFX5LoX7",
data : {
text : 'Workspace in classic editors is made of a single contenteditable element, used to create different HTML markups. Editor.js <mark class=\"cdx-marker\">workspace consists of separate Blocks: paragraphs, headings, images, lists, quotes, etc</mark>. Each of them is an independent contenteditable element (or more complex structure) provided by Plugin and united by Editor\'s Core.'
}
},
{
type : 'paragraph',
id: "mTrPOHAQTe",
data : {
text : `There are dozens of <a href="https://github.com/editor-js">ready-to-use Blocks</a> and the <a href="https://editorjs.io/creating-a-block-tool">simple API</a> for creation any Block you need. For example, you can implement Blocks for Tweets, Instagram posts, surveys and polls, CTA-buttons and even games.`
}
},
{
type: "header",
id: "1sYMhUrznu",
data: {
text: "What does it mean clean data output",
level: 3
@ -264,34 +272,40 @@
},
{
type : 'paragraph',
id: "jpd7WEXrJG",
data : {
text : 'Classic WYSIWYG-editors produce raw HTML-markup with both content data and content appearance. On the contrary, Editor.js outputs JSON object with data of each Block. You can see an example below'
}
},
{
type : 'paragraph',
id: "0lOGNUKxqt",
data : {
text : `Given data can be used as you want: render with HTML for <code class="inline-code">Web clients</code>, render natively for <code class="inline-code">mobile apps</code>, create markup for <code class="inline-code">Facebook Instant Articles</code> or <code class="inline-code">Google AMP</code>, generate an <code class="inline-code">audio version</code> and so on.`
}
},
{
type : 'paragraph',
id: "WvX7kBjp0I",
data : {
text : 'Clean data is useful to sanitize, validate and process on the backend.'
}
},
{
type : 'delimiter',
id: "H9LWKQ3NYd",
data : {}
},
{
type : 'paragraph',
id: "h298akk2Ad",
data : {
text : 'We have been working on this project more than three years. Several large media projects help us to test and debug the Editor, to make its core more stable. At the same time we significantly improved the API. Now, it can be used to create any plugin for any task. Hope you enjoy. 😏'
}
},
{
type: 'image',
id: "9802bjaAA2",
data: {
url: 'assets/codex2x.png',
caption: '',

View file

@ -24,7 +24,7 @@
"svg": "svg-sprite-generate -d src/assets/ -o dist/sprite.svg",
"pull_tools": "git submodule update --init --recursive",
"checkout_tools": "git submodule foreach git pull origin master",
"test:e2e": "cypress run"
"test:e2e": "yarn build && cypress run"
},
"author": "CodeX",
"license": "Apache-2.0",
@ -87,6 +87,7 @@
},
"dependencies": {
"codex-notifier": "^1.1.2",
"codex-tooltip": "^1.0.2"
"codex-tooltip": "^1.0.2",
"nanoid": "^3.1.22"
}
}

View file

@ -14,6 +14,14 @@ function BlockAPI(
block: Block
): void {
const blockAPI: BlockAPIInterface = {
/**
* Block id
*
* @returns {string}
*/
get id(): string {
return block.id;
},
/**
* Tool name
*

View file

@ -23,6 +23,11 @@ import ToolsCollection from '../tools/collection';
* Interface describes Block class constructor argument
*/
interface BlockConstructorOptions {
/**
* Block's id. Should be passed for existed block, and omitted for a new one.
*/
id?: string;
/**
* Initial Block data
*/
@ -98,6 +103,11 @@ export default class Block {
};
}
/**
* Block unique identifier
*/
public id: string;
/**
* Block Tool`s name
*/
@ -206,13 +216,14 @@ export default class Block {
/**
* @param {object} options - block constructor options
* @param {string} [options.id] - block's id. Will be generated if omitted.
* @param {BlockToolData} options.data - Tool's initial data
* @param {BlockToolConstructable} options.Tool Tool's class
* @param {ToolSettings} options.settings - default tool's config
* @param {BlockToolConstructable} options.tool block's tool
* @param options.api - Editor API module for pass it to the Block Tunes
* @param {boolean} options.readOnly - Read-Only flag
*/
constructor({
id = _.generateBlockId(),
data,
tool,
api,
@ -220,6 +231,7 @@ export default class Block {
tunesData,
}: BlockConstructorOptions) {
this.name = tool.name;
this.id = id;
this.settings = tool.settings;
this.config = tool.settings.config || {};
this.api = api;
@ -567,6 +579,7 @@ export default class Block {
measuringEnd = window.performance.now();
return {
id: this.id,
tool: this.name,
data: finishedExtraction,
tunes: tunesData,

View file

@ -23,6 +23,7 @@ export default class BlocksAPI extends Module {
swap: (fromIndex: number, toIndex: number): void => this.swap(fromIndex, toIndex),
move: (toIndex: number, fromIndex?: number): void => this.move(toIndex, fromIndex),
getBlockByIndex: (index: number): BlockAPIInterface | void => this.getBlockByIndex(index),
getById: (id: string): BlockAPIInterface | null => this.getById(id),
getCurrentBlockIndex: (): number => this.getCurrentBlockIndex(),
getBlocksCount: (): number => this.getBlocksCount(),
stretchBlock: (index: number, status = true): void => this.stretchBlock(index, status),
@ -66,6 +67,23 @@ export default class BlocksAPI extends Module {
return new BlockAPI(block);
}
/**
* Returns BlockAPI object by Block id
*
* @param id - id of block to get
*/
public getById(id: string): BlockAPIInterface | null {
const block = this.Editor.BlockManager.getBlockById(id);
if (block === undefined) {
_.logLabeled('There is no block with id `' + id + '`', 'warn');
return null;
}
return new BlockAPI(block);
}
/**
* Call Block Manager method that swap Blocks
*

View file

@ -216,6 +216,7 @@ export default class BlockManager extends Module {
*
* @param {object} options - block creation options
* @param {string} options.tool - tools passed in editor config {@link EditorConfig#tools}
* @param {string} [options.id] - unique id for this block
* @param {BlockToolData} [options.data] - constructor params
*
* @returns {Block}
@ -223,11 +224,13 @@ export default class BlockManager extends Module {
public composeBlock({
tool: name,
data = {},
id = undefined,
tunes: tunesData = {},
}: {tool: string; data?: BlockToolData; tunes?: {[name: string]: BlockTuneData}}): Block {
}: {tool: string; id?: string; data?: BlockToolData; tunes?: {[name: string]: BlockTuneData}}): Block {
const readOnly = this.Editor.ReadOnly.isEnabled;
const tool = this.Editor.Tools.blockTools.get(name);
const block = new Block({
id,
data,
tool,
api: this.Editor.API,
@ -246,15 +249,17 @@ export default class BlockManager extends Module {
* Insert new block into _blocks
*
* @param {object} options - insert options
* @param {string} options.tool - plugin name, by default method inserts the default block type
* @param {object} options.data - plugin data
* @param {number} options.index - index where to insert new Block
* @param {boolean} options.needToFocus - flag shows if needed to update current Block index
* @param {boolean} options.replace - flag shows if block by passed index should be replaced with inserted one
* @param {string} [options.id] - block's unique id
* @param {string} [options.tool] - plugin name, by default method inserts the default block type
* @param {object} [options.data] - plugin data
* @param {number} [options.index] - index where to insert new Block
* @param {boolean} [options.needToFocus] - flag shows if needed to update current Block index
* @param {boolean} [options.replace] - flag shows if block by passed index should be replaced with inserted one
*
* @returns {Block}
*/
public insert({
id = undefined,
tool = this.config.defaultBlock,
data = {},
index,
@ -262,6 +267,7 @@ export default class BlockManager extends Module {
replace = false,
tunes = {},
}: {
id?: string;
tool?: string;
data?: BlockToolData;
index?: number;
@ -276,6 +282,7 @@ export default class BlockManager extends Module {
}
const block = this.composeBlock({
id,
tool,
data,
tunes,
@ -514,6 +521,17 @@ export default class BlockManager extends Module {
return this._blocks[index];
}
/**
* Returns the Block by passed id
*
* @param id - id of block to get
*
* @returns {Block}
*/
public getBlockById(id): Block {
return this._blocks.array.find(block => block.id === id);
}
/**
* Get Block instance by html element
*

View file

@ -739,7 +739,7 @@ export default class Paste extends Module {
*
* @returns {void}
*/
private insertEditorJSData(blocks: Pick<SavedData, 'data' | 'tool'>[]): void {
private insertEditorJSData(blocks: Pick<SavedData, 'id' | 'data' | 'tool'>[]): void {
const { BlockManager, Caret, Tools } = this.Editor;
const sanitizedBlocks = sanitizeBlocks(blocks, (name) =>
Tools.blockTools.get(name).sanitizeConfig

View file

@ -23,12 +23,14 @@ export default class Renderer extends Module {
*
* blocks: [
* {
* id : 'oDe-EVrGWA',
* type : 'paragraph',
* data : {
* text : 'Hello from Codex!'
* }
* },
* {
* id : 'Ld5BJjJCHs',
* type : 'paragraph',
* data : {
* text : 'Leave feedback if you like it!'
@ -64,11 +66,12 @@ export default class Renderer extends Module {
*/
public async insertBlock(item: OutputBlockData): Promise<void> {
const { Tools, BlockManager } = this.Editor;
const { type: tool, data, tunes } = item;
const { type: tool, data, tunes, id } = item;
if (Tools.available.has(tool)) {
try {
BlockManager.insert({
id,
tool,
data,
tunes,
@ -81,6 +84,7 @@ export default class Renderer extends Module {
/** If Tool is unavailable, create stub Block for it */
const stubData = {
savedData: {
id,
type: tool,
data,
},
@ -94,6 +98,7 @@ export default class Renderer extends Module {
}
const stub = BlockManager.insert({
id,
tool: Tools.stubTool,
data: stubData,
});

View file

@ -81,7 +81,7 @@ export default class Saver extends Module {
_.log('[Editor.js saving]:', 'groupCollapsed');
allExtractedData.forEach(({ tool, data, tunes, time, isValid }) => {
allExtractedData.forEach(({ id, tool, data, tunes, time, isValid }) => {
totalTime += time;
/**
@ -108,6 +108,7 @@ export default class Saver extends Module {
}
const output = {
id,
type: tool,
data,
...!_.isEmpty(tunes) && {

View file

@ -2,6 +2,7 @@
* Class Util
*/
import { nanoid } from 'nanoid';
import Dom from './dom';
/**
@ -607,6 +608,15 @@ export function getValidUrl(url: string): string {
}
}
/**
* Create a block id
*
* @returns {string}
*/
export function generateBlockId(): string {
return nanoid(10);
}
/**
* Opens new Tab with passed URL
*

View file

@ -5,7 +5,7 @@
* --------------------------------------------------
*/
import type { EditorConfig } from './../../../types/index';
import type { EditorConfig, OutputData } from './../../../types/index';
import type EditorJS from '../../../types/index';
import Chainable = Cypress.Chainable;
@ -114,3 +114,14 @@ Cypress.Commands.add('cut', { prevSubject: true }, async (subject) => {
return clipboardData;
});
/**
* Calls EditorJS API render method
*
* @param data data to render
*/
Cypress.Commands.add('render', { prevSubject: true }, async (subject: EditorJS, data: OutputData): Promise<EditorJS> => {
await subject.render(data);
return subject;
});

View file

@ -2,7 +2,7 @@
// load type definitions that come with Cypress module
/// <reference types="cypress" />
import type { EditorConfig } from './../../../types/index';
import type { EditorConfig, OutputData } from './../../../types/index';
import type EditorJS from '../../../types/index'
declare global {
@ -40,6 +40,13 @@ declare global {
* cy.get('div').cut().then(data => {})
*/
cut(): Chainable<{ [type: string]: any }>;
/**
* Calls EditorJS API render method
*
* @param data data to render
*/
render(data: OutputData): Chainable<EditorJS>;
}
interface ApplicationWindow {

View file

@ -0,0 +1,53 @@
/**
* There will be described test cases of 'blocks.*' API
*/
describe('api.blocks', () => {
const firstBlock = {
id: 'bwnFX5LoX7',
type: 'paragraph',
data: {
text: 'The first block content mock.',
},
};
const editorDataMock = {
blocks: [
firstBlock,
],
};
beforeEach(() => {
if (this && this.editorInstance) {
this.editorInstance.destroy();
} else {
cy.createEditor({
data: editorDataMock,
}).as('editorInstance');
}
});
/**
* api.blocks.getById(id)
*/
describe('.getById()', () => {
/**
* Check that api.blocks.getByUd(id) returns the Block for existed id
*/
it('should return Block API for existed id', () => {
cy.get('@editorInstance').then(async (editor: any) => {
const block = editor.blocks.getById(firstBlock.id);
expect(block).not.to.be.undefined;
expect(block.id).to.be.eq(firstBlock.id);
});
});
/**
* Check that api.blocks.getByUd(id) returns null for the not-existed id
*/
it('should return null for not-existed id', () => {
cy.get('@editorInstance').then(async (editor: any) => {
expect(editor.blocks.getById('not-existed-id')).to.be.null;
});
});
});
});

View file

@ -0,0 +1,130 @@
import Header from '../../../example/tools/header';
import { nanoid } from 'nanoid';
describe.only('Block ids', () => {
beforeEach(() => {
if (this && this.editorInstance) {
this.editorInstance.destroy();
} else {
cy.createEditor({
tools: {
header: Header,
},
}).as('editorInstance');
}
});
it('Should generate unique block ids for new blocks', () => {
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.click()
.type('First block ')
.type('{enter}')
.get('div.ce-block')
.last()
.type('Second block ')
.type('{enter}');
cy.get('[data-cy=editorjs]')
.get('div.ce-toolbar__plus')
.click();
cy.get('[data-cy=editorjs]')
.get('li.ce-toolbox__button[data-tool=header]')
.click();
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.last()
.click()
.type('Header');
cy.get('@editorInstance')
.then(async (editor: any) => {
const data = await editor.save();
data.blocks.forEach(block => {
expect(typeof block.id).to.eq('string');
});
});
});
it('should preserve passed ids', () => {
const blocks = [
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'First block',
},
},
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'Second block',
},
},
];
cy.get('@editorInstance')
.render({
blocks,
});
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.first()
.click()
.type('{movetoend} Some more text');
cy.get('@editorInstance')
.then(async (editor: any) => {
const data = await editor.save();
data.blocks.forEach((block, index) => {
expect(block.id).to.eq(blocks[index].id);
});
});
});
it('should preserve passed ids if blocks were added', () => {
const blocks = [
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'First block',
},
},
{
id: nanoid(),
type: 'paragraph',
data: {
text: 'Second block',
},
},
];
cy.get('@editorInstance')
.render({
blocks,
});
cy.get('[data-cy=editorjs]')
.get('div.ce-block')
.first()
.click()
.type('{enter}')
.next()
.type('Middle block');
cy.get('@editorInstance')
.then(async (editor: any) => {
const data = await editor.save();
expect(data.blocks[0].id).to.eq(blocks[0].id);
expect(data.blocks[2].id).to.eq(blocks[1].id);
});
});
});

View file

@ -126,7 +126,8 @@ describe('Copy pasting from Editor', () => {
});
cy.get('[data-cy=editorjs]')
.get('img')
// In Edge test are performed slower, so we need to increase timeout to wait until image is loaded on the page
.get('img', { timeout: 10000 })
.should('have.attr', 'src', 'https://codex.so/public/app/img/external/codex2x.png');
});
});

View file

@ -5,6 +5,11 @@ import {SavedData} from '../data-formats';
* @interface BlockAPI Describes Block API methods and properties
*/
export interface BlockAPI {
/**
* Block unique identifier
*/
readonly id: string;
/**
* Tool name
*/

View file

@ -54,6 +54,12 @@ export interface Blocks {
*/
getBlockByIndex(index: number): BlockAPI | void;
/**
* Returns Block API object by passed Block id
* @param id - id of the block
*/
getById(id: string): BlockAPI | null;
/**
* Returns current Block index
* @returns {number}

View file

@ -4,6 +4,7 @@ import {BlockToolData} from '../tools';
* Tool's saved data
*/
export interface SavedData {
id: string;
tool: string;
data: BlockToolData;
time: number;
@ -13,6 +14,7 @@ export interface SavedData {
* Tool's data after validation
*/
export interface ValidatedData {
id?: string;
tool?: string;
data?: BlockToolData;
time?: number;

View file

@ -1,5 +1,5 @@
import {BlockToolData} from '../tools';
import {BlockTuneData} from "../block-tunes/block-tune-data";
import {BlockTuneData} from '../block-tunes/block-tune-data';
/**
* Output of one Tool
@ -8,6 +8,10 @@ import {BlockTuneData} from "../block-tunes/block-tune-data";
* @template Data - the structure describing a data object supported by the tool
*/
export interface OutputBlockData<Type extends string = string, Data extends object = any> {
/**
* Unique Id of the block
*/
id?: string;
/**
* Tool type
*/
@ -18,7 +22,7 @@ export interface OutputBlockData<Type extends string = string, Data extends obje
data: BlockToolData<Data>;
/**
* Block Tunes data
* Block Tunes data
*/
tunes?: {[name: string]: BlockTuneData};
}

View file

@ -6248,6 +6248,11 @@ nanoid@3.1.20:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.20.tgz#badc263c6b1dcf14b71efaa85f6ab4c1d6cfc788"
integrity sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw==
nanoid@^3.1.22:
version "3.1.22"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.22.tgz#b35f8fb7d151990a8aebd5aa5015c03cf726f844"
integrity sha512-/2ZUaJX2ANuLtTvqTlgqBQNJoQO398KyJgZloL0PZkC0dpysjncRUPsFe3DUPzz/y3h+u7C46np8RMuvF3jsSQ==
nanomatch@^1.2.9:
version "1.2.13"
resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"