From 2831423d78203ce2e6da433cd49d05b1dd57f06e Mon Sep 17 00:00:00 2001 From: Georgy Berezhnoy Date: Sat, 16 Oct 2021 23:03:34 +0300 Subject: [PATCH 1/2] Extract inline fragments from HTML markup --- example/example-dev.html | 266 +++++++++++++++-------- package.json | 2 +- src/components/modules/blockManager.ts | 90 ++++++++ src/components/modules/renderer.ts | 3 +- src/components/modules/saver.ts | 109 +++++++++- src/components/utils/sanitizer.ts | 2 +- tsconfig.json | 2 +- types/data-formats/index.d.ts | 1 + types/data-formats/inline-fragments.d.ts | 9 + types/data-formats/output-data.d.ts | 3 + yarn.lock | 7 +- 11 files changed, 398 insertions(+), 96 deletions(-) create mode 100644 types/data-formats/inline-fragments.d.ts diff --git a/example/example-dev.html b/example/example-dev.html index 8064cb07e..373385dbc 100644 --- a/example/example-dev.html +++ b/example/example-dev.html @@ -69,12 +69,12 @@ - + - + @@ -131,11 +131,11 @@ */ image: SimpleImage, - list: { - class: NestedList, - inlineToolbar: true, - shortcut: 'CMD+SHIFT+L' - }, + // list: { + // class: NestedList, + // inlineToolbar: true, + // shortcut: 'CMD+SHIFT+L' + // }, checklist: { class: Checklist, @@ -176,12 +176,12 @@ raw: RawTool, embed: Embed, - - table: { - class: Table, - inlineToolbar: true, - shortcut: 'CMD+ALT+T' - }, + // + // table: { + // class: Table, + // inlineToolbar: true, + // shortcut: 'CMD+ALT+T' + // }, }, @@ -194,126 +194,220 @@ * Initial Editor data */ data: { - blocks: [ + "time" : 1634414421172, + "blocks" : [ { - id: "zcKCF1S7X8", - type: "header", - data: { - text: "Editor.js", - level: 2 + "id" : "zcKCF1S7X8", + "type" : "header", + "data" : { + "text" : "Editor.js", + "level" : 2 } }, { - "id": "b6ji-DvaKb", - "type": "paragraph", - "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." + "id" : "b6ji-DvaKb", + "type" : "paragraph", + "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 + "id" : "7ItVl5biRo", + "type" : "header", + "data" : { + "text" : "Key features", + "level" : 3 } }, { - type : 'list', - id: "SSBSguGvP7", - data : { - items : [ + "id" : "SSBSguGvP7", + "type" : "list", + "data" : { + "items" : [ { - content: 'It is a block-styled editor', - items: [] + "content" : "It is a block-styled editor", + "items" : [] }, { - content: 'It returns clean data output in JSON', - items: [] + "content" : "It returns clean data output in JSON", + "items" : [] }, { - content: 'Designed to be extendable and pluggable with a simple API', - items: [] + "content" : "Designed to be extendable and pluggable with a simple API", + "items" : [] } ], - style: 'unordered' + "style" : "unordered" } }, { - type: "header", - id: "QZFox1m_ul", - data: { - text: "What does it mean «block-styled editor»", - level: 3 + "id" : "QZFox1m_ul", + "type" : "header", + "data" : { + "text" : "What does it mean «block-styled editor»", + "level" : 3 } }, { - 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 workspace consists of separate Blocks: paragraphs, headings, images, lists, quotes, etc. Each of them is an independent contenteditable element (or more complex structure) provided by Plugin and united by Editor\'s Core.' + "id" : "bwnFX5LoX7", + "type" : "paragraph", + "data" : { + "text" : "Workspace in classic editors is made of a single contenteditable element, used to create different HTML markups. Editor.js workspace consists of separate Blocks: paragraphs, headings, images, lists, quotes, etc. Each of them is an independent contenteditable element (or more complex structure) provided by Plugin and united by Editor's Core." + }, + "fragments" : { + "text" : [ + { + "range" : [ + 123, + 210 + ], + "element" : "MARK", + "attributes" : { + "class" : "cdx-marker" + } + } + ] } }, { - type : 'paragraph', - id: "mTrPOHAQTe", - data : { - text : `There are dozens of ready-to-use Blocks and the simple API for creation any Block you need. For example, you can implement Blocks for Tweets, Instagram posts, surveys and polls, CTA-buttons and even games.` + "id" : "mTrPOHAQTe", + "type" : "paragraph", + "data" : { + "text" : "There are dozens of ready-to-use Blocks and the simple API for creation any Block you need. For example, you can implement Blocks for Tweets, Instagram posts, surveys and polls, CTA-buttons and even games." + }, + "fragments" : { + "text" : [ + { + "range" : [ + 20, + 39 + ], + "element" : "A", + "attributes" : { + "href" : "https://github.com/editor-js" + } + }, + { + "range" : [ + 48, + 58 + ], + "element" : "A", + "attributes" : { + "href" : "https://editorjs.io/creating-a-block-tool" + } + } + ] } }, { - type: "header", - id: "1sYMhUrznu", - data: { - text: "What does it mean clean data output", - level: 3 + "id" : "1sYMhUrznu", + "type" : "header", + "data" : { + "text" : "What does it mean clean data output", + "level" : 3 } }, { - 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' + "id" : "jpd7WEXrJG", + "type" : "paragraph", + "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 Web clients, render natively for mobile apps, create markup for Facebook Instant Articles or Google AMP, generate an audio version and so on.` + "id" : "0lOGNUKxqt", + "type" : "paragraph", + "data" : { + "text" : "Given data can be used as you want: render with HTML for Web clients, render natively for mobile apps, create markup for Facebook Instant Articles or Google AMP, generate an audio version and so on." + }, + "fragments" : { + "text" : [ + { + "range" : [ + 57, + 68 + ], + "element" : "CODE", + "attributes" : { + "class" : "inline-code" + } + }, + { + "range" : [ + 90, + 101 + ], + "element" : "CODE", + "attributes" : { + "class" : "inline-code" + } + }, + { + "range" : [ + 121, + 146 + ], + "element" : "CODE", + "attributes" : { + "class" : "inline-code" + } + }, + { + "range" : [ + 150, + 160 + ], + "element" : "CODE", + "attributes" : { + "class" : "inline-code" + } + }, + { + "range" : [ + 174, + 187 + ], + "element" : "CODE", + "attributes" : { + "class" : "inline-code" + } + } + ] } }, { - type : 'paragraph', - id: "WvX7kBjp0I", - data : { - text : 'Clean data is useful to sanitize, validate and process on the backend.' + "id" : "WvX7kBjp0I", + "type" : "paragraph", + "data" : { + "text" : "Clean data is useful to sanitize, validate and process on the backend." } }, { - type : 'delimiter', - id: "H9LWKQ3NYd", - data : {} + "id" : "H9LWKQ3NYd", + "type" : "delimiter", + "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. 😏' + "id" : "h298akk2Ad", + "type" : "paragraph", + "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: '', - stretched: false, - withBorder: true, - withBackground: false, + "id" : "9802bjaAA2", + "type" : "image", + "data" : { + "url" : "assets/codex2x.png", + "caption" : "", + "withBorder" : true, + "withBackground" : false, + "stretched" : false } - }, - ] + } + ], + "version" : "2.23.0-rc.0" }, onReady: function(){ saveButton.click(); diff --git a/package.json b/package.json index f96813aa3..b928617a3 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "terser-webpack-plugin": "^2.3.6", "ts-loader": "^7.0.1", "tslint": "^6.1.1", - "typescript": "3.8.3", + "typescript": "4.4.3", "webpack": "^4.43.0", "webpack-cli": "^3.3.11" }, diff --git a/src/components/modules/blockManager.ts b/src/components/modules/blockManager.ts index 10da5ef29..3db05f27f 100644 --- a/src/components/modules/blockManager.ts +++ b/src/components/modules/blockManager.ts @@ -15,6 +15,7 @@ import { BlockToolData, PasteEvent } from '../../../types'; import { BlockTuneData } from '../../../types/block-tunes/block-tune-data'; import BlockAPI from '../block/api'; import { BlockMutationType } from '../../../types/events/block/mutation-type'; +import { InlineFragment, InlineFragmentsDict, SavedData } from '../../../types/data-formats'; /** * @typedef {BlockManager} BlockManager @@ -268,6 +269,7 @@ export default class BlockManager extends Module { needToFocus = true, replace = false, tunes = {}, + fragments, }: { id?: string; tool?: string; @@ -276,6 +278,7 @@ export default class BlockManager extends Module { needToFocus?: boolean; replace?: boolean; tunes?: {[name: string]: BlockTuneData}; + fragments?: InlineFragmentsDict; } = {}): Block { let newIndex = index; @@ -283,6 +286,10 @@ export default class BlockManager extends Module { newIndex = this.currentBlockIndex + (replace ? 0 : 1); } + if (fragments !== undefined) { + data = this.insertInlineFragments(data, fragments); + } + const block = this.composeBlock({ id, tool, @@ -318,6 +325,89 @@ export default class BlockManager extends Module { return block; } + /** + * + * @param data + * @param fragmentsDict + * @private + */ + public insertInlineFragments(data: Pick, fragmentsDict: InlineFragmentsDict): Pick { + const insertToString = (str: string, fragments: InlineFragment[]): string => { + const template = document.createElement('template'); + + template.innerHTML = str; + + const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_TEXT); + + const root = walker.currentNode; + + fragments.forEach(fragment => { + let offset = 0; + let startOffset = 0; + let endOffset = 0; + let startNode: Node | null = null; + let endNode: Node | null = null; + + while (!startNode || !endNode) { + const node = walker.nextNode(); + + if (!startNode && offset + node.textContent.length > fragment.range[0]) { + startNode = node; + startOffset = fragment.range[0] - offset; + } + + if (!endNode && offset + node.textContent.length >= fragment.range[1]) { + endNode = node; + endOffset = fragment.range[1] - offset; + } + + offset += node.textContent.length; + } + + const range = new Range(); + + range.setStart(startNode, startOffset); + range.setEnd(endNode, endOffset); + + const element = document.createElement(fragment.element); + + Object.entries(fragment.attributes).map(([name, value]) => element.setAttribute(name, value as string)); + + range.surroundContents(element); + + walker.currentNode = root; + }); + + return template.innerHTML; + }; + + const insert = (dataToProcess: Record, fragments: InlineFragmentsDict): Pick => { + Object + .entries(dataToProcess) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + dataToProcess[key] = value.map(v => insert(v, fragments[key] as InlineFragmentsDict)); + + return; + } + + if (typeof value === 'object') { + dataToProcess[key] = insert(value as Record, fragments[key] as InlineFragmentsDict); + + return; + } + + if (typeof value === 'string') { + dataToProcess[key] = insertToString(value, fragments[key] as InlineFragment[]); + } + }); + + return dataToProcess as Pick; + }; + + return insert(data, fragmentsDict); + } + /** * Replace current working block * diff --git a/src/components/modules/renderer.ts b/src/components/modules/renderer.ts index 9aad79ff8..a4fe507ec 100644 --- a/src/components/modules/renderer.ts +++ b/src/components/modules/renderer.ts @@ -73,7 +73,7 @@ export default class Renderer extends Module { */ public async insertBlock(item: OutputBlockData): Promise { const { Tools, BlockManager } = this.Editor; - const { type: tool, data, tunes, id } = item; + const { type: tool, data, tunes, id, fragments } = item; if (Tools.available.has(tool)) { try { @@ -82,6 +82,7 @@ export default class Renderer extends Module { tool, data, tunes, + fragments, }); } catch (error) { _.log(`Block «${tool}» skipped because of plugins error`, 'warn', data); diff --git a/src/components/modules/saver.ts b/src/components/modules/saver.ts index 59725f65c..5219cd54a 100644 --- a/src/components/modules/saver.ts +++ b/src/components/modules/saver.ts @@ -7,10 +7,11 @@ */ import Module from '../__module'; import { OutputData } from '../../../types'; -import { SavedData, ValidatedData } from '../../../types/data-formats'; +import { InlineFragment, InlineFragmentsDict, SavedData, ValidatedData } from '../../../types/data-formats'; import Block from '../block'; import * as _ from '../utils'; -import { sanitizeBlocks } from '../utils/sanitizer'; +import $ from '../dom'; +import { deepSanitize, sanitizeBlocks } from '../utils/sanitizer'; declare const VERSION: string; @@ -43,11 +44,25 @@ export default class Saver extends Module { }); const extractedData = await Promise.all(chainData) as Array>; - const sanitizedData = await sanitizeBlocks(extractedData, (name) => { + const sanitizedData = sanitizeBlocks(extractedData, (name) => { return Tools.blockTools.get(name).sanitizeConfig; }); + const withFragments = sanitizedData.map(savedData => { + if (savedData.tool === this.Editor.Tools.stubTool) { + return savedData; + } - return this.makeOutput(sanitizedData); + const fragments = this.extractInlineFragments(savedData.data); + + savedData.data = deepSanitize(savedData.data, {}); + + return { + ...savedData, + fragments, + }; + }); + + return this.makeOutput(withFragments); } catch (e) { _.logLabeled(`Saving failed due to the Error %o`, 'error', e); } finally { @@ -83,7 +98,7 @@ export default class Saver extends Module { _.log('[Editor.js saving]:', 'groupCollapsed'); - allExtractedData.forEach(({ id, tool, data, tunes, time, isValid }) => { + allExtractedData.forEach(({ id, tool, data, tunes, fragments, time, isValid }) => { totalTime += time; /** @@ -116,6 +131,9 @@ export default class Saver extends Module { ...!_.isEmpty(tunes) && { tunes, }, + ...!_.isEmpty(fragments) && { + fragments, + }, }; blocks.push(output); @@ -130,4 +148,85 @@ export default class Saver extends Module { version: VERSION, }; } + + /** + * + * @param data + * @private + */ + private extractInlineFragments(data: Pick): InlineFragmentsDict { + const extractFromString = (str: string): InlineFragment[] => { + const template = $.make('template') as HTMLTemplateElement; + + template.innerHTML = str; + + const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); + + let node: Node | null; + let offset = 0; + const fragments: InlineFragment[] = []; + + while ((node = walker.nextNode()) !== null) { + switch (node.nodeType) { + case Node.TEXT_NODE: + offset += node.textContent.length; + break; + case Node.ELEMENT_NODE: { + const length = node.textContent.length; + const fragment: InlineFragment = { + range: [offset, offset + length], + element: node.nodeName, + attributes: Object.fromEntries(Array.from((node as HTMLElement).attributes).map(attr => ([attr.nodeName, attr.nodeValue]))), + }; + + fragments.push(fragment); + + break; + } + } + } + + return fragments; + }; + + const extract = (obj: Record): InlineFragmentsDict => { + const result: InlineFragmentsDict = {}; + + Object + .entries(obj) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + const fragments = value.map(v => extract(v)).filter(fragment => Object.keys(fragment).length > 0); + + if (fragments.length > 0) { + result[key] = fragments; + } + + return; + } + + if (typeof value === 'object') { + const fragments = extract(obj); + + if (Object.keys(fragments).length > 0) { + result[key] = fragments; + } + + return; + } + + if (typeof value === 'string') { + const fragments = extractFromString(value); + + if (fragments.length > 0) { + result[key] = fragments; + } + } + }); + + return result; + }; + + return extract(data); + } } diff --git a/src/components/utils/sanitizer.ts b/src/components/utils/sanitizer.ts index 5b4bc8a28..55cdd6b13 100644 --- a/src/components/utils/sanitizer.ts +++ b/src/components/utils/sanitizer.ts @@ -87,7 +87,7 @@ export function clean(taintString: string, customConfig: SanitizerConfig = {} as * @param {BlockToolData|object|*} dataToSanitize - taint string or object/array that contains taint string * @param {SanitizerConfig} rules - object with sanitizer rules */ -function deepSanitize(dataToSanitize: object | string, rules: SanitizerConfig): object | string { +export function deepSanitize(dataToSanitize: object | string, rules: SanitizerConfig): object | string { /** * BlockData It may contain 3 types: * - Array diff --git a/tsconfig.json b/tsconfig.json index c95c063bb..67923b684 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "target": "es2017", "declaration": false, "moduleResolution": "node", // This resolution strategy attempts to mimic the Node.js module resolution mechanism at runtime - "lib": ["dom", "es2017", "es2018"], + "lib": ["dom", "es2017", "es2018", "ES2019"], // allows to import .json files for i18n "resolveJsonModule": true, diff --git a/types/data-formats/index.d.ts b/types/data-formats/index.d.ts index 410f36539..f1f143a7e 100644 --- a/types/data-formats/index.d.ts +++ b/types/data-formats/index.d.ts @@ -1,2 +1,3 @@ export * from './block-data'; +export * from './inline-fragments'; export * from './output-data'; diff --git a/types/data-formats/inline-fragments.d.ts b/types/data-formats/inline-fragments.d.ts new file mode 100644 index 000000000..a4b54bf89 --- /dev/null +++ b/types/data-formats/inline-fragments.d.ts @@ -0,0 +1,9 @@ +export interface InlineFragment { + range: [number, number]; + tool?: string; + element: string; + attributes?: Record; +} + +export type InlineFragmentsDict = {[key: string]: InlineFragment[] | InlineFragmentsDict | InlineFragmentsDict[]}; + diff --git a/types/data-formats/output-data.d.ts b/types/data-formats/output-data.d.ts index 07f296e10..c1d58707f 100644 --- a/types/data-formats/output-data.d.ts +++ b/types/data-formats/output-data.d.ts @@ -1,5 +1,6 @@ import {BlockToolData} from '../tools'; import {BlockTuneData} from '../block-tunes/block-tune-data'; +import {InlineFragmentsDict} from './inline-fragments'; /** * Output of one Tool @@ -25,6 +26,8 @@ export interface OutputBlockData Date: Thu, 14 Jul 2022 15:04:55 +0100 Subject: [PATCH 2/2] WIP --- example/example-dev.html | 185 +-------- package.json | 5 +- src/components/block/index.ts | 8 + .../block/inline-fragments/container.ts | 351 ++++++++++++++++++ .../block/inline-fragments/range.ts | 223 +++++++++++ .../inline-tools/inline-tool-bold.ts | 12 + .../inline-tools/inline-tool-italic.ts | 12 + .../inline-tools/inline-tool-link.ts | 22 ++ src/components/modules/saver.ts | 36 +- src/components/modules/toolbar/inline.ts | 59 +-- src/components/utils.ts | 11 +- src/components/utils/sanitizer.ts | 2 +- src/styles/variables.css | 2 +- test/cypress/tests/api/block.spec.ts | 2 +- test/cypress/tests/block-ids.spec.ts | 2 +- test/cypress/tests/copy-paste.spec.ts | 2 +- test/cypress/tests/i18n.spec.ts | 6 +- test/cypress/tests/selection.spec.ts | 2 +- tsconfig.json | 2 +- types/data-formats/block-data.d.ts | 1 + types/tools/inline-tool.d.ts | 4 + yarn.lock | 12 +- 22 files changed, 723 insertions(+), 238 deletions(-) create mode 100644 src/components/block/inline-fragments/container.ts create mode 100644 src/components/block/inline-fragments/range.ts diff --git a/example/example-dev.html b/example/example-dev.html index f8687184e..5c048c9d1 100644 --- a/example/example-dev.html +++ b/example/example-dev.html @@ -138,75 +138,12 @@ * Tools list */ tools: { + paragraph: { + inlineToolbar: ['italic', 'bold', 'link'] + } /** * Each Tool is a Plugin. Pass them via 'class' option with necessary settings {@link docs/tools.md} */ - header: { - class: Header, - inlineToolbar: ['marker', 'link'], - config: { - placeholder: 'Header' - }, - shortcut: 'CMD+SHIFT+H' - }, - - /** - * Or pass class directly without any configuration - */ - image: SimpleImage, - - list: { - class: NestedList, - inlineToolbar: true, - shortcut: 'CMD+SHIFT+L' - }, - - checklist: { - class: Checklist, - inlineToolbar: true, - }, - - quote: { - class: Quote, - inlineToolbar: true, - config: { - quotePlaceholder: 'Enter a quote', - captionPlaceholder: 'Quote\'s author', - }, - shortcut: 'CMD+SHIFT+O' - }, - - warning: Warning, - - marker: { - class: Marker, - shortcut: 'CMD+SHIFT+M' - }, - - code: { - class: CodeTool, - shortcut: 'CMD+SHIFT+C' - }, - - delimiter: Delimiter, - - inlineCode: { - class: InlineCode, - shortcut: 'CMD+SHIFT+C' - }, - - linkTool: LinkTool, - - raw: RawTool, - - embed: Embed, - - table: { - class: Table, - inlineToolbar: true, - shortcut: 'CMD+ALT+T' - }, - }, /** @@ -220,123 +157,11 @@ data: { blocks: [ { - id: "zcKCF1S7X8", - type: "header", - data: { - text: "Editor.js", - level: 1 - } - }, - { - "id": "b6ji-DvaKb", "type": "paragraph", "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: 2 - } - }, - { - type : 'list', - id: "SSBSguGvP7", - data : { - items : [ - { - content: 'It is a block-styled editor', - items: [] - }, - { - content: 'It returns clean data output in JSON', - items: [] - }, - { - content: 'Designed to be extendable and pluggable with a simple API', - items: [] - } - ], - style: 'unordered' - } - }, - { - type: "header", - id: "QZFox1m_ul", - data: { - text: "What does it mean «block-styled editor»", - level: 2 - } - }, - { - 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 workspace consists of separate Blocks: paragraphs, headings, images, lists, quotes, etc. 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 ready-to-use Blocks and the simple API 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: 2 - } - }, - { - 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 Web clients, render natively for mobile apps, create markup for Facebook Instant Articles or Google AMP, generate an audio version 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: '', - stretched: false, - withBorder: true, - withBackground: false, + "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer commodo, neque in pulvinar sodales, ante lacus blandit massa, vel tempor." } - }, + } ] }, onReady: function(){ diff --git a/package.json b/package.json index 2c5452aef..4dab0d381 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "terser-webpack-plugin": "^2.3.6", "ts-loader": "^7.0.1", "tslint": "^6.1.1", - "typescript": "4.4.3", + "typescript": "^4.6.4", "webpack": "^4.43.0", "webpack-cli": "^3.3.11" }, @@ -101,5 +101,8 @@ "codex-notifier": "^1.1.2", "codex-tooltip": "^1.0.5", "nanoid": "^3.1.22" + }, + "resolutions": { + "typescript": "4.6.4" } } diff --git a/src/components/block/index.ts b/src/components/block/index.ts index 9a9f98515..616aa7614 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -3,6 +3,7 @@ import { BlockTool as IBlockTool, BlockToolData, BlockTune as IBlockTune, + InlineTool as IInlineTool, SanitizerConfig, ToolConfig } from '../../../types'; @@ -19,6 +20,7 @@ import BlockTune from '../tools/tune'; import { BlockTuneData } from '../../../types/block-tunes/block-tune-data'; import ToolsCollection from '../tools/collection'; import EventsDispatcher from '../utils/events'; +import InlineFragmentsContainer from './inline-fragments/container'; /** * Interface describes Block class constructor argument @@ -144,6 +146,8 @@ export default class Block extends EventsDispatcher { */ public readonly config: ToolConfig; + public fragments: InlineFragmentsContainer; + /** * Cached inputs * @@ -276,6 +280,8 @@ export default class Block extends EventsDispatcher { this.composeTunes(tunesData); this.holder = this.compose(); + + this.fragments = new InlineFragmentsContainer(tool, this.inputs[0]); } /** @@ -583,6 +589,8 @@ export default class Block extends EventsDispatcher { const extractedBlock = await this.toolInstance.save(this.pluginsContent as HTMLElement); const tunesData: { [name: string]: BlockTuneData } = this.unavailableTunesData; + console.log(this.fragments.save()); + [ ...this.tunesInstances.entries(), ...this.defaultTunesInstances.entries(), diff --git a/src/components/block/inline-fragments/container.ts b/src/components/block/inline-fragments/container.ts new file mode 100644 index 000000000..1b8c81eef --- /dev/null +++ b/src/components/block/inline-fragments/container.ts @@ -0,0 +1,351 @@ +import SelectionUtils from '../../selection'; +import { nanoid } from 'nanoid'; +import { InlineTool as IInlineTool } from '../../../../types'; +import BlockTool from '../../tools/block'; +import ToolsCollection from '../../tools/collection'; +import InlineTool from '../../tools/inline'; +import FragmentRange from './range'; + +export interface InlineFragmentMeta { + id: string; + tool: string; + data: T; +} + +/** + * + */ +export default class InlineFragmentsContainer { + public inlineTools: ToolsCollection; + public instances: Map; + + private ranges: Map = new Map(); + private meta: Map = new Map(); + private element: HTMLElement; + + /** + * + * @param tool + * @param element + */ + constructor(tool: BlockTool, element: HTMLElement, fragments = []) { + this.element = element; + this.inlineTools = tool.inlineTools; + this.instances = new Map( + Array + .from(this.inlineTools) + .map(([name, inlineTool]) => ([name, inlineTool.create()])) + ); + } + + /** + * @param name + */ + public insert(name: string, initialMeta?: InlineFragmentMeta): void { + const range = FragmentRange.from(SelectionUtils.range); + + const [parent, {id, data}] = this.insertElement(range, this.instances.get(name), initialMeta); + const meta: InlineFragmentMeta = { + id, + data, + tool: name, + }; + + this.meta.set(id, meta); + + this.flatten(); + + this.merge(); + + this.restoreRanges(); + + console.log(Array.from(this.ranges.values()).map(r => r.toString())); + } + + /** + * + * @param name + */ + public isToolActive(name: string): boolean { + return !!this.activeTools.find(({tool}) => tool === name); + } + + /** + * + */ + public get activeTools(): InlineFragmentMeta[] { + const range = FragmentRange.from(SelectionUtils.range); + + return Array + .from(this.ranges) + .filter(([_, r]) => r.includesRange(range)) + .map(([id]) => this.meta.get(id)); + } + + public save() { + const ranges = Array.from(this.ranges); + + const getAbsoluteCoord = (container: Node, offset: number) => { + let absoluteOffset = 0; + + if (container.nodeType === Node.ELEMENT_NODE) { + for (let i = 0; i < offset; i++) { + absoluteOffset += container.childNodes[i].textContent.length; + } + } else { + absoluteOffset += offset; + } + + let node = container; + + // @ts-ignore + while (!(node.classList && node.classList.contains('ce-block__content'))) { + // @ts-ignore + const nodeIndex = Array.from(node.parentNode.childNodes).indexOf(node); + + for (let i = 0; i < nodeIndex; i++) { + absoluteOffset += node.parentNode.childNodes[i].textContent.length; + } + + node = node.parentElement; + } + + return absoluteOffset; + } + + return ranges.map(([id, range]) => { + const start = getAbsoluteCoord(range.startContainer, range.startOffset); + const end = getAbsoluteCoord(range.endContainer, range.endOffset); + + return { + id, + range: [start, end], + meta: this.meta.get(id), + } + }) + } + + /** + * + * @private + */ + private restoreRanges(): void { + const rangeElements = this.getElements(); + const id2elements = new Map(); + + this.ranges = new Map(); + + rangeElements.forEach((element) => { + if (!element.textContent.length) { + element.remove(); + + return; + } + + const id = element.dataset.rangeId; + + if (!id2elements.has(id)) { + id2elements.set(id, []); + } + + id2elements.get(id).push(element); + }); + + Array + .from(id2elements) + .forEach(([id, elements]) => { + const first = elements.shift(); + const last = elements.pop(); + + const range = new Range(); + + range.setStartBefore(first); + range.setEndAfter(last ?? first); + + this.ranges.set(id, FragmentRange.from(range)); + }); + } + + /** + * + * @param element + * @private + */ + private flatten() { + const nodes = (Array.from(this.element.children) as HTMLElement[]).filter(n => !!n.dataset.rangeId); + + const flattenNode = (node: HTMLElement) => { + const childNodes = Array.from(node.childNodes); + + childNodes.forEach(child => { + if (child.nodeType === Node.ELEMENT_NODE) { + flattenNode(child as HTMLElement); + } + }); + + const flattenedChildNodes = Array.from(node.childNodes); + + if (flattenedChildNodes.length === 1) { + return; + } + + flattenedChildNodes.forEach(child => { + const clone = node.cloneNode(); + + clone.appendChild(child); + + node.parentNode.insertBefore(clone, node); + }); + + node.remove(); + } + + nodes.forEach(flattenNode); + } + + /** + * + * @param nodes + * @private + */ + private merge(): void { + const nodes = (Array.from(this.element.children) as HTMLElement[]).filter(n => !!n.dataset.rangeId); + + const mergeNodeInternals = (node: HTMLElement) => { + const allFragments = Array.from(node.querySelectorAll('*')) as HTMLElement[]; + + allFragments.unshift(node); + + const fragmentsMeta = allFragments.map(n => this.metaForNode(n)); + const result = []; + const nodesToRemove = []; + + fragmentsMeta.forEach(meta => { + /** @todo data is equal */ + const sameMeta = result.find(m => m.tool === meta.tool); + + if (sameMeta) { + nodesToRemove.push(meta.id); + return; + } + + result.push(meta); + }); + + nodesToRemove.forEach(id => { + const fragmentToRemove = allFragments.find(n => n.dataset.rangeId === id); + + Array.from(fragmentToRemove.childNodes).forEach(child => { + fragmentToRemove.parentElement.insertBefore(child, fragmentToRemove); + }); + + fragmentToRemove.remove(); + }); + + return node; + } + + const mergeTwoFragments = (previousFragment: HTMLElement, fragment: HTMLElement) => { + const previousFragments = Array.from(previousFragment.querySelectorAll('*')) as HTMLElement[]; + + previousFragments.unshift(previousFragment); + + const fragments = Array.from(fragment.querySelectorAll('*')) as HTMLElement[]; + + fragments.unshift(fragment); + + const previousFragmentsMeta = previousFragments.map(n => this.metaForNode(n)); + const fragmentsMeta = fragments.map(n => this.metaForNode(n)); + + /** Special case */ + if (previousFragmentsMeta.length === fragmentsMeta.length && fragmentsMeta.every(meta => { + /** @todo check if data equals */ + return previousFragmentsMeta.find(m => m.tool === meta.tool); + })) { + const deepestNode = previousFragments.at(-1); + + deepestNode.append(fragment.textContent); + + deepestNode.normalize(); + + fragment.remove(); + + return; + } + + fragmentsMeta.forEach(meta => { + /** @todo check if data equals */ + const sameMeta = previousFragmentsMeta.find(m => m.tool === meta.tool); + + if (!sameMeta) { + return; + } + + const node = fragments.find(f => f.dataset.rangeId === meta.id)!; + + node.dataset.rangeId = sameMeta.id; + }); + } + + const firstNode = nodes.shift(); + + mergeNodeInternals(firstNode); + + nodes.reduce((previousNode, node) => { + mergeNodeInternals(node); + + mergeTwoFragments(previousNode, node); + + if (!node.parentNode) { + return previousNode; + } + + return node; + }, firstNode); + } + + /** + * + * @param node + * @private + */ + private metaForNode(node: HTMLElement): InlineFragmentMeta { + const id = node.dataset.rangeId; + + return this.meta.get(id); + } + + /** + * + * @param id + * @private + */ + private getElements(id?: string): HTMLElement[] { + const selector = id ? `[data-range-id="${id}"]` : '[data-range-id]'; + + return Array.from(this.element.querySelectorAll(selector)); + } + + /** + * + * @param range + * @param tool + * @private + */ + private insertElement(range: Range, tool: IInlineTool, initialMeta?: InlineFragmentMeta): [HTMLElement, Omit] { + const id = nanoid(6); + const contents = range.extractContents(); + const {element, meta} = tool.apply(contents, initialMeta); + + element.dataset.rangeId = id; + + range.insertNode(element); + range.setStart(element, 0); + range.setEnd(element, element.childNodes.length); + + return [element, { + id, + data: meta, + }]; + } +} diff --git a/src/components/block/inline-fragments/range.ts b/src/components/block/inline-fragments/range.ts new file mode 100644 index 000000000..ec62f28f3 --- /dev/null +++ b/src/components/block/inline-fragments/range.ts @@ -0,0 +1,223 @@ +import { multiply } from '../../utils'; + +/** + * + */ +export default class FragmentRange extends Range { + /** + * + * @param range + * @param clone + */ + public static from(range: Range, clone = false): FragmentRange { + let result = range; + + if (clone) { + result = range.cloneRange(); + } + + Object.setPrototypeOf(result, new FragmentRange()); + + return result as FragmentRange; + } + + /** + * + */ + public cloneRange(): FragmentRange { + return FragmentRange.from(super.cloneRange()); + } + + /** + * + * @param range + */ + public equalsToRange(range: Range): boolean { + const { s2s, e2e } = this.getBoundaryComparison(range); + + return s2s === 0 && e2e === 0; + } + + /** + * + * @param range + */ + public includesRange(range: Range): boolean { + return this.isPointInRange(range.startContainer, range.startOffset) && this.isPointInRange(range.endContainer, range.endOffset); + } + + /** + * + * @param range + */ + public subtractRange(range: FragmentRange): null | FragmentRange | [FragmentRange, FragmentRange] { + if (!this.intersectsRange(range)) { + return this.cloneRange(); + } + + if (this.equalsToRange(range)) { + return null; + } + + if (range.includesRange(this)) { + return range.subtractRange(this); + } + + if (this.includesRange(range)) { + const left = this.cloneRange(); + const right = this.cloneRange(); + + left.setEnd(range.startContainer, range.startOffset); + + right.setStart(range.endContainer, range.endOffset); + + const { s2s, e2e } = this.getBoundaryComparison(range); + + if (s2s === 0) { + return right; + } + + if (e2e === 0) { + return left; + } + + return [left, right]; + } + + const result = this.cloneRange(); + + if (this.startsBefore(range)) { + result.setEnd(range.startContainer, range.startOffset); + } else { + result.setStart(range.endContainer, range.endOffset); + } + + return result; + } + + /** + * + * @param range + */ + public mergeRange(range: FragmentRange): FragmentRange { + if (!this.intersectsRange(range)) { + return null; + } + + if (this.includesRange(range)) { + return this.cloneRange(); + } + + if (range.includesRange(this)) { + return range.cloneRange(); + } + + const result = this.cloneRange(); + + if (this.startsBefore(range)) { + result.setEnd(range.endContainer, range.endOffset); + } else { + result.setStart(range.startContainer, range.startOffset); + } + + return result; + } + + /** + * + * @param range + */ + public intersectsRange(range: Range): boolean { + const { s2s, s2e, e2s, e2e } = this.getBoundaryComparison(range); + + if (this.equalsToRange(range)) { + return true; + } + + if ( + multiply(s2s, s2e) === -1 || + multiply(e2s, e2e) === -1 || + multiply(s2s, e2s) === -1 || + multiply(s2e, e2e) === -1 + ) { + return true; + } + + return false; + } + + /** + * + * @param range + */ + public startsBefore(range: Range): boolean { + const { s2s } = this.getBoundaryComparison(range); + + return s2s === -1; + } + + /** + * + * @param node + */ + public includesNode(node: Node): boolean { + const isText = node.nodeType === Node.TEXT_NODE; + + return this.comparePoint(node, 0) > -1 && this.comparePoint(node, isText ? (node as Text).length : node.childNodes.length) < 1; + } + + /** + * + */ + public getIncludedElements(): HTMLElement[] { + const walker = document.createTreeWalker(this.commonAncestorContainer, NodeFilter.SHOW_ELEMENT, { + acceptNode: (node: HTMLElement): number => { + return this.includesNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; + }, + }); + + const elements: HTMLElement[] = []; + + while (walker.nextNode()) { + elements.push(walker.currentNode as HTMLElement); + } + + return elements; + } + + /** + * + */ + public unwrap(): void { + const contents = this.extractContents(); + + if (contents.childNodes.length > 1) { + throw new Error('Range includes more than one top-level element'); + } + + contents.append(...Array.from(contents.firstChild.childNodes)); + + contents.firstChild.remove(); + + this.insertNode(contents); + } + + /** + * + * @param range + * @private + */ + private getBoundaryComparison(range: Range): { s2s: number; s2e: number; e2s: number; e2e: number } { + const s2s = this.compareBoundaryPoints(Range.START_TO_START, range); + const s2e = this.compareBoundaryPoints(Range.START_TO_END, range); + const e2s = this.compareBoundaryPoints(Range.END_TO_START, range); + const e2e = this.compareBoundaryPoints(Range.END_TO_END, range); + + return { + s2s, + s2e, + e2s, + e2e, + }; + } +} diff --git a/src/components/inline-tools/inline-tool-bold.ts b/src/components/inline-tools/inline-tool-bold.ts index a33753ded..be835c770 100644 --- a/src/components/inline-tools/inline-tool-bold.ts +++ b/src/components/inline-tools/inline-tool-bold.ts @@ -98,4 +98,16 @@ export default class BoldInlineTool implements InlineTool { public get shortcut(): string { return 'CMD+B'; } + + public apply(contents: DocumentFragment): { element: HTMLElement } { + const b = document.createElement('b'); + + b.append(contents); + + return { element: b }; + } + + public set active(state: boolean) { + this.nodes.button.classList.toggle(this.CSS.buttonActive, state); + } } diff --git a/src/components/inline-tools/inline-tool-italic.ts b/src/components/inline-tools/inline-tool-italic.ts index da728b81f..f6c25b808 100644 --- a/src/components/inline-tools/inline-tool-italic.ts +++ b/src/components/inline-tools/inline-tool-italic.ts @@ -94,4 +94,16 @@ export default class ItalicInlineTool implements InlineTool { public get shortcut(): string { return 'CMD+I'; } + + public apply(contents: DocumentFragment): { element: HTMLElement } { + const i = document.createElement('i'); + + i.append(contents); + + return { element: i }; + } + + public set active(state: boolean) { + this.nodes.button.classList.toggle(this.CSS.buttonActive, state); + } } diff --git a/src/components/inline-tools/inline-tool-link.ts b/src/components/inline-tools/inline-tool-link.ts index 6c5db6d5c..b5708509d 100644 --- a/src/components/inline-tools/inline-tool-link.ts +++ b/src/components/inline-tools/inline-tool-link.ts @@ -24,6 +24,7 @@ export default class LinkInlineTool implements InlineTool { * Title for hover-tooltip */ public static title = 'Link'; + private fakeBackground: HTMLSpanElement; /** * Sanitizer Rule @@ -228,6 +229,27 @@ export default class LinkInlineTool implements InlineTool { return 'CMD+K'; } + public apply(contents: DocumentFragment): { element: HTMLElement } { + this.fakeBackground = document.createElement('span'); + + this.fakeBackground.style.background = '#a8d6ff'; + + this.fakeBackground.append(contents); + + this.openActions(true); + + const a = document.createElement('a'); + + a.append(contents); + + return { element: this.fakeBackground }; + } + + public set active(state: boolean) { + this.nodes.button.classList.toggle(this.CSS.buttonUnlink, state); + this.nodes.button.classList.toggle(this.CSS.buttonActive, state); + } + /** * Show/close link input */ diff --git a/src/components/modules/saver.ts b/src/components/modules/saver.ts index a2bb25c74..a7183236a 100644 --- a/src/components/modules/saver.ts +++ b/src/components/modules/saver.ts @@ -38,26 +38,26 @@ export default class Saver extends Module { chainData.push(this.getSavedData(block)); }); - const extractedData = await Promise.all(chainData) as Array>; + const extractedData = await Promise.all(chainData) as Pick[]; const sanitizedData = sanitizeBlocks(extractedData, (name) => { return Tools.blockTools.get(name).sanitizeConfig; }); - const withFragments = sanitizedData.map(savedData => { - if (savedData.tool === this.Editor.Tools.stubTool) { - return savedData; - } - - const fragments = this.extractInlineFragments(savedData.data); - - savedData.data = deepSanitize(savedData.data, {}); - - return { - ...savedData, - fragments, - }; - }); - - return this.makeOutput(withFragments); + // const withFragments = sanitizedData.map(savedData => { + // if (savedData.tool === this.Editor.Tools.stubTool) { + // return savedData; + // } + // + // const fragments = this.extractInlineFragments(savedData.data); + // + // savedData.data = deepSanitize(savedData.data, {}); + // + // return { + // ...savedData, + // fragments, + // }; + // }); + + return this.makeOutput(sanitizedData); } catch (e) { _.logLabeled(`Saving failed due to the Error %o`, 'error', e); } @@ -71,10 +71,12 @@ export default class Saver extends Module { */ private async getSavedData(block: Block): Promise { const blockData = await block.save(); + const fragments = block.fragments.save(); const isValid = blockData && await block.validate(blockData.data); return { ...blockData, + fragments, isValid, }; } diff --git a/src/components/modules/toolbar/inline.ts b/src/components/modules/toolbar/inline.ts index 799b60fd5..340682958 100644 --- a/src/components/modules/toolbar/inline.ts +++ b/src/components/modules/toolbar/inline.ts @@ -11,6 +11,8 @@ import Tooltip from '../../utils/tooltip'; import { ModuleConfig } from '../../../types-internal/module-config'; import InlineTool from '../../tools/inline'; import { CommonInternalSettings } from '../../tools/base'; +import Block from '../../block'; +import { debug } from 'webpack'; /** * Inline Toolbar elements @@ -239,6 +241,8 @@ export default class InlineToolbar extends Module { */ public open(needToShowConversionToolbar = true): void { if (this.opened) { + this.checkToolsState(); + return; } /** @@ -516,8 +520,8 @@ export default class InlineToolbar extends Module { this.nodes.actions.innerHTML = ''; this.toolsInstances = new Map(); - Array.from(currentBlock.tool.inlineTools.values()).forEach(tool => { - this.addTool(tool); + Array.from(currentBlock.fragments.instances).forEach(([name, tool]) => { + this.addTool(currentBlock.fragments.inlineTools.get(name), tool, currentBlock.fragments.isToolActive(name)); }); /** @@ -527,13 +531,12 @@ export default class InlineToolbar extends Module { } /** - * Add tool button and activate clicks + * Add toolInstance button and activate clicks * - * @param {InlineTool} tool - InlineTool object + * @param {InlineTool} toolInstance - InlineTool object */ - private addTool(tool: InlineTool): void { - const instance = tool.create(); - const button = instance.render(); + private addTool(tool: InlineTool, toolInstance: IInlineTool, isActive: boolean): void { + const button = toolInstance.render(); if (!button) { _.log('Render method must return an instance of Node', 'warn', tool.name); @@ -543,16 +546,16 @@ export default class InlineToolbar extends Module { button.dataset.tool = tool.name; this.nodes.buttons.appendChild(button); - this.toolsInstances.set(tool.name, instance); + this.toolsInstances.set(tool.name, toolInstance); - if (_.isFunction(instance.renderActions)) { - const actions = instance.renderActions(); + if (_.isFunction(toolInstance.renderActions)) { + const actions = toolInstance.renderActions(); this.nodes.actions.appendChild(actions); } this.listeners.on(button, 'click', (event) => { - this.toolClicked(instance); + this.toolClicked(tool.name); event.preventDefault(); }); @@ -560,7 +563,7 @@ export default class InlineToolbar extends Module { if (shortcut) { try { - this.enableShortcuts(instance, shortcut); + this.enableShortcuts(tool.name, toolInstance, shortcut); } catch (e) {} } @@ -586,7 +589,7 @@ export default class InlineToolbar extends Module { hidingDelay: 100, }); - instance.checkState(SelectionUtils.get()); + toolInstance.active = isActive; } /** @@ -623,7 +626,7 @@ export default class InlineToolbar extends Module { * @param {InlineTool} tool - Tool instance * @param {string} shortcut - shortcut according to the ShortcutData Module format */ - private enableShortcuts(tool: IInlineTool, shortcut: string): void { + private enableShortcuts(name: string, tool: IInlineTool, shortcut: string): void { Shortcuts.add({ name: shortcut, handler: (event) => { @@ -648,7 +651,7 @@ export default class InlineToolbar extends Module { } event.preventDefault(); - this.toolClicked(tool); + this.toolClicked(name); }, on: this.Editor.UI.nodes.redactor, }); @@ -659,10 +662,16 @@ export default class InlineToolbar extends Module { * * @param {InlineTool} tool - Tool's instance */ - private toolClicked(tool: IInlineTool): void { - const range = SelectionUtils.range; + private toolClicked(name: string): void { + const currentSelection = SelectionUtils.get(); + + const currentBlock = this.Editor.BlockManager.getBlock(currentSelection.anchorNode as HTMLElement); + + currentBlock.fragments.insert(name); + // const currentBlock = this + // const range = SelectionUtils.range; - tool.surround(range); + // tool.surround(range); this.checkToolsState(); } @@ -670,9 +679,17 @@ export default class InlineToolbar extends Module { * Check Tools` state by selection */ private checkToolsState(): void { - this.toolsInstances.forEach((toolInstance) => { - toolInstance.checkState(SelectionUtils.get()); - }); + const { currentBlock } = this.Editor.BlockManager; + + Array + .from(currentBlock.fragments.instances) + .forEach(([name, tool]) => { + tool.active = currentBlock.fragments.isToolActive(name); + }); + + // this.toolsInstances.forEach((toolInstance) => { + // toolInstance.active(SelectionUtils.get()); + // }); } /** diff --git a/src/components/utils.ts b/src/components/utils.ts index 2f1650bf8..df97c08d8 100644 --- a/src/components/utils.ts +++ b/src/components/utils.ts @@ -778,4 +778,13 @@ export const isIosDevice = window.navigator && window.navigator.platform && (/iP(ad|hone|od)/.test(window.navigator.platform) || - (window.navigator.platform === 'MacIntel' && window.navigator.maxTouchPoints > 1)); \ No newline at end of file + (window.navigator.platform === 'MacIntel' && window.navigator.maxTouchPoints > 1)); + +/** + * Returns production of multiplication of arguments + * + * @param numbers - numbers to multiply + */ +export function multiply(...numbers: number[]) { + return numbers.reduce((prod, m) => prod * m, 1); +} diff --git a/src/components/utils/sanitizer.ts b/src/components/utils/sanitizer.ts index 55cdd6b13..ea224998c 100644 --- a/src/components/utils/sanitizer.ts +++ b/src/components/utils/sanitizer.ts @@ -54,7 +54,7 @@ export function sanitizeBlocks( return block; } - block.data = deepSanitize(block.data, toolConfig) as BlockToolData; + block.data = deepSanitize(block.data, {}) as BlockToolData; return block; }); diff --git a/src/styles/variables.css b/src/styles/variables.css index ca9b14b36..32135659d 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -1,5 +1,5 @@ /** - * Updating values in media queries should also include changes in utils.ts@isMobile + * Updating values in media queries should also include changes in rangeUtils.ts@isMobile */ @custom-media --mobile (width <= 650px); @custom-media --not-mobile (width >= 651px); diff --git a/test/cypress/tests/api/block.spec.ts b/test/cypress/tests/api/block.spec.ts index 09b55efb6..466e0c97c 100644 --- a/test/cypress/tests/api/block.spec.ts +++ b/test/cypress/tests/api/block.spec.ts @@ -22,7 +22,7 @@ describe('BlockAPI', () => { */ const EditorJSApiMock = Cypress.sinon.match.any; - beforeEach(() => { + beforeEach(function () { if (this && this.editorInstance) { this.editorInstance.destroy(); } else { diff --git a/test/cypress/tests/block-ids.spec.ts b/test/cypress/tests/block-ids.spec.ts index e207e58fb..62a7b478e 100644 --- a/test/cypress/tests/block-ids.spec.ts +++ b/test/cypress/tests/block-ids.spec.ts @@ -3,7 +3,7 @@ import Header from '@editorjs/header'; import { nanoid } from 'nanoid'; describe.only('Block ids', () => { - beforeEach(() => { + beforeEach(function () { if (this && this.editorInstance) { this.editorInstance.destroy(); } else { diff --git a/test/cypress/tests/copy-paste.spec.ts b/test/cypress/tests/copy-paste.spec.ts index 5a337832b..784594835 100644 --- a/test/cypress/tests/copy-paste.spec.ts +++ b/test/cypress/tests/copy-paste.spec.ts @@ -3,7 +3,7 @@ import Image from '@editorjs/simple-image'; import * as _ from '../../../src/components/utils'; describe('Copy pasting from Editor', () => { - beforeEach(() => { + beforeEach(function () { if (this && this.editorInstance) { this.editorInstance.destroy(); } else { diff --git a/test/cypress/tests/i18n.spec.ts b/test/cypress/tests/i18n.spec.ts index 2f31d48c7..52ca213a6 100644 --- a/test/cypress/tests/i18n.spec.ts +++ b/test/cypress/tests/i18n.spec.ts @@ -18,7 +18,7 @@ class TestTool { describe('Editor i18n', () => { context('Toolbox', () => { - it('should translate tool title in a toolbox', () => { + it('should translate tool title in a toolbox', function () { if (this && this.editorInstance) { this.editorInstance.destroy(); } @@ -50,7 +50,7 @@ describe('Editor i18n', () => { .should('contain.text', toolNamesDictionary.Heading); }); - it('should use capitalized tool name as translation key if toolbox title is missing', () => { + it('should use capitalized tool name as translation key if toolbox title is missing', function () { if (this && this.editorInstance) { this.editorInstance.destroy(); } @@ -81,4 +81,4 @@ describe('Editor i18n', () => { .should('contain.text', toolNamesDictionary.TestTool); }); }); -}); \ No newline at end of file +}); diff --git a/test/cypress/tests/selection.spec.ts b/test/cypress/tests/selection.spec.ts index a721e6751..8de72dc9e 100644 --- a/test/cypress/tests/selection.spec.ts +++ b/test/cypress/tests/selection.spec.ts @@ -1,7 +1,7 @@ import * as _ from '../../../src/components/utils'; describe('Blocks selection', () => { - beforeEach(() => { + beforeEach(function () { if (this && this.editorInstance) { this.editorInstance.destroy(); } else { diff --git a/tsconfig.json b/tsconfig.json index 67923b684..0c1423b79 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "target": "es2017", "declaration": false, "moduleResolution": "node", // This resolution strategy attempts to mimic the Node.js module resolution mechanism at runtime - "lib": ["dom", "es2017", "es2018", "ES2019"], + "lib": ["dom", "es2017", "es2018", "ES2019", "ES2022"], // allows to import .json files for i18n "resolveJsonModule": true, diff --git a/types/data-formats/block-data.d.ts b/types/data-formats/block-data.d.ts index f4843a47b..bd42e6bd0 100644 --- a/types/data-formats/block-data.d.ts +++ b/types/data-formats/block-data.d.ts @@ -18,5 +18,6 @@ export interface ValidatedData { tool?: string; data?: BlockToolData; time?: number; + fragments?: any; isValid: boolean; } diff --git a/types/tools/inline-tool.d.ts b/types/tools/inline-tool.d.ts index 00b96e272..146fa16c4 100644 --- a/types/tools/inline-tool.d.ts +++ b/types/tools/inline-tool.d.ts @@ -35,6 +35,10 @@ export interface InlineTool extends BaseTool { * Better to create the 'destroy' method in a future. */ clear?(): void; + + apply(contents: DocumentFragment, meta?: unknown): { element: HTMLElement, meta?: unknown } + + active: boolean; } diff --git a/yarn.lock b/yarn.lock index 20477c1ec..e3f4b60fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8580,14 +8580,10 @@ typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" -typescript@4.4.3: - version "4.4.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.4.3.tgz#bdc5407caa2b109efd4f82fe130656f977a29324" - integrity sha512-4xfscpisVgqqDfPaJo5vkd+Qd/ItkoagnHpufr+i2QCHBsNYp+G7UAoyFl8aPtx879u38wPV65rZ8qbGZijalA== - -typescript@^3.7.3: - version "3.8.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061" +typescript@4.6.4, typescript@^3.7.3, typescript@^4.6.4: + version "4.6.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.4.tgz#caa78bbc3a59e6a5c510d35703f6a09877ce45e9" + integrity sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg== umd@^3.0.0: version "3.0.3"