diff --git a/controls/barcodegenerator/CHANGELOG.md b/controls/barcodegenerator/CHANGELOG.md
index a94e225c9f..2bc62c417c 100644
--- a/controls/barcodegenerator/CHANGELOG.md
+++ b/controls/barcodegenerator/CHANGELOG.md
@@ -2,7 +2,7 @@
## [Unreleased]
-## 29.2.7 (2025-05-27)
+## 30.1.37 (2025-06-25)
### Barcode
diff --git a/controls/barcodegenerator/README.md b/controls/barcodegenerator/README.md
index 4ba87e23a7..1b6a92e2d4 100644
--- a/controls/barcodegenerator/README.md
+++ b/controls/barcodegenerator/README.md
@@ -1,5 +1,3 @@
-[](http://ej2.syncfusion.com/badges/ej2-barcode-generator)
-
# JavaScript Barcode Generator Control
The [JavaScript Barcode](https://www.syncfusion.com/javascript-ui-controls/js-barcode?utm_source=npm&utm_medium=listing&utm_campaign=javascript-barcode-npm) (QR Code) Generator Control is a light-weight and high-performance control that displays industry-standard 1D and 2D barcodes in JavaScript applications. Generated barcodes are optimized for printing and on-screen scanning. It is designed for ease of use and does not require fonts.
diff --git a/controls/barcodegenerator/package.json b/controls/barcodegenerator/package.json
index ffb36bac96..5de7a0f6b7 100644
--- a/controls/barcodegenerator/package.json
+++ b/controls/barcodegenerator/package.json
@@ -1,6 +1,6 @@
{
"name": "@syncfusion/ej2-barcode-generator",
- "version": "19.4.0",
+ "version": "30.1.37",
"description": "Barcode generator component is a pure JavaScript library which will convert a string to Barcode and show it to the user. This supports major 1D and 2D barcodes including coda bar, code 128, QR Code.",
"author": "Syncfusion Inc.",
"license": "SEE LICENSE IN license",
@@ -9,8 +9,7 @@
"es2015": "./dist/es6/ej2-barcode-generator.es5.js",
"dependencies": {
"@syncfusion/ej2-base": "*",
- "@syncfusion/ej2-data": "*",
- "markdown-spellcheck": "^1.3.1"
+ "@syncfusion/ej2-data": "*"
},
"devDependencies": {
"@syncfusion/ej2-staging": "^1.0.1",
diff --git a/controls/barcodegenerator/styles/barcode/_theme.scss b/controls/barcodegenerator/styles/barcode/_theme.scss
index 2a5208e694..f725daf5fd 100644
--- a/controls/barcodegenerator/styles/barcode/_theme.scss
+++ b/controls/barcodegenerator/styles/barcode/_theme.scss
@@ -14,4 +14,10 @@
max-height: 100px;
max-width: 100px;
}
+ .e-datamatrix-blazor-rect{
+ shape-rendering: crispEdges;
+ }
+ .e-qrcode-blazor-rect{
+ shape-rendering: crispEdges;
+ }
}
diff --git a/controls/base/CHANGELOG.md b/controls/base/CHANGELOG.md
index ebd30db0b5..5c3ccea311 100644
--- a/controls/base/CHANGELOG.md
+++ b/controls/base/CHANGELOG.md
@@ -2,15 +2,15 @@
## [Unreleased]
-## 29.2.7 (2025-05-27)
+## 30.2.5 (2025-08-13)
### Common
#### Bug Fixes
-- `#I718268` - Resolved Persistence not properly working when using `SetCulture` in Grid.
+- `#I741850` - Resolved a security vulnerability in the base library.
-## 29.1.35 (2025-04-01)
+## 30.1.37 (2025-06-25)
### Common
diff --git a/controls/base/ReadMe.md b/controls/base/ReadMe.md
index a13b6e3f9a..42b9a0fb11 100644
--- a/controls/base/ReadMe.md
+++ b/controls/base/ReadMe.md
@@ -4,7 +4,7 @@
A common package of Essential® JS 2 which contains base libraries, methods and class definitions.
-> This is a commercial product and requires a paid license for possession or use. Syncfusion® licensed software, including this component, is subject to the terms and conditions of Syncfusion® [EULA](https://www.syncfusion.com/eula/es/). To acquire a license, you can purchase [here](https://www.syncfusion.com/sales/products) or start a free 30-day trial [here](https://www.syncfusion.com/account/manage-trials/start-trials).
+> This is a commercial product and requires a paid license for possession or use. Syncfusion® licensed software, including this component, is subject to the teerms and conditioens of Syncfusion® [EULA](https://www.syncfusion.com/eula/es/). To acquire a license, you can purchase [here](https://www.syncfusion.com/sales/products) or start a free 30-day trial [here](https://www.syncfusion.com/account/manage-trials/start-trials).
>
> A free [community license](https://www.syncfusion.com/products/communitylicense) is also available for companies and individuals whose organizations have less than $1 million USD in annual gross revenue and five or fewer developers.
diff --git a/controls/base/package.json b/controls/base/package.json
index 93dc4f5bca..1adc32e862 100644
--- a/controls/base/package.json
+++ b/controls/base/package.json
@@ -1,6 +1,6 @@
{
"name": "@syncfusion/ej2-base",
- "version": "29.1.35",
+ "version": "30.1.37",
"description": "A common package of Essential JS 2 base libraries, methods and class definitions",
"author": "Syncfusion Inc.",
"license": "SEE LICENSE IN license",
diff --git a/controls/base/spec/intl/date-parser.spec.ts b/controls/base/spec/intl/date-parser.spec.ts
index 5e1d753777..ff3d7cf541 100644
--- a/controls/base/spec/intl/date-parser.spec.ts
+++ b/controls/base/spec/intl/date-parser.spec.ts
@@ -892,7 +892,7 @@ describe('DateParser', () => {
it('year only format input returns correct year value',()=>{
let tFormatter: Date = DateParser.dateParser('en', { format:'yy',calendar:'islamic' }, cldrData)('40');
let iFormatter: Date = DateParser.dateParser('en', { format:'y',calendar:'islamic' }, cldrData)('1444');
- expect(iFormatter.getFullYear()).toBe(2023);
+ expect(iFormatter.getFullYear()).toBe(2022);
});
it('full skeletom eleton returns proper value',()=>{
let iFormatter: Date = DateParser.dateParser('en', { skeleton: 'full',calendar:'islamic' }, cldrData)('Tuesday, Safar 19, 1437 AH');
diff --git a/controls/base/src/component.ts b/controls/base/src/component.ts
index 357c654cc8..4f2d45f310 100644
--- a/controls/base/src/component.ts
+++ b/controls/base/src/component.ts
@@ -114,7 +114,7 @@ export abstract class Component extends Base extends Base extends BasemessageHandler, false);
- window.postMessage(secret, '*');
+ window.postMessage(secret, window.location.origin);
return unbind = () => {
window.removeEventListener('message', messageHandler);
handler = messageHandler = secret = undefined;
diff --git a/controls/base/src/validate-lic.ts b/controls/base/src/validate-lic.ts
index fb1b674bb6..499bd8d886 100644
--- a/controls/base/src/validate-lic.ts
+++ b/controls/base/src/validate-lic.ts
@@ -14,7 +14,7 @@ let accountURL: string;
class LicenseValidator {
private isValidated: boolean = false;
public isLicensed: boolean = true;
- public version: string = '29';
+ public version: string = '30';
public platform: RegExp = /JavaScript|ASPNET|ASPNETCORE|ASPNETMVC|FileFormats|essentialstudio/i;
private errors: IErrorType = {
noLicense: 'This application was built using a trial version of Syncfusion® Essential Studio® .' +
@@ -129,7 +129,7 @@ class LicenseValidator {
}
}
if (validateMsg && typeof document !== 'undefined' && !isNullOrUndefined(document)) {
- accountURL = (validateURL && validateURL !== '') ? validateURL : 'https://www.syncfusion.com/account/claim-license-key?pl=SmF2YVNjcmlwdA==&vs=Mjk=&utm_source=es_license_validation_banner&utm_medium=listing&utm_campaign=license-information';
+ accountURL = (validateURL && validateURL !== '') ? validateURL : 'https://www.syncfusion.com/account/claim-license-key?pl=SmF2YVNjcmlwdA==&vs=MzA=&utm_source=es_license_validation_banner&utm_medium=listing&utm_campaign=license-information';
const errorDiv: HTMLElement = createElement('div', {
innerHTML: `
+ Getting Started .
+ Online demos .
+ Learn more
+
+
+
+
+
+
+## Key features
+
+* **Multiple block types**: Includes Heading levels 1-4, Paragraph, Lists, Checklist, Quote, Callout, Divider, Code block, and more.
+* **Slash commands**: Interactive `/` commands to insert or transform content blocks efficiently.
+* **Drag and drop**: Reorder blocks effortlessly with built-in drag-and-drop support.
+* **Rich text formatting**: Apply styles such as Bold, Italic, Underline, Strikethrough, Uppercase and more.
+* **Action menu**: Perform block-level operations such as Move, Delete, and Duplicate.
+* **Contextmenu support**: Right-click context menus for quick block actions.
+* **Inline content support**: Insert inline elements like Links, Labels and Mention directly within blocks.
+* **Undo/Redo operations**: Undo and redo support for the user interactions.
+
+
+Trusted by the world's leading companies
+
+
+
+
+
+## Setup
+
+To install `blockeditor` and its dependent packages, use the following command,
+
+```sh
+npm install @syncfusion/ej2-blockeditor
+```
+
+## Supported frameworks
+
+Input controls are also offered in following list of frameworks.
+
+| [ ](https://www.syncfusion.com/angular-ui-components?utm_medium=listing&utm_source=github) [Angular](https://www.syncfusion.com/angular-ui-components?utm_medium=listing&utm_source=github) | [ ](https://www.syncfusion.com/react-ui-components?utm_medium=listing&utm_source=github) [React](https://www.syncfusion.com/react-ui-components?utm_medium=listing&utm_source=github) | [ ](https://www.syncfusion.com/vue-ui-components?utm_medium=listing&utm_source=github) [Vue](https://www.syncfusion.com/vue-ui-components?utm_medium=listing&utm_source=github) | [ ](https://www.syncfusion.com/aspnet-core-ui-controls?utm_medium=listing&utm_source=github) [ASP.NET Core](https://www.syncfusion.com/aspnet-core-ui-controls?utm_medium=listing&utm_source=github) | [ ](https://www.syncfusion.com/aspnet-mvc-ui-controls?utm_medium=listing&utm_source=github) [ASP.NET MVC](https://www.syncfusion.com/aspnet-mvc-ui-controls?utm_medium=listing&utm_source=github) |
+| :-----: | :-----: | :-----: | :-----: | :-----: |
+
+## Showcase samples
+
+* Expanse Tracker - [Source](https://github.com/syncfusion/ej2-sample-ts-expensetracker), [Live Demo]( https://ej2.syncfusion.com/showcase/typescript/expensetracker/?utm_source=npm&utm_campaign=numerictextbox#/expense)
+* Loan Calculator - [Source](https://github.com/syncfusion/ej2-sample-ts-loancalculator), [Live Demo]( https://ej2.syncfusion.com/showcase/typescript/loancalculator/?utm_source=npm&utm_campaign=slider)
+* Cloud Pricing - [Live Demo](https://ej2.syncfusion.com/demos/?utm_source=npm&utm_campaign=slider#/fluent2/slider/azure-pricing.html)
+
+## Support
+
+Product support is available through following mediums.
+
+* [Support ticket](https://support.syncfusion.com/support/tickets/create) - Guaranteed Response in 24 hours | Unlimited tickets | Holiday support
+* [Community forum](https://www.syncfusion.com/forums/essential-js2?utm_source=npm&utm_medium=listing&utm_campaign=javascript-blockeditor-npm)
+* [GitHub issues](https://github.com/syncfusion/ej2-javascript-ui-controls/issues/new)
+* [Request feature or report bug](https://www.syncfusion.com/feedback/javascript?utm_source=npm&utm_medium=listing&utm_campaign=javascript-blockeditor-npm)
+* Live chat
+
+## Changelog
+
+Check the changelog [here](https://github.com/syncfusion/ej2-javascript-ui-controls/blob/master/controls/blockeditor/CHANGELOG.md/?utm_source=npm&utm_campaign=input). Get minor improvements and bug fixes every week to stay up to date with frequent updates.
+
+## License and copyright
+
+> This is a commercial product and requires a paid license for possession or use. Syncfusion’s licensed software, including this component, is subject to the terms and conditions of Syncfusion's [EULA](https://www.syncfusion.com/eula/es/). To acquire a license for 80+ [JavaScript UI controls](https://www.syncfusion.com/javascript-ui-controls), you can [purchase](https://www.syncfusion.com/sales/products) or [start a free 30-day trial](https://www.syncfusion.com/account/manage-trials/start-trials).
+
+> A [free community license](https://www.syncfusion.com/products/communitylicense) is also available for companies and individuals whose organizations have less than $1 million USD in annual gross revenue and five or fewer developers.
+
+See [LICENSE FILE](https://github.com/syncfusion/ej2-javascript-ui-controls/blob/master/license/?utm_source=npm&utm_campaign=input) for more info.
+
+© Copyright 2025 Syncfusion, Inc. All Rights Reserved. The Syncfusion Essential Studio license and copyright applies to this distribution.
diff --git a/controls/blockeditor/gulpfile.js b/controls/blockeditor/gulpfile.js
new file mode 100644
index 0000000000..ccb6a38c7f
--- /dev/null
+++ b/controls/blockeditor/gulpfile.js
@@ -0,0 +1,81 @@
+'use strict';
+
+var gulp = require('gulp');
+
+/**
+ * Build ts and scss files
+ */
+gulp.task('build', gulp.series('scripts', 'styles'));
+
+/**
+ * Compile ts files
+ */
+gulp.task('scripts', function(done) {
+ var ts = require('gulp-typescript');
+ var tsProject = ts.createProject('tsconfig.json', { typescript: require('typescript') });
+
+ var tsResult = gulp.src(['./**/*.ts','./**/*.tsx', '!./node_modules/**/*.ts','!./node_modules/**/*.tsx'], { base: '.' })
+ .pipe(tsProject());
+ tsResult.js.pipe(gulp.dest('./'))
+ .on('end', function() {
+ done();
+ });
+});
+
+/**
+ * Compile styles
+ */
+gulp.task('styles', function() {
+ var sass = require('gulp-sass');
+ return gulp.src(['./**/*.scss', '!./node_modules/**/*.scss'], { base: './' })
+ .pipe(sass({
+ outputStyle: 'expanded',
+ includePaths: './node_modules/@syncfusion/'
+ }))
+ .pipe(gulp.dest('.'));
+});
+
+/* jshint strict: false */
+/* jshint undef: false */
+
+var service, proxyPort;
+
+/**
+ * Run test scripts
+ */
+gulp.task('test', function(done) {
+ var path = require('path');
+ var packageJson = require('./package.json');
+ if (packageJson.dependencies['@syncfusion/ej2-data'] || packageJson.name === '@syncfusion/ej2-data') {
+ console.log('Service Started');
+ var spawn = require('child_process').spawn;
+ service = spawn('node', [path.join(__dirname, '/spec/services/V4service.js')]);
+
+ service.stdout.on('data', (data) => {
+ proxyPort = data.toString().trim();
+ console.log('Proxy port: ' + proxyPort);
+ startKarma(done);
+ });
+ } else {
+ startKarma(done);
+ }
+});
+
+function startKarma(done) {
+ var karma = require('karma');
+ return new karma.Server({
+ configFile: __dirname + '/karma.conf.js',
+ singleRun: true,
+ browsers: ['ChromeHeadless']
+ }, function(e) {
+ if (service) {
+ service.kill();
+ }
+ if (e === 1) {
+ console.log('Karma has exited with ' + e);
+ process.exit(e);
+ } else {
+ done();
+ }
+ }).start();
+}
\ No newline at end of file
diff --git a/controls/blockeditor/karma.conf.js b/controls/blockeditor/karma.conf.js
new file mode 100644
index 0000000000..95e151ed17
--- /dev/null
+++ b/controls/blockeditor/karma.conf.js
@@ -0,0 +1,115 @@
+// Karma configuration
+// Generated on Tue Apr 26 2016 09:56:05 GMT+0530 (India Standard Time)
+
+module.exports = function (config) {
+ config.set({
+
+ // base path that will be used to resolve all patterns (eg. files, exclude)
+ basePath: '',
+
+
+ // frameworks to use
+ // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
+ frameworks: ['jasmine-ajax', 'jasmine', 'requirejs'],
+
+
+ // list of files / patterns to load in the browser
+ files: [
+ "node_modules/@syncfusion/ej2-base/styles/material.css",
+ "node_modules/@syncfusion/ej2-icons/styles/material.css",
+ "node_modules/@syncfusion/ej2-lists/styles/material.css",
+ "node_modules/@syncfusion/ej2-popups/styles/material.css",
+ "node_modules/@syncfusion/ej2-buttons/styles/material.css",
+ "node_modules/@syncfusion/ej2-inputs/styles/material.css",
+ "node_modules/@syncfusion/ej2-notifications/styles/material.css",
+ "node_modules/@syncfusion/ej2-navigations/styles/material.css",
+ "node_modules/@syncfusion/ej2-splitbuttons/styles/material.css",
+ "node_modules/@syncfusion/ej2-dropdowns/styles/material.css",
+
+ "styles/material.css",
+
+ "test-main.js",
+ { pattern: "src/**/*.js", included: false },
+ { pattern: "spec/**/*.spec.js", included: false },
+
+ { pattern: "node_modules/@syncfusion/ej2-base/**/*.js", included: false },
+ { pattern: "node_modules/@syncfusion/ej2-data/**/*.js", included: false },
+ { pattern: "node_modules/@syncfusion/ej2-lists/**/*.js", included: false },
+ { pattern: "node_modules/@syncfusion/ej2-popups/**/*.js", included: false },
+ { pattern: "node_modules/@syncfusion/ej2-buttons/**/*.js", included: false },
+ { pattern: "node_modules/@syncfusion/ej2-inputs/**/*.js", included: false },
+ { pattern: "node_modules/@syncfusion/ej2-notifications/**/*.js", included: false },
+ { pattern: "node_modules/@syncfusion/ej2-navigations/**/*.js", included: false },
+ { pattern: "node_modules/@syncfusion/ej2-splitbuttons/**/*.js", included: false },
+ { pattern: "node_modules/@syncfusion/ej2-dropdowns/**/*.js", included: false },
+ // Add dependent package's script files here
+ ],
+
+
+ // list of files to exclude
+ exclude: [
+ ],
+
+
+ // preprocess matching files before serving them to the browser
+ // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
+ preprocessors: {},
+
+
+ // test results reporter to use
+ // possible values: 'dots', 'progress'
+ // available reporters: https://npmjs.org/browse/keyword/karma-reporter
+ reporters: ['dots', 'html'],
+
+ // the default html configuration
+ htmlReporter: {
+ outputFile: "test-report/units.html",
+ pageTitle: "Unit Tests",
+ subPageTitle: "Asampleprojectdescription"
+ },
+
+ // web server port
+ port: 9876,
+
+
+ // enable / disable colors in the output (reporters and logs)
+ colors: true,
+
+
+ // level of logging
+ // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
+ logLevel: config.LOG_INFO,
+
+
+ // enable / disable watching file and executing tests whenever any file changes
+ autoWatch: true,
+
+
+ // start these browsers
+ // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
+ // browsers: ['ChromeHeadless', 'Chrome', 'Firefox'],
+ browsers: ['ChromeHeadless', 'Chrome'],
+
+ // Continuous Integration mode
+ // if true, Karma captures browsers, runs the tests and exits
+ singleRun: false,
+
+ // Concurrency level
+ // how many browser should be started simultaneous
+ concurrency: Infinity,
+ failOnEmptyTestSuite: false,
+
+
+ coverageReporter: {
+ type: "html",
+ check: {
+ each: {
+ statements: 90,
+ branches: 90,
+ functions: 100,
+ lines: 90
+ }
+ }
+ }
+ })
+}
\ No newline at end of file
diff --git a/controls/blockeditor/license b/controls/blockeditor/license
new file mode 100644
index 0000000000..4e57cc05a2
--- /dev/null
+++ b/controls/blockeditor/license
@@ -0,0 +1,10 @@
+Essential JS 2 library is available under the Syncfusion Essential Studio program, and can be licensed either under the Syncfusion Community License Program or the Syncfusion commercial license.
+
+To be qualified for the Syncfusion Community License Program you must have a gross revenue of less than one (1) million U.S. dollars ($1,000,000.00 USD) per year and have less than five (5) developers in your organization, and agree to be bound by Syncfusion’s terms and conditions.
+
+Customers who do not qualify for the community license can contact sales@syncfusion.com for commercial licensing options.
+
+Under no circumstances can you use this product without (1) either a Community License or a commercial license and (2) without agreeing and abiding by Syncfusion’s license containing all terms and conditions.
+
+The Syncfusion license that contains the terms and conditions can be found at
+https://www.syncfusion.com/content/downloads/syncfusion_license.pdf
\ No newline at end of file
diff --git a/controls/blockeditor/package.json b/controls/blockeditor/package.json
new file mode 100644
index 0000000000..1f66030466
--- /dev/null
+++ b/controls/blockeditor/package.json
@@ -0,0 +1,54 @@
+{
+ "name": "@syncfusion/ej2-blockeditor",
+ "version": "27.1.48",
+ "description": "Feature Rich Block Editor control with built in support editing, formatting content.",
+ "author": "Syncfusion Inc.",
+ "license": "SEE LICENSE IN license",
+ "main": "./dist/ej2-blockeditor.umd.min.js",
+ "module": "./index.js",
+ "es2015": "./dist/es6/ej2-blockeditor.es5.js",
+ "typings": "index.d.ts",
+ "dependencies": {
+ "@syncfusion/ej2-base": "*",
+ "@syncfusion/ej2-dropdowns": "*",
+ "markdown-spellcheck": "^1.3.1"
+ },
+ "devDependencies": {
+ "@syncfusion/ej2-staging": "^1.0.1",
+ "@types/chai": "^3.4.28",
+ "@types/jasmine": "2.8.9",
+ "@types/jasmine-ajax": "^3.1.27",
+ "@types/requirejs": "^2.1.26",
+ "es6-promise": "^3.2.1",
+ "gulp": "^3.9.1",
+ "gulp-sass": "^3.1.0",
+ "gulp-typescript": "^3.1.6",
+ "requirejs": "^2.3.3",
+ "typescript": "2.3.4",
+ "canteen": "^1.0.5",
+ "jasmine-ajax": "^3.3.1",
+ "jasmine-core": "^2.6.1",
+ "karma": "6.4.2",
+ "karma-chrome-launcher": "^2.2.0",
+ "karma-generic-preprocessor": "^1.1.0",
+ "karma-htmlfile-reporter": "^0.3.5",
+ "karma-jasmine": "^1.1.0",
+ "karma-jasmine-ajax": "^0.1.13",
+ "karma-requirejs": "^1.1.0",
+ "nedb": "^1.8.0",
+ "simple-odata-server": "^0.3.1"
+ },
+ "keywords": [
+ "ej2",
+ "syncfusion",
+ "blockeditor"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/essential-studio/ej2-blockeditor-component.git"
+ },
+ "scripts": {
+ "build": "gulp build",
+ "test": "gulp test"
+ }
+}
\ No newline at end of file
diff --git a/controls/blockeditor/spec/actions/clipboard.spec.ts b/controls/blockeditor/spec/actions/clipboard.spec.ts
new file mode 100644
index 0000000000..7f3512f824
--- /dev/null
+++ b/controls/blockeditor/spec/actions/clipboard.spec.ts
@@ -0,0 +1,1296 @@
+import { createElement } from '@syncfusion/ej2-base';
+import { BlockModel } from '../../src/blockeditor/models/index';
+import { BlockEditor, BlockType, ContentType, getBlockContentElement, getContentModelById, getSelectionRange, IClipboardPayload, setCursorPosition } from '../../src/index';
+import { createEditor } from '../common/util.spec';
+import { allBlockData } from '../common/data.spec';
+
+function createMockClipboardEvent(type: string, clipboardData: any = {}): ClipboardEvent {
+ const event: any = {
+ type,
+ preventDefault: jasmine.createSpy(),
+ clipboardData: clipboardData,
+ bubbles: true,
+ cancelable: true
+ };
+ return event as ClipboardEvent;
+}
+
+
+describe('Clipboard Actions', () => {
+ beforeAll(() => {
+ const isDef: any = (o: any) => o !== undefined && o !== null;
+ if (!isDef(window.performance)) {
+ console.log('Unsupported environment, window.performance.memory is unavailable');
+ pending();
+ return;
+ }
+ });
+
+ describe('Copy and Paste within Editor', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ {
+ id: 'paragraph1',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'paragraph1-content', type: ContentType.Text, content: 'First paragraph' }
+ ]
+ },
+ {
+ id: 'paragraph2',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'paragraph2-content', type: ContentType.Text, content: 'Second paragraph' }
+ ]
+ }
+ ];
+ editor = createEditor({ blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ }
+ document.body.removeChild(editorElement);
+ });
+
+ it('copy & paste whole block', (done) => {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(getBlockContentElement(blockElement), 0);
+ const copiedData = editor.clipboardAction.getClipboardPayload().blockeditorData;
+
+ const mockClipboard: any = {
+ setData: jasmine.createSpy(),
+ getData: (format: string) => {
+ if (format === 'text/blockeditor') {
+ return copiedData;
+ }
+ return '';
+ }
+ };
+
+ editor.clipboardAction.handleCopy(createMockClipboardEvent('copy', mockClipboard));
+ editor.clipboardAction.handlePaste(createMockClipboardEvent('paste', mockClipboard));
+
+ setTimeout(() => {
+ expect(editor.blocks.length).toBe(3);
+ expect(editor.blocks[1].content[0].content).toBe('First paragraph');
+ expect(blockElement.nextElementSibling.id).toBe(editor.blocks[1].id);
+ done();
+ }, 100);
+ });
+
+ it('cut & paste whole block', (done) => {
+ const initialBlockCount = editor.blocks.length;
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(getBlockContentElement(blockElement), 0);
+ const copiedData = editor.clipboardAction.getClipboardPayload().blockeditorData;
+
+ const mockClipboard: any = {
+ setData: jasmine.createSpy(),
+ getData: (format: string) => {
+ if (format === 'text/blockeditor') {
+ return copiedData;
+ }
+ return '';
+ }
+ };
+
+ editor.clipboardAction.handleCut(createMockClipboardEvent('cut', mockClipboard));
+
+ setTimeout(() => {
+ expect(editor.blocks.length).toBe(initialBlockCount - 1);
+ expect(editorElement.querySelector('#paragraph1')).toBeNull();
+
+ const blockElement2 = editorElement.querySelector('#paragraph2') as HTMLElement;
+ editor.setFocusToBlock(blockElement2);
+ setCursorPosition(getBlockContentElement(blockElement2), 0);
+ editor.clipboardAction.handlePaste(createMockClipboardEvent('paste', mockClipboard));
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[1].content[0].content).toBe('First paragraph');
+ expect(blockElement2.nextElementSibling.id).toBe(editor.blocks[1].id);
+ done();
+ });
+ });
+
+ it('copy & paste partial content', (done) => {
+ if (editor) editor.destroy();
+ editor = createEditor({
+ blocks: [
+ {
+ id: 'block1',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'bold', type: ContentType.Text, content: 'Boldedtext', styles: { bold: true } },
+ { id: 'italic', type: ContentType.Text, content: 'Italictext', styles: { italic: true } },
+ { id: 'underline', type: ContentType.Text, content: 'Underlinedtext', styles: { underline: true } }
+ ]
+ },
+ {
+ id: 'block2',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'test', type: ContentType.Text, content: 'TestContent', styles: { bold: true } }
+ ]
+ }
+ ]
+ });
+ editor.appendTo('#editor');
+ editor.setFocusToBlock(editor.element.querySelector('#block1'));
+ //create range
+ var range = document.createRange();
+ var startNode = editor.element.querySelector('#italic').firstChild;
+ var endNode = editor.element.querySelector('#underline').firstChild;
+ range.setStart(startNode, 0);
+ range.setEnd(endNode, 6);
+ var selection = document.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+ const copiedData = editor.clipboardAction.getClipboardPayload().blockeditorData;
+
+ const mockClipboard: any = {
+ setData: jasmine.createSpy(),
+ getData: (format: string) => {
+ if (format === 'text/blockeditor') {
+ return copiedData;
+ }
+ return '';
+ }
+ };
+
+ editor.clipboardAction.handleCopy(createMockClipboardEvent('copy', mockClipboard));
+
+ const blockElement = editorElement.querySelector('#block2') as HTMLElement;
+ const contentElement = getBlockContentElement(blockElement);
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(contentElement, 4);
+ const initialLength = editor.blocks[1].content.length;
+
+ editor.clipboardAction.handlePaste(createMockClipboardEvent('paste', mockClipboard));
+
+ setTimeout(() => {
+ expect(editor.blocks[1].content.length).toBe(initialLength + 3);
+ expect(editor.blocks[1].content[0].content).toBe('Test');
+ expect(editor.blocks[1].content[1].content).toBe('Italictext');
+ expect(editor.blocks[1].content[1].styles.italic).toBe(true);
+ expect(editor.blocks[1].content[2].content).toBe('Underl');
+ expect(editor.blocks[1].content[2].styles.underline).toBe(true);
+ expect(editor.blocks[1].content[3].content).toBe('Content');
+ expect(contentElement.childNodes.length).toBe(4);
+ expect(contentElement.childNodes[1].textContent).toBe('Italictext');
+ expect((contentElement.childNodes[1] as HTMLElement).tagName).toBe('EM');
+ expect((contentElement.childNodes[2] as HTMLElement).textContent).toBe('Underl');
+ expect((contentElement.childNodes[2] as HTMLElement).tagName).toBe('U');
+ done();
+ }, 100);
+ });
+
+ it('multi block paste when cursor is at middle', (done) => {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ editor.selectAllBlocks();
+ const copiedData = editor.clipboardAction.getClipboardPayload().blockeditorData;
+
+ const mockClipboard: any = {
+ setData: jasmine.createSpy(),
+ getData: (format: string) => {
+ if (format === 'text/blockeditor') {
+ return copiedData;
+ }
+ return '';
+ }
+ };
+
+ editor.clipboardAction.handleCopy(createMockClipboardEvent('copy', mockClipboard));
+
+ setCursorPosition(getBlockContentElement(blockElement), 6);
+
+ editor.clipboardAction.handlePaste(createMockClipboardEvent('paste', mockClipboard));
+
+ // First block will be splitted at cursor and clipboard's first block content gets merged here
+ // Remaining clipboard blocks will be added after this block
+ // So, total blocks will be 4
+ setTimeout(() => {
+ expect(editor.blocks.length).toBe(4);
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(4);
+
+ expect(editor.blocks[0].content[0].content).toBe('First ');
+ expect(editor.blocks[0].content[1].content).toBe('First paragraph');
+
+ expect(editor.blocks[1].content[0].content).toBe('Second paragraph');
+ expect(editor.blocks[2].content[0].content).toBe('paragraph');
+ expect(editor.blocks[3].content[0].content).toBe('Second paragraph');
+
+ expect(editorElement.querySelectorAll('.e-block')[0].querySelector('p').textContent).toBe('First First paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[1].querySelector('p').textContent).toBe('Second paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[2].querySelector('p').textContent).toBe('paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[3].querySelector('p').textContent).toBe('Second paragraph');
+ done();
+ });
+ });
+
+ it('multi block paste when cursor is at start', (done) => {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ editor.selectAllBlocks();
+ const copiedData = editor.clipboardAction.getClipboardPayload().blockeditorData;
+
+ const mockClipboard: any = {
+ setData: jasmine.createSpy(),
+ getData: (format: string) => {
+ if (format === 'text/blockeditor') {
+ return copiedData;
+ }
+ return '';
+ }
+ };
+
+ editor.clipboardAction.handleCopy(createMockClipboardEvent('copy', mockClipboard));
+
+ setCursorPosition(getBlockContentElement(blockElement), 0);
+
+ editor.clipboardAction.handlePaste(createMockClipboardEvent('paste', mockClipboard));
+
+ // All Clipboard blocks will be pasted after the focused block
+ // So, total blocks will be 4
+ setTimeout(() => {
+ expect(editor.blocks.length).toBe(4);
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(4);
+
+ expect(editor.blocks[0].content[0].content).toBe('First paragraph');
+
+ expect(editor.blocks[1].content[0].content).toBe('First paragraph');
+ expect(editor.blocks[2].content[0].content).toBe('Second paragraph');
+ expect(editor.blocks[3].content[0].content).toBe('Second paragraph');
+
+ expect(editorElement.querySelectorAll('.e-block')[0].querySelector('p').textContent).toBe('First paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[1].querySelector('p').textContent).toBe('First paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[2].querySelector('p').textContent).toBe('Second paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[3].querySelector('p').textContent).toBe('Second paragraph');
+ done();
+ });
+ });
+
+ it('multi block paste when cursor is at empty block', function (done) {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ editor.selectAllBlocks();
+ const copiedData = editor.clipboardAction.getClipboardPayload().blockeditorData;
+
+ const mockClipboard: any = {
+ setData: jasmine.createSpy(),
+ getData: (format: string) => {
+ if (format === 'text/blockeditor') {
+ return copiedData;
+ }
+ return '';
+ }
+ };
+
+ editor.clipboardAction.handleCopy(createMockClipboardEvent('copy', mockClipboard));
+
+ const contentElement = getBlockContentElement(blockElement);
+ contentElement.textContent = '';
+ editor.updateContentOnUserTyping(blockElement);
+ setCursorPosition(contentElement, 0);
+
+ editor.clipboardAction.handlePaste(createMockClipboardEvent('paste', mockClipboard));
+ setTimeout(function () {
+ expect(editor.blocks.length).toBe(3);
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(3);
+ expect(editor.blocks[0].content[0].content).toBe('First paragraph');
+ expect(editor.blocks[1].content[0].content).toBe('Second paragraph');
+ expect(editor.blocks[2].content[0].content).toBe('Second paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[0].querySelector('p').textContent).toBe('First paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[1].querySelector('p').textContent).toBe('Second paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[2].querySelector('p').textContent).toBe('Second paragraph');
+ done();
+ });
+ });
+
+ it('multi block paste in child type block when cursor is at empty', function (done) {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ editor.selectAllBlocks();
+ const copiedData = editor.clipboardAction.getClipboardPayload().blockeditorData;
+
+ const mockClipboard: any = {
+ setData: jasmine.createSpy(),
+ getData: (format: string) => {
+ if (format === 'text/blockeditor') {
+ return copiedData;
+ }
+ return '';
+ }
+ };
+
+ editor.clipboardAction.handleCopy(createMockClipboardEvent('copy', mockClipboard));
+
+ const lastBlockElement = editorElement.querySelector('#paragraph2') as HTMLElement;
+ editor.setFocusToBlock(lastBlockElement);
+
+ editor.addBlock({
+ id: 'callout-block',
+ type: 'Callout',
+ children: [{
+ id: 'callout-child1',
+ type: 'Paragraph',
+ content: [{ id: 'callout-child1-content', type: ContentType.Text, content: '' }]
+ }]
+ }, lastBlockElement.id);
+
+ const calloutParentBlock = editorElement.querySelector('#callout-block') as HTMLElement;
+ const calloutChildBlock = editorElement.querySelector('#callout-child1') as HTMLElement;
+ editor.setFocusToBlock(calloutChildBlock);
+
+ const contentElement = getBlockContentElement(calloutChildBlock);
+ setCursorPosition(contentElement, 0);
+
+ editor.clipboardAction.handlePaste(createMockClipboardEvent('paste', mockClipboard));
+ setTimeout(function () {
+ expect(editor.blocks[2].children.length).toBe(2);
+ expect(calloutParentBlock.querySelectorAll('.e-block').length).toBe(2);
+ expect(editor.blocks[2].children[0].content[0].content).toBe('First paragraph');
+ expect(editor.blocks[2].children[1].content[0].content).toBe('Second paragraph');
+ expect(calloutParentBlock.querySelectorAll('.e-block')[0].querySelector('p').textContent).toBe('First paragraph');
+ expect(calloutParentBlock.querySelectorAll('.e-block')[1].querySelector('p').textContent).toBe('Second paragraph');
+ done();
+ });
+ });
+
+ it('should delete selected content and paste correctly - Single Block', (done) => {
+ if (editor) editor.destroy();
+ editor = createEditor({
+ blocks: [
+ {
+ id: 'source-block',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'source-content', type: ContentType.Text, content: 'Source text to copy' }
+ ]
+ },
+ {
+ id: 'target-block',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'target-content', type: ContentType.Text, content: 'This text will be partially selected' }
+ ]
+ }
+ ]
+ });
+ editor.appendTo('#editor');
+
+ // Copy content from the first block
+ const sourceBlock = editorElement.querySelector('#source-block') as HTMLElement;
+ editor.setFocusToBlock(sourceBlock);
+ const sourceRange = document.createRange();
+ const sourceContent = sourceBlock.querySelector('#source-content') as HTMLElement;
+ sourceRange.selectNodeContents(sourceContent);
+
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(sourceRange);
+
+ const copiedData = editor.clipboardAction.getClipboardPayload();
+
+ const mockClipboard: any = {
+ setData: jasmine.createSpy(),
+ getData: (format: string) => {
+ if (format === 'text/blockeditor') {
+ return copiedData.blockeditorData;
+ } else if (format === 'text/html') {
+ return copiedData.html;
+ } else if (format === 'text/plain') {
+ return copiedData.text;
+ }
+ return '';
+ }
+ };
+
+ // Select partial text in the second block
+ const targetBlock = editorElement.querySelector('#target-block') as HTMLElement;
+ editor.setFocusToBlock(targetBlock);
+ const targetRange = document.createRange();
+ const targetContent = targetBlock.querySelector('#target-content') as HTMLElement;
+ targetRange.setStart(targetContent.firstChild, 5); // 'This '
+ targetRange.setEnd(targetContent.firstChild, 18); // 'This text will be'
+
+ selection.removeAllRanges();
+ selection.addRange(targetRange);
+
+ //this should delete the selected content and paste new content
+ editor.clipboardAction.handlePaste(createMockClipboardEvent('paste', mockClipboard));
+
+ setTimeout(() => {
+ const updatedContent = editor.blocks[1].content;
+ expect(updatedContent.length).toBe(3); // Should be split into three parts
+ expect(updatedContent[0].content).toBe('This '); // Text before selection
+ expect(updatedContent[1].content).toBe('Source text to copy'); // Pasted content
+ expect(updatedContent[2].content).toBe('partially selected'); // Text after selection
+
+ const updatedElement = editorElement.querySelector('#target-block .e-block-content');
+ expect(updatedElement.textContent).toBe('This Source text to copypartially selected');
+ done();
+ }, 100);
+ });
+
+ it('should delete selected content and paste correctly - Multi Blocks', (done) => {
+ if (editor) editor.destroy();
+ editor = createEditor({
+ blocks: [
+ {
+ id: 'source-block',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'source-content', type: ContentType.Text, content: 'Source text to copy. This text will be' }
+ ]
+ },
+ {
+ id: 'target-block',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'target-content', type: ContentType.Text, content: 'partially selected' }
+ ]
+ }
+ ]
+ });
+ editor.appendTo('#editor');
+
+ // Copy content from the first block
+ const sourceBlock = editorElement.querySelector('#source-block') as HTMLElement;
+ editor.setFocusToBlock(sourceBlock);
+ const sourceRange = document.createRange();
+ const sourceContent = sourceBlock.querySelector('#source-content') as HTMLElement;
+ sourceRange.setStart(sourceContent.firstChild, 0);
+ sourceRange.setEnd(sourceContent.firstChild, 20);
+
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(sourceRange);
+
+ const copiedData = editor.clipboardAction.getClipboardPayload();
+
+ const mockClipboard: any = {
+ setData: jasmine.createSpy(),
+ getData: (format: string) => {
+ if (format === 'text/blockeditor') {
+ return copiedData.blockeditorData;
+ } else if (format === 'text/html') {
+ return copiedData.html;
+ } else if (format === 'text/plain') {
+ return copiedData.text;
+ }
+ return '';
+ }
+ };
+
+ // Select partial text in first block and partial in the second block
+ const targetBlock = editorElement.querySelector('#target-block') as HTMLElement;
+ editor.setFocusToBlock(targetBlock);
+ const targetRange = document.createRange();
+ const targetContent = targetBlock.querySelector('#target-content') as HTMLElement;
+ targetRange.setStart(sourceContent.firstChild, 21);
+ targetRange.setEnd(targetContent.firstChild, 10);
+
+ selection.removeAllRanges();
+ selection.addRange(targetRange);
+
+ //this should delete the selected content and paste new content
+ editor.clipboardAction.handlePaste(createMockClipboardEvent('paste', mockClipboard));
+
+ setTimeout(() => {
+ expect(editor.blocks.length).toBe(1);
+ const updatedContent = editor.blocks[0].content;
+ expect(updatedContent.length).toBe(3); // Should be split into three parts
+ expect(updatedContent[0].content).toBe('Source text to copy. '); // Text before selection
+ expect(updatedContent[1].content).toBe('Source text to copy.'); // Pasted content
+ expect(updatedContent[2].content).toBe('selected'); // Text after selection
+
+ const updatedElement = editorElement.querySelector('#source-block .e-block-content');
+ expect(updatedElement.textContent).toBe('Source text to copy. Source text to copy.selected');
+ done();
+ }, 100);
+ });
+ });
+
+ describe('Paste Plain Text', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'paragraph', type: BlockType.Paragraph, content: [{ id: 'paragraph-content', type: ContentType.Text, content: 'Hello world' }] }
+ ];
+ editor = createEditor({
+ blocks: blocks,
+ pasteSettings: {
+ plainText: true
+ }
+ });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ }
+ document.body.removeChild(editorElement);
+ });
+
+ it('should paste plain text as paragraph blocks', (done) => {
+ // Mock clipboard event with plain text
+ const mockEvent = createMockClipboardEvent('paste', {
+ getData: (format: string) => {
+ if (format === 'text/plain') {
+ return 'Line 1\n \nLine 2\nLine 3';
+ }
+ return '';
+ }
+ });
+
+ const blockElement: HTMLElement = editorElement.querySelector('#paragraph') as HTMLElement;
+ const contentElement: HTMLElement = getBlockContentElement(blockElement);
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(contentElement, 0);
+ editor.clipboardAction.handlePaste(mockEvent);
+
+ setTimeout(() => {
+ expect(editor.blocks.length).toBe(4); // Original + 3 new lines
+ expect(editor.blocks[0].content[0].content).toBe('Hello world');
+ expect(editor.blocks[1].content[0].content).toBe('Line 1');
+ expect(editor.blocks[2].content[0].content).toBe('Line 2');
+ expect(editor.blocks[3].content[0].content).toBe('Line 3');
+ expect(editor.blockWrapper.querySelectorAll('.e-block').length).toBe(4);
+ done();
+ }, 100);
+ });
+
+ it('should detect and convert bullet lists in plain text', (done) => {
+ // Mock clipboard event with plain text containing bullet list markers
+ const mockEvent = createMockClipboardEvent('paste', {
+ getData: (format: string) => {
+ if (format === 'text/plain') {
+ return '* Item 1\n* Item 2\n* Item 3';
+ }
+ return '';
+ }
+ });
+ const blockElement: HTMLElement = editorElement.querySelector('#paragraph') as HTMLElement;
+ const contentElement: HTMLElement = getBlockContentElement(blockElement);
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(contentElement, 0);
+
+ // Trigger paste event
+ editor.clipboardAction.handlePaste(mockEvent);
+
+ setTimeout(() => {
+ expect(editor.blocks.length).toBe(4); // Original + 3 new list items
+ expect(editor.blocks[1].type).toBe(BlockType.BulletList);
+ expect(editor.blocks[2].type).toBe(BlockType.BulletList);
+ expect(editor.blocks[3].type).toBe(BlockType.BulletList);
+ expect(editor.blockWrapper.querySelectorAll('.e-block').length).toBe(4);
+ done();
+ }, 100);
+ });
+
+ it('should detect and convert numbered lists in plain text', (done) => {
+ // Mock clipboard event with plain text containing numbered list markers
+ const mockEvent = createMockClipboardEvent('paste', {
+ getData: (format: string) => {
+ if (format === 'text/plain') {
+ return '1. Item 1\n2. Item 2\n3. Item 3';
+ }
+ return '';
+ }
+ });
+ const blockElement: HTMLElement = editorElement.querySelector('#paragraph') as HTMLElement;
+ const contentElement: HTMLElement = getBlockContentElement(blockElement);
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(contentElement, 0);
+
+ // Trigger paste event
+ editor.clipboardAction.handlePaste(mockEvent);
+
+ setTimeout(() => {
+ expect(editor.blocks.length).toBe(4); // Original + 3 new list items
+ expect(editor.blocks[1].type).toBe(BlockType.NumberedList);
+ expect(editor.blocks[2].type).toBe(BlockType.NumberedList);
+ expect(editor.blocks[3].type).toBe(BlockType.NumberedList);
+ expect(editor.blockWrapper.querySelectorAll('.e-block').length).toBe(4);
+ done();
+ }, 100);
+ });
+ });
+
+ describe('Paste HTML Content', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+ const blocks: BlockModel[] = [
+ { id: 'paragraph', type: BlockType.Paragraph, content: [{ id: 'paragraph-content', type: ContentType.Text, content: 'Hello world' }] }
+ ];
+
+ beforeEach((done) => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ editor = createEditor({
+ blocks: blocks
+ });
+ editor.appendTo('#editor');
+ done();
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ }
+ document.body.removeChild(editorElement);
+ });
+
+ it('should parse and paste HTML content with formatting', (done) => {
+ // Mock clipboard event with HTML content
+ const mockEvent = createMockClipboardEvent('paste', {
+ getData: (format: string) => {
+ if (format === 'text/html') {
+ return 'Formatted bold and italic text
';
+ }
+ return '';
+ }
+ });
+ const blockElement: HTMLElement = editorElement.querySelector('#paragraph') as HTMLElement;
+ const contentElement: HTMLElement = getBlockContentElement(blockElement);
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(contentElement, 0);
+ // Trigger paste event
+ editor.clipboardAction.handlePaste(mockEvent);
+
+ setTimeout(() => {
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[1].type).toBe(BlockType.Paragraph);
+ expect(editor.blocks[1].content.length).toBe(5);
+ expect(editor.blocks[1].content[0].content).toBe('Formatted ');
+
+ expect(editor.blocks[1].content[1].content).toBe('bold');
+ expect(editor.blocks[1].content[1].styles.bold).toBe(true);
+
+ expect(editor.blocks[1].content[2].content).toBe(' and ');
+
+ expect(editor.blocks[1].content[3].content).toBe('italic');
+ expect(editor.blocks[1].content[3].styles.italic).toBe(true);
+
+ expect(editor.blocks[1].content[4].content).toBe(' text');
+ done();
+ }, 100);
+ });
+
+ it('should convert HTML lists to list blocks', (done) => {
+ // Mock clipboard event with HTML list content
+ const mockEvent = createMockClipboardEvent('paste', {
+ getData: (format: string) => {
+ if (format === 'text/html') {
+ // Orderered list with nested case
+ return 'Item 1 Item 2Subitem 2.1 Subitem 2.2 Item 3 ';
+ }
+ return '';
+ }
+ });
+ const blockElement: HTMLElement = editorElement.querySelector('#paragraph') as HTMLElement;
+ const contentElement: HTMLElement = getBlockContentElement(blockElement);
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(contentElement, 0);
+ // Trigger paste event
+ editor.clipboardAction.handlePaste(mockEvent);
+
+ setTimeout(() => {
+ expect(editor.blocks.length).toBe(6); // Original + 5 list items
+ expect(editor.blocks[1].type).toBe(BlockType.NumberedList);
+ expect(editor.blocks[2].type).toBe(BlockType.NumberedList);
+ expect(editor.blocks[3].type).toBe(BlockType.NumberedList);
+ expect(editor.blocks[4].type).toBe(BlockType.NumberedList);
+ expect(editor.blocks[5].type).toBe(BlockType.NumberedList);
+
+ expect(editor.blocks[1].content[0].content).toBe('Item 1');
+ expect(editor.blocks[2].content[0].content).toBe('Item 2');
+ expect(editor.blocks[3].indent).toBe(1);
+ expect(editor.blocks[3].content[0].content).toBe('Subitem 2.1');
+ expect(editor.blocks[4].indent).toBe(1);
+ expect(editor.blocks[4].content[0].content).toBe('Subitem 2.2');
+ expect(editor.blocks[5].content[0].content).toBe('Item 3');
+
+ //DOm check
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(6);
+ expect(editorElement.querySelectorAll('.e-list-block').length).toBe(5);
+ done();
+ }, 100);
+ });
+
+ it('should paste inline elements as contents within block', (done) => {
+ // Mock clipboard event with HTML list content
+ const mockEvent = createMockClipboardEvent('paste', {
+ getData: (format: string) => {
+ if (format === 'text/html') {
+ // Orderered list with nested case
+ return 'Inline element within block ';
+ }
+ return '';
+ }
+ });
+ const blockElement: HTMLElement = editorElement.querySelector('#paragraph') as HTMLElement;
+ const contentElement: HTMLElement = getBlockContentElement(blockElement);
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(contentElement, 0);
+ // Trigger paste event
+ editor.clipboardAction.handlePaste(mockEvent);
+
+ setTimeout(() => {
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ expect(editor.blocks[0].content[0].content).toBe('Inline element within block');
+ expect(editor.blocks[0].content[1].content).toBe('Hello world');
+
+ //DOM
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(1);
+ expect(editorElement.querySelector('#paragraph').textContent).toBe('Inline element within blockHello world');
+ done();
+ }, 100);
+ });
+ });
+
+ describe('Copy to External Applications', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeAll(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ editor = createEditor({ blocks: allBlockData });
+ editor.appendTo('#editor');
+ });
+
+ beforeEach((done: DoneFn) => done());
+
+ afterAll(() => {
+ if (editor) {
+ editor.destroy();
+ }
+ document.body.removeChild(editorElement);
+ });
+
+ it('should generate clean HTML for external clipboard', (done) => {
+ // // Mock copy event
+ editor.selectAllBlocks();
+ const { html, text, blockeditorData }: IClipboardPayload = editor.clipboardAction.getClipboardPayload();
+ expect(html).toContain('Welcome to the Block Editor Demo! ');
+ expect(html).toContain('Block Editor is a powerful rich text editor
');
+ expect(html).toContain('Try selecting text to see formatting options , or type "/" to access the command menu.
');
+ expect(html).toContain('Block Types ');
+ expect(html).toContain('The Block Editor makes document creation a seamless experience with its intuitive block-based approach. ');
+ expect(html).toContain('List Types ');
+ expect(html).toContain('Text blocks: Paragraph, Heading 1-4, Quote, Callout ');
+ expect(html).toContain('Lists: Bullet lists, Numbered lists, Check lists ');
+ expect(html).toContain('Special blocks: Divider, Toggle, Code block ');
+ expect(html).toContain(' ');
+ expect(html).toContain('Text Formatting Examples ');
+ expect(html).toContain('Bold Italic Underline Strikethrough Superscript Subscript uppercase LOWERCASE
');
+ expect(html).toContain('Visit Syncfusion for more information.
');
+ expect(html).toContain('This block contains a Progress: In-progress label.
');
+ expect(html).toContain('Try it out! Click anywhere and start typing, or type "/" to see available commands.
');
+
+ // Optionally validate structure
+ expect(Array.isArray(JSON.parse(blockeditorData).blocks)).toBe(true);
+ expect(typeof text).toBe('string');
+ expect(typeof html).toBe('string');
+ done();
+ });
+ });
+
+ describe('Code Block Paste', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ {
+ id: 'code-block',
+ type: BlockType.Code,
+ content: [{
+ id: 'code-content',
+ type: ContentType.Text,
+ content: '// JavaScript code\n'
+ }]
+ }
+ ];
+ editor = createEditor({ blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ }
+ document.body.removeChild(editorElement);
+ });
+
+ it('should handle code block content paste correctly', (done) => {
+ // Mock clipboard event with code content
+ const codeToInsert = 'function hello() {\n console.log("Hello world!");\n}';
+ const mockEvent = createMockClipboardEvent('paste', {
+ getData: (format: string) => {
+ if (format === 'text/plain') {
+ return codeToInsert;
+ }
+ return '';
+ }
+ });
+
+ // Select the code block
+ const codeBlock = editorElement.querySelector('#code-block') as HTMLElement;
+ editor.setFocusToBlock(codeBlock);
+
+ // Get the code content element
+ const codeContent = codeBlock.querySelector('.e-code-content') as HTMLElement;
+ setCursorPosition(codeContent, codeContent.textContent.length);
+
+ // Paste the code
+ editor.clipboardAction.handlePaste(mockEvent);
+
+ setTimeout(() => {
+ // Check if the code was correctly pasted
+ expect(editor.blocks[0].content[0].content).toBe('// JavaScript code\n' + codeToInsert);
+ expect(codeContent.textContent).toBe('// JavaScript code\n' + codeToInsert);
+ done();
+ }, 100);
+ });
+
+ it('should preserve code block type when pasting', (done) => {
+ // Mock clipboard event with various content types
+ const mixedContent = 'Regular text
var x = 10;
';
+ const mockEvent = createMockClipboardEvent('paste', {
+ getData: (format: string) => {
+ if (format === 'text/html') {
+ return mixedContent;
+ } else if (format === 'text/plain') {
+ return 'Regular text\nvar x = 10;';
+ }
+ return '';
+ }
+ });
+
+ // Select the code block
+ const codeBlock = editorElement.querySelector('#code-block') as HTMLElement;
+ editor.setFocusToBlock(codeBlock);
+
+ // Get the code content element
+ const codeContent = codeBlock.querySelector('.e-code-content') as HTMLElement;
+ setCursorPosition(codeContent, codeContent.textContent.length);
+
+ // Paste the mixed content
+ editor.clipboardAction.handlePaste(mockEvent);
+
+ setTimeout(() => {
+ // The content should be pasted as plain text in the code block
+ expect(editor.blocks[0].type).toBe(BlockType.Code);
+ expect(editor.blocks[0].content[0].content).toContain('var x = 10;');
+ done();
+ }, 100);
+ });
+
+ it('should preserve br when selecting whole content and paste', (done) => {
+ // Mock clipboard event with code content
+ const codeToInsert = 'function hello() {\n console.log("Hello world!");\n}';
+ const mockEvent = createMockClipboardEvent('paste', {
+ getData: (format: string) => {
+ if (format === 'text/plain') {
+ return codeToInsert;
+ }
+ return '';
+ }
+ });
+
+ spyOn((editor.clipboardAction as any), 'handleCodeBlockContentPaste').and.callFake(() => {});
+
+ // Select the code block
+ const codeBlock = editorElement.querySelector('#code-block') as HTMLElement;
+ editor.setFocusToBlock(codeBlock);
+
+ // Get the code content element
+ const codeContent = codeBlock.querySelector('.e-code-content') as HTMLElement;
+ editor.nodeSelection.createRangeWithOffsets(
+ codeContent.firstChild, codeContent.firstChild, 0, codeContent.textContent.length
+ );
+
+ // Paste the code
+ editor.clipboardAction.handlePaste(mockEvent);
+
+ setTimeout(() => {
+ expect((editor.clipboardAction as any).handleCodeBlockContentPaste).toHaveBeenCalled();
+ done();
+ }, 100);
+ });
+ });
+
+ describe('Context Menu Clipboard Operations', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ {
+ id: 'paragraph1',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'paragraph1-content', type: ContentType.Text, content: 'Context menu clipboard test' }
+ ]
+ },
+ {
+ id: 'paragraph2',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'paragraph2-content', type: ContentType.Text, content: 'Second paragraph' }
+ ]
+ }
+ ];
+ editor = createEditor({ blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ }
+ document.body.removeChild(editorElement);
+ });
+
+ it('should handle context copy operation', (done) => {
+ // spy on the methods we call
+ const spy = spyOn(editor.clipboardAction, 'getClipboardPayload').and.returnValue({
+ html: 'Test HTML
',
+ text: 'Test text',
+ blockeditorData: JSON.stringify({ block: { id: 'test', content: [] } })
+ });
+
+ // Mock the clipboard.write method
+ const writePromise = Promise.resolve();
+ spyOn((navigator as any).clipboard, 'write').and.returnValue(writePromise);
+
+ // Select content
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+
+ // Call context copy
+ editor.clipboardAction.handleContextCopy().then(() => {
+ expect(spy).toHaveBeenCalled();
+ expect((navigator as any).clipboard.write).toHaveBeenCalled();
+ done();
+ }).catch(done.fail);
+ });
+
+ it('should handle context cut operation', (done) => {
+ // Spy on related methods
+ const copySpy = spyOn(editor.clipboardAction, 'handleContextCopy').and.returnValue(Promise.resolve());
+ const cutSpy = spyOn(editor.clipboardAction as any, 'performCutOperation').and.callThrough();
+
+ // Select content
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+
+ // Create a selection range
+ const range = document.createRange();
+ const contentElement = blockElement.querySelector('#paragraph1-content');
+ range.selectNodeContents(contentElement);
+
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ // Call context cut
+ editor.clipboardAction.handleContextCut().then(() => {
+ expect(copySpy).toHaveBeenCalled();
+ expect(cutSpy).toHaveBeenCalled();
+ done();
+ }).catch(done.fail);
+ });
+
+ it('should handle context paste operation', (done) => {
+ // Spy on clipboard.read and performPasteOperation
+ const readPromise = Promise.resolve([{
+ types: ['text/plain', 'text/html'],
+ getType: (type: string) => {
+ return Promise.resolve({
+ text: () => Promise.resolve(type === 'text/plain' ? 'Test text' : 'Test HTML
')
+ });
+ }
+ }]);
+
+ spyOn((navigator as any).clipboard, 'read').and.returnValue(readPromise);
+ const pasteSpy = spyOn(editor.clipboardAction as any, 'performPasteOperation').and.callThrough();
+
+ // Set cursor in block
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(getBlockContentElement(blockElement), 0);
+
+ // Call context paste
+ editor.clipboardAction.handleContextPaste().then(() => {
+ expect((navigator as any).clipboard.read).toHaveBeenCalled();
+ expect(pasteSpy).toHaveBeenCalled();
+
+ // Check if correct data was passed to performPasteOperation
+ const args = pasteSpy.calls.mostRecent().args[0];
+ expect(args.html).toBe('Test HTML
');
+ expect(args.text).toBe('Test text');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('should handle image paste through context menu', (done) => {
+ // Create a mock Blob that simulates an image file
+ const mockImageBlob = new Blob(['image data'], { type: 'image/png' });
+
+ // Spy on clipboard.read with mock image data
+ const readPromise = Promise.resolve([{
+ types: ['image/png', 'text/plain'],
+ getType: (type: string) => {
+ if (type === 'image/png') {
+ return Promise.resolve(mockImageBlob);
+ }
+ return Promise.resolve({
+ text: () => Promise.resolve('Alt text for image')
+ });
+ }
+ }]);
+
+ spyOn((navigator as any).clipboard, 'read').and.returnValue(readPromise);
+
+ // Spy on performPasteOperation
+ const pasteSpy = spyOn(editor.clipboardAction as any, 'performPasteOperation').and.callThrough();
+ const imagePasteHandler = spyOn(editor.blockAction.imageRenderer, 'handleFilePaste').and.callFake(() => Promise.resolve());
+
+ // Set cursor in block
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(getBlockContentElement(blockElement), 0);
+
+ // Call context paste
+ editor.clipboardAction.handleContextPaste().then(() => {
+ expect((navigator as any).clipboard.read).toHaveBeenCalled();
+
+ expect(pasteSpy).toHaveBeenCalled();
+ expect(imagePasteHandler).toHaveBeenCalled();
+ const args = pasteSpy.calls.mostRecent().args[0];
+
+ // Should include the file object
+ expect(args.file).toBe(mockImageBlob);
+ expect(args.text).toBe('Alt text for image');
+
+ done();
+ }).catch(done.fail);
+ });
+
+ it('should handle clipboard empty check', (done) => {
+ // Test for empty clipboard
+ spyOn((navigator as any).clipboard, 'read').and.returnValue(Promise.resolve([]));
+
+ editor.clipboardAction.isClipboardEmpty().then((isEmpty) => {
+ expect(isEmpty).toBe(true);
+ done();
+ }).catch(done.fail);
+ });
+
+ it('should handle context paste with fallback', (done) => {
+ // Mock read to throw an error and readText to succeed
+ spyOn((navigator as any).clipboard, 'read').and.returnValue(Promise.reject('Security error'));
+ spyOn((navigator as any).clipboard, 'readText').and.returnValue(Promise.resolve('Fallback text'));
+
+ // Set cursor in block
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(getBlockContentElement(blockElement), 0);
+
+ const pasteSpy = spyOn(editor.clipboardAction as any, 'performPasteOperation').and.callThrough();
+
+ editor.clipboardAction.handleContextPaste().then(() => {
+ expect((navigator as any).clipboard.read).toHaveBeenCalled();
+ expect((navigator as any).clipboard.readText).toHaveBeenCalled();
+ expect(pasteSpy).toHaveBeenCalled();
+
+ // Check fallback data
+ const args = pasteSpy.calls.mostRecent().args[0];
+ expect(args.text).toBe('Fallback text');
+
+ done();
+ }).catch(done.fail);
+ });
+ });
+
+ describe('Other actions testing', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'paragraph-1', type: BlockType.Paragraph, content: [{ id: 'paragraph-content-1', type: ContentType.Text, content: 'Hello world 1' }] },
+ { id: 'paragraph-2', type: BlockType.Paragraph, content: [{ id: 'paragraph-content-2', type: ContentType.Text, content: 'Hello world 2' }] }
+ ];
+ editor = createEditor({
+ blocks: blocks
+ });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ }
+ document.body.removeChild(editorElement);
+ });
+
+ it('should handle edge cases properly', (done) => {
+ // Empty block data
+ const blocks = (editor.clipboardAction as any).createPartialBlockModels(document.createElement('div'), []);
+ expect(blocks.length).toBe(0);
+
+ // Invalid content element
+ const container = document.createElement('div');
+ container.innerHTML = 'Test
';
+
+ const contents = (editor.clipboardAction as any).createPartialContentModels(container, editor.blocks[0]);
+ expect(contents.length).toBe(0);
+
+ // Sending null data for parse will be catched by try catch block
+ const spyconsole = spyOn(console, 'error').and.callFake(() => {});
+ const parsedData = (editor.clipboardAction as any).handleBlockEditorPaste('this is not valid json');
+ expect(parsedData).toBeUndefined();
+ expect(console.error).toHaveBeenCalled();
+ expect(console.error).toHaveBeenCalledWith('Error parsing Block Editor clipboard data:', jasmine.any(Error));
+ spyconsole.calls.reset();
+
+ //Pasting empty content
+ const pasteData = (editor.clipboardAction as any).handleContentPasteWithinBlock([]);
+ expect(pasteData).toBeUndefined();
+
+ var rangeSpy = spyOn(editor.nodeSelection, 'getRange').and.returnValue(null);
+ //Range test
+ const pasteData1 = (editor.clipboardAction as any).handleContentPasteWithinBlock([ { content: 'Fake' } ]);
+ expect(pasteData1).toBeUndefined();
+
+ // Sending null data
+ const data1 = (editor.clipboardAction as any).handleMultiBlocksPaste([]);
+ expect(data1).toBeUndefined();
+
+ // Range test
+ const data2 = (editor.clipboardAction as any).handleMultiBlocksPaste([{ type: 'Fake' }]);
+ expect(data2).toBeUndefined();
+
+ const text = (editor.clipboardAction as any).getBlockText({ content: null })
+ expect(text).toBe('');
+ rangeSpy.calls.reset();
+ done();
+ });
+
+ it('should unwrap the deepest block container', (done) => {
+ const container = document.createElement('div');
+ const span = document.createElement('span');
+ span.innerHTML = '';
+ container.appendChild(span);
+
+ const unwrapped = (editor.clipboardAction as any).unWrapContainer(container);
+ expect(unwrapped).not.toBeNull();
+ expect(unwrapped.firstChild.id).toBe('nestedContainer');
+ done();
+ });
+
+ it('should exit when before paste event is prevented', (done) => {
+ const blockElement = editorElement.querySelector('#paragraph-1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(getBlockContentElement(blockElement), 0);
+
+ editor.beforePaste = (args) => {
+ args.cancel = true;
+ }
+
+ (editor.clipboardAction as any).performPasteOperation({
+ html: '', text: '', file: null
+ });
+
+ expect(editor.blocks.length).toBe(2);
+ done();
+ });
+
+ it('should focus prev block when cut performed on last block', (done) => {
+ setTimeout(() => {
+ const blockElement = editorElement.querySelector('#paragraph-2') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(getBlockContentElement(blockElement), 0);
+ const copiedData = editor.clipboardAction.getClipboardPayload().blockeditorData;
+
+ const mockClipboard: any = {
+ setData: jasmine.createSpy(),
+ getData: (format: string) => {
+ if (format === 'text/blockeditor') {
+ return copiedData;
+ }
+ return '';
+ }
+ };
+
+ editor.clipboardAction.handleCut(createMockClipboardEvent('cut', mockClipboard));
+
+ setTimeout(() => {
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.currentFocusedBlock.id).toBe('paragraph-1');
+ done();
+ }, 300);
+ }, 200);
+ });
+
+ it('should paste image from clipboard properly', (done) => {
+ setTimeout(() => {
+ const blockElement = editorElement.querySelector('#paragraph-1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(getBlockContentElement(blockElement), 0);
+
+ // Create a mock file that simulates an image
+ const mockFile = new File([''], 'test-image.png', { type: 'image/png' });
+
+ // Create a mock FileList-like object
+ const mockItems = [{
+ kind: 'file',
+ type: 'image/png',
+ getAsFile: () => mockFile
+ }];
+
+ const mockClipboard: any = {
+ setData: jasmine.createSpy(),
+ getData: () => {},
+ items: mockItems
+ };
+
+ editor.clipboardAction.handlePaste(createMockClipboardEvent('paste', mockClipboard));
+
+ setTimeout(() => {
+ expect(editor.blocks.length).toBe(3);
+ expect(editor.blocks[1].type).toBe('Image');
+ expect(editorElement.querySelector('img')).not.toBeNull();
+ done();
+ }, 500);
+ }, 200);
+ });
+ });
+});
diff --git a/controls/blockeditor/spec/actions/content-types.spec.ts b/controls/blockeditor/spec/actions/content-types.spec.ts
new file mode 100644
index 0000000000..82654afe77
--- /dev/null
+++ b/controls/blockeditor/spec/actions/content-types.spec.ts
@@ -0,0 +1,166 @@
+import { createElement, remove } from '@syncfusion/ej2-base';
+import { ClickEventArgs } from '@syncfusion/ej2-navigations';
+import { BlockModel, UserModel } from '../../src/blockeditor/models';
+import { BlockEditor, BlockType, ContentType, setCursorPosition, setSelectionRange, getBlockContentElement } from '../../src/index';
+import { createEditor } from '../common/util.spec';
+
+describe('Content types', () => {
+ beforeAll(() => {
+ const isDef: any = (o: any) => o !== undefined && o !== null;
+ if (!isDef(window.performance)) {
+ console.log('Unsupported environment, window.performance.memory is unavailable');
+ pending(); // skips test (in Chai)
+ return;
+ }
+ });
+
+ describe('Ensuring basic rendering of content types', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ {
+ id: 'text-block',
+ type: BlockType.Paragraph,
+ content: [{ type: ContentType.Text, content: 'helloworld' }]
+ },
+ {
+ id: 'link-block',
+ type: BlockType.Paragraph,
+ content: [{ type: ContentType.Link, content: 'syncfusion', linkSettings: { url: 'www.syncfusion.com', openInNewWindow: true } }]
+ },
+ {
+ id: 'code-block',
+ type: BlockType.Paragraph,
+ content: [{ type: ContentType.Code, content: 'console.log("hello world")' }]
+ },
+ {
+ id: 'mention-block',
+ type: BlockType.Paragraph,
+ content: [{ type: ContentType.Mention, id: 'user1' }]
+ },
+ {
+ id: 'label-block',
+ type: BlockType.Paragraph,
+ content: [{ type: ContentType.Label, id: 'progress' }]
+ },
+ {
+ id: 'combined-block',
+ type: BlockType.Paragraph,
+ content: [
+ { type: ContentType.Text, content: 'To navigate to syncfusion site, ' },
+ { type: ContentType.Link, content: 'click here ', linkSettings: { url: 'www.syncfusion.com', openInNewWindow: true } },
+ { type: ContentType.Code, content: 'console.log("hello world"), ' },
+ { type: ContentType.Mention, id: 'user2' },
+ { type: ContentType.Label, id: 'progress' }
+ ]
+ },
+ ];
+ const users: UserModel[] = [
+ { id: 'user1', user: 'John Paul' },
+ { id: 'user2', user: 'John Snow' }
+ ];
+ editor = createEditor({ blocks: blocks, users: users });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('ensure content type text rendering', () => {
+ const blockElement = editorElement.querySelector('#text-block') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ const contentElement = getBlockContentElement(blockElement);
+ expect(contentElement).not.toBeNull();
+ expect(contentElement.textContent).toBe('helloworld');
+ });
+
+ it('ensure content type link rendering', () => {
+ const blockElement = editorElement.querySelector('#link-block') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ const contentElement = getBlockContentElement(blockElement);
+ expect(contentElement).not.toBeNull();
+ const anchorEle = (contentElement.firstChild as HTMLElement);
+ expect(anchorEle.tagName).toBe('A');
+ expect(anchorEle.textContent).toBe('syncfusion');
+ expect(anchorEle.getAttribute('href')).toBe('https://www.syncfusion.com');
+ expect(anchorEle.getAttribute('target')).toBe('_blank');
+ expect(editor.blocks[1].content[0].id).toBe(anchorEle.id);
+ });
+
+ it('ensure content type code rendering', () => {
+ const blockElement = editorElement.querySelector('#code-block') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ const contentElement = getBlockContentElement(blockElement);
+ expect(contentElement).not.toBeNull();
+ const codeEle = (contentElement.firstChild as HTMLElement);
+ expect(codeEle.tagName).toBe('CODE');
+ expect(codeEle.textContent).toBe('console.log("hello world")');
+ expect(editor.blocks[2].content[0].id).toBe(codeEle.id);
+ });
+
+ it('ensure content type mention rendering', () => {
+ const blockElement = editorElement.querySelector('#mention-block') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ const contentElement = getBlockContentElement(blockElement);
+ expect(contentElement).not.toBeNull();
+ const mentionChipEle = (contentElement.firstChild as HTMLElement);
+ expect(mentionChipEle.tagName).toBe('DIV');
+ expect((mentionChipEle.querySelector('.em-content') as HTMLElement).textContent).toBe('John Paul');
+ expect(editor.blocks[3].content[0].dataId).toBe(mentionChipEle.id);
+ expect(editor.blocks[3].content[0].id).toBe(mentionChipEle.getAttribute('data-user-id'));
+ });
+
+ it('ensure content type label rendering', () => {
+ const blockElement = editorElement.querySelector('#label-block') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ const contentElement = getBlockContentElement(blockElement);
+ expect(contentElement).not.toBeNull();
+ const labelChipEle = (contentElement.firstChild as HTMLElement);
+ expect(labelChipEle.tagName).toBe('SPAN');
+ expect(labelChipEle.textContent).toBe('Progress: In-progress');
+ expect(editor.blocks[4].content[0].dataId).toBe(labelChipEle.id);
+ expect(editor.blocks[4].content[0].id).toBe(labelChipEle.getAttribute('data-label-id'));
+ });
+
+ it('ensure all content types combined rendering', () => {
+ const blockElement = editorElement.querySelector('#combined-block') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ const contentElement = getBlockContentElement(blockElement);
+ expect(contentElement).not.toBeNull();
+ expect(contentElement.textContent).toBe('To navigate to syncfusion site, click here console.log("hello world"), JSJohn SnowProgress: In-progress');
+ const textEle = contentElement.firstChild as HTMLElement;
+ expect(textEle.tagName).toBe('SPAN');
+ expect(textEle.textContent).toBe('To navigate to syncfusion site, ');
+ expect(editor.blocks[5].content[0].id).toBe(textEle.id);
+
+ const anchorEle = contentElement.childNodes[1] as HTMLElement;
+ expect(anchorEle.tagName).toBe('A');
+ expect(anchorEle.textContent).toBe('click here ');
+ expect(anchorEle.getAttribute('href')).toBe('https://www.syncfusion.com');
+ expect(anchorEle.getAttribute('target')).toBe('_blank');
+ expect(editor.blocks[5].content[1].id).toBe(anchorEle.id);
+
+ const codeEle = contentElement.childNodes[2] as HTMLElement;
+ expect(codeEle.tagName).toBe('CODE');
+ expect(codeEle.textContent).toBe('console.log("hello world"), ');
+ expect(editor.blocks[5].content[2].id).toBe(codeEle.id);
+
+ const mentionEle = contentElement.childNodes[3] as HTMLElement;
+ expect(mentionEle.tagName).toBe('DIV');
+ expect((mentionEle.querySelector('.em-content') as HTMLElement).textContent).toBe('John Snow');
+
+ const labelEle = contentElement.lastChild as HTMLElement;
+ expect(labelEle.tagName).toBe('SPAN');
+ expect(labelEle.textContent).toBe('Progress: In-progress');
+ });
+ });
+});
diff --git a/controls/blockeditor/spec/actions/drag.spec.ts b/controls/blockeditor/spec/actions/drag.spec.ts
new file mode 100644
index 0000000000..8604773f12
--- /dev/null
+++ b/controls/blockeditor/spec/actions/drag.spec.ts
@@ -0,0 +1,716 @@
+import { createElement, remove } from '@syncfusion/ej2-base';
+import { BlockType, ContentType } from '../../src/blockeditor/base/enums';
+import { BlockModel } from '../../src/blockeditor/models';
+import { BlockEditor } from '../../src/index';
+import { BlockDragEventArgs } from '../../src/blockeditor/base/eventargs';
+
+describe('DragAndDrop', () => {
+ let editor: BlockEditor;
+ let block1: HTMLElement, block2: HTMLElement, block3: HTMLElement, block4: HTMLElement, block5: HTMLElement;
+ let editorElement: HTMLElement;
+ function triggerMouseMove(node: HTMLElement, x: number, y: number): void {
+ const event = new MouseEvent('mousemove', { bubbles: true, cancelable: true, clientX: x, clientY: y });
+ node.dispatchEvent(event);
+ }
+
+ function triggerDragEvent(node: HTMLElement, eventType: string, x: number, y: number, dataTransfer: DataTransfer = new DataTransfer()): void {
+ const dragEvent = new DragEvent(eventType, { bubbles: true, cancelable: true, clientX: x, clientY: y, dataTransfer: dataTransfer });
+ node.dispatchEvent(dragEvent);
+ }
+
+ describe('DragAndDrop Reordering', () => {
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'block1', type: BlockType.BulletList, content: [{ id: 'content1', type: ContentType.Text, content: 'Block 1 content' }] },
+ { id: 'block2', type: BlockType.BulletList, content: [{ id: 'content2', type: ContentType.Text, content: 'Block 2 content' }] },
+ { id: 'block3', type: BlockType.Paragraph, content: [{ id: 'content3', type: ContentType.Text, content: 'Block 3 content' }] }
+ ];
+
+ editor = new BlockEditor({
+ blocks: blocks,
+ enableDragAndDrop: true
+ });
+ editor.appendTo('#editor');
+
+ block1 = document.getElementById('block1');
+ block2 = document.getElementById('block2');
+ block3 = document.getElementById('block3');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('should correctly reorder blocks after drag-and-drop', (done) => {
+ triggerMouseMove(block1, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+
+ const dataTransfer = new DataTransfer();
+ // 75px is the padding left value of content present inside the block
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ triggerDragEvent(block3, 'dragenter', 75, block3.offsetTop + 10, dataTransfer);
+ triggerDragEvent(block3, 'dragover', 75, block3.offsetTop + 10, dataTransfer);
+ triggerDragEvent(dragIcon, 'drag', 75, block3.offsetTop + (block3.offsetHeight/2) + 10, dataTransfer);
+
+ setTimeout(() => {
+ triggerDragEvent(dragIcon, 'dragend', 0, 0, dataTransfer);
+
+ setTimeout(() => {
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(3);
+ expect(updatedBlocks[0].textContent).toContain('Block 2 content');
+ expect(updatedBlocks[1].textContent).toContain('Block 3 content');
+ expect(updatedBlocks[2].textContent).toContain('Block 1 content');
+ expect(editor.element.querySelector('.dragging-clone') as HTMLElement).toBeNull();
+ done();
+ }, 50);
+ }, 100);
+ });
+
+ it('drag the block down and drop on the second block', (done) => {
+ triggerMouseMove(block1, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+
+ const dataTransfer = new DataTransfer();
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ triggerDragEvent(block2, 'dragover', 75, block2.offsetTop + 10, dataTransfer);
+ triggerDragEvent(dragIcon, 'drag', 75, block2.offsetTop + (block2.offsetHeight/2) + 10, dataTransfer);
+
+ setTimeout(() => {
+ triggerDragEvent(dragIcon, 'dragend', 0, 0, dataTransfer);
+
+ setTimeout(() => {
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(3);
+ expect(updatedBlocks[0].id).toBe('block2');
+ expect(updatedBlocks[1].id).toBe('block1');
+ expect(updatedBlocks[2].id).toBe('block3');
+ expect(updatedBlocks[0].textContent).toContain('Block 2 content');
+ expect(updatedBlocks[1].textContent).toContain('Block 1 content');
+ expect(updatedBlocks[2].textContent).toContain('Block 3 content');
+ expect(editor.element.querySelector('.dragging-clone') as HTMLElement).toBeNull();
+ done();
+ }, 50);
+ }, 100);
+ });
+
+ it('drag the block down and dropping on the last block', (done) => {
+ triggerMouseMove(block1, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+
+ const dataTransfer = new DataTransfer();
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ triggerDragEvent(block3, 'dragover', 75, block3.offsetTop + 10, dataTransfer);
+ triggerDragEvent(dragIcon, 'drag', 75, block3.offsetTop + (block3.offsetHeight/2) + 10, dataTransfer);
+
+ setTimeout(() => {
+ triggerDragEvent(dragIcon, 'dragend', 0, 0, dataTransfer);
+
+ setTimeout(() => {
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(3);
+ expect(updatedBlocks[0].id).toBe('block2');
+ expect(updatedBlocks[1].id).toBe('block3');
+ expect(updatedBlocks[2].id).toBe('block1');
+ expect(updatedBlocks[0].textContent).toContain('Block 2 content');
+ expect(updatedBlocks[1].textContent).toContain('Block 3 content');
+ expect(updatedBlocks[2].textContent).toContain('Block 1 content');
+ expect(editor.element.querySelector('.dragging-clone') as HTMLElement).toBeNull();
+ done();
+ }, 100);
+ }, 100);
+ });
+
+ it('drag the block towards up and dropping on the second block', (done) => {
+ triggerMouseMove(block3, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+
+ const dataTransfer = new DataTransfer();
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ triggerDragEvent(block2, 'dragover', 75, block2.offsetTop + 10, dataTransfer);
+ triggerDragEvent(dragIcon, 'drag', 75, (block2.offsetTop + 10), dataTransfer);
+
+ setTimeout(() => {
+ triggerDragEvent(dragIcon, 'dragend', 0, 0, dataTransfer);
+
+ setTimeout(() => {
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(3);
+ expect(updatedBlocks[0].id).toBe('block1');
+ expect(updatedBlocks[1].id).toBe('block3');
+ expect(updatedBlocks[2].id).toBe('block2');
+ expect(updatedBlocks[0].textContent).toContain('Block 1 content');
+ expect(updatedBlocks[1].textContent).toContain('Block 3 content');
+ expect(updatedBlocks[2].textContent).toContain('Block 2 content');
+ expect(editor.element.querySelector('.dragging-clone') as HTMLElement).toBeNull();
+ done();
+ }, 100);
+ }, 100);
+ });
+ it('drag the block towards up and dropping on the first block', (done) => {
+ triggerMouseMove(block3, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+
+ const dataTransfer = new DataTransfer();
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ triggerDragEvent(block1, 'dragover', 75, block1.offsetTop + 10, dataTransfer);
+ triggerDragEvent(dragIcon, 'drag', 75, (block1.offsetTop + 10), dataTransfer);
+
+ setTimeout(() => {
+ triggerDragEvent(dragIcon, 'dragend', 0, 0, dataTransfer);
+
+ setTimeout(() => {
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(3);
+ expect(updatedBlocks[0].id).toBe('block3');
+ expect(updatedBlocks[1].id).toBe('block1');
+ expect(updatedBlocks[2].id).toBe('block2');
+ expect(updatedBlocks[0].textContent).toContain('Block 3 content');
+ expect(updatedBlocks[1].textContent).toContain('Block 1 content');
+ expect(updatedBlocks[2].textContent).toContain('Block 2 content');
+ expect(editor.element.querySelector('.dragging-clone') as HTMLElement).toBeNull();
+ done();
+ }, 100);
+ }, 100);
+ });
+ it('drag and drop on the same block', (done) => {
+ triggerMouseMove(block1, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+
+ const dataTransfer = new DataTransfer();
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ triggerDragEvent(block1, 'dragover', 75, block1.offsetTop, dataTransfer);
+ triggerDragEvent(dragIcon, 'drag', 75, (block1.offsetTop), dataTransfer);
+
+ setTimeout(() => {
+ triggerDragEvent(dragIcon, 'dragend', 0, 0, dataTransfer);
+
+ setTimeout(() => {
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(3);
+ expect(updatedBlocks[0].id).toBe('block1');
+ expect(updatedBlocks[1].id).toBe('block2');
+ expect(updatedBlocks[2].id).toBe('block3');
+ expect(updatedBlocks[0].textContent).toContain('Block 1 content');
+ expect(updatedBlocks[1].textContent).toContain('Block 2 content');
+ expect(updatedBlocks[2].textContent).toContain('Block 3 content');
+ expect(editor.element.querySelector('.dragging-clone') as HTMLElement).toBeNull();
+ done();
+ }, 100);
+ }, 100);
+ });
+
+ it('Check drag clone when dragging outside and inside of the editor', (done) => {
+ triggerMouseMove(block1, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+
+ const dataTransfer = new DataTransfer();
+ // 75px is the padding left value of content present inside the block
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ triggerDragEvent(block3, 'dragenter', 75, block3.offsetTop + 10, dataTransfer);
+ triggerDragEvent(block3, 'dragover', 75, block3.offsetTop + 10, dataTransfer);
+ // drag the block out of editor
+ triggerDragEvent(dragIcon, 'drag', 0, block3.offsetTop + (block3.offsetHeight / 2) + 10, dataTransfer);
+ const dragClone: HTMLElement = editor.element.querySelector('.dragging-clone');
+ expect(dragClone.style.opacity).toBe('0');
+ // drag the block inside the editor
+ triggerDragEvent(dragIcon, 'drag', 75, block3.offsetTop + (block3.offsetHeight/2) + 10, dataTransfer);
+ expect(dragClone.style.opacity).not.toBe('0');
+ setTimeout(() => {
+ triggerDragEvent(dragIcon, 'dragend', 0, 0, dataTransfer);
+
+ setTimeout(() => {
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(3);
+ expect(updatedBlocks[0].textContent).toContain('Block 2 content');
+ expect(updatedBlocks[1].textContent).toContain('Block 3 content');
+ expect(updatedBlocks[2].textContent).toContain('Block 1 content');
+ expect(editor.element.querySelector('.dragging-clone') as HTMLElement).toBeNull();
+ done();
+ }, 50);
+ }, 100);
+ });
+
+ it('dragging check with change in current hovered', (done) => {
+ // for assigning current hovered block as null
+ triggerMouseMove(editorElement, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+
+ const dataTransfer = new DataTransfer();
+ // 75px is the padding left value of content present inside the block
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ // update current hovered block
+ triggerMouseMove(block1, 10, 10);
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ triggerDragEvent(block3, 'dragenter', 75, block3.offsetTop + 10, dataTransfer);
+ triggerDragEvent(block3, 'dragover', 75, block3.offsetTop + 10, dataTransfer);
+ triggerDragEvent(dragIcon, 'drag', 75, block3.offsetTop + (block3.offsetHeight/2) + 10, dataTransfer);
+ setTimeout(() => {
+ triggerDragEvent(dragIcon, 'dragend', 0, 0, dataTransfer);
+
+ setTimeout(() => {
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(3);
+ expect(updatedBlocks[0].textContent).toContain('Block 2 content');
+ expect(updatedBlocks[1].textContent).toContain('Block 3 content');
+ expect(updatedBlocks[2].textContent).toContain('Block 1 content');
+ expect(editor.element.querySelector('.dragging-clone') as HTMLElement).toBeNull();
+ done();
+ }, 50);
+ }, 100);
+
+ });
+
+ it('should return when hovered block is null', function (done) {
+ editor.currentHoveredBlock = null;
+ expect((editor.dragAndDropAction as any).handleDragMove()).toBeUndefined();
+
+ expect((editor.dragAndDropAction as any).handleDragStop()).toBeUndefined();
+ done();
+ });
+ });
+
+ describe('DragAndDrop Event Handling', () => {
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ });
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+ it('dynamic prop handling enableDragAndDrop', (done) => {
+ const blocks: BlockModel[] = [
+ { id: 'block1', type: BlockType.Paragraph, content: [{ id: 'content1', type: ContentType.Text, content: 'Block 1 content' }] },
+ { id: 'block2', type: BlockType.Paragraph, content: [{ id: 'content2', type: ContentType.Text, content: 'Block 2 content' }] }
+ ];
+
+ editor = new BlockEditor({
+ blocks: blocks,
+ enableDragAndDrop: true
+ });
+ editor.appendTo('#editor');
+
+ block1 = document.getElementById('block1');
+ block2 = document.getElementById('block2');
+
+ // Initially, drag and drop is enabled; now disable it
+ editor.enableDragAndDrop = false;
+ editor.dataBind();
+
+ // Attempt to reorder blocks
+ triggerMouseMove(block1, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+
+ const dataTransfer = new DataTransfer();
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ triggerDragEvent(block2, 'dragover', 75, block2.offsetTop + 10, dataTransfer);
+ triggerDragEvent(dragIcon, 'drag', 75, block2.offsetTop + (block2.offsetHeight/2) + 10, dataTransfer);
+
+ setTimeout(() => {
+ triggerDragEvent(dragIcon, 'dragend', 0, 0, dataTransfer);
+
+ setTimeout(() => {
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ // Ensure no reordering happened
+ expect(updatedBlocks[0].id).toBe('block1');
+ expect(updatedBlocks[1].id).toBe('block2');
+ expect(editor.element.querySelector('.dragging-clone') as HTMLElement).toBeNull();
+ done();
+ }, 50);
+ }, 100);
+ });
+ it('dynamic prop handling enableDragAndDrop false to true', (done) => {
+ const blocks: BlockModel[] = [
+ { id: 'block1', type: BlockType.Paragraph, content: [{ id: 'content1', type: ContentType.Text, content: 'Block 1 content' }] },
+ { id: 'block2', type: BlockType.Paragraph, content: [{ id: 'content2', type: ContentType.Text, content: 'Block 2 content' }] }
+ ];
+
+ editor = new BlockEditor({
+ blocks: blocks,
+ enableDragAndDrop: false
+ });
+ editor.appendTo('#editor');
+
+ block1 = document.getElementById('block1');
+ block2 = document.getElementById('block2');
+
+ // Initially, drag and drop is enabled; now disable it
+ editor.enableDragAndDrop = true;
+ editor.dataBind();
+
+ // Attempt to reorder blocks
+ triggerMouseMove(block1, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+
+ const dataTransfer = new DataTransfer();
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ triggerDragEvent(block2, 'dragover', 75, block2.offsetTop + 10, dataTransfer);
+ triggerDragEvent(dragIcon, 'drag', 75, block2.offsetTop + (block2.offsetHeight/2) + 10, dataTransfer);
+
+ setTimeout(() => {
+ triggerDragEvent(dragIcon, 'dragend', 0, 0, dataTransfer);
+
+ setTimeout(() => {
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ // Ensure reordering happened
+ expect(updatedBlocks[1].id).toBe('block1');
+ expect(updatedBlocks[0].id).toBe('block2');
+ expect(editor.element.querySelector('.dragging-clone') as HTMLElement).toBeNull();
+ done();
+ }, 50);
+ }, 100);
+ });
+ it('should cancel drag operation on blockDragStart if event args.cancel is true', (done) => {
+ const blocks: BlockModel[] = [
+ { id: 'block1', type: BlockType.Paragraph, content: [{ id: 'content1', type: ContentType.Text, content: 'Block 1 content' }] },
+ { id: 'block2', type: BlockType.Paragraph, content: [{ id: 'content2', type: ContentType.Text, content: 'Block 2 content' }] }
+ ];
+ editor = new BlockEditor({
+ blocks: blocks,
+ blockDragStart: function (args: BlockDragEventArgs) {
+ args.cancel = true
+ }
+ });
+ editor.appendTo('#editor');
+ block1 = document.getElementById('block1');
+ block2 = document.getElementById('block2');
+ triggerMouseMove(block1, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+
+ const dataTransfer = new DataTransfer();
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ triggerDragEvent(block2, 'dragover', 75, block2.offsetTop + 10, dataTransfer);
+ triggerDragEvent(dragIcon, 'drag', 75, block2.offsetTop + (block2.offsetHeight/2) + 10, dataTransfer);
+
+ setTimeout(() => {
+ triggerDragEvent(dragIcon, 'dragend', 0, 0, dataTransfer);
+
+ setTimeout(() => {
+ // Ensure no reordering happened
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks[0].id).toBe('block1');
+ expect(updatedBlocks[1].id).toBe('block2');
+ done();
+ }, 50);
+ }, 100);
+ });
+ it('should cancel drop operation on blockDrag if event args.cancel is true', (done) => {
+ const blocks: BlockModel[] = [
+ { id: 'block1', type: BlockType.Paragraph, content: [{ id: 'content1', type: ContentType.Text, content: 'Block 1 content' }] },
+ { id: 'block2', type: BlockType.Paragraph, content: [{ id: 'content2', type: ContentType.Text, content: 'Block 2 content' }] }
+ ];
+ editor = new BlockEditor({
+ blocks: blocks,
+ blockDrag: function (args: BlockDragEventArgs) {
+ args.cancel = true
+ }
+ });
+ editor.appendTo('#editor');
+ block1 = document.getElementById('block1');
+ block2 = document.getElementById('block2');
+ triggerMouseMove(block1, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+ const dataTransfer = new DataTransfer();
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ triggerDragEvent(block2, 'dragover', 75, block2.offsetTop + 10, dataTransfer);
+ triggerDragEvent(dragIcon, 'drag', 75, block2.offsetTop + (block2.offsetHeight/2) + 10, dataTransfer);
+ setTimeout(() => {
+ triggerDragEvent(dragIcon, 'dragend', 0, 0, dataTransfer);
+ setTimeout(() => {
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks[0].id).toBe('block1'); // Ensure no reordering happened
+ expect(updatedBlocks[1].id).toBe('block2');
+ done();
+ }, 50);
+ }, 100);
+ });
+ });
+
+ describe('DragAndDrop Reordering Multiple Blocks', () => {
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'block1', type: BlockType.Paragraph, content: [{ id: 'content1', type: ContentType.Text, content: 'Block 1 content' }] },
+ { id: 'block2', type: BlockType.Paragraph, content: [{ id: 'content2', type: ContentType.Text, content: 'Block 2 content' }] },
+ { id: 'block3', type: BlockType.Paragraph, content: [{ id: 'content3', type: ContentType.Text, content: 'Block 3 content' }] },
+ { id: 'block4', type: BlockType.Paragraph, content: [{ id: 'content4', type: ContentType.Text, content: 'Block 4 content' }] },
+ { id: 'block5', type: BlockType.Paragraph, content: [{ id: 'content5', type: ContentType.Text, content: 'Block 5 content' }] }
+ ];
+
+ editor = new BlockEditor({
+ blocks: blocks,
+ enableDragAndDrop: true
+ });
+ editor.appendTo('#editor');
+
+ block1 = document.getElementById('block1');
+ block2 = document.getElementById('block2');
+ block3 = document.getElementById('block3');
+ block4 = document.getElementById('block4');
+ block5 = document.getElementById('block5');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ // Helper function to simulate selecting multiple blocks
+ function simulateMultiBlockSelection(startBlock: HTMLElement, endBlock: HTMLElement): void {
+ // Create a mock selection range
+ const range = document.createRange();
+ range.setStartBefore(startBlock);
+ range.setEndAfter(endBlock);
+
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ // Spy on getSelectedBlocks to return the appropriate blocks
+ const startBlockId = startBlock.id;
+ const endBlockId = endBlock.id;
+
+ // Get all blocks between start and end blocks inclusive
+ const allBlocks = editor.blocksInternal;
+ const startIdx = allBlocks.findIndex(b => b.id === startBlockId);
+ const endIdx = allBlocks.findIndex(b => b.id === endBlockId);
+ const selectedBlocks = allBlocks.slice(
+ Math.min(startIdx, endIdx),
+ Math.max(startIdx, endIdx) + 1
+ );
+
+ spyOn(editor, 'getSelectedBlocks').and.returnValue(selectedBlocks);
+ }
+
+ it('drag the block 1 and 2 down and drop below the third block', (done) => {
+ // Simulate selection of blocks 1 and 2
+ simulateMultiBlockSelection(block1, block2);
+
+ triggerMouseMove(block1, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+
+ const dataTransfer = new DataTransfer();
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ triggerDragEvent(block3, 'dragover', 75, block3.offsetTop + 10, dataTransfer);
+ triggerDragEvent(dragIcon, 'drag', 75, block3.offsetTop + (block3.offsetHeight/2) + 10, dataTransfer);
+
+ setTimeout(() => {
+ triggerDragEvent(dragIcon, 'dragend', 0, 0, dataTransfer);
+
+ setTimeout(() => {
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(5);
+ expect(updatedBlocks[0].id).toBe('block3');
+ expect(updatedBlocks[1].id).toBe('block1');
+ expect(updatedBlocks[2].id).toBe('block2');
+ expect(updatedBlocks[3].id).toBe('block4');
+ expect(updatedBlocks[4].id).toBe('block5');
+ expect(editor.element.querySelector('.dragging-clone') as HTMLElement).toBeNull();
+ done();
+ }, 100);
+ }, 100);
+ });
+
+ it('drag the block 2 and 3 down and drop below the fourth block', (done) => {
+ simulateMultiBlockSelection(block2, block3);
+
+ triggerMouseMove(block2, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+
+ const dataTransfer = new DataTransfer();
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ triggerDragEvent(block4, 'dragover', 75, block4.offsetTop + 10, dataTransfer);
+ triggerDragEvent(dragIcon, 'drag', 75, block4.offsetTop + (block4.offsetHeight/2) + 10, dataTransfer);
+
+ setTimeout(() => {
+ triggerDragEvent(dragIcon, 'dragend', 0, 0, dataTransfer);
+
+ setTimeout(() => {
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(5);
+ expect(updatedBlocks[0].id).toBe('block1');
+ expect(updatedBlocks[1].id).toBe('block4');
+ expect(updatedBlocks[2].id).toBe('block2');
+ expect(updatedBlocks[3].id).toBe('block3');
+ expect(updatedBlocks[4].id).toBe('block5');
+ expect(editor.element.querySelector('.dragging-clone') as HTMLElement).toBeNull();
+ done();
+ }, 100);
+ }, 100);
+ });
+
+ it('drag the block 3 and 4 down and drop below the fifth block', (done) => {
+ simulateMultiBlockSelection(block3, block4);
+
+ triggerMouseMove(block3, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+
+ const dataTransfer = new DataTransfer();
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ triggerDragEvent(block5, 'dragover', 75, block5.offsetTop + 10, dataTransfer);
+ triggerDragEvent(dragIcon, 'drag', 75, block5.offsetTop + (block5.offsetHeight/2) + 10, dataTransfer);
+
+ setTimeout(() => {
+ triggerDragEvent(dragIcon, 'dragend', 0, 0, dataTransfer);
+
+ setTimeout(() => {
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(5);
+ expect(updatedBlocks[0].id).toBe('block1');
+ expect(updatedBlocks[1].id).toBe('block2');
+ expect(updatedBlocks[2].id).toBe('block5');
+ expect(updatedBlocks[3].id).toBe('block3');
+ expect(updatedBlocks[4].id).toBe('block4');
+ expect(editor.element.querySelector('.dragging-clone') as HTMLElement).toBeNull();
+ done();
+ }, 100);
+ }, 100);
+ });
+
+ it('drag the block 3 and 4 up and drop above the first block', (done) => {
+ simulateMultiBlockSelection(block3, block4);
+
+ triggerMouseMove(block3, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+
+ const dataTransfer = new DataTransfer();
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ triggerDragEvent(block1, 'dragover', 75, block1.offsetTop + 10, dataTransfer);
+ triggerDragEvent(dragIcon, 'drag', 75, (block1.offsetTop + 10), dataTransfer);
+
+ setTimeout(() => {
+ triggerDragEvent(dragIcon, 'dragend', 0, 0, dataTransfer);
+
+ setTimeout(() => {
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(5);
+ expect(updatedBlocks[0].id).toBe('block3');
+ expect(updatedBlocks[1].id).toBe('block4');
+ expect(updatedBlocks[2].id).toBe('block1');
+ expect(updatedBlocks[3].id).toBe('block2');
+ expect(updatedBlocks[4].id).toBe('block5');
+ expect(editor.element.querySelector('.dragging-clone') as HTMLElement).toBeNull();
+ done();
+ }, 100);
+ }, 100);
+ });
+
+ it('drag the block 3 and 4 up and drop below the first block', (done) => {
+ simulateMultiBlockSelection(block3, block4);
+
+ triggerMouseMove(block3, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+
+ const dataTransfer = new DataTransfer();
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ triggerDragEvent(block2, 'dragover', 75, block2.offsetTop + 10, dataTransfer);
+ triggerDragEvent(dragIcon, 'drag', 75, block2.offsetTop + 10, dataTransfer);
+
+ setTimeout(() => {
+ triggerDragEvent(dragIcon, 'dragend', 0, 0, dataTransfer);
+
+ setTimeout(() => {
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(5);
+ expect(updatedBlocks[0].id).toBe('block1');
+ expect(updatedBlocks[1].id).toBe('block3');
+ expect(updatedBlocks[2].id).toBe('block4');
+ expect(updatedBlocks[3].id).toBe('block2');
+ expect(updatedBlocks[4].id).toBe('block5');
+ expect(editor.element.querySelector('.dragging-clone') as HTMLElement).toBeNull();
+ done();
+ }, 100);
+ }, 100);
+ });
+
+ it('drag the block 4 and 5 up and drop above the first block', (done) => {
+ simulateMultiBlockSelection(block4, block5);
+
+ triggerMouseMove(block4, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+
+ const dataTransfer = new DataTransfer();
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ triggerDragEvent(block1, 'dragover', 75, block1.offsetTop + 10, dataTransfer);
+ triggerDragEvent(dragIcon, 'drag', 75, (block1.offsetTop + 10), dataTransfer);
+
+ setTimeout(() => {
+ triggerDragEvent(dragIcon, 'dragend', 0, 0, dataTransfer);
+
+ setTimeout(() => {
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(5);
+ expect(updatedBlocks[0].id).toBe('block4');
+ expect(updatedBlocks[1].id).toBe('block5');
+ expect(updatedBlocks[2].id).toBe('block1');
+ expect(updatedBlocks[3].id).toBe('block2');
+ expect(updatedBlocks[4].id).toBe('block3');
+ expect(editor.element.querySelector('.dragging-clone') as HTMLElement).toBeNull();
+ done();
+ }, 100);
+ }, 100);
+ });
+
+ it('drag the block 4 and 5 up and below below the second block', (done) => {
+ simulateMultiBlockSelection(block4, block5);
+
+ triggerMouseMove(block4, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+
+ const dataTransfer = new DataTransfer();
+ triggerDragEvent(dragIcon, 'dragstart', 10, 10, dataTransfer);
+ triggerDragEvent(block3, 'dragover', 75, block3.offsetTop + 10, dataTransfer);
+ triggerDragEvent(dragIcon, 'drag', 75, block3.offsetTop + 10, dataTransfer);
+
+ setTimeout(() => {
+ triggerDragEvent(dragIcon, 'dragend', 0, 0, dataTransfer);
+
+ setTimeout(() => {
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(5);
+ expect(updatedBlocks[0].id).toBe('block1');
+ expect(updatedBlocks[1].id).toBe('block2');
+ expect(updatedBlocks[2].id).toBe('block4');
+ expect(updatedBlocks[3].id).toBe('block5');
+ expect(updatedBlocks[4].id).toBe('block3');
+ expect(editor.element.querySelector('.dragging-clone') as HTMLElement).toBeNull();
+ done();
+ }, 100);
+ }, 100);
+ });
+ });
+});
diff --git a/controls/blockeditor/spec/actions/formatting.spec.ts b/controls/blockeditor/spec/actions/formatting.spec.ts
new file mode 100644
index 0000000000..ff7c96e852
--- /dev/null
+++ b/controls/blockeditor/spec/actions/formatting.spec.ts
@@ -0,0 +1,559 @@
+import { createElement, remove } from '@syncfusion/ej2-base';
+import { ClickEventArgs } from '@syncfusion/ej2-navigations';
+import { BlockModel } from '../../src/blockeditor/models';
+import { BlockEditor, BlockType, ContentType, setCursorPosition, setSelectionRange, getBlockContentElement, getSelectionRange } from '../../src/index';
+import { createEditor } from '../common/util.spec';
+
+describe('Formatting Actions', () => {
+ beforeAll(() => {
+ const isDef: any = (o: any) => o !== undefined && o !== null;
+ if (!isDef(window.performance)) {
+ console.log('Unsupported environment, window.performance.memory is unavailable');
+ pending(); // skips test (in Chai)
+ return;
+ }
+ });
+
+ describe('Ensuring proper basic formatting', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeAll(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ {
+ id: 'paragraph1',
+ type: BlockType.Paragraph,
+ content: [{
+ type: ContentType.Text,
+ content: 'BoldItalicUnderlineStrikethrough'
+ }]
+ },
+ {
+ id: 'paragraph2',
+ type: BlockType.Paragraph,
+ content: [{
+ type: ContentType.Text,
+ content: 'LowercaseUppercaseColorBgColorCustom'
+ }]
+ },
+ {
+ id: 'paragraph3',
+ type: BlockType.Paragraph,
+ content: [{
+ type: ContentType.Text,
+ content: 'SuperscriptSubscript'
+ }]
+ }
+ ];
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterAll(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('applying bold formatting', () => {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ //Select range of text(world) and apply bold formatting
+ setSelectionRange((contentElement.lastChild as HTMLElement), 0, 4);
+ editor.formattingAction.execCommand({ command: 'bold' });
+ expect(contentElement.childElementCount).toBe(2);
+ expect(contentElement.querySelector('strong').textContent).toBe('Bold');
+ expect(contentElement.querySelector('span').textContent).toBe('ItalicUnderlineStrikethrough');
+ expect(editor.blocks[0].content[0].styles.bold).toBe(true);
+ });
+
+ it('applying italic formatting', () => {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ //Select range of text(world) and apply italic formatting
+ setSelectionRange((contentElement.lastChild.childNodes[0] as HTMLElement), 0, 6);
+ editor.formattingAction.execCommand({ command: 'italic' });
+ expect(contentElement.childElementCount).toBe(3);
+ expect(contentElement.querySelector('em').textContent).toBe('Italic');
+ expect(contentElement.querySelector('span').textContent).toBe('UnderlineStrikethrough');
+ expect(editor.blocks[0].content[1].styles.italic).toBe(true);
+ });
+
+ it('applying underline formatting', () => {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ //Select range of text(world) and apply underline formatting
+ setSelectionRange((contentElement.lastChild.childNodes[0] as HTMLElement), 0, 9);
+ editor.formattingAction.execCommand({ command: 'underline' });
+ expect(contentElement.childElementCount).toBe(4);
+ expect(contentElement.querySelector('u').textContent).toBe('Underline');
+ expect(contentElement.querySelector('span').textContent).toBe('Strikethrough');
+ expect(editor.blocks[0].content[2].styles.underline).toBe(true);
+ });
+
+ it('applying strikethrough formatting', () => {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ //Select range of text(world) and apply strikethrough formatting
+ setSelectionRange((contentElement.lastChild.childNodes[0] as HTMLElement), 0, 13);
+ editor.formattingAction.execCommand({ command: 'strikethrough' });
+ expect(contentElement.childElementCount).toBe(4);
+ expect(contentElement.querySelector('s').textContent).toBe('Strikethrough');
+ expect(editor.blocks[0].content[3].styles.strikethrough).toBe(true);
+ });
+
+ it('applying lowercase formatting', () => {
+ const blockElement = editorElement.querySelector('#paragraph2') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ //Select range of text(world) and apply lowercase formatting
+ setSelectionRange((contentElement.lastChild as HTMLElement), 0, 9);
+ editor.formattingAction.execCommand({ command: 'lowercase' });
+ expect(contentElement.childElementCount).toBe(2);
+ const textDecoration = (contentElement.querySelector(`#${editor.blocks[1].content[0].id}`) as HTMLElement).style.textTransform;
+ expect(textDecoration).toBe('lowercase');
+ expect(editor.blocks[1].content[0].styles.lowercase).toBe(true);
+ });
+
+ it('applying uppercase formatting', () => {
+ const blockElement = editorElement.querySelector('#paragraph2') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ //Select range of text(world) and apply uppercase formatting
+ setSelectionRange((contentElement.lastChild.childNodes[0] as HTMLElement), 0, 9);
+ editor.formattingAction.execCommand({ command: 'uppercase' });
+ expect(contentElement.childElementCount).toBe(3);
+ const textDecoration = (contentElement.querySelector(`#${editor.blocks[1].content[1].id}`) as HTMLElement).style.textTransform;
+ expect(textDecoration).toBe('uppercase');
+ expect(editor.blocks[1].content[1].styles.uppercase).toBe(true);
+ });
+
+ it('applying color formatting', () => {
+ const blockElement = editorElement.querySelector('#paragraph2') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ //Select range of text(world) and apply color formatting
+ setSelectionRange((contentElement.lastChild.childNodes[0] as HTMLElement), 0, 5);
+ editor.formattingAction.execCommand({ command: 'color', value: '#EE0000' });
+ expect(contentElement.childElementCount).toBe(4);
+ const color = (contentElement.querySelector(`#${editor.blocks[1].content[2].id}`) as HTMLElement).style.color;
+ expect(color).toBe('rgb(238, 0, 0)');
+ expect(editor.blocks[1].content[2].styles.color).toBe('#EE0000');
+ });
+
+ it('removing the applied color formatting', () => {
+ const blockElement = editorElement.querySelector('#paragraph2') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ //Select range of text(world) and apply color formatting
+ const colorSpan = (contentElement.lastChild as HTMLElement).previousElementSibling.childNodes[0] as HTMLElement;
+ setSelectionRange(colorSpan, 0, 5);
+ // setSelectionRange((contentElement.lastChild as HTMLElement).previousElementSibling.childNodes[0], 0, 5);
+ editor.formattingAction.execCommand({ command: 'color', value: '' });
+ expect(contentElement.childElementCount).toBe(4);
+ const color = (contentElement.querySelector(`#${editor.blocks[1].content[2].id}`) as HTMLElement).style.color;
+ expect(color).toBe('');
+ expect(editor.blocks[1].content[2].styles.color).toBe('');
+ });
+
+ it('applying BgColor formatting', () => {
+ const blockElement = editorElement.querySelector('#paragraph2') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ //Select range of text(world) and apply BgColor formatting
+ setSelectionRange((contentElement.lastChild.childNodes[0] as HTMLElement), 0, 7);
+ editor.formattingAction.execCommand({ command: 'bgColor', value: '#F8F8F8' });
+ expect(contentElement.childElementCount).toBe(5);
+ const bgColor = (contentElement.querySelector(`#${editor.blocks[1].content[3].id}`) as HTMLElement).style.backgroundColor;
+ expect(bgColor).toBe('rgb(248, 248, 248)');
+ expect(editor.blocks[1].content[3].styles.bgColor).toBe('#F8F8F8');
+ });
+
+ it('applying custom formatting', () => {
+ const blockElement = editorElement.querySelector('#paragraph2') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ //Select range of text(world) and apply custom formatting
+ setSelectionRange((contentElement.lastChild.childNodes[0] as HTMLElement), 0, 6);
+ editor.formattingAction.execCommand({ command: 'custom', value: 'box-shadow: 0 0 10px #000000;' });
+ expect(contentElement.childElementCount).toBe(5);
+ const custom = (contentElement.querySelector(`#${editor.blocks[1].content[4].id}`) as HTMLElement).style.boxShadow;
+ expect(custom).toBe('rgb(0, 0, 0) 0px 0px 10px');
+ expect(editor.blocks[1].content[4].styles.custom).toBe('box-shadow: 0 0 10px #000000;');
+ });
+
+ it('applying superscript formatting', () => {
+ const blockElement = editorElement.querySelector('#paragraph3') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ //Select range of text(world) and apply superscript formatting
+ setSelectionRange((contentElement.lastChild as HTMLElement), 0, 11);
+ editor.formattingAction.execCommand({ command: 'superscript' });
+ expect(contentElement.childElementCount).toBe(2);
+ expect(contentElement.querySelector('sup').textContent).toBe('Superscript');
+ expect(editor.blocks[2].content[0].styles.superscript).toBe(true);
+ });
+
+ it('applying subscript formatting', () => {
+ const blockElement = editorElement.querySelector('#paragraph3') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ //Select range of text(world) and apply subscript formatting
+ setSelectionRange((contentElement.lastChild.childNodes[0] as HTMLElement), 0, 9);
+ editor.formattingAction.execCommand({ command: 'subscript' });
+ expect(contentElement.childElementCount).toBe(2);
+ expect(contentElement.querySelector('sub').textContent).toBe('Subscript');
+ expect(editor.blocks[2].content[1].styles.subscript).toBe(true);
+ });
+ });
+
+ describe('Active Formatting during typing', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ editor = createEditor({
+ blocks: [
+ {
+ id: 'paragraph-1',
+ type: BlockType.Paragraph,
+ content: [{
+ id: 'content-1',
+ type: ContentType.Text,
+ content: 'Hello world'
+ }]
+ }
+ ]
+ });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ document.body.removeChild(editorElement);
+ });
+
+ it('should apply active formats when typing', (done) => {
+ setTimeout(() => {
+ const blockElement = editorElement.querySelector('#paragraph-1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+
+ // Activate bold formatting
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', code: 'KeyB', ctrlKey: true }));
+ expect(editor.formattingAction.activeInlineFormats.has('bold')).toBe(true);
+
+ // Simulate typing a character
+ const contentElement = getBlockContentElement(blockElement);
+ contentElement.textContent = 'Hello worldx';
+
+ // Create a collapsed selection at the end of text
+ const range = document.createRange();
+ const textNode = contentElement.firstChild;
+ range.setStart(textNode, textNode.textContent.length);
+ range.setEnd(textNode, textNode.textContent.length);
+
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ let result = editor.element.dispatchEvent(new Event('input'));
+
+ // Check if the newly typed character has formatting
+ setTimeout(() => {
+ // Last character should be bold now
+ expect(contentElement.querySelector('strong')).not.toBeNull();
+ expect(editor.blocks[0].content[1].styles.bold).toBe(true);
+ done();
+ }, 100);
+ }, 100);
+ });
+
+ it('should handle removed formats when typing', (done) => {
+ setTimeout(() => {
+ const blockElement = editorElement.querySelector('#paragraph-1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+
+ // Activate bold formatting but don't apply it yet
+ editor.formattingAction.activeInlineFormats.add('bold');
+ expect(editor.formattingAction.activeInlineFormats.has('bold')).toBe(true);
+
+ // Simulate typing a character
+ let contentElement = getBlockContentElement(blockElement);
+ contentElement.textContent = 'Hello worldx';
+
+ // Create a collapsed selection at the end of text
+ let range = document.createRange();
+ let textNode = contentElement.firstChild;
+ range.setStart(textNode, textNode.textContent.length);
+ range.setEnd(textNode, textNode.textContent.length);
+
+ let selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ // Call the method directly
+ let result = editor.formattingAction.handleTypingWithActiveFormats();
+
+ // Should return true indicating formatting was applied
+ expect(result).toBe(true);
+
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', code: 'KeyB', ctrlKey: true }));
+
+ expect(editor.formattingAction.activeInlineFormats.has('bold')).toBe(false);
+
+ contentElement = getBlockContentElement(blockElement);
+ const boldElement = contentElement.querySelector('strong');
+ boldElement.textContent = boldElement.textContent + 'y';
+
+ // Create a collapsed selection at the end of updated node
+ range = document.createRange();
+ textNode = boldElement.firstChild;
+ range.setStart(textNode, textNode.textContent.length);
+ range.setEnd(textNode, textNode.textContent.length);
+
+ selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+
+ // Call the method directly
+ result = editor.formattingAction.handleTypingWithActiveFormats();
+
+ expect(result).toBe(true);
+
+ // Check if the newly typed character has formatting
+ setTimeout(() => {
+ // Last character should not be bold now
+ expect(contentElement.querySelector('strong')).not.toBeNull();
+ expect(editor.blocks[0].content[1].styles.bold).toBe(true);
+ expect(editor.blocks[0].content[2].styles.bold).toBe(false);
+ done();
+ }, 100);
+ }, 100);
+ });
+
+ it('should not apply formatting if all active formats already applied', (done) => {
+ setTimeout(() => {
+ const blockElement = editorElement.querySelector('#paragraph-1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+
+ // First make the entire text bold
+ setSelectionRange(getBlockContentElement(blockElement).firstChild as HTMLElement, 0, 11);
+ editor.formattingAction.execCommand({ command: 'bold' });
+
+ // Add bold to active formats
+ editor.formattingAction.activeInlineFormats.add('bold');
+
+ // Simulate typing a character within a bold element
+ const contentElement = getBlockContentElement(blockElement);
+ const boldElement = contentElement.querySelector('strong');
+ boldElement.textContent = boldElement.textContent + 'x';
+
+ // Create a collapsed selection at the end of text
+ const range = document.createRange();
+ const textNode = boldElement.firstChild;
+ range.setStart(textNode, textNode.textContent.length);
+ range.setEnd(textNode, textNode.textContent.length);
+
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ // Call the method directly
+ const result = editor.formattingAction.handleTypingWithActiveFormats();
+
+ // Should return false because formatting already exists
+ expect(result).toBe(false);
+ done();
+ }, 100);
+ });
+
+ it('should apply multiple active formats', (done) => {
+ setTimeout(() => {
+ const blockElement = editorElement.querySelector('#paragraph-1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+
+ // Activate multiple formats
+ editor.formattingAction.activeInlineFormats.add('bold');
+ editor.formattingAction.activeInlineFormats.add('italic');
+
+ // Simulate typing a character
+ const contentElement = getBlockContentElement(blockElement);
+ contentElement.textContent = 'Hello worldx';
+
+ // Create a collapsed selection at the end of text
+ const range = document.createRange();
+ const textNode = contentElement.firstChild;
+ range.setStart(textNode, textNode.textContent.length);
+ range.setEnd(textNode, textNode.textContent.length);
+
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ // Call the method directly
+ editor.formattingAction.handleTypingWithActiveFormats();
+
+ // Check if multiple formats were applied
+ setTimeout(() => {
+ const strongElement = contentElement.querySelector('strong');
+ const italicElement = contentElement.querySelector('em');
+
+ expect(strongElement).not.toBeNull();
+ expect(italicElement).not.toBeNull();
+ done();
+ }, 100);
+ }, 100);
+ });
+ });
+
+ describe('Other actions testing', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'paragraph-1', type: BlockType.Paragraph, content: [{ id: 'paragraph-content', type: ContentType.Text, content: 'Hello world' }] }
+ ];
+ editor = createEditor({
+ blocks: blocks
+ });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ }
+ document.body.removeChild(editorElement);
+ });
+
+ it('should handle edge cases properly', (done) => {
+ const blockElement = editorElement.querySelector('#paragraph-1') as HTMLElement;
+ const contentElement = getBlockContentElement(blockElement);
+ const originalContentElement = contentElement.cloneNode(true);
+
+ //Spy the getSelection method on utils
+ const rangeSpy = jasmine.createSpy('getSelectionRange', getSelectionRange).and.returnValue(null);
+ //Range test
+ const data1 = (editor.formattingAction as any).handleFormatting();
+ expect(data1).toBeUndefined();
+
+ rangeSpy.calls.reset();
+
+ // Invalid range
+ editor.nodeSelection.createRangeWithOffsets(editorElement, editorElement, 0, 0);
+ const data2 = (editor.formattingAction as any).handleFormatting();
+ expect(data2).toBeUndefined();
+
+ // Null content element
+ contentElement.remove();
+ editor.nodeSelection.createRangeWithOffsets(blockElement, blockElement, 0, 0);
+ const data3 = (editor.formattingAction as any).handleFormatting();
+ expect(data3).toBeUndefined();
+
+ // Invalid block id
+ blockElement.appendChild(originalContentElement);
+ blockElement.id = 'fake'
+ setCursorPosition(contentElement, 0);
+ const data4 = (editor.formattingAction as any).handleFormatting();
+ expect(data4).toBeUndefined();
+ blockElement.id = 'paragraph-1';
+
+ //Selection collapsed
+ editor.setSelection('paragraph-content', 2, 4);
+ const data5 = editor.formattingAction.handleTypingWithActiveFormats();
+ expect(data5).toBe(false);
+
+ // Invalid range
+ setCursorPosition(contentElement, 0);
+ editor.nodeSelection.createRangeWithOffsets(editorElement, editorElement, 0, 0);
+ const data6 = editor.formattingAction.handleTypingWithActiveFormats();
+ expect(data6).toBe(false);
+
+ done();
+ });
+
+ it('isNodeFormattedWith method should work properly', (done) => {
+ const strongElement = createElement('strong');
+ strongElement.innerHTML = 'Hello';
+ const italicElement = createElement('em');
+ italicElement.innerHTML = 'World';
+ const underlineElement = createElement('u');
+ underlineElement.innerHTML = 'Underline';
+ const strikethroughElement = createElement('s');
+ strikethroughElement.innerHTML = 'Strikethrough';
+ const fakeFormat = createElement('span');
+ fakeFormat.innerHTML = 'Fake';
+
+ expect((editor.formattingAction as any).isNodeFormattedWith(strongElement, 'bold')).toBe(true);
+ expect((editor.formattingAction as any).isNodeFormattedWith(italicElement, 'italic')).toBe(true);
+ expect((editor.formattingAction as any).isNodeFormattedWith(underlineElement, 'underline')).toBe(true);
+ expect((editor.formattingAction as any).isNodeFormattedWith(strikethroughElement, 'strikethrough')).toBe(true);
+ expect((editor.formattingAction as any).isNodeFormattedWith(fakeFormat, 'fake')).toBe(false);
+ done();
+ });
+
+ it('should apply formatting for middle node properly', (done) => {
+ const blockElement = editorElement.querySelector('#paragraph-1') as HTMLElement;
+
+ editor.setFocusToBlock(blockElement);
+
+ editor.addBlock({
+ id: 'paragraph-2', type: BlockType.Paragraph,
+ content: [
+ { id: 'con-1', type: ContentType.Text, content: 'Hi', styles: { bold: true } },
+ { id: 'con-2', type: ContentType.Text, content: 'Hello' },
+ { id: 'con-3', type: ContentType.Text, content: 'World', styles: { italic: true } },
+ ]});
+
+ const newBlockElement = editorElement.querySelector('#paragraph-2') as HTMLElement;
+ const newBlockContent = getBlockContentElement(newBlockElement);
+ editor.setFocusToBlock(newBlockElement);
+
+ // Select whole block
+ const startNode = newBlockContent.querySelector('#con-1').firstChild;
+ const endNode = newBlockContent.querySelector('#con-3').firstChild;
+ editor.nodeSelection.createRangeWithOffsets(startNode, endNode, 0, 5);
+
+ editor.formattingAction.execCommand({ command: 'underline' });
+ expect(newBlockContent.childElementCount).toBe(3);
+ expect(newBlockContent.querySelector('strong').textContent).toBe('Hi');
+ expect(newBlockContent.querySelector('u#con-2').textContent).toBe('Hello');
+ expect(newBlockContent.querySelector('em').textContent).toBe('World');
+ expect(editor.blocks[1].content[0].styles.bold).toBe(true);
+ expect(editor.blocks[1].content[1].styles.underline).toBe(true);
+ expect(editor.blocks[1].content[2].styles.italic).toBe(true);
+ done();
+ });
+
+
+ });
+});
diff --git a/controls/blockeditor/spec/actions/list-block.spec.ts b/controls/blockeditor/spec/actions/list-block.spec.ts
new file mode 100644
index 0000000000..63d76e8d70
--- /dev/null
+++ b/controls/blockeditor/spec/actions/list-block.spec.ts
@@ -0,0 +1,334 @@
+import { createElement, remove } from '@syncfusion/ej2-base';
+import { BlockModel } from '../../src/blockeditor/models';
+import { BlockEditor, BlockType, ContentType, setCursorPosition, getBlockContentElement } from '../../src/index';
+import { createEditor } from '../common/util.spec';
+
+describe('List Block Actions', () => {
+ beforeAll(() => {
+ const isDef: any = (o: any) => o !== undefined && o !== null;
+ if (!isDef(window.performance)) {
+ console.log('Unsupported environment, window.performance.memory is unavailable');
+ pending(); // skips test (in Chai)
+ return;
+ }
+ });
+
+ describe('Creates block on list trigger keys', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeAll(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ {
+ id: 'block1',
+ type: BlockType.Paragraph
+ },
+ {
+ id: 'block2',
+ type: BlockType.Paragraph
+ },
+ {
+ id: 'block3',
+ type: BlockType.Paragraph
+ },
+ {
+ id: 'block4',
+ type: BlockType.Paragraph
+ },
+ {
+ id: 'block4',
+ type: BlockType.Paragraph
+ },
+ {
+ id: 'block5',
+ type: BlockType.Paragraph
+ }
+ ];
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterAll(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('should render bullet list on (*) trigger', (done) => {
+ const paragraph = editorElement.querySelector('#block1 p') as HTMLElement;
+ editor.setFocusToBlock(paragraph.closest('.e-block') as HTMLElement);
+ setCursorPosition(paragraph, 0);
+ paragraph.textContent = '* ';
+ editor.updateContentOnUserTyping(paragraph.closest('.e-block') as HTMLElement);
+ editorElement.dispatchEvent(new Event('input', { bubbles: true }));
+ //trigger space key
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true }));
+ setTimeout(() => {
+ const newContentElement = editorElement.querySelector('#block1 li') as HTMLElement;
+ expect(editor.blocks[0].type).toBe(BlockType.BulletList);
+ expect(newContentElement.style.getPropertyValue('list-style-type')).toContain('• ');
+ done();
+ }, 800);
+ });
+
+ it('should render bullet list on (-) trigger', (done) => {
+ const paragraph = editorElement.querySelector('#block2 p') as HTMLElement;
+ editor.setFocusToBlock(paragraph.closest('.e-block') as HTMLElement);
+ setCursorPosition(paragraph, 0);
+ paragraph.textContent = '- ';
+ editorElement.dispatchEvent(new Event('input', { bubbles: true }));
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true }));
+ setTimeout(() => {
+ const newContentElement = editorElement.querySelector('#block1 li') as HTMLElement;
+ expect(editor.blocks[1].type).toBe(BlockType.BulletList);
+ expect(newContentElement.style.getPropertyValue('list-style-type')).toContain('• ');
+ done();
+ }, 800);
+ });
+
+ it('should render numbered list on (1.) trigger', (done) => {
+ const paragraph = editorElement.querySelector('#block3 p') as HTMLElement;
+ editor.setFocusToBlock(paragraph.closest('.e-block') as HTMLElement);
+ setCursorPosition(paragraph, 0);
+ paragraph.textContent = '1. ';
+ editorElement.dispatchEvent(new Event('input', { bubbles: true }));
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true }));
+ setTimeout(() => {
+ const newContentElement = editorElement.querySelector('#block3 li') as HTMLElement;
+ expect(editor.blocks[2].type).toBe(BlockType.NumberedList);
+ expect(newContentElement.style.getPropertyValue('list-style-type')).toContain('1. ');
+ done();
+ }, 800);
+ });
+
+ it('should render checklist on ([]) trigger', (done) => {
+ const paragraph = editorElement.querySelector('#block4 p') as HTMLElement;
+ editor.setFocusToBlock(paragraph.closest('.e-block') as HTMLElement);
+ setCursorPosition(paragraph, 0);
+ paragraph.textContent = '[] ';
+ editorElement.dispatchEvent(new Event('input', { bubbles: true }));
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true }));
+ setTimeout(() => {
+ const newContentElement = editorElement.querySelector('#block1 li') as HTMLElement;
+ expect(editor.blocks[3].type).toBe(BlockType.CheckList);
+ expect(newContentElement.querySelector('span.e-checkmark')).toBeDefined();
+ done();
+ }, 800);
+ });
+
+ it('should return when block content is empty', (done) => {
+ const paragraph = editorElement.querySelector('#block4 p') as HTMLElement;
+ editor.setFocusToBlock(paragraph.closest('.e-block') as HTMLElement);
+ setCursorPosition(paragraph, 0);
+ paragraph.textContent = '';
+ expect(editor.listBlockAction.handleListTriggerKey(null, paragraph, null)).toBeUndefined();
+ done();
+ });
+
+ it('should return def index when block is not found', function (done) {
+ var paragraph = editorElement.querySelector('#block5 p') as HTMLElement;
+ editor.setFocusToBlock(paragraph.closest('.e-block') as HTMLElement);
+ setCursorPosition(paragraph, 0);
+ (editor.listBlockAction as any).getNumberedListItemIndex(null);
+ expect((editor.listBlockAction as any).getNumberedListItemIndex(null)).toBe(1);
+ done();
+ });
+ });
+
+ describe('Keyboard actions', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeAll(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'bulletlist', type: BlockType.BulletList, content: [{ id: 'bulletlist-content', type: ContentType.Text, content: 'Bullet item' }] },
+ { id: 'numberedlist', type: BlockType.NumberedList, content: [{ id: 'numberedlist-content', type: ContentType.Text, content: 'Numbered item' }] },
+ { id: 'checklist', type: BlockType.CheckList, content: [{ id: 'checklist-content', type: ContentType.Text, content: 'Checklist item' }] }
+ ];
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterAll(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('should create a new list item on Enter key press', () => {
+ const bulletListBlock = editorElement.querySelector('#bulletlist') as HTMLElement;
+ const numberedListBlock = editorElement.querySelector('#numberedlist') as HTMLElement;
+ const checkListBlock = editorElement.querySelector('#checklist') as HTMLElement;
+ const bulletContent = getBlockContentElement(bulletListBlock);
+ const numberedContent = getBlockContentElement(numberedListBlock);
+ const checkContent = getBlockContentElement(checkListBlock);
+ let newListBlock = null;
+
+ //Bullet list test
+ editor.setFocusToBlock(bulletListBlock);
+ setCursorPosition(bulletContent, bulletContent.textContent.length);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ newListBlock = bulletListBlock.nextElementSibling as HTMLElement;
+ expect(newListBlock).toBeDefined();
+ expect(newListBlock.textContent).toBe('');
+ expect(newListBlock.style.getPropertyValue('--block-indent')).toBe(bulletListBlock.style.getPropertyValue('--block-indent'));
+
+ //Numbered list test
+ editor.setFocusToBlock(numberedListBlock);
+ setCursorPosition(numberedContent, numberedContent.textContent.length);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ newListBlock = numberedListBlock.nextElementSibling as HTMLElement;
+ const newListContent = getBlockContentElement(newListBlock);
+ newListContent.textContent = 'New list item';
+ editorElement.dispatchEvent(new Event('input', { bubbles: true }));
+ expect(newListBlock).toBeDefined();
+ expect(newListContent.textContent).toBe('New list item');
+ expect(newListBlock.style.getPropertyValue('--block-indent')).toBe(numberedListBlock.style.getPropertyValue('--block-indent'));
+ expect(newListContent.style.getPropertyValue('list-style-type')).toContain('2. ');
+
+ //Checklist test
+ editor.setFocusToBlock(checkListBlock);
+ setCursorPosition(checkContent, checkContent.textContent.length);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ newListBlock = checkListBlock.nextElementSibling as HTMLElement;
+ expect(newListBlock).toBeDefined();
+ expect(newListBlock.textContent).toBe('');
+ expect(newListBlock.style.getPropertyValue('--block-indent')).toBe(checkListBlock.style.getPropertyValue('--block-indent'));
+ });
+
+ it('should reduce indent level on Enter key in empty block', (done) => {
+ setTimeout(() => {
+ editor.blocks[0].indent = 1;
+ editor.dataBind();
+ const bulletBlock = editorElement.querySelector('#bulletlist') as HTMLElement;
+ const numberedContent = getBlockContentElement(bulletBlock);
+ expect(bulletBlock.style.getPropertyValue('--block-indent')).toBe('20');
+
+ editor.setFocusToBlock(bulletBlock);
+ numberedContent.textContent = '';
+ editor.blocks[0].content[0].content = '';
+ setCursorPosition(numberedContent, 0);
+
+ //Enter once to reduce indent
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+
+ expect(editor.blocks[0].indent).toBe(0)
+ expect(bulletBlock.style.getPropertyValue('--block-indent')).toBe('0');
+
+ //Enter again should transform list into paragraph
+ setCursorPosition(numberedContent, 0);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ expect(editor.blocks[0].type).toBe('Paragraph');
+ expect(getBlockContentElement(bulletBlock).tagName).toBe('P');
+ done();
+ }, 200);
+ });
+
+ it('should not prevent default when backspace at middle of text', () => {
+ const numberedListBlock = editorElement.querySelector('#numberedlist') as HTMLElement;
+ const numberedContent = getBlockContentElement(numberedListBlock);
+
+ editor.setFocusToBlock(numberedListBlock);
+ setCursorPosition(numberedContent, 3);
+
+ spyOn(editor.blockAction, 'transformBlockToParagraph').and.callThrough();
+
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));
+
+ expect(editor.blockAction.transformBlockToParagraph).not.toHaveBeenCalled();
+ });
+
+ it('should update indent level on Tab/Shift+Tab key press', () => {
+ const bulletListBlock = editorElement.querySelector('#bulletlist').nextElementSibling as HTMLElement;
+ const numberedListBlock = editorElement.querySelector('#numberedlist').nextElementSibling as HTMLElement;
+ const checkListBlock = editorElement.querySelector('#checklist').nextElementSibling as HTMLElement;
+ const bulletContent = getBlockContentElement(bulletListBlock);
+ const numberedContent = getBlockContentElement(numberedListBlock);
+ const checkContent = getBlockContentElement(checkListBlock);
+
+ //Bullet list test
+ editor.setFocusToBlock(bulletListBlock);
+ setCursorPosition(bulletContent, 0);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
+ expect(bulletListBlock.style.getPropertyValue('--block-indent')).toBe('20');
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, shiftKey: true }));
+ expect(bulletListBlock.style.getPropertyValue('--block-indent')).toBe('0');
+
+ //Numbered list test
+ editor.setFocusToBlock(numberedListBlock);
+ setCursorPosition(numberedContent, numberedContent.textContent.length);
+ //Press tab once for one indent level
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
+ expect(numberedListBlock.style.getPropertyValue('--block-indent')).toBe('20');
+ expect(numberedContent.style.getPropertyValue('list-style-type')).toContain('a. ');
+ //Enter on indented list should create a new list item with the same indent level
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ const newListBlock = numberedListBlock.nextElementSibling as HTMLElement;
+ expect(newListBlock).toBeDefined();
+ expect(newListBlock.textContent).toBe('');
+ const newListContent = getBlockContentElement(newListBlock);
+ expect(newListBlock.style.getPropertyValue('--block-indent')).toBe(numberedListBlock.style.getPropertyValue('--block-indent'));
+ expect(newListContent.style.getPropertyValue('list-style-type')).toContain('b. ');
+
+ //Press tab once for second indent level
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
+ expect(newListBlock.style.getPropertyValue('--block-indent')).toBe('40');
+ expect(newListContent.style.getPropertyValue('list-style-type')).toContain('i. ');
+
+ //On second indent, press shift+tab to go back to first indent level
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, shiftKey: true }));
+ expect(newListBlock.style.getPropertyValue('--block-indent')).toBe('20');
+ expect(newListContent.style.getPropertyValue('list-style-type')).toContain('b. ');
+
+ //On first indent, press shift+tab to go back to initial indent
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, shiftKey: true }));
+ expect(newListBlock.style.getPropertyValue('--block-indent')).toBe('0');
+ expect(newListContent.style.getPropertyValue('list-style-type')).toContain('2. ');
+
+ //Check list test
+ editor.setFocusToBlock(checkListBlock);
+ setCursorPosition(checkContent, 0);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
+ expect(checkListBlock.style.getPropertyValue('--block-indent')).toBe('20');
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, shiftKey: true }));
+ expect(checkListBlock.style.getPropertyValue('--block-indent')).toBe('0');
+ });
+
+ it('should toggle list to paragraph when backspace is pressed at the start of a list item', () => {
+ const numberedListBlock = editorElement.querySelector('#numberedlist') as HTMLElement;
+ const numberedContent = numberedListBlock.querySelector('#numberedlist-content') as HTMLElement;
+
+ editor.setFocusToBlock(numberedListBlock);
+ setCursorPosition(numberedContent, 0);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));
+ expect(editor.blocks[2].type).toBe(BlockType.Paragraph);
+ expect(editor.blocks[2].content[0].content).toBe('Numbered item');
+ expect(editorElement.querySelector('#numberedlist p')).not.toBeNull();
+ expect(editorElement.querySelector('#numberedlist p').textContent).toBe('Numbered item');
+ });
+
+ it('should split list content when enter is pressed at middle of text', () => {
+ const checkListBlock = editorElement.querySelector('#checklist') as HTMLElement;
+ const checkContent = checkListBlock.querySelector('#checklist-content') as HTMLElement;
+
+ editor.setFocusToBlock(checkListBlock);
+ setCursorPosition(checkContent, 6);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ expect(editor.blocks[5].content[0].content).toBe('Checkl');
+ const newListBlock = checkListBlock.nextElementSibling as HTMLElement;
+ expect(newListBlock).not.toBeNull();
+ expect(newListBlock.getAttribute('data-block-type')).toBe('CheckList');
+ const newListContent = getBlockContentElement(newListBlock);
+ expect(checkContent.textContent).toBe('Checkl');
+ expect(newListContent.textContent).toBe('ist item');
+ });
+ });
+});
diff --git a/controls/blockeditor/spec/actions/methods.spec.ts b/controls/blockeditor/spec/actions/methods.spec.ts
new file mode 100644
index 0000000000..459e5ebba0
--- /dev/null
+++ b/controls/blockeditor/spec/actions/methods.spec.ts
@@ -0,0 +1,988 @@
+import { createElement } from '@syncfusion/ej2-base';
+import { BlockEditor, BlockType, BuiltInToolbar, ContentType, getBlockContentElement, getBlockIndexById, getBlockModelById } from '../../src/index';
+import { createEditor } from '../common/util.spec';
+
+describe('BlockEditor Methods', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ editor = createEditor({
+ blocks: [
+ {
+ id: 'paragraph1',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'content1', type: ContentType.Text, content: 'Initial content' }
+ ]
+ }
+ ]
+ });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ }
+ document.body.removeChild(editorElement);
+ });
+
+ describe('addBlock method', () => {
+ it('should add a new block to the editor', (done) => {
+ const initialBlockCount = editor.blocks.length;
+
+ // Create a new block to add
+ const newBlock = {
+ id: 'paragraph2',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'content2', type: ContentType.Text, content: 'New content' }
+ ]
+ };
+
+ setTimeout(() => {
+ // Add the block after the existing paragraph
+ editor.addBlock(newBlock, 'paragraph1', true);
+
+ expect(editor.blocks.length).toBe(initialBlockCount + 1);
+ expect(editor.blocks[1].id).toBe('paragraph2');
+ expect(editor.blocks[1].content[0].content).toBe('New content');
+ const addedElement = editorElement.querySelector('#paragraph2') as HTMLElement;
+
+ expect(addedElement).toBeDefined();
+ expect(addedElement.id).toBe('paragraph2');
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(initialBlockCount + 1);
+ const contentElement = getBlockContentElement(addedElement);
+ expect(contentElement.textContent).toBe('New content');
+ done();
+ });
+ });
+
+ it('should add block at the beginning when isAfter is false', (done) => {
+ const newBlock = {
+ id: 'heading1',
+ type: BlockType.Heading1,
+ content: [
+ { id: 'headingContent', type: ContentType.Text, content: 'New Heading' }
+ ]
+ };
+
+ setTimeout(() => {
+ editor.addBlock(newBlock, 'paragraph1', false);
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[0].id).toBe('heading1');
+ expect(editor.blocks[0].type).toBe(BlockType.Heading1);
+ const addedElement = editorElement.querySelector('#heading1') as HTMLElement;
+
+ expect(addedElement.id).toBe('heading1');
+ expect(editorElement.querySelector('.e-block').id).toBe('heading1');
+ done();
+ });
+ });
+ });
+
+ describe('removeBlock method', () => {
+ it('should remove a block from the editor', (done) => {
+ const initialBlockCount = editor.blocks.length;
+
+ // Add another block to remove
+ const newBlock = {
+ id: 'paragraph2',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'content2', type: ContentType.Text, content: 'Block to remove' }
+ ]
+ };
+ setTimeout(() => {
+ editor.addBlock(newBlock, 'paragraph1', true);
+ expect(editor.blocks.length).toBe(initialBlockCount + 1);
+
+ // Remove the added block
+ editor.removeBlock('paragraph2');
+
+ // Check model update
+ expect(editor.blocks.length).toBe(initialBlockCount);
+ expect(editor.blocks[0].id).toBe('paragraph1');
+
+ // Check DOM update
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(initialBlockCount);
+ expect(editorElement.querySelector('#paragraph2')).toBeNull();
+ done();
+ });
+ });
+ });
+
+ describe('getBlock method', () => {
+ it('should retrieve a block by ID', () => {
+ const block = editor.getBlock('paragraph1');
+
+ expect(block).not.toBeNull();
+ expect(block.id).toBe('paragraph1');
+ expect(block.type).toBe(BlockType.Paragraph);
+ expect(block.content[0].content).toBe('Initial content');
+ });
+
+ it('should return null for nonexistent block ID', () => {
+ const block = editor.getBlock('nonExistentId');
+
+ expect(block).toBeNull();
+ });
+ });
+
+ describe('moveBlock method', () => {
+ it('should move a block to another position', (done) => {
+ setTimeout(() => {
+ // Add blocks to test moving
+ editor.addBlock({
+ id: 'heading1',
+ type: BlockType.Heading1,
+ content: [{ id: 'headingContent', type: ContentType.Text, content: 'Heading' }]
+ }, 'paragraph1', false);
+
+ editor.addBlock({
+ id: 'paragraph2',
+ type: BlockType.Paragraph,
+ content: [{ id: 'content2', type: ContentType.Text, content: 'Last paragraph' }]
+ }, 'paragraph1', true);
+
+ // Initial order should be: heading1, paragraph1, paragraph2
+ expect(editor.blocks[0].id).toBe('heading1');
+ expect(editor.blocks[1].id).toBe('paragraph1');
+ expect(editor.blocks[2].id).toBe('paragraph2');
+
+ // Move paragraph1 to the end (after paragraph2)
+ editor.moveBlock('paragraph1', 'paragraph2');
+
+ // New order should be: heading1, paragraph2, paragraph1
+ expect(editor.blocks[0].id).toBe('heading1');
+ expect(editor.blocks[1].id).toBe('paragraph2');
+ expect(editor.blocks[2].id).toBe('paragraph1');
+
+ // Check DOM update
+ const blockElements = editorElement.querySelectorAll('.e-block');
+ expect(blockElements[0].id).toBe('heading1');
+ expect(blockElements[1].id).toBe('paragraph2');
+ expect(blockElements[2].id).toBe('paragraph1');
+ done();
+ });
+ });
+ });
+
+ describe('updateBlock method', () => {
+ it('should update block properties', () => {
+ // Update the paragraph to heading
+ const updateResult = editor.updateBlock('paragraph1', {
+ type: BlockType.Heading2,
+ content: [
+ { id: 'content1', type: ContentType.Text, content: 'Updated content' }
+ ]
+ });
+
+ // Check update result
+ expect(updateResult).toBe(true);
+
+ // Check model update
+ const updatedBlock = editor.getBlock('paragraph1');
+ expect(updatedBlock.type).toBe(BlockType.Heading2);
+ expect(updatedBlock.content[0].content).toBe('Updated content');
+
+ // Check DOM update
+ const blockElement = editorElement.querySelector('#paragraph1');
+ expect(blockElement.getAttribute('data-block-type')).toBe(BlockType.Heading2);
+ const contentElement = getBlockContentElement(blockElement as HTMLElement);
+ expect(contentElement.tagName).toBe('H2');
+ expect(contentElement.textContent).toBe('Updated content');
+ });
+
+ it('should return false when updating non-existent block', () => {
+ const updateResult = editor.updateBlock('nonExistentId', {
+ type: BlockType.Heading1
+ });
+
+ expect(updateResult).toBe(false);
+ });
+ });
+
+ describe('Inline Toolbar methods', () => {
+ it('executeToolbarAction method', () => {
+ // Select the content
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ const contentElement = getBlockContentElement(blockElement);
+ editor.setFocusToBlock(blockElement);
+
+ editor.setSelection(contentElement.id, 0, 15);
+
+ // Execute bold command
+ editor.executeToolbarAction(BuiltInToolbar.Bold);
+
+ // Check if formatting is applied in the model
+ expect(editor.blocks[0].content[0].styles.bold).toBe(true);
+
+ // Check DOM update (should have wrapped content in tag)
+ const strongElement = contentElement.querySelector('strong');
+ expect(strongElement).not.toBeNull();
+ expect(strongElement.textContent).toBe('Initial content');
+ });
+
+ it('executeToolbarAction should handle invalid values', () => {
+ // Select the content
+ spyOn(editor.formattingAction, 'execCommand').and.callThrough();
+
+ // Execute bold command
+ (editor.executeToolbarAction as any)('invalid');
+
+ expect(editor.formattingAction.execCommand).not.toHaveBeenCalled();
+ });
+
+ it('enableDisableToolbarItems method - DISABLE', () => {
+ const popup = document.querySelector('.e-blockeditor-inline-toolbar');
+
+ editor.disableToolbarItems(['bold', 'italic']);
+
+ expect(popup.querySelector('#bold').getAttribute('aria-disabled')).toBe('true');
+ expect(popup.querySelector('#italic').getAttribute('aria-disabled')).toBe('true');
+ });
+
+ it('enableDisableToolbarItems method - ENABLE', () => {
+ const popup = document.querySelector('.e-blockeditor-inline-toolbar');
+
+ editor.enableToolbarItems(['bold', 'italic']);
+
+ expect(popup.querySelector('#bold').getAttribute('aria-disabled')).toBe('false');
+ expect(popup.querySelector('#italic').getAttribute('aria-disabled')).toBe('false');
+ });
+
+ it('enableDisableToolbarItems method - DISABLE - SINGLE ITEM', () => {
+ const popup = document.querySelector('.e-blockeditor-inline-toolbar');
+
+ editor.disableToolbarItems('bold');
+
+ expect(popup.querySelector('#bold').getAttribute('aria-disabled')).toBe('true');
+ });
+
+ it('enableDisableToolbarItems method - ENABLE - SINGLE ITEM', () => {
+ const popup = document.querySelector('.e-blockeditor-inline-toolbar');
+
+ editor.enableToolbarItems('bold');
+
+ expect(popup.querySelector('#bold').getAttribute('aria-disabled')).toBe('false');
+ });
+ });
+
+ describe('setSelection and setCursorPosition methods', () => {
+ it('should set selection for a content element', (done) => {
+ // Set selection for the content
+ editor.setSelection('content1', 3, 7);
+
+ const selection = window.getSelection();
+ expect(selection.toString()).toBe('tial');
+ done();
+ });
+
+ it('should set cursor position in a block', (done) => {
+ // Set cursor position
+ editor.setCursorPosition('paragraph1', 3);
+
+ const selection = window.getSelection();
+ expect(selection.rangeCount).toBe(1);
+ const range = selection.getRangeAt(0);
+ expect(range.startOffset).toBe(3);
+ expect(range.collapsed).toBe(true);
+ done();
+ });
+
+ it('setSelection should handle invalid content element', (done) => {
+ // Set selection for the content
+ editor.setSelection('fake', 0, 0);
+
+ const selection = window.getSelection();
+ expect(selection.toString()).toBe('');
+ done();
+ });
+
+ it('setCursorPosition should handle invalid block element', (done) => {
+ // Set selection for the content
+ editor.setCursorPosition('invalid', 3);
+ done();
+ });
+ });
+
+ describe('getSelectedBlocks method', () => {
+ it('should return selected blocks', (done) => {
+ setTimeout(() => {
+ // Add an extra block
+ editor.addBlock({
+ id: 'paragraph2',
+ type: BlockType.Paragraph,
+ content: [{ id: 'content2', type: ContentType.Text, content: 'Second paragraph' }]
+ }, 'paragraph1', true);
+
+ // Create a range to select both blocks
+ const range = document.createRange();
+ const startNode = editorElement.querySelector('#paragraph1');
+ const endNode = editorElement.querySelector('#paragraph2');
+ range.setStartBefore(startNode);
+ range.setEndAfter(endNode);
+
+ // Set the selection
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ // Get selected blocks
+ const selectedBlocks = editor.getSelectedBlocks();
+
+ expect(selectedBlocks.length).toBe(2);
+ expect(selectedBlocks[0].id).toBe('paragraph1');
+ expect(selectedBlocks[1].id).toBe('paragraph2');
+ done();
+ });
+ });
+
+ it('should return selected blocks properly inside children type blocks', (done) => {
+ setTimeout(() => {
+ const parentBlock = {
+ id: 'toggle-block',
+ type: BlockType.ToggleParagraph,
+ content: [{ type: ContentType.Text, content: 'Click here to expand' }],
+ isExpanded: true,
+ children: [
+ {
+ id: 'toggleChild1',
+ parentId: 'toggle-block',
+ type: BlockType.BulletList,
+ content: [{ id: 'toggleChildContent', type: ContentType.Text, content: 'toggle content' }],
+ },
+ {
+ id: 'toggleChild2',
+ type: BlockType.Paragraph,
+ content: [{ id: 'content2', type: ContentType.Text, content: 'Second paragraph' }]
+ }
+ ]
+ };
+ editor.addBlock(parentBlock);
+
+ // Create a range to select both blocks
+ const range = document.createRange();
+ const startNode = editorElement.querySelector('#toggleChild1');
+ const endNode = editorElement.querySelector('#toggleChild2');
+ range.setStartBefore(startNode);
+ range.setEndAfter(endNode);
+
+ // Set the selection
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ // Get selected blocks
+ const selectedBlocks = editor.getSelectedBlocks();
+
+ expect(selectedBlocks.length).toBe(2);
+ expect(selectedBlocks[0].id).toBe('toggleChild1');
+ expect(selectedBlocks[1].id).toBe('toggleChild2');
+ done();
+ });
+ });
+
+ it('should not select a block if it is not in editor', (done) => {
+ setTimeout(() => {
+ // Add an extra block
+ editor.addBlock({
+ id: 'paragraph2',
+ type: BlockType.Paragraph,
+ content: [{ id: 'content2', type: ContentType.Text, content: 'Second paragraph' }]
+ }, 'paragraph1', true);
+
+ // Create a range to select both blocks
+ const range = document.createRange();
+ const startNode = editorElement.querySelector('#paragraph1');
+ const endNode = editorElement.querySelector('#paragraph2');
+ range.setStartBefore(startNode);
+ range.setEndAfter(endNode);
+
+ // Set the selection
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ // Make it invalid
+ editorElement.querySelector('#paragraph2').id = 'invalid';
+
+ // Get selected blocks
+ const selectedBlocks = editor.getSelectedBlocks();
+
+ expect(selectedBlocks.length).toBe(1);
+ expect(selectedBlocks[0].id).toBe('paragraph1');
+ done();
+ });
+ });
+ });
+
+ describe('selectBlock and selectAllBlocks methods', () => {
+ it('should select a specific block', (done) => {
+ setTimeout(() => {
+ // Add an extra block
+ editor.addBlock({
+ id: 'paragraph2',
+ type: BlockType.Paragraph,
+ content: [{ id: 'content2', type: ContentType.Text, content: 'Second paragraph' }]
+ }, 'paragraph1', true);
+
+ // Select the first block
+ editor.selectBlock('paragraph1');
+
+ // Check if selection is applied
+ const selection = window.getSelection();
+ expect(selection.toString()).toBe('Initial content');
+ done();
+ });
+ });
+
+ it('should select all blocks', (done) => {
+ setTimeout(() => {
+ // Add an extra block
+ editor.addBlock({
+ id: 'paragraph2',
+ type: BlockType.Paragraph,
+ content: [{ id: 'content2', type: ContentType.Text, content: 'Second paragraph' }]
+ }, 'paragraph1', true);
+
+ // Select all blocks
+ editor.selectAllBlocks();
+
+ // Check if selection is applied to all content
+ const selection = window.getSelection();
+ expect(selection.toString().indexOf('Initial content')).not.toBe(-1);
+ expect(selection.toString().indexOf('Second paragraph')).not.toBe(-1);
+ done();
+ });
+ });
+
+ it('should handle null values', (done) => {
+ // Set selection for the content
+ editor.selectBlock('invalid');
+ done();
+ });
+ });
+
+ describe('focusIn and focusOut methods', () => {
+ it('should focus in and out of the editor', () => {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ // Focus out first
+ editor.focusOut();
+
+ // Check that editor doesn't have focus
+ expect(document.activeElement).not.toBe(editor.blockWrapper);
+
+ // Focus in
+ editor.focusIn();
+
+ // Check that editor has focus
+ expect(document.activeElement).toBe(editor.blockWrapper);
+
+ editor.setFocusToBlock(blockElement);
+
+ // Focus out again
+ editor.focusOut();
+
+ // Check that editor doesn't have focus
+ expect(document.activeElement).not.toBe(editor.blockWrapper);
+ });
+ });
+
+ describe('getBlockCount method', () => {
+ it('should return the correct number of blocks', (done) => {
+ setTimeout(() => {
+ expect(editor.getBlockCount()).toBe(1);
+
+ // Add a new block
+ editor.addBlock({
+ id: 'paragraph2',
+ type: BlockType.Paragraph,
+ content: [{ id: 'content2', type: ContentType.Text, content: 'New paragraph' }]
+ }, 'paragraph1', true);
+
+ expect(editor.getBlockCount()).toBe(2);
+
+ // Remove a block
+ editor.removeBlock('paragraph1');
+
+ expect(editor.getBlockCount()).toBe(1);
+ done();
+ });
+ });
+ });
+
+ describe('getDataAsJson method', () => {
+ it('should return all blocks as JSON when no blockId is provided', (done) => {
+ setTimeout(() => {
+ // Add another block
+ editor.addBlock({
+ id: 'heading1',
+ type: BlockType.Heading1,
+ content: [{ id: 'headingContent', type: ContentType.Text, content: 'Heading' }]
+ }, 'paragraph1', false);
+
+ const json = editor.getDataAsJson();
+
+ expect(Array.isArray(json)).toBe(true);
+ expect(json.length).toBe(2);
+ expect(json[0].id).toBe('heading1');
+ expect(json[1].id).toBe('paragraph1');
+ done();
+ });
+ });
+
+ it('should return a specific block as JSON when blockId is provided', () => {
+ const json = editor.getDataAsJson('paragraph1');
+
+ expect(json).not.toBeNull();
+ expect(json.id).toBe('paragraph1');
+ expect(json.type).toBe(BlockType.Paragraph);
+ expect(json.content[0].content).toBe('Initial content');
+ });
+
+ it('should return null for non-existent block ID', () => {
+ const json = editor.getDataAsJson('nonExistentId');
+
+ expect(json).toBeNull();
+ });
+ });
+
+ describe('getDataAsHtml method', function () {
+ it('should return all blocks as HTML when no blockId is provided', function (done) {
+ setTimeout(function () {
+ editor.addBlock({
+ id: 'heading1',
+ type: BlockType.Heading1,
+ content: [{ id: 'headingContent', type: ContentType.Text, content: 'Heading' }]
+ }, 'paragraph1', false);
+ const html = editor.getDataAsHtml();
+ expect(html).toBe('Heading Initial content
');
+ done();
+ });
+ });
+
+ it('should return a specific block as html when blockId is provided', function () {
+ const html = editor.getDataAsHtml('paragraph1');
+ expect(html).not.toBeNull();
+ expect(html).toBe('Initial content
');
+ });
+
+ it('should return null for non-existent block ID', function () {
+ const html = editor.getDataAsHtml('nonExistentId');
+ expect(html).toBeNull();
+ });
+
+ it('should render quote blocks correctly', function (done) {
+ setTimeout(function () {
+ editor.addBlock({
+ id: 'quote1',
+ type: BlockType.Quote,
+ content: [{ id: 'quoteContent', type: ContentType.Text, content: 'Important quote' }]
+ }, 'paragraph1', true);
+
+ const html = editor.getDataAsHtml('quote1');
+ expect(html).toBe('Important quote ');
+ done();
+ });
+ });
+
+ it('should render divider blocks correctly', function (done) {
+ setTimeout(function () {
+ editor.addBlock({
+ id: 'divider1',
+ type: BlockType.Divider
+ }, 'paragraph1', true);
+
+ const html = editor.getDataAsHtml('divider1');
+ expect(html).toBe(' ');
+ done();
+ });
+ });
+
+ it('should render code blocks correctly', function (done) {
+ setTimeout(function () {
+ editor.addBlock({
+ id: 'code1',
+ type: BlockType.Code,
+ content: [{ id: 'codeContent', type: ContentType.Text, content: 'function test() { return true; }' }]
+ }, 'paragraph1', true);
+
+ const html = editor.getDataAsHtml('code1');
+ expect(html).toBe('function test() { return true; }
');
+ done();
+ });
+ });
+
+ it('should render image blocks correctly', function (done) {
+ setTimeout(function () {
+ editor.addBlock({
+ id: 'image1',
+ type: BlockType.Image,
+ imageSettings: {
+ src: 'https://example.com/image.jpg',
+ altText: 'Sample image'
+ }
+ }, 'paragraph1', true);
+
+ const html = editor.getDataAsHtml('image1');
+ expect(html).toBe(' ');
+ done();
+ });
+ });
+
+ it('should not render image blocks with empty src', function (done) {
+ setTimeout(function () {
+ editor.addBlock({
+ id: 'emptyImage',
+ type: BlockType.Image,
+ imageSettings: {
+ src: '',
+ altText: 'Empty image'
+ }
+ }, 'paragraph1', true);
+
+ const html = editor.getDataAsHtml('emptyImage');
+ expect(html).toBe('');
+ done();
+ });
+ });
+
+ it('should render callout blocks correctly', function (done) {
+ setTimeout(function () {
+ const calloutBlock = {
+ id: 'callout1',
+ type: BlockType.Callout,
+ content: [{ id: 'calloutTitle', type: ContentType.Text, content: 'Callout Title' }],
+ children: [{
+ id: 'calloutPara',
+ type: BlockType.Paragraph,
+ content: [{ id: 'calloutContent', type: ContentType.Text, content: 'Callout content' }]
+ }]
+ };
+
+ editor.addBlock(calloutBlock, 'paragraph1', true);
+
+ const html = editor.getDataAsHtml('callout1');
+ expect(html).toBe('');
+ done();
+ });
+ });
+
+ it('should render toggle blocks correctly', function (done) {
+ setTimeout(function () {
+ const toggleBlock = {
+ id: 'toggle1',
+ type: BlockType.ToggleParagraph,
+ content: [{ id: 'toggleTitle', type: ContentType.Text, content: 'Toggle Title' }],
+ children: [{
+ id: 'togglePara',
+ type: BlockType.Paragraph,
+ content: [{ id: 'toggleContent', type: ContentType.Text, content: 'Toggle content' }]
+ }]
+ };
+
+ editor.addBlock(toggleBlock, 'paragraph1', true);
+
+ const html = editor.getDataAsHtml('toggle1');
+ expect(html).toBe('Toggle Title
Toggle content
');
+ done();
+ });
+ });
+
+ it('should render toggle heading blocks correctly', function (done) {
+ setTimeout(function () {
+ const toggleHeadingBlock = {
+ id: 'toggleHeading1',
+ type: BlockType.ToggleHeading1,
+ content: [{ id: 'toggleHeadingTitle', type: ContentType.Text, content: 'Toggle Heading' }],
+ children: [{
+ id: 'toggleHeadingPara',
+ type: BlockType.Paragraph,
+ content: [{ id: 'toggleHeadingContent', type: ContentType.Text, content: 'Toggle heading content' }]
+ }]
+ };
+
+ editor.addBlock(toggleHeadingBlock, 'paragraph1', true);
+
+ const html = editor.getDataAsHtml('toggleHeading1');
+ expect(html).toBe('Toggle Heading
Toggle heading content
');
+ done();
+ });
+ });
+
+ it('should render links correctly in HTML', function (done) {
+ setTimeout(function () {
+ const linkBlock = {
+ id: 'link1',
+ type: BlockType.Paragraph,
+ content: [{
+ id: 'linkContent',
+ type: ContentType.Link,
+ content: 'Link text',
+ linkSettings: {
+ url: 'https://example.com',
+ openInNewWindow: true
+ }
+ }]
+ };
+
+ editor.addBlock(linkBlock, 'paragraph1', true);
+
+ const html = editor.getDataAsHtml('link1');
+ expect(html).toBe('Link text
');
+ done();
+ });
+ });
+
+ it('should render bullet list blocks correctly', function (done) {
+ setTimeout(function () {
+ editor.addBlock({
+ id: 'bulletlist1',
+ type: BlockType.BulletList,
+ content: [{ id: 'bulletContent1', type: ContentType.Text, content: 'Bullet item 1' }]
+ }, 'paragraph1', true);
+
+ editor.addBlock({
+ id: 'bulletlist2',
+ type: BlockType.BulletList,
+ content: [{ id: 'bulletContent2', type: ContentType.Text, content: 'Bullet item 2' }]
+ }, 'bulletlist1', true);
+
+ const html = editor.getDataAsHtml();
+ expect(html.includes('Bullet item 1 Bullet item 2 ')).toBe(true);
+ done();
+ });
+ });
+
+ it('should render numbered list blocks correctly', function (done) {
+ setTimeout(function () {
+ editor.removeBlock('paragraph1');
+
+ editor.addBlock({
+ id: 'numberedlist1',
+ type: BlockType.NumberedList,
+ content: [{ id: 'numberedContent1', type: ContentType.Text, content: 'Number item 1' }]
+ });
+
+ editor.addBlock({
+ id: 'numberedlist2',
+ type: BlockType.NumberedList,
+ content: [{ id: 'numberedContent2', type: ContentType.Text, content: 'Number item 2' }]
+ }, 'numberedlist1', true);
+
+ const html = editor.getDataAsHtml();
+ expect(html).toBe('Number item 1 Number item 2 ');
+ done();
+ });
+ });
+
+ it('should render formatted text correctly in HTML', function (done) {
+ setTimeout(function () {
+ const formattedBlock = {
+ id: 'formatted1',
+ type: BlockType.Paragraph,
+ content: [{
+ id: 'formattedContent',
+ type: ContentType.Text,
+ content: 'Formatted text',
+ styles: {
+ bold: true,
+ italic: true,
+ underline: true
+ }
+ }]
+ };
+
+ editor.addBlock(formattedBlock, 'paragraph1', true);
+
+ const html = editor.getDataAsHtml('formatted1');
+ expect(html).toBe('Formatted text
');
+ done();
+ });
+ });
+ });
+
+ describe('getCurrentFocusedBlockModel method', () => {
+ it('should return the current focused block model', (done) => {
+ setTimeout(() => {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+
+ const focusedBlockModel = editor.getCurrentFocusedBlockModel();
+ expect(focusedBlockModel).not.toBeNull();
+ expect(focusedBlockModel.id).toBe('paragraph1');
+ expect(focusedBlockModel.type).toBe(BlockType.Paragraph);
+ done();
+ });
+ });
+
+ it('should return null when no block is focused', (done) => {
+ setTimeout(() => {
+ editor.currentFocusedBlock = null;
+ const focusedBlockModel = editor.getCurrentFocusedBlockModel();
+ expect(focusedBlockModel).toBeNull();
+ done();
+ });
+ });
+ });
+
+ describe('updateBlock method with parent blocks', () => {
+ it('should update callout block properly', (done) => {
+ setTimeout(() => {
+ const parentBlock = {
+ id: 'callout1',
+ type: BlockType.Callout,
+ children: [{
+ id: 'calloutChild1',
+ type: BlockType.Paragraph,
+ content: [{ id: 'calloutChildContent', type: ContentType.Text, content: 'Callout content' }],
+ parentId: 'callout1'
+ }]
+ };
+ editor.addBlock(parentBlock);
+
+ const updateResult = editor.updateBlock('calloutChild1', {
+ content: [{ id: 'calloutChildContent', type: ContentType.Text, content: 'Updated callout content' }]
+ });
+
+ expect(updateResult).toBe(true);
+
+ const updatedBlock = editor.getBlock('calloutChild1');
+ expect(updatedBlock).not.toBeNull();
+ expect(updatedBlock.content[0].content).toBe('Updated callout content');
+
+ const updatedElement = editorElement.querySelector('#calloutChild1');
+ expect(updatedElement).not.toBeNull();
+ const contentElement = getBlockContentElement(updatedElement as HTMLElement);
+ expect(contentElement.textContent).toBe('Updated callout content');
+ done();
+ });
+ });
+
+ it('should update toggle block properly', (done) => {
+ setTimeout(() => {
+ const parentBlock = {
+ id: 'toggle-block',
+ type: BlockType.ToggleParagraph,
+ content: [{ type: ContentType.Text, content: 'Click here to expand' }],
+ children: [{
+ id: 'toggleChild1',
+ parentId: 'toggle-block',
+ type: BlockType.BulletList,
+ content: [{ id: 'toggleChildContent', type: ContentType.Text, content: 'toggle content' }],
+ }]
+ };
+ editor.addBlock(parentBlock);
+
+ const updateResult = editor.updateBlock('toggleChild1', {
+ indent: 1,
+ });
+
+ expect(updateResult).toBe(true);
+
+ const updatedBlock = editor.getBlock('toggleChild1');
+ expect(updatedBlock).not.toBeNull();
+ expect(updatedBlock.indent).toBe(1);
+
+ const updatedElement = editorElement.querySelector('#toggleChild1') as HTMLElement;
+ expect(updatedElement).not.toBeNull();
+ expect(updatedElement.style.getPropertyValue('--block-indent')).toBe('20');
+ done();
+ });
+ });
+ });
+
+ describe('getRange and selectRange methods', () => {
+ it('should get and set the range properly', (done) => {
+ setTimeout(() => {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ editor.setSelection('content1', 0, 5);
+
+ const range = editor.getRange();
+ expect(range).not.toBeNull();
+ expect(range.toString()).toBe('Initi');
+
+ const newRange = document.createRange();
+ const contentElement = blockElement.querySelector('#content1');
+ newRange.setStart(contentElement.firstChild, 6);
+ newRange.setEnd(contentElement.firstChild, 10);
+
+ editor.selectRange(newRange);
+
+ const newSelection = window.getSelection();
+ expect(newSelection.toString()).toBe('l co');
+ done();
+ });
+ });
+
+ it('should handle null selection properly', function (done) {
+ setTimeout(function () {
+ var originalGetSelection = window.getSelection;
+ window.getSelection = function () { return null; };
+ expect(editor.selectRange(null)).toBeUndefined();
+ window.getSelection = originalGetSelection;
+ done();
+ });
+ });
+ });
+
+ describe('renderTemplate method', () => {
+ it('should render a custom template for a block', (done) => {
+ setTimeout(() => {
+ const customBlock = {
+ id: 'templateBlock',
+ type: BlockType.Template,
+ template: 'Template content
'
+ };
+
+ editor.addBlock(customBlock, 'paragraph1');
+
+ const blockElement = editorElement.querySelector('#templateBlock') as HTMLElement;
+
+ setTimeout(() => {
+ const customTemplateElement = blockElement.querySelector('.custom-template');
+ expect(customTemplateElement).not.toBeNull();
+ expect(customTemplateElement.textContent).toBe('Template content');
+ done();
+ }, 100);
+ });
+ });
+ });
+
+ describe('print method', () => {
+ it('should call print functionality', (done) => {
+ setTimeout(() => {
+ const originalOpen = window.open;
+ const mockWindow = {
+ document: {
+ write: jasmine.createSpy('write'),
+ close: jasmine.createSpy('close')
+ },
+ focus: jasmine.createSpy('focus'),
+ print: jasmine.createSpy('print'),
+ close: jasmine.createSpy('close'),
+ resizeTo: jasmine.createSpy('resizeTo')
+ };
+
+ spyOn(window, 'open').and.returnValue(mockWindow as any);
+
+ // Call print method
+ editor.print();
+
+ // Verify window.open was called
+ expect(window.open).toHaveBeenCalled();
+
+ // Restore original function
+ window.open = originalOpen;
+ done();
+ });
+ });
+ });
+});
diff --git a/controls/blockeditor/spec/actions/undo.spec.ts b/controls/blockeditor/spec/actions/undo.spec.ts
new file mode 100644
index 0000000000..eeb7991f21
--- /dev/null
+++ b/controls/blockeditor/spec/actions/undo.spec.ts
@@ -0,0 +1,1814 @@
+import { createElement, remove } from '@syncfusion/ej2-base';
+import { BlockType, ContentType } from '../../src/blockeditor/base/enums';
+import { BlockModel } from '../../src/blockeditor/models';
+import { BlockEditor, setSelectionRange, getBlockContentElement, setCursorPosition } from '../../src/index';
+import { createEditor } from '../common/util.spec';
+
+
+describe('UndoRedo', () => {
+ let editor: BlockEditor;
+ let block1: HTMLElement, block2: HTMLElement, block3: HTMLElement, block4: HTMLElement, block5: HTMLElement;
+ let editorElement: HTMLElement;
+
+ function triggerUndo(editorElement: HTMLElement) : void {
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' }));
+ }
+
+ function triggerRedo(editorElement: HTMLElement) : void {
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'y', ctrlKey: true, code: 'KeyY' }));
+ }
+
+ describe('ContentChanged', () => {
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'block1', type: BlockType.Paragraph, content: [{ id: 'paragraph-content1', type: ContentType.Text, content: 'Block 1 content' }] },
+ { id: 'block2', type: BlockType.Paragraph, content: [{ id: 'paragraph-content2', type: ContentType.Text, content: 'Block 2 content' }] },
+ { id: 'block3', type: BlockType.Paragraph, content: [{ id: 'paragraph-content3', type: ContentType.Text, content: 'Block 3 content' }] }
+ ];
+
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+
+ block1 = document.getElementById('block1');
+ block2 = document.getElementById('block2');
+ block3 = document.getElementById('block3');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('Undo action for content changed', (done) => {
+
+ const paragraph = editorElement.querySelector('#paragraph-content1');
+ paragraph.textContent = 'Updated content';
+ editor.updateContentOnUserTyping((paragraph.closest('.e-block') as HTMLElement));
+
+ setTimeout(() => {
+ editor.setFocusToBlock(paragraph.closest('.e-block') as HTMLElement);
+ // check updated block content before undo action
+ expect(paragraph.textContent).toBe('Updated content');
+ expect(editor.blocks[0].content[0].content).toBe('Updated content');
+ const undoEvent: KeyboardEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ expect(paragraph.textContent).toBe('Block 1 content');
+ expect(editor.blocks[0].content[0].content).toBe('Block 1 content');
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(3);
+ expect(updatedBlocks[0].textContent).toContain('Block 1 content');
+ expect(updatedBlocks[1].textContent).toContain('Block 2 content');
+ expect(updatedBlocks[2].textContent).toContain('Block 3 content');
+ done();
+ }, 10);
+ });
+
+ it('Redo action for content changed', (done) => {
+
+ const paragraph = editorElement.querySelector('#paragraph-content1');
+ paragraph.textContent = 'Updated content';
+ editor.updateContentOnUserTyping((paragraph.closest('.e-block') as HTMLElement));
+
+ setTimeout(() => {
+ editor.setFocusToBlock(paragraph.closest('.e-block') as HTMLElement);
+ // check updated block content before undo action
+ expect(paragraph.textContent).toBe('Updated content');
+ expect(editor.blocks[0].content[0].content).toBe('Updated content');
+ const undoEvent: KeyboardEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ // check updated block content after undo action
+ expect(paragraph.textContent).toBe('Block 1 content');
+ expect(editor.blocks[0].content[0].content).toBe('Block 1 content');
+ let updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(3);
+ expect(updatedBlocks[0].textContent).toContain('Block 1 content');
+ expect(updatedBlocks[1].textContent).toContain('Block 2 content');
+ expect(updatedBlocks[2].textContent).toContain('Block 3 content');
+ const redoEvent: KeyboardEvent = new KeyboardEvent('keydown', { key: 'y', ctrlKey: true, code: 'KeyY' });
+ editorElement.dispatchEvent(redoEvent);
+ // check updated block content after redo action
+ expect(paragraph.textContent).toBe('Updated content');
+ expect(editor.blocks[0].content[0].content).toBe('Updated content');
+ updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(3);
+ expect(updatedBlocks[0].textContent).toContain('Updated content');
+ expect(updatedBlocks[1].textContent).toContain('Block 2 content');
+ expect(updatedBlocks[2].textContent).toContain('Block 3 content');
+ done();
+ }, 10);
+ });
+
+ it('Undo action when stack length is 0', (done) => {
+ const paragraph = editorElement.querySelector('#paragraph-content1');
+ setTimeout(() => {
+ editor.setFocusToBlock(paragraph.closest('.e-block') as HTMLElement);
+ const undoEvent: KeyboardEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ expect(paragraph.textContent).toBe('Block 1 content');
+ expect(editor.blocks[0].content[0].content).toBe('Block 1 content');
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(3);
+ expect(updatedBlocks[0].textContent).toContain('Block 1 content');
+ expect(updatedBlocks[1].textContent).toContain('Block 2 content');
+ expect(updatedBlocks[2].textContent).toContain('Block 3 content');
+ done();
+ }, 10);
+ });
+
+ it('Redo action when stack length is 0', (done) => {
+ const paragraph = editorElement.querySelector('#paragraph-content1');
+ setTimeout(() => {
+ editor.setFocusToBlock(paragraph.closest('.e-block') as HTMLElement);
+ const undoEvent: KeyboardEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ // no content changes after undo as stack is empty
+ expect(paragraph.textContent).toBe('Block 1 content');
+ expect(editor.blocks[0].content[0].content).toBe('Block 1 content');
+ let updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(3);
+ expect(updatedBlocks[0].textContent).toContain('Block 1 content');
+ expect(updatedBlocks[1].textContent).toContain('Block 2 content');
+ expect(updatedBlocks[2].textContent).toContain('Block 3 content');
+ const redoEvent: KeyboardEvent = new KeyboardEvent('keydown', { key: 'y', ctrlKey: true, code: 'KeyY' });
+ editorElement.dispatchEvent(redoEvent);
+ // no content changes after redo as stack is empty
+ expect(paragraph.textContent).toBe('Block 1 content');
+ expect(editor.blocks[0].content[0].content).toBe('Block 1 content');
+ updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(3);
+ expect(updatedBlocks[0].textContent).toContain('Block 1 content');
+ expect(updatedBlocks[1].textContent).toContain('Block 2 content');
+ expect(updatedBlocks[2].textContent).toContain('Block 3 content');
+ done();
+ }, 10);
+ });
+
+ it('Undo action for content split', (done) => {
+ const blockElement = editorElement.querySelector('#block1') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ setTimeout(() => {
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ setCursorPosition(contentElement, 6);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ expect(editor.blocks.length).toBe(4);
+ expect(editor.blocks[0].content[0].content).toBe('Block ');
+ expect(editor.blocks[1].content[0].content).toBe('1 content');
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(4);
+ expect(editorElement.querySelectorAll('.e-block')[0].textContent).toBe('Block ');
+ expect(editorElement.querySelectorAll('.e-block')[1].textContent).toBe('1 content');
+ const undoEvent: KeyboardEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ expect(editor.blocks.length).toBe(3);
+ expect(editor.blocks[0].content[0].content).toBe('Block 1 content');
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(3);
+ expect(updatedBlocks[0].textContent).toContain('Block 1 content');
+ expect(updatedBlocks[1].textContent).toContain('Block 2 content');
+ expect(updatedBlocks[2].textContent).toContain('Block 3 content');
+ done();
+ }, 10);
+ });
+
+ it('Redo action for content split', (done) => {
+ const blockElement = editorElement.querySelector('#block1') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ setTimeout(() => {
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ setCursorPosition(contentElement, 6);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ expect(editor.blocks.length).toBe(4);
+ expect(editor.blocks[0].content[0].content).toBe('Block ');
+ expect(editor.blocks[1].content[0].content).toBe('1 content');
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(4);
+ expect(editorElement.querySelectorAll('.e-block')[0].textContent).toBe('Block ');
+ expect(editorElement.querySelectorAll('.e-block')[1].textContent).toBe('1 content');
+ const undoEvent: KeyboardEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ expect(editor.blocks.length).toBe(3);
+ expect(editor.blocks[0].content[0].content).toBe('Block 1 content');
+ const updatedBlocks = editor.element.querySelectorAll('.e-block');
+ expect(updatedBlocks.length).toBe(3);
+ expect(updatedBlocks[0].textContent).toContain('Block 1 content');
+ expect(updatedBlocks[1].textContent).toContain('Block 2 content');
+ expect(updatedBlocks[2].textContent).toContain('Block 3 content');
+ const redoEvent: KeyboardEvent = new KeyboardEvent('keydown', { key: 'y', ctrlKey: true, code: 'KeyY' });
+ editorElement.dispatchEvent(redoEvent);
+ expect(editor.blocks.length).toBe(4);
+ expect(editor.blocks[0].content[0].content).toBe('Block ');
+ expect(editor.blocks[1].content[0].content).toBe('1 content');
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(4);
+ expect(editorElement.querySelectorAll('.e-block')[0].textContent).toBe('Block ');
+ expect(editorElement.querySelectorAll('.e-block')[1].textContent).toBe('1 content');
+ done();
+ }, 10);
+ });
+ });
+
+ describe('Formatting Action', () => {
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'block1', type: BlockType.Paragraph, content: [{ id: 'paragraph-content1', type: ContentType.Text, content: 'Block 1 content' }] },
+ {
+ id: 'callout', type: BlockType.Callout, children: [
+ {
+ id: 'callout-block-1', type: BlockType.Paragraph, content: [{ id: 'callout-content-1', type: ContentType.Text, content: 'Callout item 1' }],
+ }
+ ]
+ }
+ ];
+
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+
+ block1 = document.getElementById('block1');
+ block2 = document.getElementById('callout');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('Undo action for paragraph content formatted to bold', (done) => {
+ setTimeout(() => {
+ const blockElement = editorElement.querySelector('#block1') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ const contentElement = getBlockContentElement(blockElement);
+ //Select range of text(world) and apply bold formatting
+ setSelectionRange((contentElement.lastChild as HTMLElement), 0, 6);
+ editor.formattingAction.execCommand({ command: 'bold' });
+ expect(contentElement.childElementCount).toBe(2);
+ expect(contentElement.querySelector('strong').textContent).toBe('Block ');
+ expect(contentElement.querySelector('span').textContent).toBe('1 content');
+ expect(editor.blocks[0].content[0].styles.bold).toBe(true);
+ const undoEvent: KeyboardEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ // check updated block content after undo action
+ expect(contentElement.childElementCount).toBe(0);
+ expect(contentElement.textContent).toBe('Block 1 content');
+ expect(editor.blocks[0].content[0].styles.bold).toBe(false);
+ done();
+ }, 10);
+ });
+
+ it('Redo action for paragraph content formatted to bold', (done) => {
+ setTimeout(() => {
+ const blockElement = editorElement.querySelector('#block1') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ const contentElement = getBlockContentElement(blockElement);
+ //Select range of text(world) and apply bold formatting
+ setSelectionRange((contentElement.lastChild as HTMLElement), 0, 6);
+ editor.formattingAction.execCommand({ command: 'bold' });
+ expect(contentElement.childElementCount).toBe(2);
+ expect(contentElement.querySelector('strong').textContent).toBe('Block ');
+ expect(contentElement.querySelector('span').textContent).toBe('1 content');
+ expect(editor.blocks[0].content[0].styles.bold).toBe(true);
+ const undoEvent: KeyboardEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ // check updated block content after undo action
+ expect(contentElement.childElementCount).toBe(0);
+ expect(contentElement.textContent).toBe('Block 1 content');
+ expect(editor.blocks[0].content[0].styles.bold).toBe(false);
+
+ const redoEvent: KeyboardEvent = new KeyboardEvent('keydown', { key: 'y', ctrlKey: true, code: 'KeyY' });
+ editorElement.dispatchEvent(redoEvent);
+ // check updated block content after redo action
+ expect(contentElement.childElementCount).toBe(2);
+ expect(contentElement.querySelector('strong').textContent).toBe('Block ');
+ expect(contentElement.querySelector('span').textContent).toBe('1 content');
+ expect(editor.blocks[0].content[0].styles.bold).toBe(true);
+ done();
+ }, 10);
+ });
+
+ it('Undo action for content formatted to bold in callout', (done) => {
+ setTimeout(() => {
+ const blockElement = editorElement.querySelector('#callout') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ const contentElement = getBlockContentElement(blockElement);
+ //Select range of text(world) and apply bold formatting
+ setSelectionRange((contentElement.lastChild as HTMLElement), 0, 8);
+ editor.formattingAction.execCommand({ command: 'bold' });
+ expect(contentElement.childElementCount).toBe(2);
+ expect(contentElement.querySelector('strong').textContent).toBe('Callout ');
+ expect(contentElement.querySelector('span').textContent).toBe('item 1');
+ expect(editor.blocks[1].children[0].content[0].styles.bold).toBe(true);
+ const undoEvent: KeyboardEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ // check updated block content after undo action
+ expect(contentElement.childElementCount).toBe(0);
+ expect(contentElement.textContent).toBe('Callout item 1');
+ expect(editor.blocks[1].children[0].content[0].styles.bold).toBe(false);
+ done();
+ }, 10);
+ });
+
+ it('Redo action for content formatted to bold in callout', (done) => {
+ setTimeout(() => {
+ const blockElement = editorElement.querySelector('#callout') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ const contentElement = getBlockContentElement(blockElement);
+ //Select range of text(world) and apply bold formatting
+ setSelectionRange((contentElement.lastChild as HTMLElement), 0, 8);
+ editor.formattingAction.execCommand({ command: 'bold' });
+ expect(contentElement.childElementCount).toBe(2);
+ expect(contentElement.querySelector('strong').textContent).toBe('Callout ');
+ expect(contentElement.querySelector('span').textContent).toBe('item 1');
+ expect(editor.blocks[1].children[0].content[0].styles.bold).toBe(true);
+ const undoEvent: KeyboardEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ // check updated block content after undo action
+ expect(contentElement.childElementCount).toBe(0);
+ expect(contentElement.textContent).toBe('Callout item 1');
+ expect(editor.blocks[1].children[0].content[0].styles.bold).toBe(false);
+
+ const redoEvent: KeyboardEvent = new KeyboardEvent('keydown', { key: 'y', ctrlKey: true, code: 'KeyY' });
+ editorElement.dispatchEvent(redoEvent);
+ expect(contentElement.childElementCount).toBe(2);
+ expect(contentElement.querySelector('strong').textContent).toBe('Callout ');
+ expect(contentElement.querySelector('span').textContent).toBe('item 1');
+ expect(editor.blocks[1].children[0].content[0].styles.bold).toBe(true);
+ done();
+ }, 10);
+ });
+ });
+
+ describe('Block Addition', () => {
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'block1', type: BlockType.Paragraph, content: [{ id: 'paragraph-content1', type: ContentType.Text, content: 'Block 1 content' }] },
+ { id: 'block2', type: BlockType.Paragraph, content: [{ id: 'paragraph-content2', type: ContentType.Text, content: 'Block 2 content' }] },
+ ];
+
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+
+ block1 = document.getElementById('block1');
+ block2 = document.getElementById('block2');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('Undo action for single block addition', (done) => {
+ // Initial block count
+ const initialBlockCount = editor.blocks.length;
+ expect(initialBlockCount).toBe(2);
+
+ const newBlock = {
+ id: 'block3',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'content3', type: ContentType.Text, content: 'Block 3 content' }
+ ]
+ };
+
+ setTimeout(() => {
+ editor.blockEditorMethods.addBlock(newBlock, 'block2', true);
+ // Check if block was added
+ expect(editor.blocks.length).toBe(initialBlockCount + 1);
+ expect(editor.blocks[2].id).toBe('block3');
+
+ // Undo the block addition
+ const undoEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ // Check if block was removed after undo
+ expect(editor.blocks.length).toBe(initialBlockCount);
+
+ // Check DOM update
+ const blocks = editorElement.querySelectorAll('.e-block');
+ expect(blocks.length).toBe(initialBlockCount);
+ expect(blocks[0].id).toBe('block1');
+ expect(blocks[1].id).toBe('block2');
+ expect(document.getElementById('block3')).toBeNull();
+ done();
+ }, 10);
+ });
+
+ it('Redo action for single block addition', (done) => {
+ // Initial block count
+ const initialBlockCount = editor.blocks.length;
+ expect(initialBlockCount).toBe(2);
+
+ const newBlock = {
+ id: 'block3',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'content3', type: ContentType.Text, content: 'Block 3 content' }
+ ]
+ };
+
+ setTimeout(() => {
+ editor.blockEditorMethods.addBlock(newBlock, 'block2', true);
+ // Check if block was added
+ expect(editor.blocks.length).toBe(initialBlockCount + 1);
+ expect(editor.blocks[2].id).toBe('block3');
+
+ // Undo the block addition
+ const undoEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+
+ // Check if block was removed after undo
+ expect(editor.blocks.length).toBe(initialBlockCount);
+
+ // Redo the block addition
+ const redoEvent = new KeyboardEvent('keydown', { key: 'y', ctrlKey: true, code: 'KeyY' });
+ editorElement.dispatchEvent(redoEvent);
+
+ // Check if block was added back after redo
+ expect(editor.blocks.length).toBe(initialBlockCount + 1);
+ expect(editor.blocks[2].id).toBe('block3');
+
+ // Check DOM update
+ const blocks = editorElement.querySelectorAll('.e-block');
+ expect(blocks.length).toBe(initialBlockCount + 1);
+ expect(blocks[0].id).toBe('block1');
+ expect(blocks[1].id).toBe('block2');
+ expect(blocks[2].id).toBe('block3');
+ expect(document.getElementById('block3')).not.toBeNull();
+ expect(blocks[2].textContent).toContain('Block 3 content');
+
+ done();
+ }, 10);
+ });
+
+ it('Undo action for multiple block addition', (done) => {
+ // Initial block count
+ const initialBlockCount = editor.blocks.length;
+ expect(initialBlockCount).toBe(2);
+
+ const newBlock1 = {
+ id: 'block3',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'content3', type: ContentType.Text, content: 'Block 3 content' }
+ ]
+ };
+
+ setTimeout(() => {
+ editor.blockEditorMethods.addBlock(newBlock1, 'block2', true);
+ const newBlock2 = {
+ id: 'block4',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'content4', type: ContentType.Text, content: 'Block 4 content' }
+ ]
+ };
+
+ editor.blockEditorMethods.addBlock(newBlock2, 'block3', true);
+ // Check if both blocks were added
+ expect(editor.blocks.length).toBe(initialBlockCount + 2);
+ expect(editor.blocks[2].id).toBe('block3');
+ expect(editor.blocks[3].id).toBe('block4');
+
+ // Undo the last block addition (block4)
+ const undoEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+
+ // Check if block4 was removed
+ expect(editor.blocks.length).toBe(initialBlockCount + 1);
+ expect(editor.blocks[2].id).toBe('block3');
+ expect(document.getElementById('block4')).toBeNull();
+
+ // Undo the first block addition (block3)
+ editorElement.dispatchEvent(undoEvent);
+
+ // Check if block3 was removed
+ expect(editor.blocks.length).toBe(initialBlockCount);
+ expect(document.getElementById('block3')).toBeNull();
+
+ // Check DOM update
+ const blocks = editorElement.querySelectorAll('.e-block');
+ expect(blocks.length).toBe(initialBlockCount);
+ expect(blocks[0].id).toBe('block1');
+ expect(blocks[1].id).toBe('block2');
+
+ done();
+ }, 10);
+ });
+
+ it('Redo action for multiple block addition', (done) => {
+ // Initial block count
+ const initialBlockCount = editor.blocks.length;
+ expect(initialBlockCount).toBe(2);
+
+ // Add block3
+ const newBlock1 = {
+ id: 'block3',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'content3', type: ContentType.Text, content: 'Block 3 content' }
+ ]
+ };
+
+ setTimeout(() => {
+ editor.blockEditorMethods.addBlock(newBlock1, 'block2', true);
+ const newBlock2 = {
+ id: 'block4',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'content4', type: ContentType.Text, content: 'Block 4 content' }
+ ]
+ };
+
+ editor.blockEditorMethods.addBlock(newBlock2, 'block3', true);
+ // Check if both blocks were added
+ expect(editor.blocks.length).toBe(initialBlockCount + 2);
+ expect(editor.blocks[2].id).toBe('block3');
+ expect(editor.blocks[3].id).toBe('block4');
+
+ // Undo twice to remove both blocks
+ const undoEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+
+ editorElement.dispatchEvent(undoEvent);
+
+ // Check if both blocks were removed
+ expect(editor.blocks.length).toBe(initialBlockCount);
+ expect(document.getElementById('block3')).toBeNull();
+ expect(document.getElementById('block4')).toBeNull();
+
+ // Redo to add block3 back
+ const redoEvent = new KeyboardEvent('keydown', { key: 'y', ctrlKey: true, code: 'KeyY' });
+ editorElement.dispatchEvent(redoEvent);
+
+ // Check if block3 was added back
+ expect(editor.blocks.length).toBe(initialBlockCount + 1);
+ expect(editor.blocks[2].id).toBe('block3');
+ expect(document.getElementById('block3')).not.toBeNull();
+ expect(document.getElementById('block3').textContent).toContain('Block 3 content');
+
+ // Redo to add block4 back
+ editorElement.dispatchEvent(redoEvent);
+
+ // Check if block4 was added back
+ expect(editor.blocks.length).toBe(initialBlockCount + 2);
+ expect(editor.blocks[3].id).toBe('block4');
+ expect(document.getElementById('block4')).not.toBeNull();
+ expect(document.getElementById('block4').textContent).toContain('Block 4 content');
+
+ // Check final DOM state
+ const blocks = editorElement.querySelectorAll('.e-block');
+ expect(blocks.length).toBe(initialBlockCount + 2);
+ expect(blocks[0].id).toBe('block1');
+ expect(blocks[1].id).toBe('block2');
+ expect(blocks[2].id).toBe('block3');
+ expect(blocks[3].id).toBe('block4');
+ done();
+ }, 10);
+ });
+ });
+
+ describe('Block Removal', () => {
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'block1', type: BlockType.Paragraph, content: [{ id: 'paragraph-content1', type: ContentType.Text, content: 'Block 1 content' }] },
+ { id: 'block2', type: BlockType.Paragraph, content: [{ id: 'paragraph-content2', type: ContentType.Text, content: 'Block 2 content' }] },
+ { id: 'block3', type: BlockType.Paragraph, content: [{ id: 'paragraph-content3', type: ContentType.Text, content: 'Block 3 content' }] },
+ { id: 'block4', type: BlockType.Paragraph, content: [{ id: 'paragraph-content4', type: ContentType.Text, content: 'Block 4 content' }] },
+ ];
+
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+
+ block1 = document.getElementById('block1');
+ block2 = document.getElementById('block2');
+ block3 = document.getElementById('block3');
+ block4 = document.getElementById('block4');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('Undo action for single block deletion', (done) => {
+ // Initial block count
+ const initialBlockCount = editor.blocks.length;
+ expect(initialBlockCount).toBe(4);
+ setTimeout(() => {
+ editor.blockEditorMethods.removeBlock('block4');
+ // Check if block was removed
+ expect(editor.blocks.length).toBe(initialBlockCount - 1);
+ expect(editor.blocks[editor.blocks.length - 1].id).toBe('block3');
+ expect(document.getElementById('block4')).toBeNull();
+ // Undo the block removed
+ const undoEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ // Check if block was added after undo
+ expect(editor.blocks.length).toBe(initialBlockCount);
+
+ // Check DOM update
+ const blocks = editorElement.querySelectorAll('.e-block');
+ expect(blocks.length).toBe(initialBlockCount);
+ expect(blocks[0].id).toBe('block1');
+ expect(blocks[1].id).toBe('block2');
+ expect(blocks[2].id).toBe('block3');
+ expect(blocks[3].id).toBe('block4');
+ expect(document.getElementById('block4')).not.toBeNull();
+ done();
+ }, 10);
+ });
+
+ it('Redo action for single block addition', (done) => {
+ // Initial block count
+ const initialBlockCount = editor.blocks.length;
+ expect(initialBlockCount).toBe(4);
+
+ setTimeout(() => {
+ editor.blockEditorMethods.removeBlock('block4');
+ // Check if block was removed
+ expect(editor.blocks.length).toBe(initialBlockCount - 1);
+ expect(editor.blocks[editor.blocks.length - 1].id).toBe('block3');
+ expect(document.getElementById('block4')).toBeNull();
+ // Undo the block removed
+ const undoEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ // Check if block was added after undo
+ expect(editor.blocks.length).toBe(initialBlockCount);
+
+ // again remove the block to test redo
+ const redoEvent = new KeyboardEvent('keydown', { key: 'y', ctrlKey: true, code: 'KeyY' });
+ editorElement.dispatchEvent(redoEvent);
+
+ // Check if block was removed after redo
+ expect(editor.blocks.length).toBe(initialBlockCount - 1);
+ expect(editor.blocks[editor.blocks.length - 1].id).toBe('block3');
+ expect(document.getElementById('block4')).toBeNull();
+
+ // Check DOM update
+ const blocks = editorElement.querySelectorAll('.e-block');
+ expect(blocks.length).toBe(initialBlockCount - 1);
+ expect(blocks[0].id).toBe('block1');
+ expect(blocks[1].id).toBe('block2');
+ expect(blocks[2].id).toBe('block3');
+ expect(document.getElementById('block4')).toBeNull();
+ done();
+ }, 10);
+ });
+
+ it('Undo action for multiple block deletion', (done) => {
+ // Initial block count
+ const initialBlockCount = editor.blocks.length;
+ expect(initialBlockCount).toBe(4);
+
+ setTimeout(() => {
+ // remove block3 and block4
+ editor.blockEditorMethods.removeBlock('block4');
+ editor.blockEditorMethods.removeBlock('block3');
+ // Check if both blocks were removed
+ expect(editor.blocks.length).toBe(initialBlockCount - 2);
+ expect(document.getElementById('block4')).toBeNull();
+ expect(document.getElementById('block3')).toBeNull();
+
+ // Undo it to add the last removed block
+ const undoEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+
+ // Check if block3 was added
+ expect(editor.blocks.length).toBe(initialBlockCount - 1);
+ expect(editor.blocks[2].id).toBe('block3');
+ expect(document.getElementById('block3')).not.toBeNull();
+ expect(document.getElementById('block4')).toBeNull();
+
+ // add the block4 again by undo
+ editorElement.dispatchEvent(undoEvent);
+
+ // Check if block4 was added
+ expect(editor.blocks.length).toBe(initialBlockCount);
+ expect(document.getElementById('block4')).not.toBeNull();
+
+ // Check DOM update
+ const blocks = editorElement.querySelectorAll('.e-block');
+ expect(blocks.length).toBe(initialBlockCount);
+ expect(blocks[0].id).toBe('block1');
+ expect(blocks[1].id).toBe('block2');
+ expect(blocks[2].id).toBe('block3');
+ expect(blocks[3].id).toBe('block4');
+ expect(document.getElementById('block4')).not.toBeNull();
+ expect(document.getElementById('block3')).not.toBeNull();
+ done();
+ }, 10);
+ });
+
+ it('Redo action for multiple block deletion', (done) => {
+ // Initial block count
+ const initialBlockCount = editor.blocks.length;
+ expect(initialBlockCount).toBe(4);
+
+ setTimeout(() => {
+ // remove block3 and block4
+ editor.blockEditorMethods.removeBlock('block4');
+ editor.blockEditorMethods.removeBlock('block3');
+ // Check if both blocks were removed
+ expect(editor.blocks.length).toBe(initialBlockCount - 2);
+ expect(document.getElementById('block4')).toBeNull();
+ expect(document.getElementById('block3')).toBeNull();
+
+ // Undo it to add the last removed block
+ const undoEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+
+ // Check if block3 was added
+ expect(editor.blocks.length).toBe(initialBlockCount - 1);
+ expect(editor.blocks[2].id).toBe('block3');
+ expect(document.getElementById('block3')).not.toBeNull();
+ expect(document.getElementById('block4')).toBeNull();
+
+ // add the block4 again by undo
+ editorElement.dispatchEvent(undoEvent);
+
+ // Check if block4 was added
+ expect(editor.blocks.length).toBe(initialBlockCount);
+ expect(document.getElementById('block4')).not.toBeNull();
+
+ onemptied
+ // Redo it to remove the last added block
+ const redoEvent = new KeyboardEvent('keydown', { key: 'y', ctrlKey: true, code: 'KeyY' });
+ editorElement.dispatchEvent(redoEvent);
+
+ // Check if block4 was removed
+ expect(editor.blocks.length).toBe(initialBlockCount - 1);
+ expect(editor.blocks[editor.blocks.length - 1].id).toBe('block3');
+ expect(document.getElementById('block3')).not.toBeNull();
+ expect(document.getElementById('block4')).toBeNull();
+
+ // remove the block3 again by redo
+ editorElement.dispatchEvent(redoEvent);
+
+ // Check if block3 was removed
+ expect(editor.blocks.length).toBe(initialBlockCount - 2);
+ expect(document.getElementById('block3')).toBeNull();
+ expect(document.getElementById('block4')).toBeNull();
+
+ // Check final DOM state
+ const blocks = editorElement.querySelectorAll('.e-block');
+ expect(blocks.length).toBe(initialBlockCount - 2);
+ expect(blocks[0].id).toBe('block1');
+ expect(blocks[1].id).toBe('block2');
+ expect(document.getElementById('block3')).toBeNull();
+ expect(document.getElementById('block4')).toBeNull();
+ done();
+ }, 10);
+ });
+ });
+
+ describe('Block Move', () => {
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'block1', type: BlockType.Paragraph, content: [{ id: 'paragraph-content1', type: ContentType.Text, content: 'Block 1 content' }] },
+ { id: 'block2', type: BlockType.Paragraph, content: [{ id: 'paragraph-content2', type: ContentType.Text, content: 'Block 2 content' }] },
+ { id: 'block3', type: BlockType.Paragraph, content: [{ id: 'paragraph-content3', type: ContentType.Text, content: 'Block 3 content' }] },
+ { id: 'block4', type: BlockType.Paragraph, content: [{ id: 'paragraph-content4', type: ContentType.Text, content: 'Block 4 content' }] },
+ ];
+
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+
+ block1 = document.getElementById('block1');
+ block2 = document.getElementById('block2');
+ block3 = document.getElementById('block3');
+ block4 = document.getElementById('block4');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('Undo action for single block Move', (done) => {
+ // Initial block count
+ const initialBlockCount = editor.blocks.length;
+ expect(initialBlockCount).toBe(4);
+ setTimeout(() => {
+ editor.blockAction.moveBlock({
+ fromBlockIds: ['block1'],
+ toBlockId: 'block2'
+ });
+ // Check if block was moved
+ expect(editor.blocks.length).toBe(initialBlockCount);
+ expect(editor.blocks[0].id).toBe('block2');
+ expect(editor.blocks[1].id).toBe('block1');
+ expect(editor.blocks[2].id).toBe('block3');
+ expect(editor.blocks[3].id).toBe('block4');
+ // Undo the block moved
+ const undoEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ expect(editor.blocks.length).toBe(initialBlockCount);
+ expect(editor.blocks[0].id).toBe('block1');
+ expect(editor.blocks[1].id).toBe('block2');
+ expect(editor.blocks[2].id).toBe('block3');
+ expect(editor.blocks[3].id).toBe('block4');
+ // Check DOM update
+ const blocks = editorElement.querySelectorAll('.e-block');
+ expect(blocks.length).toBe(initialBlockCount);
+ expect(blocks[0].id).toBe('block1');
+ expect(blocks[1].id).toBe('block2');
+ expect(blocks[2].id).toBe('block3');
+ expect(blocks[3].id).toBe('block4');
+ done();
+ }, 10);
+ });
+
+ it('Redo action for single block Move', (done) => {
+ // Initial block count
+ const initialBlockCount = editor.blocks.length;
+ expect(initialBlockCount).toBe(4);
+
+ setTimeout(() => {
+ editor.blockAction.moveBlock({
+ fromBlockIds: ['block1'],
+ toBlockId: 'block2'
+ });
+ // Check if block was moved
+ expect(editor.blocks.length).toBe(initialBlockCount);
+ expect(editor.blocks[0].id).toBe('block2');
+ expect(editor.blocks[1].id).toBe('block1');
+ expect(editor.blocks[2].id).toBe('block3');
+ expect(editor.blocks[3].id).toBe('block4');
+ // Undo the block moved
+ const undoEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ expect(editor.blocks.length).toBe(initialBlockCount);
+
+ expect(editor.blocks[0].id).toBe('block1');
+ expect(editor.blocks[1].id).toBe('block2');
+ expect(editor.blocks[2].id).toBe('block3');
+ expect(editor.blocks[3].id).toBe('block4');
+
+ // Check DOM update
+ let blocks = editorElement.querySelectorAll('.e-block');
+ expect(blocks.length).toBe(initialBlockCount);
+ expect(blocks[0].id).toBe('block1');
+ expect(blocks[1].id).toBe('block2');
+ expect(blocks[2].id).toBe('block3');
+ expect(blocks[3].id).toBe('block4');
+
+ // Redo action
+ const redoEvent = new KeyboardEvent('keydown', { key: 'y', ctrlKey: true, code: 'KeyY' });
+ editorElement.dispatchEvent(redoEvent);
+
+ // move blocks again by redoing it
+ expect(editor.blocks.length).toBe(initialBlockCount);
+ expect(editor.blocks[0].id).toBe('block2');
+ expect(editor.blocks[1].id).toBe('block1');
+ expect(editor.blocks[2].id).toBe('block3');
+ expect(editor.blocks[3].id).toBe('block4');
+
+ // Check DOM update
+ blocks = editorElement.querySelectorAll('.e-block');
+ expect(blocks.length).toBe(initialBlockCount);
+ expect(blocks[0].id).toBe('block2');
+ expect(blocks[1].id).toBe('block1');
+ expect(blocks[2].id).toBe('block3');
+ expect(blocks[3].id).toBe('block4');
+ done();
+ }, 10);
+ });
+
+ it('Undo action for multiple block move', (done) => {
+ // Initial block count
+ const initialBlockCount = editor.blocks.length;
+ expect(initialBlockCount).toBe(4);
+
+ setTimeout(() => {
+ editor.blockAction.moveBlock({
+ fromBlockIds: ['block1', 'block2'],
+ toBlockId: 'block3'
+ });
+ // Check if block was moved
+ expect(editor.blocks.length).toBe(initialBlockCount);
+ expect(editor.blocks[0].id).toBe('block3');
+ expect(editor.blocks[1].id).toBe('block1');
+ expect(editor.blocks[2].id).toBe('block2');
+ expect(editor.blocks[3].id).toBe('block4');
+ // Undo the block moved
+ const undoEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ expect(editor.blocks.length).toBe(initialBlockCount);
+
+ expect(editor.blocks[0].id).toBe('block1');
+ expect(editor.blocks[1].id).toBe('block2');
+ expect(editor.blocks[2].id).toBe('block3');
+ expect(editor.blocks[3].id).toBe('block4');
+ // Check DOM update
+ const blocks = editorElement.querySelectorAll('.e-block');
+ expect(blocks.length).toBe(initialBlockCount);
+ expect(blocks[0].id).toBe('block1');
+ expect(blocks[1].id).toBe('block2');
+ expect(blocks[2].id).toBe('block3');
+ expect(blocks[3].id).toBe('block4');
+ done();
+ }, 10);
+ });
+
+ it('Redo action for multiple block move', (done) => {
+ // Initial block count
+ const initialBlockCount = editor.blocks.length;
+ expect(initialBlockCount).toBe(4);
+
+ setTimeout(() => {
+ editor.blockAction.moveBlock({
+ fromBlockIds: ['block1', 'block2'],
+ toBlockId: 'block3'
+ });
+ // Check if block was moved
+ expect(editor.blocks.length).toBe(initialBlockCount);
+ expect(editor.blocks[0].id).toBe('block3');
+ expect(editor.blocks[1].id).toBe('block1');
+ expect(editor.blocks[2].id).toBe('block2');
+ expect(editor.blocks[3].id).toBe('block4');
+ // Undo the block moved
+ const undoEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ expect(editor.blocks.length).toBe(initialBlockCount);
+
+ expect(editor.blocks[0].id).toBe('block1');
+ expect(editor.blocks[1].id).toBe('block2');
+ expect(editor.blocks[2].id).toBe('block3');
+ expect(editor.blocks[3].id).toBe('block4');
+
+ // Check DOM update
+ let blocks = editorElement.querySelectorAll('.e-block');
+ expect(blocks.length).toBe(initialBlockCount);
+ expect(blocks[0].id).toBe('block1');
+ expect(blocks[1].id).toBe('block2');
+ expect(blocks[2].id).toBe('block3');
+ expect(blocks[3].id).toBe('block4');
+
+ // Redo action
+ const redoEvent = new KeyboardEvent('keydown', { key: 'y', ctrlKey: true, code: 'KeyY' });
+ editorElement.dispatchEvent(redoEvent);
+
+ // move blocks again by redoing it
+ expect(editor.blocks.length).toBe(initialBlockCount);
+ expect(editor.blocks[0].id).toBe('block3');
+ expect(editor.blocks[1].id).toBe('block1');
+ expect(editor.blocks[2].id).toBe('block2');
+ expect(editor.blocks[3].id).toBe('block4');
+
+ // Check DOM update
+ blocks = editorElement.querySelectorAll('.e-block');
+ expect(blocks.length).toBe(initialBlockCount);
+ expect(blocks[0].id).toBe('block3');
+ expect(blocks[1].id).toBe('block1');
+ expect(blocks[2].id).toBe('block2');
+ expect(blocks[3].id).toBe('block4');
+ done();
+ }, 10);
+ });
+ });
+
+ describe('Transform blocks using slash command', () => {
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ {
+ id: 'block1',
+ type: BlockType.Paragraph,
+ content: [{ id: 'paragraph-content', type: ContentType.Text, content: 'Hello world' }]
+ }
+ ];
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+ block1 = document.getElementById('block1');
+ });
+ beforeEach((done: DoneFn) => done());
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('transforming block paragraph to heading -> Undo', (done) => {
+ const blockElement = block1;
+ expect(blockElement).not.toBeNull();
+ const contentElement = getBlockContentElement(blockElement);
+ setCursorPosition(contentElement, 0);
+ contentElement.textContent = '/' + contentElement.textContent;
+ setCursorPosition(contentElement, 1);
+ editorElement.querySelector('.e-mention.e-editable-element').dispatchEvent(new KeyboardEvent('keyup', { key: '/', code: 'Slash', bubbles: true }));
+ setTimeout(() => {
+ const slashCommandElement = document.querySelector('.e-popup.e-blockeditor-command-menu') as HTMLElement;
+ expect(slashCommandElement).not.toBeNull();
+ // click heading li element inside the popup
+ const headingElement = slashCommandElement.querySelector('li[data-value="Heading 1"]') as HTMLElement;
+ expect(headingElement).not.toBeNull();
+ headingElement.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
+ expect(editor.blocks[0].type).toBe(BlockType.Heading1);
+ setTimeout(() => {
+ expect(blockElement.querySelector('h1').textContent).toBe('Hello world');
+ const undoEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ expect(blockElement.querySelector('p').textContent).toBe('Hello world');
+ done();
+ }, 200)
+ }, 1000);
+ });
+
+ it('transforming block paragraph to heading -> Redo', (done) => {
+ const blockElement = editorElement.querySelector('.e-block') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ const contentElement = getBlockContentElement(blockElement);
+ setCursorPosition(contentElement, 0);
+ contentElement.textContent = '/' + contentElement.textContent;
+ setCursorPosition(contentElement, 1);
+ editorElement.querySelector('.e-mention.e-editable-element').dispatchEvent(new KeyboardEvent('keyup', { key: '/', code: 'Slash', bubbles: true }));
+ setTimeout(() => {
+ const slashCommandElement = document.querySelector('.e-popup.e-blockeditor-command-menu') as HTMLElement;
+ expect(slashCommandElement).not.toBeNull();
+ // click heading li element inside the popup
+ const headingElement = slashCommandElement.querySelector('li[data-value="Heading 1"]') as HTMLElement;
+ expect(headingElement).not.toBeNull();
+ headingElement.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
+ expect(editor.blocks[0].type).toBe(BlockType.Heading1);
+ setTimeout(() => {
+ expect(blockElement.querySelector('h1').textContent).toBe('Hello world');
+ const undoEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ expect(blockElement.querySelector('p').textContent).toBe('Hello world');
+ const redoEvent = new KeyboardEvent('keydown', { key: 'y', ctrlKey: true, code: 'KeyY' });
+ editorElement.dispatchEvent(redoEvent);
+ expect(editor.blocks[0].type).toBe(BlockType.Heading1);
+ done();
+ }, 200)
+ }, 1000);
+ });
+
+ it('transforming block into specialType -> Undo', (done) => {
+ const blockElement = editorElement.querySelector('#block1') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ const contentElement = getBlockContentElement(blockElement);
+ setTimeout(() => {
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(contentElement, blockElement.textContent.length);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[0].content[0].content).toBe('Hello world');
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(2);
+ expect(editorElement.querySelectorAll('.e-block')[0].textContent).toBe('Hello world');
+ expect(editorElement.querySelectorAll('.e-block')[1].textContent).toBe('');
+ let blocks = editorElement.querySelectorAll('.e-block');
+ const transFormBlockElement = blocks[1] as HTMLElement;
+ expect(transFormBlockElement).not.toBeNull();
+ const newContentElement = getBlockContentElement(transFormBlockElement);
+ setCursorPosition(newContentElement, 0);
+ newContentElement.textContent = '/' + newContentElement.textContent;
+ setCursorPosition(newContentElement, 1);
+ editor.updateContentOnUserTyping(transFormBlockElement);
+ editorElement.querySelector('.e-mention.e-editable-element').dispatchEvent(new KeyboardEvent('keyup', { key: '/', code: 'Slash', bubbles: true }));
+ setTimeout(() => {
+ const slashCommandElement = document.querySelector('.e-popup.e-blockeditor-command-menu') as HTMLElement;
+ expect(slashCommandElement).not.toBeNull();
+ // click heading li element inside the popup
+ const dividerEle = slashCommandElement.querySelector('li[data-value="Divider"]') as HTMLElement;
+ expect(dividerEle).not.toBeNull();
+ dividerEle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
+ expect(editor.blocks.length).toBe(3);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ expect(editor.blocks[1].type).toBe(BlockType.Divider);
+ expect(editor.blocks[2].type).toBe(BlockType.Paragraph);
+ setTimeout(() => {
+ // undo to remove the last added empty paragraph block
+ const undoEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ expect(editor.blocks[1].type).toBe(BlockType.Divider);
+ // undo to transform divider into paragraph block
+ editorElement.dispatchEvent(undoEvent);
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ expect(editor.blocks[1].type).toBe(BlockType.Paragraph);
+ // undo to remove the new block added
+ editorElement.dispatchEvent(undoEvent);
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ done();
+ }, 200)
+ }, 1000);
+ }, 100);
+ });
+
+ it('transforming block into specialType -> redo', (done) => {
+ const blockElement = editorElement.querySelector('#block1') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ const contentElement = getBlockContentElement(blockElement);
+ setTimeout(() => {
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(contentElement, blockElement.textContent.length);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[0].content[0].content).toBe('Hello world');
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(2);
+ expect(editorElement.querySelectorAll('.e-block')[0].textContent).toBe('Hello world');
+ expect(editorElement.querySelectorAll('.e-block')[1].textContent).toBe('');
+ let blocks = editorElement.querySelectorAll('.e-block');
+ const transFormBlockElement = blocks[1] as HTMLElement;
+ expect(transFormBlockElement).not.toBeNull();
+ const newContentElement = getBlockContentElement(transFormBlockElement);
+ setCursorPosition(newContentElement, 0);
+ newContentElement.textContent = '/' + newContentElement.textContent;
+ setCursorPosition(newContentElement, 1);
+ editor.updateContentOnUserTyping(transFormBlockElement);
+ editorElement.querySelector('.e-mention.e-editable-element').dispatchEvent(new KeyboardEvent('keyup', { key: '/', code: 'Slash', bubbles: true }));
+ setTimeout(() => {
+ const slashCommandElement = document.querySelector('.e-popup.e-blockeditor-command-menu') as HTMLElement;
+ expect(slashCommandElement).not.toBeNull();
+ // click heading li element inside the popup
+ const dividerEle = slashCommandElement.querySelector('li[data-value="Divider"]') as HTMLElement;
+ expect(dividerEle).not.toBeNull();
+ dividerEle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
+ expect(editor.blocks.length).toBe(3);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ expect(editor.blocks[1].type).toBe(BlockType.Divider);
+ expect(editor.blocks[2].type).toBe(BlockType.Paragraph);
+ setTimeout(() => {
+ // undo to remove the last added empty paragraph block
+ const undoEvent = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' });
+ editorElement.dispatchEvent(undoEvent);
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ expect(editor.blocks[1].type).toBe(BlockType.Divider);
+ // undo to transform divider into paragraph block
+ editorElement.dispatchEvent(undoEvent);
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ expect(editor.blocks[1].type).toBe(BlockType.Paragraph);
+ // undo to remove the new block added
+ editorElement.dispatchEvent(undoEvent);
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+
+ // Redo to remove the last added empty paragraph block
+ const redoEvent = new KeyboardEvent('keydown', { key: 'y', ctrlKey: true, code: 'KeyY' });
+ editorElement.dispatchEvent(redoEvent);
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ expect(editor.blocks[1].type).toBe(BlockType.Paragraph);
+
+ // Redo to transform paragraph into divider block
+ editorElement.dispatchEvent(redoEvent);
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ expect(editor.blocks[1].type).toBe(BlockType.Divider);
+
+ // Redo to add a new paragraph block
+ editorElement.dispatchEvent(redoEvent);
+ expect(editor.blocks.length).toBe(3);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ expect(editor.blocks[1].type).toBe(BlockType.Divider);
+ expect(editor.blocks[2].type).toBe(BlockType.Paragraph);
+ done();
+ }, 200)
+ }, 1000);
+ }, 100);
+ });
+ });
+
+ describe('Clipboard actions', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ function createMockClipboardEvent(type: string, clipboardData: any = {}): ClipboardEvent {
+ const event: any = {
+ type,
+ preventDefault: jasmine.createSpy(),
+ clipboardData: clipboardData,
+ bubbles: true,
+ cancelable: true
+ };
+ return event as ClipboardEvent;
+ }
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ {
+ id: 'paragraph1',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'paragraph1-content', type: ContentType.Text, content: 'First paragraph' }
+ ]
+ },
+ {
+ id: 'paragraph2',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'paragraph2-content', type: ContentType.Text, content: 'Second paragraph' }
+ ]
+ }
+ ];
+ editor = createEditor({ blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ }
+ document.body.removeChild(editorElement);
+ });
+
+ it('copy & paste whole block - UNDO & REDO', (done) => {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(getBlockContentElement(blockElement), 0);
+ const copiedData = editor.clipboardAction.getClipboardPayload().blockeditorData;
+
+ const mockClipboard: any = {
+ setData: jasmine.createSpy(),
+ getData: (format: string) => {
+ if (format === 'text/blockeditor') {
+ return copiedData;
+ }
+ return '';
+ }
+ };
+
+ editor.clipboardAction.handleCopy(createMockClipboardEvent('copy', mockClipboard));
+ editor.clipboardAction.handlePaste(createMockClipboardEvent('paste', mockClipboard));
+
+ setTimeout(() => {
+ expect(editor.blocks.length).toBe(3);
+ expect(editor.blocks[1].content[0].content).toBe('First paragraph');
+ expect(blockElement.nextElementSibling.id).toBe(editor.blocks[1].id);
+
+ //Trigger UNDO
+ triggerUndo(editorElement);
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[1].content[0].content).toBe('Second paragraph');
+
+ //Trigger REDO
+ triggerRedo(editorElement);
+ expect(editor.blocks.length).toBe(3);
+ expect(editor.blocks[1].content[0].content).toBe('First paragraph');
+ expect(blockElement.nextElementSibling.id).toBe(editor.blocks[1].id);
+ done();
+ }, 100);
+ });
+
+ it('cut & paste whole block', (done) => {
+ const initialBlockCount = editor.blocks.length;
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(getBlockContentElement(blockElement), 0);
+ const copiedData = editor.clipboardAction.getClipboardPayload().blockeditorData;
+
+ const mockClipboard: any = {
+ setData: jasmine.createSpy(),
+ getData: (format: string) => {
+ if (format === 'text/blockeditor') {
+ return copiedData;
+ }
+ return '';
+ }
+ };
+
+ editor.clipboardAction.handleCut(createMockClipboardEvent('cut', mockClipboard));
+
+ setTimeout(() => {
+ expect(editor.blocks.length).toBe(initialBlockCount - 1);
+ expect(editorElement.querySelector('#paragraph1')).toBeNull();
+
+ const blockElement2 = editorElement.querySelector('#paragraph2') as HTMLElement;
+ editor.setFocusToBlock(blockElement2);
+ setCursorPosition(getBlockContentElement(blockElement2), 0);
+ editor.clipboardAction.handlePaste(createMockClipboardEvent('paste', mockClipboard));
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[1].content[0].content).toBe('First paragraph');
+ expect(blockElement2.nextElementSibling.id).toBe(editor.blocks[1].id);
+
+ //On First undo, the pasted block should be removed
+ triggerUndo(editorElement);
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.blocks[0].content[0].content).toBe('Second paragraph');
+
+ //On next undo, the cut block should be restored at the original position
+ triggerUndo(editorElement);
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[0].content[0].content).toBe('First paragraph');
+ expect(editor.blocks[1].content[0].content).toBe('Second paragraph');
+
+ //On First redo, the block should be cut again
+ triggerRedo(editorElement);
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.blocks[0].content[0].content).toBe('Second paragraph');
+
+ //On next redo, the block should be pasted again
+ triggerRedo(editorElement);
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[0].content[0].content).toBe('Second paragraph');
+ expect(editor.blocks[1].content[0].content).toBe('First paragraph');
+
+ done();
+ });
+ });
+
+ it('copy & paste partial content', (done) => {
+ if (editor) editor.destroy();
+ editor = createEditor({
+ blocks: [
+ {
+ id: 'block1',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'bold', type: ContentType.Text, content: 'Boldedtext', styles: { bold: true } },
+ { id: 'italic', type: ContentType.Text, content: 'Italictext', styles: { italic: true } },
+ { id: 'underline', type: ContentType.Text, content: 'Underlinedtext', styles: { underline: true } }
+ ]
+ },
+ {
+ id: 'block2',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'test', type: ContentType.Text, content: 'TestContent', styles: { bold: true } }
+ ]
+ }
+ ]
+ });
+ editor.appendTo('#editor');
+ editor.setFocusToBlock(editor.element.querySelector('#block1'));
+ //create range
+ var range = document.createRange();
+ var startNode = editor.element.querySelector('#italic').firstChild;
+ var endNode = editor.element.querySelector('#underline').firstChild;
+ range.setStart(startNode, 0);
+ range.setEnd(endNode, 6);
+ var selection = document.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+ const copiedData = editor.clipboardAction.getClipboardPayload().blockeditorData;
+
+ const mockClipboard: any = {
+ setData: jasmine.createSpy(),
+ getData: (format: string) => {
+ if (format === 'text/blockeditor') {
+ return copiedData;
+ }
+ return '';
+ }
+ };
+
+ editor.clipboardAction.handleCopy(createMockClipboardEvent('copy', mockClipboard));
+
+ const blockElement = editorElement.querySelector('#block2') as HTMLElement;
+ const contentElement = getBlockContentElement(blockElement);
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(contentElement, 4);
+ const initialLength = editor.blocks[1].content.length;
+
+ editor.clipboardAction.handlePaste(createMockClipboardEvent('paste', mockClipboard));
+
+ setTimeout(() => {
+ expect(editor.blocks[1].content.length).toBe(initialLength + 3);
+ expect(editor.blocks[1].content[0].content).toBe('Test');
+ expect(editor.blocks[1].content[1].content).toBe('Italictext');
+ expect(editor.blocks[1].content[1].styles.italic).toBe(true);
+ expect(editor.blocks[1].content[2].content).toBe('Underl');
+ expect(editor.blocks[1].content[2].styles.underline).toBe(true);
+ expect(editor.blocks[1].content[3].content).toBe('Content');
+ expect(contentElement.childNodes.length).toBe(4);
+ expect(contentElement.childNodes[1].textContent).toBe('Italictext');
+ expect((contentElement.childNodes[1] as HTMLElement).tagName).toBe('EM');
+ expect((contentElement.childNodes[2] as HTMLElement).textContent).toBe('Underl');
+ expect((contentElement.childNodes[2] as HTMLElement).tagName).toBe('U');
+
+ //Trigger UNDO
+ triggerUndo(editorElement);
+ expect(editor.blocks[1].content.length).toBe(initialLength);
+ expect(editor.blocks[1].content[0].content).toBe('TestContent');
+ expect(getBlockContentElement(blockElement).childNodes.length).toBe(1);
+
+ //Trigger REDO
+ triggerRedo(editorElement);
+ expect(editor.blocks[1].content.length).toBe(initialLength + 3);
+ expect(editor.blocks[1].content[0].content).toBe('Test');
+ expect(editor.blocks[1].content[1].content).toBe('Italictext');
+ expect(editor.blocks[1].content[1].styles.italic).toBe(true);
+ expect(editor.blocks[1].content[2].content).toBe('Underl');
+ expect(editor.blocks[1].content[2].styles.underline).toBe(true);
+ expect(editor.blocks[1].content[3].content).toBe('Content');
+ expect(contentElement.childNodes.length).toBe(4);
+ expect(contentElement.childNodes[1].textContent).toBe('Italictext');
+ expect((contentElement.childNodes[1] as HTMLElement).tagName).toBe('EM');
+ expect((contentElement.childNodes[2] as HTMLElement).textContent).toBe('Underl');
+ expect((contentElement.childNodes[2] as HTMLElement).tagName).toBe('U');
+ done();
+ }, 100);
+ });
+
+ it('multi block paste when cursor is at middle', (done) => {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ editor.selectAllBlocks();
+ const copiedData = editor.clipboardAction.getClipboardPayload().blockeditorData;
+
+ const mockClipboard: any = {
+ setData: jasmine.createSpy(),
+ getData: (format: string) => {
+ if (format === 'text/blockeditor') {
+ return copiedData;
+ }
+ return '';
+ }
+ };
+
+ editor.clipboardAction.handleCopy(createMockClipboardEvent('copy', mockClipboard));
+
+ setCursorPosition(getBlockContentElement(blockElement), 6);
+
+ editor.clipboardAction.handlePaste(createMockClipboardEvent('paste', mockClipboard));
+
+ // First block will be splitted at cursor and clipboard's first block content gets merged here
+ // Remaining clipboard blocks will be added after this block
+ // So, total blocks will be 4
+ setTimeout(() => {
+ expect(editor.blocks.length).toBe(4);
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(4);
+
+ expect(editor.blocks[0].content[0].content).toBe('First ');
+ expect(editor.blocks[0].content[1].content).toBe('First paragraph');
+ expect(editor.blocks[1].content[0].content).toBe('Second paragraph');
+ expect(editor.blocks[2].content[0].content).toBe('paragraph');
+ expect(editor.blocks[3].content[0].content).toBe('Second paragraph');
+
+ expect(editorElement.querySelectorAll('.e-block')[0].querySelector('p').textContent).toBe('First First paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[1].querySelector('p').textContent).toBe('Second paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[2].querySelector('p').textContent).toBe('paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[3].querySelector('p').textContent).toBe('Second paragraph');
+
+ //Trigger UNDO
+ triggerUndo(editorElement);
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[0].content[0].content).toBe('First paragraph');
+ expect(editor.blocks[1].content[0].content).toBe('Second paragraph');
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(2);
+ expect(editorElement.querySelectorAll('.e-block')[0].querySelector('p').textContent).toBe('First paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[1].querySelector('p').textContent).toBe('Second paragraph');
+
+ //Trigger REDO
+ triggerRedo(editorElement);
+ expect(editor.blocks.length).toBe(4);
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(4);
+
+ expect(editor.blocks[0].content[0].content).toBe('First ');
+ expect(editor.blocks[0].content[1].content).toBe('First paragraph');
+ expect(editor.blocks[1].content[0].content).toBe('Second paragraph');
+ expect(editor.blocks[2].content[0].content).toBe('paragraph');
+ expect(editor.blocks[3].content[0].content).toBe('Second paragraph');
+
+ expect(editorElement.querySelectorAll('.e-block')[0].querySelector('p').textContent).toBe('First First paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[1].querySelector('p').textContent).toBe('Second paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[2].querySelector('p').textContent).toBe('paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[3].querySelector('p').textContent).toBe('Second paragraph');
+ done();
+ });
+ });
+
+ it('multi block paste when cursor is at start', (done) => {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ editor.selectAllBlocks();
+ const copiedData = editor.clipboardAction.getClipboardPayload().blockeditorData;
+
+ const mockClipboard: any = {
+ setData: jasmine.createSpy(),
+ getData: (format: string) => {
+ if (format === 'text/blockeditor') {
+ return copiedData;
+ }
+ return '';
+ }
+ };
+
+ editor.clipboardAction.handleCopy(createMockClipboardEvent('copy', mockClipboard));
+
+ setCursorPosition(getBlockContentElement(blockElement), 0);
+
+ editor.clipboardAction.handlePaste(createMockClipboardEvent('paste', mockClipboard));
+
+ // All Clipboard blocks will be pasted after the focused block
+ // So, total blocks will be 4
+ setTimeout(() => {
+ expect(editor.blocks.length).toBe(4);
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(4);
+
+ expect(editor.blocks[0].content[0].content).toBe('First paragraph');
+ expect(editor.blocks[1].content[0].content).toBe('First paragraph');
+ expect(editor.blocks[2].content[0].content).toBe('Second paragraph');
+ expect(editor.blocks[3].content[0].content).toBe('Second paragraph');
+
+ expect(editorElement.querySelectorAll('.e-block')[0].querySelector('p').textContent).toBe('First paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[1].querySelector('p').textContent).toBe('First paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[2].querySelector('p').textContent).toBe('Second paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[3].querySelector('p').textContent).toBe('Second paragraph');
+
+ //Trigger UNDO
+ triggerUndo(editorElement);
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[0].content[0].content).toBe('First paragraph');
+ expect(editor.blocks[1].content[0].content).toBe('Second paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[0].querySelector('p').textContent).toBe('First paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[1].querySelector('p').textContent).toBe('Second paragraph');
+
+ //Trigger REDO
+ triggerRedo(editorElement);
+ expect(editor.blocks.length).toBe(4);
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(4);
+
+ expect(editor.blocks[0].content[0].content).toBe('First paragraph');
+ expect(editor.blocks[1].content[0].content).toBe('First paragraph');
+ expect(editor.blocks[2].content[0].content).toBe('Second paragraph');
+ expect(editor.blocks[3].content[0].content).toBe('Second paragraph');
+
+ expect(editorElement.querySelectorAll('.e-block')[0].querySelector('p').textContent).toBe('First paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[1].querySelector('p').textContent).toBe('First paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[2].querySelector('p').textContent).toBe('Second paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[3].querySelector('p').textContent).toBe('Second paragraph');
+ done();
+ });
+ });
+
+ it('multi block paste when cursor is at empty block', function (done) {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ editor.selectAllBlocks();
+ const copiedData = editor.clipboardAction.getClipboardPayload().blockeditorData;
+
+ const mockClipboard: any = {
+ setData: jasmine.createSpy(),
+ getData: (format: string) => {
+ if (format === 'text/blockeditor') {
+ return copiedData;
+ }
+ return '';
+ }
+ };
+
+ editor.clipboardAction.handleCopy(createMockClipboardEvent('copy', mockClipboard));
+
+ const contentElement = getBlockContentElement(blockElement);
+ contentElement.textContent = '';
+ editor.updateContentOnUserTyping(blockElement);
+ setCursorPosition(contentElement, 0);
+
+ editor.clipboardAction.handlePaste(createMockClipboardEvent('paste', mockClipboard));
+ setTimeout(function () {
+ expect(editor.blocks.length).toBe(3);
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(3);
+ expect(editor.blocks[0].content[0].content).toBe('First paragraph');
+ expect(editor.blocks[1].content[0].content).toBe('Second paragraph');
+ expect(editor.blocks[2].content[0].content).toBe('Second paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[0].querySelector('p').textContent).toBe('First paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[1].querySelector('p').textContent).toBe('Second paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[2].querySelector('p').textContent).toBe('Second paragraph');
+
+ //Trigger UNDO
+ triggerUndo(editorElement);
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[1].content[0].content).toBe('Second paragraph');
+ expect(editorElement.querySelectorAll('.e-block')[1].querySelector('p').textContent).toBe('Second paragraph');
+
+ //Trigger REDO
+ triggerRedo(editorElement);
+ expect(editor.blocks.length).toBe(3);
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(3);
+ done();
+ });
+ });
+ });
+
+ describe('Selective Deletion of blocks', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ function triggerUndo(editorElement: HTMLElement) : void {
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' }));
+ }
+
+ function triggerRedo(editorElement: HTMLElement) : void {
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'y', ctrlKey: true, code: 'KeyY' }));
+ }
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'paragraph', type: BlockType.Paragraph, content: [
+ { id: 'p-content', type: ContentType.Text, content: 'Paragraph ' },
+ { id: 'bolded-content', type: ContentType.Text, content: 'content', styles: { bold: true } },
+ ] },
+ { id: 'heading', type: BlockType.Heading3, content: [
+ { id: 'h-content', type: ContentType.Text, content: 'Heading ' },
+ { id: 'italic-content', type: ContentType.Text, content: 'content', styles: { italic: true } },
+ ] },
+ { id: 'bullet-list', type: BlockType.BulletList, content: [
+ { id: 'bullet-list-content', type: ContentType.Text, content: 'Bullet list ' },
+ { id: 'underline-content', type: ContentType.Text, content: 'content', styles: { underline: true } },
+ ] },
+ { id: 'quote', type: BlockType.Quote, content: [
+ { id: 'q-content', type: ContentType.Text, content: 'Quote ' },
+ { id: 'strike-content', type: ContentType.Text, content: 'content', styles: { strikethrough: true } },
+ ] }
+ ];
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('Entire editor deletion using backspace', (done) => {
+ editor.setFocusToBlock(editorElement.querySelector('#paragraph'));
+ editor.selectAllBlocks();
+ editor.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', code: 'Backspace', bubbles: true }));
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.element.querySelectorAll('.e-block').length).toBe(1);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ const emptyBlockId = editor.blocks[0].id;
+
+ triggerUndo(editorElement);
+ expect(editor.blocks.length).toBe(4);
+ expect(editor.element.querySelectorAll('.e-block').length).toBe(4);
+
+ triggerRedo(editorElement);
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.element.querySelectorAll('.e-block').length).toBe(1);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ expect(editor.blocks[0].id === emptyBlockId).toBe(true);
+ done();
+ });
+
+ it('Partial deletion using backspace', (done) => {
+ const range = document.createRange();
+ const selection = document.getSelection();
+ const startBlockElement = editorElement.querySelector('#paragraph') as HTMLElement;
+ const startNode = startBlockElement.querySelector('#p-content').firstChild;
+ const startOffset = 9;
+ const endBlockElement = editorElement.querySelector('#quote') as HTMLElement;
+ const endNode = endBlockElement.querySelector('#q-content').firstChild;
+ const endOffset = 6;
+
+ range.setStart(startNode, startOffset);
+ range.setEnd(endNode, endOffset);
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ editor.setFocusToBlock(startBlockElement);
+
+ editor.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', code: 'Backspace', bubbles: true }));
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.element.querySelectorAll('.e-block').length).toBe(1);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ expect(editor.blocks[0].content.length).toBe(2);
+ expect(editor.blocks[0].content[0].content).toBe('Paragraph');
+ expect(editor.blocks[0].content[1].content).toBe('content');
+ expect(editor.blocks[0].content[1].styles.strikethrough).toBe(true);
+ expect(getBlockContentElement(startBlockElement).childElementCount).toBe(2);
+ expect(getBlockContentElement(startBlockElement).children[0].textContent).toBe('Paragraph');
+ expect(getBlockContentElement(startBlockElement).children[1].textContent).toBe('content');
+
+ triggerUndo(editorElement);
+ expect(editor.blocks.length).toBe(4);
+ expect(editor.element.querySelectorAll('.e-block').length).toBe(4);
+ expect(getBlockContentElement(startBlockElement).childElementCount).toBe(2);
+ expect(getBlockContentElement(startBlockElement).children[0].textContent).toBe('Paragraph ');
+ expect(getBlockContentElement(startBlockElement).children[1].textContent).toBe('content');
+
+ triggerRedo(editorElement);
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.element.querySelectorAll('.e-block').length).toBe(1);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ expect(editor.blocks[0].content.length).toBe(2);
+ expect(editor.blocks[0].content[0].content).toBe('Paragraph');
+ expect(editor.blocks[0].content[1].content).toBe('content');
+ expect(editor.blocks[0].content[1].styles.strikethrough).toBe(true);
+ expect(getBlockContentElement(startBlockElement).childElementCount).toBe(2);
+ expect(getBlockContentElement(startBlockElement).children[0].textContent).toBe('Paragraph');
+ expect(getBlockContentElement(startBlockElement).children[1].textContent).toBe('content');
+ done();
+ });
+ });
+
+ describe('Other actions', () => {
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'block1', type: BlockType.Paragraph, content: [{ id: 'paragraph-content1', type: ContentType.Text, content: 'Block 1 content' }] },
+ { id: 'block2', type: BlockType.Paragraph, content: [{ id: 'paragraph-content2', type: ContentType.Text, content: 'Block 2 content' }] },
+ { id: 'block3', type: BlockType.Paragraph, content: [{ id: 'paragraph-content3', type: ContentType.Text, content: 'Block 3 content' }] }
+ ];
+
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('Indent and outdent blocks', (done) => {
+ const blockElement = editorElement.querySelector('#block2') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+
+ editor.blockAction.handleBlockIndentation({
+ blockIDs: [blockElement.id],
+ shouldDecrease: false
+ });
+ expect(editor.blocks[1].indent).toBe(1);
+
+ triggerUndo(editorElement);
+
+ expect(editor.blocks[1].indent).toBe(0);
+
+ triggerRedo(editorElement);
+
+ expect(editor.blocks[1].indent).toBe(1);
+ done();
+ });
+
+ it('Line breaks addition removal', (done) => {
+ const blockElement = editorElement.querySelector('#block2') as HTMLElement;
+ const contentElement = getBlockContentElement(blockElement);
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(contentElement, 8);
+
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, code: 'Enter' }));
+ expect(editor.blocks[1].content[0].content).toContain('\n');
+
+ triggerUndo(editorElement);
+ expect(editor.blocks[1].content[0].content).not.toContain('\n');
+
+ triggerRedo(editorElement);
+ expect(editor.blocks[1].content[0].content).toContain('\n');
+ done();
+ });
+
+ it('Should shift the stack when limit exceeds', (done) => {
+ editor.undoRedoStack = 1;
+ const blockElement = editorElement.querySelector('#block2') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(blockElement.querySelector('.e-block-content'), 8);
+
+ //Record first action
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, code: 'Enter' }));
+
+ //Record second action - first action should be shifted out of the stack
+ editor.blockAction.handleBlockIndentation({
+ blockIDs: [blockElement.id],
+ shouldDecrease: false
+ });
+
+ expect(editor.undoRedoAction.undoStack.length).toBe(1);
+ expect(editor.undoRedoAction.undoStack[0].action).toBe('indent');
+
+ done();
+ });
+ });
+});
diff --git a/controls/blockeditor/spec/base/blockeditor.spec.ts b/controls/blockeditor/spec/base/blockeditor.spec.ts
new file mode 100644
index 0000000000..09e9f96d92
--- /dev/null
+++ b/controls/blockeditor/spec/base/blockeditor.spec.ts
@@ -0,0 +1,933 @@
+import { createElement, remove } from '@syncfusion/ej2-base';
+import { ClickEventArgs } from '@syncfusion/ej2-navigations';
+import { BlockModel } from '../../src/blockeditor/models';
+import { BlockEditor, BlockType, ContentType, setCursorPosition, setSelectionRange, getBlockContentElement, getSelectionRange } from '../../src/index';
+import { CodeSettingsModel, CodeLanguageModel } from '../../src/blockeditor/models';
+import { createEditor } from '../common/util.spec';
+// import { setCursorPosition, setSelectionRange } from '../../src/blockeditor/utils/selection';
+// import { getBlockContentElement } from '../../src/blockeditor/utils/block';
+
+describe('Block Editor', () => {
+ beforeAll(() => {
+ const isDef: any = (o: any) => o !== undefined && o !== null;
+ if (!isDef(window.performance)) {
+ console.log('Unsupported environment, window.performance.memory is unavailable');
+ pending(); // skips test (in Chai)
+ return;
+ }
+ });
+
+ function triggerMouseMove(node: HTMLElement, x: number, y: number): void {
+ const event = new MouseEvent('mousemove', { bubbles: true, cancelable: true, clientX: x, clientY: y });
+ node.dispatchEvent(event);
+ }
+
+ describe('Default testing', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeAll(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ editor = new BlockEditor();
+ editor.appendTo('#editor');
+ });
+
+ beforeEach((done: DoneFn) => done());
+
+ afterAll(() => {
+ if (editor) {
+ editor.destroy();
+ }
+ document.body.removeChild(editorElement);
+ });
+
+ it('default values of all properties', () => {
+ expect(editor.height).toBe('100%');
+ expect(editor.width).toBe('100%');
+ expect(editor.undoRedoStack).toBe(30);
+
+ expect(editor.enableAutoHttps).toBe(true);
+ expect(editor.enableDragAndDrop).toBe(true);
+ expect(editor.enableHtmlSanitizer).toBe(true);
+ expect(editor.enableHtmlEncode).toBe(false);
+ expect(editor.readOnly).toBe(false);
+
+ expect(editor.commandMenu.commands.length).toBeGreaterThan(0);
+ expect(editor.inlineToolbar.items.length).toBeGreaterThan(0);
+ expect(editor.blockActionsMenu.items.length).toBeGreaterThan(0);
+ expect(editor.contextMenu.items.length).toBeGreaterThan(0);
+
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ expect(editor.element.id).toBeDefined();
+ });
+ it('Setting width and height as null', () => {
+ editor.width = null;
+ editor.height = null;
+ editor.dataBind();
+ expect(editorElement.style.height).toBe('100%');
+ expect(editorElement.style.width).toBe('100%');
+ });
+ it('getPersistData checking', () => {
+ expect(((editorElement).ej2_instances[0] as any).getPersistData()).toEqual('{}');
+ });
+
+ it('getDirective checking', () => {
+ expect(((editorElement).ej2_instances[0] as any).getDirective()).toEqual('EJS-BLOCKEDITOR');
+ });
+
+ it('preRender checking', () => {
+ editorElement.id = '';
+ expect(((editorElement).ej2_instances[0] as any).preRender()).not.toEqual('');
+ });
+
+ it('should change the direction of editor when enableRtl is changed', () => {
+ expect(editor.element.classList.contains('e-rtl')).toBe(false);
+ editor.enableRtl = true;
+ editor.dataBind();
+ expect(editor.element.classList.contains('e-rtl')).toBe(true);
+ });
+
+ it('should render a default block if no blocks are provided', () => {
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ const blockElement = editorElement.querySelector('.e-block');
+ expect(blockElement).not.toBeNull();
+ const contentElement = blockElement.querySelector('p');
+ expect(contentElement).not.toBeNull();
+ });
+ });
+
+ describe('Testing block splitting and deleting', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeAll(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'paragraph', type: BlockType.Paragraph, content: [{ id: 'paragraph-content', type: ContentType.Text, content: 'Hello world' }] }
+ ];
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterAll(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('should create new block on enter', () => {
+ const blockElement = editorElement.querySelector('.e-block') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ const contentElement = getBlockContentElement(blockElement);
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(contentElement, blockElement.textContent.length);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[0].content[0].content).toBe('Hello world');
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(2);
+ expect(editorElement.querySelectorAll('.e-block')[0].textContent).toBe('Hello world');
+ expect(editorElement.querySelectorAll('.e-block')[1].textContent).toBe('');
+ });
+
+ it('should delete block on backspace', () => {
+ const blockElement = editorElement.querySelectorAll('.e-block')[1] as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ setCursorPosition(contentElement, 0);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.blocks[0].content[0].content).toBe('Hello world');
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(1);
+ expect(editorElement.querySelectorAll('.e-block')[0].textContent).toBe('Hello world');
+ });
+
+ it('should split blocks when pressed enter at middle of text', () => {
+ const blockElement = editorElement.querySelector('.e-block') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ setCursorPosition(contentElement, 6);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[0].content[0].content).toBe('Hello ');
+ expect(editor.blocks[1].content[0].content).toBe('world');
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(2);
+ expect(editorElement.querySelectorAll('.e-block')[0].textContent).toBe('Hello ');
+ expect(editorElement.querySelectorAll('.e-block')[1].textContent).toBe('world');
+ });
+
+ it('should merge blocks when pressed backspace at start of text', () => {
+ const blockElement = editorElement.querySelectorAll('.e-block')[1] as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ setCursorPosition(contentElement, 0);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.blocks[0].content[0].content).toBe('Hello world');
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(1);
+ expect(editorElement.querySelectorAll('.e-block')[0].textContent).toBe('Hello world');
+ });
+
+ it('should merge blocks when pressed delete at end of text', () => {
+ const blockElement = editorElement.querySelector('.e-block') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ setCursorPosition(contentElement, 6);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(contentElement, blockElement.textContent.length);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.blocks[0].content[0].content).toBe('Hello world');
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(1);
+ expect(editorElement.querySelectorAll('.e-block')[0].textContent).toBe('Hello world');
+ });
+
+ it('splitting blocks(Enter) with formatting applied', () => {
+ const blockElement = editorElement.querySelector('.e-block') as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ const contentElement = getBlockContentElement(blockElement);
+ //Select range of text(world) and apply bold formatting
+ setSelectionRange((contentElement.lastChild as HTMLElement), 6, blockElement.textContent.length);
+ editor.formattingAction.execCommand({ command: 'bold' });
+ expect(contentElement.childElementCount).toBe(2);
+ expect(contentElement.querySelector('span').textContent).toBe('Hello ');
+ expect(contentElement.querySelector('strong').textContent).toBe('world');
+ expect(editor.blocks[0].content[1].styles.bold).toBe(true);
+
+ //Split block at middle of text and check formatting applied correctly
+ setCursorPosition(contentElement, 6);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[0].content[0].content).toBe('Hello ');
+ expect(editor.blocks[1].content[0].content).toBe('world');
+ expect(editor.blocks[1].content[0].styles.bold).toBe(true);
+ expect(editorElement.querySelectorAll('.e-block').length).toBe(2);
+ expect(editorElement.querySelectorAll('.e-block')[0].textContent).toBe('Hello ');
+ expect(editorElement.querySelectorAll('.e-block')[1].getElementsByTagName('strong')[0].textContent).toBe('world');
+ });
+
+ it('merging blocks(Backspace) with formatting applied', () => {
+ const blockElement = editorElement.querySelectorAll('.e-block')[1] as HTMLElement;
+ expect(blockElement).not.toBeNull();
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ setCursorPosition(contentElement, 0);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.blocks[0].content[0].content).toBe('Hello ');
+ expect(editor.blocks[0].content[1].content).toBe('world');
+ expect(editor.blocks[0].content[1].styles.bold).toBe(true);
+ const contentElement2 = getBlockContentElement(editorElement.querySelector('.e-block'));
+ expect(contentElement2.childElementCount).toBe(2);
+ expect(contentElement2.querySelector('span').textContent).toBe('Hello ');
+ expect(contentElement2.querySelector('strong').textContent).toBe('world');
+ });
+ });
+
+ describe('Testing deletion of blocks', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ {
+ id: 'paragraph', type: BlockType.Paragraph, content: [
+ { id: 'p-content', type: ContentType.Text, content: 'Paragraph ' },
+ { id: 'bolded-content', type: ContentType.Text, content: 'content', styles: { bold: true } },
+ ]
+ },
+ {
+ id: 'heading', type: BlockType.Heading3, content: [
+ { id: 'h-content', type: ContentType.Text, content: 'Heading ' },
+ { id: 'italic-content', type: ContentType.Text, content: 'content', styles: { italic: true } },
+ ]
+ },
+ {
+ id: 'bullet-list', type: BlockType.BulletList, content: [
+ { id: 'bullet-list-content', type: ContentType.Text, content: 'Bullet list ' },
+ { id: 'underline-content', type: ContentType.Text, content: 'content', styles: { underline: true } },
+ ]
+ },
+ {
+ id: 'quote', type: BlockType.Quote, content: [
+ { id: 'q-content', type: ContentType.Text, content: 'Quote ' },
+ { id: 'strike-content', type: ContentType.Text, content: 'content', styles: { strikethrough: true } },
+ ]
+ }
+ ];
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('Entire editor deletion using backspace', () => {
+ editor.setFocusToBlock(editorElement.querySelector('#paragraph'));
+ editor.selectAllBlocks();
+ editor.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', code: 'Backspace', bubbles: true }));
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.element.querySelectorAll('.e-block').length).toBe(1);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ });
+
+ it('Partial deletion using backspace', () => {
+ const range = document.createRange();
+ const selection = document.getSelection();
+ const startBlockElement = editorElement.querySelector('#paragraph') as HTMLElement;
+ const startNode = startBlockElement.querySelector('#p-content').firstChild;
+ const startOffset = 9;
+ const endBlockElement = editorElement.querySelector('#quote') as HTMLElement;
+ const endNode = endBlockElement.querySelector('#q-content').firstChild;
+ const endOffset = 6;
+
+ range.setStart(startNode, startOffset);
+ range.setEnd(endNode, endOffset);
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ editor.setFocusToBlock(startBlockElement);
+
+ editor.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', code: 'Backspace', bubbles: true }));
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.element.querySelectorAll('.e-block').length).toBe(1);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ expect(editor.blocks[0].content.length).toBe(2);
+ expect(editor.blocks[0].content[0].content).toBe('Paragraph');
+ expect(editor.blocks[0].content[1].content).toBe('content');
+ expect(editor.blocks[0].content[1].styles.strikethrough).toBe(true);
+ expect(getBlockContentElement(startBlockElement).childElementCount).toBe(2);
+ expect(getBlockContentElement(startBlockElement).children[0].textContent).toBe('Paragraph');
+ expect(getBlockContentElement(startBlockElement).children[1].textContent).toBe('content');
+ });
+
+ it('Partial deletion using delete', () => {
+ const range = document.createRange();
+ const selection = document.getSelection();
+ const startBlockElement = editorElement.querySelector('#paragraph') as HTMLElement;
+ const startNode = startBlockElement.querySelector('#p-content').firstChild;
+ const startOffset = 9;
+ const endBlockElement = editorElement.querySelector('#quote') as HTMLElement;
+ const endNode = endBlockElement.querySelector('#q-content').firstChild;
+ const endOffset = 6;
+
+ range.setStart(startNode, startOffset);
+ range.setEnd(endNode, endOffset);
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ editor.setFocusToBlock(startBlockElement);
+
+ editor.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', bubbles: true }));
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.element.querySelectorAll('.e-block').length).toBe(1);
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ expect(editor.blocks[0].content.length).toBe(2);
+ expect(editor.blocks[0].content[0].content).toBe('Paragraph');
+ expect(editor.blocks[0].content[1].content).toBe('content');
+ expect(editor.blocks[0].content[1].styles.strikethrough).toBe(true);
+ expect(getBlockContentElement(startBlockElement).childElementCount).toBe(2);
+ expect(getBlockContentElement(startBlockElement).children[0].textContent).toBe('Paragraph');
+ expect(getBlockContentElement(startBlockElement).children[1].textContent).toBe('content');
+ });
+ });
+
+ describe('Testing the sanitizer, decode and encode testing', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeAll(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ {
+ type: BlockType.Paragraph,
+ content: [{ id: 'paragraph-content', type: ContentType.Text, content: 'Hello world
' }]
+ }
+ ];
+ editor = new BlockEditor({
+ blocks: blocks,
+ enableHtmlSanitizer: true
+ });
+ editor.appendTo('#editor');
+ });
+
+ beforeEach((done: DoneFn) => done());
+
+ afterAll(() => {
+ if (editor) {
+ editor.destroy();
+ }
+ document.body.removeChild(editorElement);
+ });
+
+ it('checking the Sanitizer and dynamic HTML encode and decode', () => {
+ editor.serializeValue(editor.blocks[0].content[0].content);
+ expect(editor.blocks[0].content[0].content).toBe("Hello world
");
+ editor.enableHtmlEncode = true;
+ editor.dataBind();
+ const content: string = editor.serializeValue(editor.blocks[0].content[0].content);
+ expect(content).toBe("<p>Hello world</p>");
+ expect(editor.blocks[0].type).toBe(BlockType.Paragraph);
+ expect(editor.element.id).toBeDefined();
+ });
+ it('should not sanitize when enableHTMLSanitizer is false', () => {
+ editor.enableHtmlSanitizer = false;
+ editor.dataBind();
+ editor.serializeValue(editor.blocks[0].content[0].content);
+ expect(editor.blocks[0].content[0].content).toBe("Hello world
");
+ });
+ });
+
+ describe('Testing on property change', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeAll(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ {
+ id: 'block1',
+ type: BlockType.Paragraph,
+ content: [{ type: ContentType.Text, content: 'Block 1' }],
+ cssClass: 'e-initial'
+ },
+ {
+ id: 'block2',
+ type: BlockType.Paragraph,
+ content: [{ type: ContentType.Text, content: 'Block 2' }]
+ },
+ {
+ id: 'block3',
+ type: BlockType.CheckList,
+ content: [{ type: ContentType.Text, content: 'Todo' }]
+ },
+ {
+ id: 'block4',
+ type: BlockType.ToggleParagraph,
+ content: [{ id: 'toggle-content-1', type: ContentType.Text, content: 'Click here to expand' }],
+ children: [
+ {
+ id: 'toggleChild',
+ type: BlockType.CheckList,
+ content: [{ type: ContentType.Text, content: 'Todo' }]
+ }
+ ]
+ }
+ ];
+ editor = new BlockEditor({
+ blocks: blocks,
+ enableHtmlSanitizer: true
+ });
+ editor.appendTo('#editor');
+ });
+
+ beforeEach((done: DoneFn) => done());
+
+ afterAll(() => {
+ if (editor) {
+ editor.destroy();
+ }
+ document.body.removeChild(editorElement);
+ });
+
+ it('checking root level props dynamic update', () => {
+ editor.width = '300px';
+ editor.height = '500px';
+ editor.cssClass = 'e-test';
+ editor.enableAutoHttps = true;
+ editor.readOnly = false;
+ editor.dataBind();
+ expect(editor.width).toBe('300px');
+ expect(editor.height).toBe('500px');
+ expect(editor.cssClass).toBe('e-test');
+ expect(editor.enableAutoHttps).toBe(true);
+ expect(editor.readOnly).toBe(false);
+ editor.width = '200px';
+ editor.dataBind();
+ expect(editor.width).toBe('200px');
+ editor.height = '700px';
+ editor.dataBind();
+ expect(editor.height).toBe('700px');
+ editor.cssClass = 'e-modified';
+ editor.dataBind();
+ expect(editor.cssClass).toBe('e-modified');
+ editor.enableAutoHttps = false;
+ editor.dataBind();
+ expect(editor.enableAutoHttps).toBe(false);
+ editor.readOnly = true;
+ editor.dataBind();
+ expect(editor.readOnly).toBe(true);
+ });
+
+ it('Dynamic change for key config', function (done) {
+ editor.keyConfig = {
+ 'bold': 'alt+b'
+ }
+ editor.dataBind();
+ let blockElement: HTMLElement = editorElement.querySelector('#block1');
+ editor.setFocusToBlock(blockElement);
+ editor.setSelection(blockElement.querySelector('.e-block-content').id, 0, 4);
+ const keyDownEve = new KeyboardEvent('keydown', {
+ key: 'b',
+ code: 'Keyb',
+ bubbles: true,
+ cancelable: true,
+ altKey: true,
+ });
+ editorElement.dispatchEvent(keyDownEve);
+ setTimeout(function () {
+ expect(editorElement.querySelector('#' + editor.currentFocusedBlock.id)).toBe(blockElement);
+ expect(editor.blocks[0].content[0].content).toBe('Bloc');
+ expect(editor.blocks[0].content[0].styles.bold).toBe(true);
+ expect(editor.blocks[0].content[1].content).toBe('k 1');
+ done();
+ }, 100);
+ });
+
+ it('checking block level props dynamic update', (done) => {
+ const firstBlock = editorElement.querySelector('#block1') as HTMLElement;
+ editor.setFocusToBlock(firstBlock);
+ var contentElement = getBlockContentElement(firstBlock);
+ contentElement.textContent = '';
+ editor.blocks[0].type = BlockType.BulletList;
+ editor.blocks[0].placeholder = 'Type heading here';
+ editor.blocks[0].cssClass = 'e-test';
+ editor.blocks[2].isChecked = true;
+ editor.blocks[3].isExpanded = true;
+ editor.blocks[3].children[0].indent = 1;
+ editor.dataBind();
+
+ expect(editor.blocks[0].type).toBe(BlockType.BulletList);
+ expect(editor.blocks[0].placeholder).toBe('Type heading here');
+ expect(editor.blocks[0].cssClass).toBe('e-test');
+ expect(editor.blocks[2].isChecked).toBe(true);
+ expect(editor.blocks[3].isExpanded).toBe(true);
+ expect(editor.blocks[3].children[0].indent).toBe(1);
+
+ //Assert dom
+ setTimeout(() => {
+ const blocks = editorElement.querySelectorAll('.e-block') as NodeListOf;
+ expect(blocks[0].classList.contains('e-list-block')).toBe(true);
+ expect(getBlockContentElement(blocks[0]).getAttribute('placeholder')).toBe('Type heading here');
+ expect(blocks[0].classList.contains('e-test')).toBe(true);
+ expect(blocks[2].querySelector('.e-checkmark-checked') !== null).toBe(true);
+ expect(blocks[3].getAttribute('data-collapsed')).toBe('false');
+ expect((blocks[3].querySelector('.e-block') as HTMLElement).style.getPropertyValue('--block-indent')).toBe('20');
+
+ // //Removing old cssclass case
+ // editor.blocks[0].cssClass = 'Updated';
+
+ // setTimeout(() => {
+ // expect(blocks[0].classList.contains('e-test')).toBe(false);
+ // expect(blocks[0].classList.contains('Updated')).toBe(true);
+ // done();
+ // }, 200);
+ done();
+ }, 100);
+ });
+
+ it('should not update for non existing block', function (done) {
+ editor.blocks[0].id = 'fake-id';
+ editor.dataBind();
+
+ expect(editorElement.querySelectorAll('.e-block')[0].id).toBe('block1');
+ done();
+ });
+
+ it('should return when prev prop is undefined', function (done) {
+ expect((editor.onPropertyChanged as any)('newProp', undefined)).toBeUndefined();
+ done();
+ });
+ });
+
+ describe('Testing editor actions, ', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ {
+ id: 'paragraph-1',
+ type: BlockType.Paragraph,
+ content: [{ id: 'paragraph-content-1', type: ContentType.Text, content: 'Hello world 1' }]
+ },
+ {
+ id: 'paragraph-2',
+ type: BlockType.Paragraph,
+ content: [{ id: 'paragraph-content-2', type: ContentType.Text, content: 'Hello world 2' }]
+ },
+ ];
+ editor = new BlockEditor({
+ blocks: blocks
+ });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ }
+ document.body.removeChild(editorElement);
+ });
+ it('should update currentFocusedBlock on mouseup', (done) => {
+ setTimeout(() => {
+ const blockElement = editorElement.querySelector('#paragraph-1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(getBlockContentElement(blockElement), 0);
+ const mouseUpEvent = new MouseEvent('mouseup', {
+ view: window,
+ bubbles: true,
+ cancelable: true
+ });
+ editorElement.dispatchEvent(mouseUpEvent);
+ setTimeout(() => {
+ expect(editorElement.querySelector('#' + editor.currentFocusedBlock.id)).toBe(blockElement);
+
+ // Different block
+ const blockElement2 = editorElement.querySelector('#paragraph-2') as HTMLElement;
+ editor.setFocusToBlock(blockElement2);
+ setCursorPosition(getBlockContentElement(blockElement2), 0);
+ const mouseUpEvent = new MouseEvent('mouseup', {
+ view: window,
+ bubbles: true,
+ cancelable: true
+ });
+ editorElement.dispatchEvent(mouseUpEvent);
+ setTimeout(() => {
+ expect(editorElement.querySelector('#' + editor.currentFocusedBlock.id)).toBe(blockElement2);
+ done();
+ }, 100);
+ }, 100);
+ }, 200);
+ });
+
+ it('should handle line breaks on shift+enter', (done) => {
+ setTimeout(() => {
+ const blockElement = editorElement.querySelector('#paragraph-1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(getBlockContentElement(blockElement), 6);
+ const shiftEnterEvent = new KeyboardEvent('keydown', {
+ key: 'Enter',
+ shiftKey: true,
+ bubbles: true,
+ cancelable: true
+ });
+ editorElement.dispatchEvent(shiftEnterEvent);
+ setTimeout(() => {
+ expect(editor.blocks[0].content[0].content).toBe('Hello \nworld 1');
+ done();
+ }, 200);
+ }, 200);
+ });
+
+ it('should set cursor in empty block on mousedown', (done) => {
+ setTimeout(() => {
+ editor.blockAction.addNewBlock({
+ block: { id: 'empty-paragraph', type: BlockType.Paragraph, content: [] },
+ preventUIUpdate: true
+ });
+ const blockElement = editorElement.querySelector('#empty-paragraph') as HTMLElement;
+ blockElement.dispatchEvent(new MouseEvent('mousedown', {
+ bubbles: true,
+ cancelable: true
+ }));
+ setTimeout(() => {
+ const range = getSelectionRange();
+ expect(blockElement.contains(range.startContainer)).toBe(true);
+ expect(range.startOffset).toBe(0);
+ done();
+ }, 100);
+ }, 200);
+ });
+
+ it('should hide popup on scroll', (done) => {
+ const blockElement = editorElement.querySelector('#paragraph-1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+
+ // Ensure block action popup
+ triggerMouseMove(blockElement, 10, 10);
+ editor.blockActionMenuModule.toggleBlockActionPopup(false);
+
+ // Trigger scroll
+ setTimeout(() => {
+ editorElement.dispatchEvent(new Event('scroll'));
+ expect(document.querySelector('.e-blockeditor-blockaction-popup').classList.contains('e-popup-open')).toBe(false);
+ done();
+ }, 200);
+ });
+
+ it('should navigate bw blocks on arrow keys', (done) => {
+ const blockElement1 = editorElement.querySelector('#paragraph-1') as HTMLElement;
+ const block1Content = getBlockContentElement(blockElement1) as HTMLElement;
+ const blockElement2 = editorElement.querySelector('#paragraph-2') as HTMLElement;
+ const block2Content = getBlockContentElement(blockElement2) as HTMLElement;
+
+ // Cursor at end of block 1 and press right arrow
+ editor.setFocusToBlock(blockElement1);
+ setCursorPosition(block1Content, block1Content.textContent.length);
+ const rightArrowEvent = new KeyboardEvent('keydown', {
+ key: 'ArrowRight',
+ bubbles: true,
+ cancelable: true
+ });
+ editorElement.dispatchEvent(rightArrowEvent);
+ expect(editorElement.querySelector('#' + editor.currentFocusedBlock.id)).toBe(blockElement2);
+
+ // Cursor at start of block 2 and press left arrow
+ editor.setFocusToBlock(blockElement2);
+ setCursorPosition(block2Content, 0);
+ const leftArrowEvent = new KeyboardEvent('keydown', {
+ key: 'ArrowLeft',
+ bubbles: true,
+ cancelable: true
+ });
+ editorElement.dispatchEvent(leftArrowEvent);
+ expect(editorElement.querySelector('#' + editor.currentFocusedBlock.id)).toBe(blockElement1);
+ done();
+ });
+
+ it('should display inline toolbar on text selection', (done) => {
+ setTimeout(() => {
+ const blockElement = editorElement.querySelector('#paragraph-1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ editor.setSelection('paragraph-content-1', 0, 4);
+ const mouseUpEvent = new MouseEvent('mouseup', {
+ view: window,
+ bubbles: true,
+ cancelable: true
+ });
+ editorElement.dispatchEvent(mouseUpEvent);
+ setTimeout(() => {
+ expect(editorElement.querySelector('#' + editor.currentFocusedBlock.id)).toBe(blockElement);
+ expect(document.querySelector('.e-blockeditor-inline-toolbar-popup').classList.contains('e-popup-open')).toBe(true);
+ done();
+ }, 100);
+ }, 200);
+ });
+
+ it('should display inline toolbar on keyboard selection', (done) => {
+ setTimeout(() => {
+ const blockElement = editorElement.querySelector('#paragraph-1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ editor.setSelection('paragraph-content-1', 0, 4);
+ const keyDownEve = new KeyboardEvent('keydown', {
+ key: 'ArrowRight',
+ bubbles: true,
+ cancelable: true,
+ ctrlKey: true,
+ shiftKey: true
+ });
+ editorElement.dispatchEvent(keyDownEve);
+ setTimeout(() => {
+ expect(editorElement.querySelector('#' + editor.currentFocusedBlock.id)).toBe(blockElement);
+ expect(document.querySelector('.e-blockeditor-inline-toolbar-popup').classList.contains('e-popup-open')).toBe(true);
+ done();
+ }, 100);
+ }, 200);
+ });
+
+ // it('should display slash menu on AddIcon click', (done) => {
+ // const blockElement = editorElement.querySelector('#paragraph-1') as HTMLElement;
+ // editor.setFocusToBlock(blockElement);
+ // triggerMouseMove(blockElement, 10, 10);
+
+ // const addIcon = editor.floatingIconContainer.querySelector('.e-block-add-icon') as HTMLElement;
+ // expect(addIcon).not.toBeNull();
+ // addIcon.click();
+ // setTimeout(() => {
+ // expect(editorElement.querySelector('#' + editor.currentHoveredBlock.id)).toBe(blockElement);
+ // expect(document.querySelector('.e-blockeditor-command-menu').classList.contains('e-popup-open')).toBe(true);
+ // done();
+ // }, 100);
+ // });
+
+ it('should handle entire selection and type any input', (done) => {
+ setTimeout(() => {
+ editor.selectAllBlocks();
+ editor.isEntireEditorSelected = true;
+ (editor as any).handleEditorInputActions(new Event('input'));
+ // Only one block should remain after input event
+ setTimeout(() => {
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.blocks[0].content[0].content).toBe('Hello world 1');
+ done();
+ }, 200);
+ }, 200);
+ });
+ });
+
+ describe('Model updates - updateContentOnUserTyping', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ {
+ id: 'paragraph-1',
+ type: BlockType.Paragraph,
+ content: [{ id: 'paragraph-content-1', type: ContentType.Text, content: 'Hello world 1' }]
+ },
+ {
+ id: 'paragraph-2',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'paragraph-content-2', type: ContentType.Text, content: 'Hello world 2' },
+ { id: 'progress', type: ContentType.Label }
+ ]
+ },
+ ];
+ editor = new BlockEditor({
+ blocks: blocks
+ });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ }
+ document.body.removeChild(editorElement);
+ });
+
+ it('should do nothing if blockElement is null', () => {
+ const initialBlock = editor.blocksInternal[0];
+
+ editor.updateContentOnUserTyping(null);
+
+ expect(editor.blocksInternal[0]).toBe(initialBlock);
+ editor.destroy();
+ });
+
+ it('should do nothing if block model is not found', () => {
+ const nonExistentBlockElement = createElement('div', { id: 'nonexistent' });
+ const blockElement = editorElement.querySelector('#paragraph-1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ const initialBlock = editor.blocksInternal[0];
+
+ editor.updateContentOnUserTyping(nonExistentBlockElement);
+
+ expect(editor.blocksInternal[0]).toBe(initialBlock);
+ });
+
+ it('should do nothing if contentElement is not found', () => {
+ const blockElement = editorElement.querySelector('#paragraph-1') as HTMLElement;
+ const contentElement = getBlockContentElement(blockElement);
+ contentElement.remove();
+
+ editor.updateContentOnUserTyping(blockElement);
+ });
+
+ it('should handle empty content array when updating', () => {
+ editor.blocks[0].content = [];
+ const blockElement = editorElement.querySelector('#paragraph-1') as HTMLElement;
+ editor.updateContentOnUserTyping(blockElement);
+ const updatedBlock = editor.getBlock('paragraph-1');
+ expect(updatedBlock.content.length).toBe(1);
+ expect(updatedBlock.content[0].content).toBe('Hello world 1');
+ });
+
+ it('should handle special elements and create content spans', (done) => {
+ const blockElement = editorElement.querySelector('#paragraph-2') as HTMLElement;
+ const contentElement = getBlockContentElement(blockElement);
+
+ // Add a text node after the label chip
+ const textNode = document.createTextNode(' text after label');
+ contentElement.appendChild(textNode);
+ setCursorPosition(contentElement, contentElement.textContent.length);
+
+ editor.updateContentOnUserTyping(blockElement);
+
+ setTimeout(() => {
+ const updatedBlock = editor.getBlock('paragraph-2');
+ expect(updatedBlock.content.length).toBe(3);
+ expect(updatedBlock.content[2].type).toBe(ContentType.Text);
+ expect(updatedBlock.content[2].content).toBe(' text after label');
+
+ // Check DOM was updated
+ const contentNodes = Array.from(contentElement.childNodes);
+ expect(contentNodes.length).toBe(3);
+ expect(contentNodes[0].textContent).toBe('Hello world 2');
+ expect((contentNodes[1] as HTMLElement).classList.contains('e-label-chip')).toBe(true);
+ expect(contentNodes[2].textContent).toBe(' text after label');
+ done();
+ }, 10);
+ });
+
+ it('should handle element nodes and create content models', (done) => {
+ const blockElement = editorElement.querySelector('#paragraph-2') as HTMLElement;
+ const contentElement = getBlockContentElement(blockElement);
+
+ // Add a element node after the label chip
+ const elem = document.createElement('span');
+ elem.id = 'custom-element';
+ elem.textContent = 'new element node';
+ contentElement.appendChild(elem);
+ setCursorPosition(contentElement, contentElement.textContent.length);
+
+ editor.updateContentOnUserTyping(blockElement);
+
+ setTimeout(() => {
+ const updatedBlock = editor.getBlock('paragraph-2');
+ expect(updatedBlock.content.length).toBe(3);
+ expect(updatedBlock.content[2].type).toBe(ContentType.Text);
+ expect(updatedBlock.content[2].id).toBe('custom-element');
+ expect(updatedBlock.content[2].content).toBe('new element node');
+ done();
+ }, 10);
+ });
+
+ it('should clean up stale content models properly', () => {
+ const blockElement = editorElement.querySelector('#paragraph-2') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ const content = [
+ { id: 'content1', content: 'Text content' },
+ { id: 'content2', content: 'Bold content - about to stale', styles: { bold: true } },
+ { id: 'content3', content: 'Another text content' }
+ ];
+
+ editor.addBlock({ id: 'newblock', content: content, type: 'Paragraph' }, 'paragraph-2');
+
+ const newblockElement = editorElement.querySelector('#newblock') as HTMLElement;
+ const contentElement = getBlockContentElement(newblockElement);
+
+ // Select range partially from content1 to content3
+ editor.nodeSelection.createRangeWithOffsets(
+ contentElement.childNodes[0].firstChild,
+ contentElement.childNodes[2].firstChild,
+ 4, 13);
+ const range = getSelectionRange();
+ range.deleteContents();
+ editor.updateContentOnUserTyping(newblockElement);
+
+ expect(editor.blocksInternal[2].content.length).toBe(2);
+ expect(editor.blocksInternal[2].content[0].id).toBe('content1');
+ expect(editor.blocksInternal[2].content[0].content).toBe('Text');
+
+ expect(editor.blocksInternal[2].content[1].id).toBe('content3');
+ expect(editor.blocksInternal[2].content[1].content).toBe('content');
+ });
+ });
+});
diff --git a/controls/blockeditor/spec/common/common.spec.ts b/controls/blockeditor/spec/common/common.spec.ts
new file mode 100644
index 0000000000..a06f631877
--- /dev/null
+++ b/controls/blockeditor/spec/common/common.spec.ts
@@ -0,0 +1,34 @@
+interface Window {
+ performance: { memory: any };
+}
+
+declare var window: Window
+
+export const inMB = (n: any) => n / 1000000;
+
+function runningAverage(arr: any, newVal: any, oldAvg: any) {
+ return ((oldAvg * (arr.length - 1) + newVal) / arr.length);
+};
+export const profile = {
+ samples: [] as any,
+ diffs: [] as any,
+ averageUsage: 0,
+ averageChange: 0,
+ //Collects a sample of memory and updates all the values in the
+ //profile object
+ sample() {
+ let newSample = getMemoryProfile();
+ this.samples.push(newSample);
+ this.averageUsage = runningAverage(this.samples, newSample, this.averageUsage);
+ let sampleLen: any = this.samples.length;
+ if (sampleLen >= 2) {
+ let newDiff = this.samples[sampleLen - 1] - this.samples[sampleLen - 2];
+ this.diffs.push(newDiff);
+ this.averageChange = runningAverage(this.diffs, newDiff, this.averageChange);
+ }
+ }
+}
+
+export const getMemoryProfile = () => {
+ return window.performance.memory.usedJSHeapSize; //Return used memory
+};
\ No newline at end of file
diff --git a/controls/blockeditor/spec/common/data.spec.ts b/controls/blockeditor/spec/common/data.spec.ts
new file mode 100644
index 0000000000..9fb6cdcdbe
--- /dev/null
+++ b/controls/blockeditor/spec/common/data.spec.ts
@@ -0,0 +1,264 @@
+import { BlockModel, ContentType } from "../../src/index";
+
+export const allBlockData: BlockModel[] = [
+ {
+ id: 'heading-block',
+ type: 'Heading1',
+ content: [{
+ id: 'heading-content',
+ type: ContentType.Text,
+ content: 'Welcome to the Block Editor Demo!'
+ }]
+ },
+ {
+ id: 'intro-block',
+ type: 'Paragraph',
+ content: [{
+ id: 'intro-content',
+ type: ContentType.Text,
+ content: 'Block Editor is a powerful rich text editor',
+ }]
+ },
+ {
+ id: 'styled-paragraph',
+ type: 'Paragraph',
+ content: [
+ {
+ id: 'styled-text-1',
+ type: ContentType.Text,
+ content: 'Try selecting text to see '
+ },
+ {
+ id: 'styled-text-2',
+ type: ContentType.Text,
+ content: 'formatting options',
+ styles: {
+ bold: true,
+ italic: true
+ }
+ },
+ {
+ id: 'styled-text-3',
+ type: ContentType.Text,
+ content: ', or type '
+ },
+ {
+ id: 'styled-text-4',
+ type: ContentType.Text,
+ content: '"/"',
+ styles: {
+ bgColor: '#F0F0F0',
+ bold: true
+ }
+ },
+ {
+ id: 'styled-text-5',
+ type: ContentType.Text,
+ content: ' to access the command menu.'
+ }
+ ]
+ },
+ {
+ id: 'block-types-heading',
+ type: 'Heading2',
+ content: [{
+ id: 'block-types-heading-content',
+ type: ContentType.Text,
+ content: 'Block Types'
+ }]
+ },
+ {
+ id: 'quote-block',
+ type: 'Quote',
+ content: [{
+ id: 'quote-content',
+ type: ContentType.Text,
+ content: 'The Block Editor makes document creation a seamless experience with its intuitive block-based approach.',
+ styles: {
+ italic: true
+ }
+ }]
+ },
+ {
+ id: 'list-types-heading',
+ type: 'Heading3',
+ content: [{
+ id: 'list-types-heading-content',
+ type: ContentType.Text,
+ content: 'List Types'
+ }]
+ },
+ {
+ id: 'bullet-list-header',
+ type: 'BulletList',
+ content: [{
+ id: 'bullet-list-header-content',
+ type: ContentType.Text,
+ content: 'Text blocks: Paragraph, Heading 1-4, Quote, Callout',
+ styles: {
+ bold: true
+ }
+ }]
+ },
+ {
+ id: 'numbered-list',
+ type: 'NumberedList',
+ content: [{
+ id: 'numbered-list-content',
+ type: ContentType.Text,
+ content: 'Lists: Bullet lists, Numbered lists, Check lists'
+ }]
+ },
+ {
+ id: 'check-list',
+ type: 'CheckList',
+ isChecked: true,
+ content: [{
+ id: 'check-list-content',
+ type: ContentType.Text,
+ content: 'Special blocks: Divider, Toggle, Code block'
+ }]
+ },
+ {
+ id: 'divider-block',
+ type: 'Divider',
+ content: []
+ },
+ {
+ id: 'formatting-heading',
+ type: 'Heading4',
+ content: [{
+ id: 'formatting-heading-content',
+ type: ContentType.Text,
+ content: 'Text Formatting Examples'
+ }]
+ },
+ {
+ id: 'formatting-examples',
+ type: 'Paragraph',
+ content: [
+ {
+ id: 'format-bold',
+ type: ContentType.Text,
+ content: 'Bold ',
+ styles: {
+ bold: true
+ }
+ },
+ {
+ id: 'format-italic',
+ type: ContentType.Text,
+ content: 'Italic ',
+ styles: {
+ italic: true
+ }
+ },
+ {
+ id: 'format-underline',
+ type: ContentType.Text,
+ content: 'Underline ',
+ styles: {
+ underline: true
+ }
+ },
+ {
+ id: 'format-strikethrough',
+ type: ContentType.Text,
+ content: 'Strikethrough ',
+ styles: {
+ strikethrough: true
+ }
+ },
+ {
+ id: 'format-superscript',
+ type: ContentType.Text,
+ content: 'Superscript ',
+ styles: {
+ superscript: true
+ }
+ },
+ {
+ id: 'format-subscript',
+ type: ContentType.Text,
+ content: 'Subscript ',
+ styles: {
+ subscript: true
+ }
+ },
+ {
+ id: 'format-uppercase',
+ type: ContentType.Text,
+ content: 'uppercase ',
+ styles: {
+ uppercase: true
+ }
+ },
+ {
+ id: 'format-lowercase',
+ type: ContentType.Text,
+ content: 'LOWERCASE',
+ styles: {
+ lowercase: true
+ }
+ }
+ ]
+ },
+ {
+ id: 'link-block',
+ type: 'Paragraph',
+ content: [
+ {
+ id: 'link-text',
+ type: ContentType.Text,
+ content: 'Visit '
+ },
+ {
+ id: 'link-content',
+ type: ContentType.Link,
+ content: 'Syncfusion',
+ linkSettings: {
+ url: 'https://www.syncfusion.com/',
+ openInNewWindow: true
+ }
+ },
+ {
+ id: 'link-text-end',
+ type: ContentType.Text,
+ content: ' for more information.'
+ }
+ ]
+ },
+ {
+ id: 'label-block',
+ type: 'Paragraph',
+ content: [
+ {
+ id: 'label-text',
+ type: ContentType.Text,
+ content: 'This block contains a '
+ },
+ {
+ id: 'progress',
+ type: ContentType.Label,
+ },
+ {
+ id: 'label-text-end',
+ type: ContentType.Text,
+ content: ' label.'
+ }
+ ]
+ },
+ {
+ id: 'try-it-block',
+ type: 'Paragraph',
+ content: [{
+ id: 'try-it-content',
+ type: ContentType.Text,
+ content: 'Try it out! Click anywhere and start typing, or type "/" to see available commands.',
+ styles: {
+ bold: true,
+ bgColor: '#F8F9FA'
+ }
+ }]
+ }
+];
diff --git a/controls/blockeditor/spec/common/util.spec.ts b/controls/blockeditor/spec/common/util.spec.ts
new file mode 100644
index 0000000000..f851d6bcfc
--- /dev/null
+++ b/controls/blockeditor/spec/common/util.spec.ts
@@ -0,0 +1,11 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import { BlockEditor } from "../../src/index";
+import { BlockEditorModel } from "../../src/blockeditor/base/blockeditor-model";
+
+export function createEditor(args: BlockEditorModel): BlockEditor {
+ args.width = '400px';
+ args.height = '400px';
+ const editor: BlockEditor = new BlockEditor(args);
+ return editor;
+}
\ No newline at end of file
diff --git a/controls/blockeditor/spec/managers/block-command-manager.spec.ts b/controls/blockeditor/spec/managers/block-command-manager.spec.ts
new file mode 100644
index 0000000000..d0a288abd8
--- /dev/null
+++ b/controls/blockeditor/spec/managers/block-command-manager.spec.ts
@@ -0,0 +1,607 @@
+import { createElement, remove } from '@syncfusion/ej2-base';
+import { BlockModel } from '../../src/blockeditor/models';
+import { BlockEditor, BlockType, ContentType, setCursorPosition, getBlockContentElement, BuiltInToolbar } from '../../src/index';
+import { createEditor } from '../common/util.spec';
+
+describe('Block Command Manager:', () => {
+ beforeAll(() => {
+ const isDef: any = (o: any) => o !== undefined && o !== null;
+ if (!isDef(window.performance)) {
+ console.log('Unsupported environment, window.performance.memory is unavailable');
+ pending(); // skips test (in Chai)
+ return;
+ }
+ });
+
+ describe('Main actions', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'paragraph1', type: BlockType.Paragraph, content: [{ id: 'paragraph1-content', type: ContentType.Text, content: 'Hello world' }] },
+ {
+ id: 'calloutblock',
+ type: 'Callout',
+ children: [
+ {
+ id: 'calloutchild1',
+ type: 'Paragraph',
+ content: [{ id: 'callout-child1-content', type: ContentType.Text, content: 'Callout child 1' }]
+ },
+ {
+ id: 'calloutchild2',
+ type: 'Paragraph',
+ content: [{ id: 'callout-child2-content', type: ContentType.Text, content: 'Callout child 2' }]
+ }
+ ]
+ },
+ {
+ id: 'toggleblock',
+ type: BlockType.ToggleParagraph,
+ content: [{ id: 'toggle-content-1', type: ContentType.Text, content: 'Click here to expand' }],
+ children: [
+ {
+ id: 'togglechild1',
+ type: BlockType.CheckList,
+ content: [{ type: ContentType.Text, content: 'Todo' }]
+ },
+ {
+ id: 'togglechild2',
+ type: BlockType.Paragraph,
+ content: [{ type: ContentType.Text, content: 'Toggle child 2' }]
+ }
+ ]
+ },
+ { id: 'paragraph2', type: BlockType.Paragraph, content: [{ id: 'paragraph2-content', type: ContentType.Text, content: '' }] },
+ ];
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('transformToggleBlocksAsRegular should transform properly', () => {
+ const blockElement = editorElement.querySelector('#toggleblock') as HTMLElement;
+ const contentElement = blockElement.querySelector('.e-block-content') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ setCursorPosition(contentElement, 0);
+ editor.blockAction.deleteBlockAtCursor({
+ blockElement: blockElement, mergeDirection: 'previous'
+ });
+ const toggleBlock = editor.blocks.find(block => block.type === 'ToggleParagraph');
+ expect(toggleBlock).toBeUndefined();
+ expect(editor.blocks[2].type).toBe('Paragraph');
+ });
+
+ it('single divider block in editor and deleting it should create paragraph block', () => {
+ if (editor) editor.destroy();
+ const blocks: BlockModel[] = [
+ { id: 'divider', type: BlockType.Divider },
+ { id: 'paragraph2', type: BlockType.Paragraph, content: [{ id: 'paragraph2-content', type: ContentType.Text, content: '' }] },
+ ];
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+ const blockElement = editorElement.querySelector('#divider') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+
+ editor.blockAction.deleteBlockAtCursor({
+ blockElement, mergeDirection: 'previous'
+ });
+ });
+
+ it('deleting a child block inside callout', () => {
+ const blockElement = editorElement.querySelector('#calloutchild2') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+
+ editor.blockAction.deleteBlock({
+ blockElement, mergeDirection: 'previous'
+ });
+
+ expect(editor.blocks[1].children.length).toBe(1);
+ expect(editorElement.querySelector('#calloutchild2')).toBeNull();
+ });
+
+ it('duplicating a child block inside callout', () => {
+ const blockElement = editorElement.querySelector('#calloutchild2') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+
+ editor.blockAction.duplicateBlock(blockElement);
+
+ expect(editor.blocks[1].children.length).toBe(3);
+ });
+
+ it('moving a child block inside callout', function () {
+ const blockElement = editorElement.querySelector('#calloutchild2') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ editor.blockAction.moveBlock({
+ fromBlockIds: ['calloutchild2'], toBlockId: 'calloutchild1'
+ });
+ expect(editor.blocks[1].children.length).toBe(2);
+ expect(editor.blocks[1].children[0].id).toBe('calloutchild2');
+ expect(editor.blocks[1].children[1].id).toBe('calloutchild1');
+ });
+
+ it('generateNewIdsForBlock should generate properly', () => {
+ editor.blocks[0].content = [
+ { id: 'paragraph1-content', type: ContentType.Text, content: 'Hello world' },
+ { id: 'progress', type: ContentType.Label },
+ { id: 'user1', type: ContentType.Mention },
+ ];
+ editor.reRenderBlockContent(editor.blocks[0]);
+ const labelDataId = editor.blocks[0].content[1].dataId;
+ const mentionDataId = editor.blocks[0].content[2].dataId;
+ const firstBlockId = editor.blocks[0].id;
+ const firstBlockContentId = editor.blocks[0].content[0].id;
+ const newBlock = editor.blockAction.generateNewIdsForBlock(editor.blocks[0]);
+ expect(newBlock.id).not.toBe(firstBlockId);
+ expect(newBlock.content[0].id).not.toBe(firstBlockContentId);
+ expect(newBlock.content[1].dataId).not.toBe(labelDataId);
+ expect(newBlock.content[2].dataId).not.toBe(mentionDataId);
+
+ const child1Id = editor.blocks[1].children[0].id;
+ const child1ContentId = editor.blocks[1].children[0].content[0].id;
+ const newChildBlock = editor.blockAction.generateNewIdsForBlock(editor.blocks[1].children[0]);
+ expect(newChildBlock.id).not.toBe(child1Id);
+ expect(newChildBlock.content[0].id).not.toBe(child1ContentId);
+ });
+
+ it('triggerWholeContentUpdate should update content model properly', () => {
+ editor.blockAction.triggerWholeContentUpdate(
+ editor.blocks[1], []
+ );
+ expect(editor.blocks[1].content.length).toBe(0);
+ editor.blockAction.triggerWholeContentUpdate(
+ editor.blocks[1].children[0], []
+ );
+ expect(editor.blocks[1].children[0].content.length).toBe(0);
+ });
+
+ });
+
+ describe('splitContent method', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeAll(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'paragraph1', type: BlockType.Paragraph, content: [{ id: 'paragraph1-content', type: ContentType.Text, content: 'Hello world' }] },
+ ];
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterAll(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ // Helper to create content elements for testing
+ const setupContentElement = (html: string): HTMLElement => {
+ const element = document.createElement('div');
+ element.innerHTML = html;
+ return element;
+ };
+
+ // Helper to verify fragment content
+ const fragmentToHTML = (fragment: DocumentFragment): string => {
+ const div = document.createElement('div');
+ div.appendChild(fragment.cloneNode(true));
+ return div.innerHTML;
+ };
+
+ it('should split simple text node correctly', () => {
+ // Setup
+ const contentElement = setupContentElement('Hello World');
+ const splitNode = contentElement.firstChild;
+ const splitOffset = 5;
+
+ // Execute
+ const result = editor.blockAction.splitContent(contentElement, splitNode, splitOffset);
+
+ // Assert
+ expect(fragmentToHTML(result.beforeFragment)).toBe('Hello');
+ expect(fragmentToHTML(result.afterFragment)).toBe(' World');
+ });
+
+ it('should split at beginning of text node', () => {
+ // Setup
+ const contentElement = setupContentElement('Hello World');
+ const splitNode = contentElement.firstChild;
+ const splitOffset = 0; // Beginning of text
+
+ // Execute
+ const result = editor.blockAction.splitContent(contentElement, splitNode, splitOffset);
+
+ // Assert
+ expect(fragmentToHTML(result.beforeFragment)).toBe('');
+ expect(fragmentToHTML(result.afterFragment)).toBe('Hello World');
+ });
+
+ it('should split at end of text node', () => {
+ // Setup
+ const contentElement = setupContentElement('Hello World');
+ const splitNode = contentElement.firstChild;
+ const splitOffset = 11; // End of text
+
+ // Execute
+ const result = editor.blockAction.splitContent(contentElement, splitNode, splitOffset);
+
+ // Assert
+ expect(fragmentToHTML(result.beforeFragment)).toBe('Hello World');
+ expect(fragmentToHTML(result.afterFragment)).toBe('');
+ });
+
+ it('should split text within a single element node', () => {
+ // Setup
+ const contentElement = setupContentElement('Hello World ');
+ const strongElement = contentElement.querySelector('strong');
+ const splitNode = strongElement.firstChild;
+ const splitOffset = 5;
+
+ // Execute
+ const result = editor.blockAction.splitContent(contentElement, splitNode, splitOffset);
+
+ expect(((result.beforeFragment.childNodes[0] as HTMLElement).tagName)).toBe('STRONG');
+ expect(((result.beforeFragment.childNodes[0] as HTMLElement).textContent)).toBe('Hello');
+ expect(((result.afterFragment.childNodes[0] as HTMLElement).tagName)).toBe('STRONG');
+ expect(((result.afterFragment.childNodes[0] as HTMLElement).textContent)).toBe(' World');
+ });
+
+ it('should split between multiple element nodes', () => {
+ // Setup
+ const contentElement = setupContentElement('Hi Hello World ');
+ const strongElement = contentElement.querySelector('strong');
+ const splitNode = strongElement.firstChild;
+ const splitOffset = 3;
+
+ // Execute
+ const result = editor.blockAction.splitContent(contentElement, splitNode, splitOffset);
+
+ // Assert
+ expect(result.beforeFragment.childNodes.length).toBe(2);
+ expect((result.beforeFragment.childNodes[0] as HTMLElement).tagName).toBe('SPAN');
+ expect((result.beforeFragment.childNodes[0] as HTMLElement).textContent).toBe('Hi');
+ expect((result.beforeFragment.childNodes[1] as HTMLElement).tagName).toBe('STRONG');
+ expect((result.beforeFragment.childNodes[1] as HTMLElement).textContent).toBe('Hel');
+
+ expect(result.afterFragment.childNodes.length).toBe(2);
+ expect((result.afterFragment.childNodes[0] as HTMLElement).tagName).toBe('STRONG');
+ expect((result.afterFragment.childNodes[0] as HTMLElement).textContent).toBe('lo');
+ expect((result.afterFragment.childNodes[1] as HTMLElement).tagName).toBe('EM');
+ expect((result.afterFragment.childNodes[1] as HTMLElement).textContent).toBe('World');
+ });
+
+ it('should handle nested element splitting', () => {
+ // Setup
+ const contentElement = setupContentElement('Hi Hello World ');
+ const emElement = contentElement.querySelector('em');
+ const splitNode = emElement.firstChild;
+ const splitOffset = 3;
+
+ // Execute
+ const result = editor.blockAction.splitContent(contentElement, splitNode, splitOffset);
+
+ // Assert
+ expect(result.beforeFragment.childNodes.length).toBe(2);
+ expect((result.beforeFragment.childNodes[0] as HTMLElement).tagName).toBe('SPAN');
+ expect((result.beforeFragment.childNodes[1] as HTMLElement).tagName).toBe('STRONG');
+
+ const beforeEm = (result.beforeFragment.childNodes[1] as HTMLElement).querySelector('em');
+ expect(beforeEm.textContent).toBe('Hel');
+
+ expect(result.afterFragment.childNodes.length).toBe(2);
+ expect((result.afterFragment.childNodes[0] as HTMLElement).tagName).toBe('STRONG');
+
+ const afterEm = (result.afterFragment.childNodes[0] as HTMLElement).querySelector('em');
+ expect(afterEm.textContent).toBe('lo');
+ expect((result.afterFragment.childNodes[1] as HTMLElement).tagName).toBe('EM');
+ expect((result.afterFragment.childNodes[1] as HTMLElement).textContent).toBe('World');
+ });
+
+ it('should handle case when splitNode is an element node itself', () => {
+ // Setup
+ const contentElement = setupContentElement('Hi Hello World ');
+ const strongElement = contentElement.querySelector('strong');
+ // Split at the strong element itself
+ const splitNode = strongElement.firstChild;
+ const splitOffset = 0;
+
+ // Execute
+ const result = editor.blockAction.splitContent(contentElement, splitNode, splitOffset);
+
+ // Assert
+ expect((result.beforeFragment.childNodes[0] as HTMLElement).tagName).toBe('SPAN');
+ expect((result.beforeFragment.childNodes[0] as HTMLElement).textContent).toBe('Hi');
+
+ expect(result.afterFragment.childNodes.length).toBe(2);
+ expect((result.afterFragment.childNodes[0] as HTMLElement).tagName).toBe('STRONG');
+ expect((result.afterFragment.childNodes[0] as HTMLElement).textContent).toBe('Hello');
+ expect((result.afterFragment.childNodes[1] as HTMLElement).tagName).toBe('EM');
+ expect((result.afterFragment.childNodes[1] as HTMLElement).textContent).toBe('World');
+ });
+
+ it('should handle content element with no children', () => {
+ // Setup
+ const contentElement = document.createElement('div');
+ // No children
+
+ // Execute
+ const result = editor.blockAction.splitContent(contentElement, contentElement, 0);
+
+ // Assert
+ expect(fragmentToHTML(result.beforeFragment)).toBe('');
+ expect(fragmentToHTML(result.afterFragment)).toBe('');
+ });
+
+ it('should preserve inline styles in split elements', () => {
+ // Setup
+ const contentElement = setupContentElement('Hi Hello ');
+ const strongElement = contentElement.querySelector('strong');
+ const splitNode = strongElement.firstChild;
+ const splitOffset = 3;
+
+ // Execute
+ const result = editor.blockAction.splitContent(contentElement, splitNode, splitOffset);
+
+ // Assert
+ expect(fragmentToHTML(result.beforeFragment)).toContain('style="font-weight:bold;"');
+ expect(fragmentToHTML(result.beforeFragment)).toContain('style="color:red;"');
+ expect(fragmentToHTML(result.beforeFragment)).toContain('Hel');
+
+ expect(fragmentToHTML(result.afterFragment)).toContain('style="color:red;"');
+ expect(fragmentToHTML(result.afterFragment)).toContain('lo');
+ });
+
+ it('should handle deeply nested structures', () => {
+ // Setup
+ const contentElement = setupContentElement(
+ 'Hi Hello World '
+ );
+ const italic = contentElement.querySelector('i');
+ const splitNode = italic.firstChild;
+ const splitOffset = 4;
+
+ // Execute
+ const result = editor.blockAction.splitContent(contentElement, splitNode, splitOffset);
+
+ // Assert
+ expect(result.beforeFragment.childNodes.length).toBe(2);
+ expect((result.beforeFragment.childNodes[0] as HTMLElement).tagName).toBe('SPAN');
+ expect((result.beforeFragment.childNodes[0] as HTMLElement).textContent).toBe('Hi');
+ expect((result.beforeFragment.childNodes[1] as HTMLElement).tagName).toBe('STRONG');
+ expect((result.beforeFragment.childNodes[1] as HTMLElement).textContent).toBe(' Hel');
+ expect((result.beforeFragment.childNodes[1].childNodes[0] as HTMLElement).tagName).toBe('EM');
+ expect((result.beforeFragment.childNodes[1].childNodes[0].childNodes[0] as HTMLElement).tagName).toBe('I');
+
+ expect(result.afterFragment.childNodes.length).toBe(2);
+ expect((result.afterFragment.childNodes[0] as HTMLElement).tagName).toBe('STRONG');
+ expect((result.afterFragment.childNodes[0] as HTMLElement).textContent).toBe('lo ');
+ expect((result.afterFragment.childNodes[0].childNodes[0] as HTMLElement).tagName).toBe('EM');
+ expect((result.afterFragment.childNodes[0].childNodes[0].childNodes[0] as HTMLElement).tagName).toBe('I');
+
+ expect((result.afterFragment.childNodes[1] as HTMLElement).tagName).toBe('EM');
+ expect((result.afterFragment.childNodes[1] as HTMLElement).textContent).toBe('World');
+ });
+ });
+
+ describe('Other actions', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'paragraph1', type: BlockType.BulletList, content: [{ id: 'paragraph1-content', type: ContentType.Text, content: 'Hello world' }] },
+ { id: 'paragraph2', type: BlockType.BulletList, content: [{ id: 'paragraph2-content', type: ContentType.Text, content: 'Paragraph 2' }] },
+ { id: 'paragraph3', type: BlockType.Paragraph,
+ content: [
+ { id: 'bold', type: ContentType.Text, content: 'Bold', styles: { bold: true } },
+ { id: 'italic', type: ContentType.Text, content: 'Italic', styles: { italic: true } },
+ ]
+ },
+ { id: 'paragraph4', type: BlockType.Paragraph,
+ content: [
+ { id: 'underline', type: ContentType.Text, content: 'Underline', styles: { underline: true } },
+ { id: 'strikethrough', type: ContentType.Text, content: 'Strikethrough', styles: { strikethrough: true } },
+ ]
+ },
+ ];
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('should delete contents and merge into empty block properly', () => {
+ const blockElement1 = editorElement.querySelector('#paragraph1') as HTMLElement;
+ const contentElement1 = blockElement1.querySelector('.e-block-content') as HTMLElement;
+
+ const blockElement2 = editorElement.querySelector('#paragraph2') as HTMLElement;
+ const contentElement2 = blockElement2.querySelector('.e-block-content') as HTMLElement;
+
+ contentElement1.textContent = '';
+ editor.updateContentOnUserTyping(blockElement1);
+ editor.setFocusToBlock(blockElement2);
+ setCursorPosition(contentElement2, 0);
+ editor.blockAction.deleteBlockAtCursor({
+ blockElement: blockElement2,
+ mergeDirection: 'previous'
+ });
+
+ expect(editor.blocks[0].content[0].content).toBe('Paragraph 2');
+ expect(editorElement.querySelector('#paragraph2')).toBeNull();
+ });
+
+ it('should delete contents and merge into formatted block properly', () => {
+ const blockElement1 = editorElement.querySelector('#paragraph1') as HTMLElement;
+ const contentElement1 = blockElement1.querySelector('.e-block-content') as HTMLElement;
+
+ const blockElement2 = editorElement.querySelector('#paragraph2') as HTMLElement;
+ const contentElement2 = blockElement2.querySelector('.e-block-content') as HTMLElement;
+
+ editor.setSelection('paragraph1-content', 6, 11);
+ editor.executeToolbarAction(BuiltInToolbar.Bold);
+
+ editor.setFocusToBlock(blockElement2);
+ setCursorPosition(contentElement2, 0);
+ editor.blockAction.deleteBlockAtCursor({
+ blockElement: blockElement2,
+ mergeDirection: 'previous'
+ });
+
+ expect(editor.blocks[0].content.length).toBe(3);
+ expect(editor.blocks[0].content[0].content).toBe('Hello ');
+ expect(editor.blocks[0].content[1].content).toBe('world');
+ expect(editor.blocks[0].content[2].content).toBe('Paragraph 2');
+ expect(editorElement.querySelector('#paragraph1').textContent).toBe('Hello worldParagraph 2');
+ });
+
+ it('should delete formatted contents and merge into unformatted block properly', () => {
+ const blockElement1 = editorElement.querySelector('#paragraph2') as HTMLElement;
+ const contentElement1 = blockElement1.querySelector('.e-block-content') as HTMLElement;
+
+ const blockElement2 = editorElement.querySelector('#paragraph3') as HTMLElement;
+ const contentElement2 = blockElement2.querySelector('.e-block-content') as HTMLElement;
+
+ editor.setFocusToBlock(blockElement2);
+ setCursorPosition(contentElement2, 0);
+ editor.blockAction.deleteBlockAtCursor({
+ blockElement: blockElement2,
+ mergeDirection: 'previous'
+ });
+
+ expect(editor.blocks[1].content.length).toBe(3);
+ expect(editor.blocks[1].content[0].content).toBe('Paragraph 2');
+ expect(editor.blocks[1].content[1].content).toBe('Bold');
+ expect(editor.blocks[1].content[2].content).toBe('Italic');
+ expect(editorElement.querySelector('#paragraph2').textContent).toBe('Paragraph 2BoldItalic');
+ });
+
+ it('should handle empty content model deletions properly', () => {
+ const blockElement1 = editorElement.querySelector('#paragraph1') as HTMLElement;
+ const contentElement1 = blockElement1.querySelector('.e-block-content') as HTMLElement;
+
+ const blockElement2 = editorElement.querySelector('#paragraph2') as HTMLElement;
+ const contentElement2 = blockElement2.querySelector('.e-block-content') as HTMLElement;
+
+ const originalContent = editor.blocks[1].content.slice();
+ //Merging empty content into a block with content
+ editor.blocks[1].content = [];
+ (editor.blockAction as any).updateContentModelsAfterDeletion(
+ contentElement1, contentElement2,
+ editor.blocks[0], editor.blocks[1]
+ );
+ expect(editor.blocks[0].content.length).toBe(1);
+ expect(editor.blocks[0].content[0].content).toBe('Hello world');
+
+ editor.blocks[1].content = originalContent;
+ //Merging a block with content into a empty content
+ editor.blocks[0].content = [];
+ (editor.blockAction as any).updateContentModelsAfterDeletion(
+ contentElement1, contentElement2,
+ editor.blocks[0], editor.blocks[1]
+ );
+ expect(editor.blocks[0].content.length).toBe(1);
+ expect(editor.blocks[0].content[0].content).toBe('Paragraph 2');
+ });
+
+ it('should handle divider deletions properly', () => {
+ const blockElement1 = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement1);
+ editor.selectAllBlocks();
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', code: 'Backspace' }));
+ expect(editor.blocks.length).toBe(1);
+
+ // Add a divider block
+ const blockElement = editorElement.querySelector('.e-block') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ setCursorPosition(contentElement, 0);
+ contentElement.textContent = '/';
+ setCursorPosition(contentElement, 1);
+ editorElement.querySelector('.e-mention.e-editable-element').dispatchEvent(new KeyboardEvent('keyup', { key: '/', code: 'Slash', bubbles: true }));
+ const slashCommandElement = document.querySelector('.e-popup.e-blockeditor-command-menu') as HTMLElement;
+ // click divider li element inside the popup
+ const dividerLiElement = slashCommandElement.querySelector('li[data-value="Divider"]') as HTMLElement;
+ dividerLiElement.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
+ // Current bullet list block should be replaced with divider block since content is empty
+ expect(editor.blocks[0].type).toBe(BlockType.Divider);
+ expect(editor.blocks[1].type).toBe(BlockType.Paragraph);
+
+ // Delete the divider block
+ const dividerElement = editorElement.querySelector('.e-divider-block') as HTMLElement;
+ editor.setFocusToBlock(dividerElement);
+ editor.blockAction.deleteBlockAtCursor({
+ blockElement: dividerElement,
+ mergeDirection: 'previous'
+ });
+
+ //Deleting the divider, focus should be set to next block
+ expect(editor.currentFocusedBlock.id).toBe(editor.blocks[0].id);
+ });
+
+ it('should handle null values properly', () => {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ const contentElement = blockElement.querySelector('.e-block-content') as HTMLElement;
+ expect(editor.blockAction.addBulkBlocks({ blocks: []})).toBeUndefined();
+
+ expect(editor.blockAction.deleteBlock({ blockElement: null })).toBeUndefined();
+
+ expect(editor.blockAction.deleteBlock({ blockElement: document.createElement('div') })).toBeUndefined();
+
+ expect(editor.blockAction.moveBlock({ fromBlockIds: [] })).toBeUndefined();
+
+ expect(editor.blockAction.moveBlock({ fromBlockIds: ['invalid'], toBlockId: 'invalid' })).toBeUndefined();
+
+ expect(editor.blockAction.duplicateBlock(null, 'above')).toBeUndefined();
+
+ expect(editor.blockAction.duplicateBlock(document.createElement('div'))).toBeUndefined();
+
+ expect(editor.blockAction.getIndexToAdjust(document.createElement('div'))).toBe(editor.blocks.length);
+
+ expect(editor.handleMultipleBlockDeletion([{ id: 'invalid' }, { id: 'invalid' }])).toBe(false);
+
+ expect(editor.blockAction.splitBlockAtCursor(blockElement, { isUndoRedoAction: true, lastChild: null })).toBeNull();
+
+ spyOn(editor.nodeSelection, 'getRange').and.returnValue({
+ startContainer: null,
+ });
+ expect(editor.blockAction.splitBlockAtCursor(blockElement)).toBeNull();
+
+ contentElement.remove();
+ expect(editor.blockAction.splitBlockAtCursor(blockElement)).toBeNull();
+
+ expect(editor.blockAction.deleteBlockAtCursor({ blockElement: null })).toBeUndefined();
+ expect(editor.blockAction.deleteBlockAtCursor({ blockElement: blockElement, mergeDirection: 'next' })).toBeUndefined();
+
+ expect((editor.blockAction as any).transformToggleBlocksAsRegular(document.createElement('div'))).toBeUndefined();
+
+ const prevFocused = editor.currentFocusedBlock;
+ editor.currentFocusedBlock = null;
+ expect((editor.blockAction as any).adjustViewForFocusedBlock()).toBeUndefined();
+ editor.currentFocusedBlock = prevFocused;
+ });
+
+ });
+});
\ No newline at end of file
diff --git a/controls/blockeditor/spec/managers/common-manager.spec.ts b/controls/blockeditor/spec/managers/common-manager.spec.ts
new file mode 100644
index 0000000000..212bf1bebc
--- /dev/null
+++ b/controls/blockeditor/spec/managers/common-manager.spec.ts
@@ -0,0 +1,272 @@
+import { createElement, remove } from '@syncfusion/ej2-base';
+import { BlockModel } from '../../src/blockeditor/models';
+import { BlockEditor, BlockType, ContentType, setCursorPosition, getBlockContentElement } from '../../src/index';
+import { createEditor } from '../common/util.spec';
+
+describe('Common Manager Actions', () => {
+ beforeAll(() => {
+ const isDef: any = (o: any) => o !== undefined && o !== null;
+ if (!isDef(window.performance)) {
+ console.log('Unsupported environment, window.performance.memory is unavailable');
+ pending(); // skips test (in Chai)
+ return;
+ }
+ });
+
+ describe('Floating Icon Manager:', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeAll(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'paragraph1', type: BlockType.Paragraph, content: [{ id: 'paragraph1-content', type: ContentType.Text, content: 'Hello world' }] },
+ { id: 'divider', type: BlockType.Divider }
+ ];
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterAll(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('drag icon click should be prevented when blockaction is disabled', () => {
+ editor.blockActionsMenu.enable = false;
+ expect((editor as any).handleDragIconClick()).toBeUndefined();
+ });
+
+ it('add icon click on non contenteditable block should add new block', () => {
+ editor.currentHoveredBlock = editorElement.querySelector('#divider') as HTMLElement;
+ spyOn(editor.blockAction, 'addNewBlock').and.callFake(() => { });
+ (editor as any).handleAddIconClick()
+ expect(editor.blockAction.addNewBlock).toHaveBeenCalled();
+ editor.slashCommandModule.hidePopup();
+ });
+
+ it('add icon click on empty block should focus same block', () => {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ const contentElement = getBlockContentElement(blockElement);
+ contentElement.textContent = '';
+ setCursorPosition(contentElement, 0);
+ editor.currentHoveredBlock = blockElement;
+ (editor as any).handleAddIconClick()
+ expect(editor.currentFocusedBlock.id).toBe('paragraph1');
+ editor.slashCommandModule.hidePopup();
+ });
+ });
+
+ describe('State Manager:', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeAll(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'paragraph1', type: BlockType.Paragraph, content: [{ id: 'paragraph1-content', type: ContentType.Text, content: 'Hello world' }] },
+ { id: 'divider', type: BlockType.Divider }
+ ];
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterAll(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('updateContentChangesToModel should handle invalid blockelement', () => {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ blockElement.id = 'invalid';
+ expect(editor.blockAction.updateContentChangesToModel(blockElement, null)).toBeUndefined();
+ });
+ });
+
+ describe('Block Renderer Manager:', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'paragraph1', type: BlockType.Paragraph, content: [{ id: 'paragraph1-content', type: ContentType.Text, content: 'Hello world' }] },
+ {
+ id: 'calloutblock',
+ type: 'Callout',
+ children: [
+ {
+ id: 'calloutchild1',
+ type: 'Paragraph',
+ content: [{ id: 'callout-child1-content', type: ContentType.Text, content: 'Callout child 1' }]
+ },
+ {
+ id: 'calloutchild2',
+ type: 'Paragraph',
+ content: [{ id: 'callout-child2-content', type: ContentType.Text, content: 'Callout child 2' }]
+ }
+ ]
+ },
+ {
+ id: 'toggleblock',
+ type: BlockType.ToggleParagraph,
+ content: [{ id: 'toggle-content-1', type: ContentType.Text, content: 'Click here to expand' }],
+ isExpanded: true,
+ children: [
+ {
+ id: 'togglechild1',
+ type: BlockType.CheckList,
+ content: [{ type: ContentType.Text, content: 'Todo' }]
+ },
+ {
+ id: 'togglechild2',
+ type: BlockType.Paragraph,
+ content: [{ type: ContentType.Text, content: 'Toggle child 2' }]
+ }
+ ]
+ },
+ { id: 'paragraph2', type: BlockType.Paragraph, content: [{ id: 'paragraph2-content', type: ContentType.Text, content: '' }] },
+ ];
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('transforming to special type inside callout', (done) => {
+ setTimeout(()=>{
+ const blockElement = editorElement.querySelector('#calloutchild2') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ setCursorPosition(contentElement, 0);
+ contentElement.textContent = '/';
+ setCursorPosition(contentElement, 1);
+ editorElement.querySelector('.e-mention.e-editable-element').dispatchEvent(new KeyboardEvent('keyup', { key: '/', code: 'Slash', bubbles: true }));
+ setTimeout(() => {
+ const slashCommandElement = document.querySelector('.e-popup.e-blockeditor-command-menu') as HTMLElement;
+ expect(slashCommandElement).not.toBeNull();
+ // click toggle paragraph li element inside the popup
+ const toggleParaEle = slashCommandElement.querySelector('li[data-value="Toggle Paragraph"]') as HTMLElement;
+ expect(toggleParaEle).not.toBeNull();
+ toggleParaEle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
+ setTimeout(() => {
+ // Toggle paragraph should be created outside the callout child as a sibling to callout parent
+ expect(editor.blocks.length).toBe(6);
+ expect(editor.blocks[2].type).toBe(BlockType.ToggleParagraph);
+ expect(editor.blocks[3].type).toBe(BlockType.Paragraph);
+ done();
+ }, 300);
+ }, 1000);
+ });
+ });
+
+ it('transforming to special type inside toggle', (done) => {
+ setTimeout(()=>{
+ const blockElement = editorElement.querySelector('#togglechild2') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ setCursorPosition(contentElement, 0);
+ contentElement.textContent = '/';
+ setCursorPosition(contentElement, 1);
+ editorElement.querySelector('.e-mention.e-editable-element').dispatchEvent(new KeyboardEvent('keyup', { key: '/', code: 'Slash', bubbles: true }));
+ setTimeout(() => {
+ const slashCommandElement = document.querySelector('.e-popup.e-blockeditor-command-menu') as HTMLElement;
+ expect(slashCommandElement).not.toBeNull();
+ // click callout li element inside the popup
+ const calloutEle = slashCommandElement.querySelector('li[data-value="Callout"]') as HTMLElement;
+ expect(calloutEle).not.toBeNull();
+ calloutEle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
+ setTimeout(() => {
+ // callout should be created outside the toggle child as a sibling to toggle parent
+ expect(editor.blocks.length).toBe(6);
+ expect(editor.blocks[3].type).toBe(BlockType.Callout);
+ expect(editor.blocks[4].type).toBe(BlockType.Paragraph);
+ expect(editor.currentFocusedBlock.id).toBe(editor.blocks[3].children[0].id);
+ done();
+ }, 300);
+ }, 1000);
+ });
+ });
+
+ it('transforming to code block', (done) => {
+ setTimeout(()=> {
+ const blockElement = editorElement.querySelector('#paragraph2') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ const contentElement = getBlockContentElement(blockElement);
+ setCursorPosition(contentElement, 0);
+ contentElement.textContent = '/';
+ setCursorPosition(contentElement, 1);
+ editorElement.querySelector('.e-mention.e-editable-element').dispatchEvent(new KeyboardEvent('keyup', { key: '/', code: 'Slash', bubbles: true }));
+ setTimeout(() => {
+ const slashCommandElement = document.querySelector('.e-popup.e-blockeditor-command-menu') as HTMLElement;
+ expect(slashCommandElement).not.toBeNull();
+ const codeEle = slashCommandElement.querySelector('li[data-value="Code"]') as HTMLElement;
+ expect(codeEle).not.toBeNull();
+ codeEle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
+ setTimeout(() => {
+ expect(editor.blocks.length).toBe(5);
+ expect(editor.blocks[3].type).toBe(BlockType.Code);
+ expect(editor.blocks[4].type).toBe(BlockType.Paragraph);
+ done();
+ }, 300);
+ }, 1000);
+ });
+ });
+
+ it('should handle null values', (done) => {
+ setTimeout(()=>{
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ const contentElement = getBlockContentElement(blockElement);
+ const originalContent = contentElement.cloneNode(true);
+
+ expect(editor.reRenderBlockContent(null)).toBeUndefined();
+
+ expect(editor.reRenderBlockContent({ id: 'invalid' })).toBeUndefined();
+
+ contentElement.remove();
+ expect(editor.reRenderBlockContent({ id: 'paragraph1' })).toBeUndefined();
+
+ expect(editor.renderBlocks([])).toBeUndefined();
+
+ blockElement.appendChild(originalContent);
+ done();
+ });
+ });
+
+ it('insertBlockIntoDOM should work properly', (done) => {
+ setTimeout(()=> {
+ const originalElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ const duplicate1 = originalElement.cloneNode(true) as HTMLElement;
+ const duplicate2 = originalElement.cloneNode(true) as HTMLElement;
+
+ duplicate1.id = 'duplicate1';
+ duplicate2.id = 'duplicate2';
+ // Passing after element null should insert at last
+ (editor as any).insertBlockIntoDOM(duplicate1, null);
+ const allElems = editorElement.querySelectorAll('.e-block') as NodeListOf;
+ expect(allElems[allElems.length - 1].id).toBe('duplicate1');
+
+ // Passing after element should insert after that element
+ (editor as any).insertBlockIntoDOM(duplicate2, originalElement);
+ expect(originalElement.nextElementSibling.id).toBe('duplicate2');
+
+ done();
+ });
+ });
+ });
+});
diff --git a/controls/blockeditor/spec/managers/event-manager.spec.ts b/controls/blockeditor/spec/managers/event-manager.spec.ts
new file mode 100644
index 0000000000..18e1089ac1
--- /dev/null
+++ b/controls/blockeditor/spec/managers/event-manager.spec.ts
@@ -0,0 +1,284 @@
+import { createElement, remove } from '@syncfusion/ej2-base';
+import { BlockModel } from '../../src/blockeditor/models';
+import { BlockEditor, BlockType, ContentType, setCursorPosition, getBlockContentElement, BuiltInToolbar, getSelectionRange } from '../../src/index';
+import { createEditor } from '../common/util.spec';
+
+describe('Event Manager:', () => {
+ beforeAll(() => {
+ const isDef: any = (o: any) => o !== undefined && o !== null;
+ if (!isDef(window.performance)) {
+ console.log('Unsupported environment, window.performance.memory is unavailable');
+ pending(); // skips test (in Chai)
+ return;
+ }
+ });
+
+ function triggerMouseMove(node: HTMLElement, x: number, y: number): void {
+ const event = new MouseEvent('mousemove', { bubbles: true, cancelable: true, clientX: x, clientY: y });
+ node.dispatchEvent(event);
+ }
+
+ describe('Main actions', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeAll(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'paragraph1', type: BlockType.Paragraph, content: [{ id: 'paragraph1-content', type: ContentType.Text, content: 'Hello world' }] },
+ { id: 'paragraph2', type: BlockType.Paragraph, content: [{ id: 'paragraph2-content', type: ContentType.Text, content: 'Hello world 2' }] },
+ {
+ id: 'calloutblock',
+ type: 'Callout',
+ children: [
+ {
+ id: 'calloutchild1',
+ type: 'Paragraph',
+ content: [{ id: 'callout-child1-content', type: ContentType.Text, content: 'Callout child 1' }]
+ },
+ {
+ id: 'calloutchild2',
+ type: 'Paragraph',
+ content: [{ id: 'callout-child2-content', type: ContentType.Text, content: 'Callout child 2' }]
+ }
+ ]
+ },
+ {
+ id: 'toggleblock',
+ type: BlockType.ToggleParagraph,
+ content: [{ id: 'toggle-content-1', type: ContentType.Text, content: 'Click here to expand' }],
+ children: [
+ {
+ id: 'togglechild1',
+ type: BlockType.CheckList,
+ content: [{ type: ContentType.Text, content: 'Todo' }]
+ },
+ {
+ id: 'togglechild2',
+ type: BlockType.Paragraph,
+ content: [{ type: ContentType.Text, content: 'Toggle child 2' }]
+ }
+ ]
+ }
+ ];
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterAll(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('should update focused blocks on mousemove', () => {
+ const blockElement1 = editorElement.querySelector('#paragraph1') as HTMLElement;
+ const contentElement1 = blockElement1.querySelector('.e-block-content') as HTMLElement;
+ const blockElement2 = editorElement.querySelector('#paragraph2') as HTMLElement;
+ const contentElement2 = blockElement2.querySelector('.e-block-content') as HTMLElement;
+ editor.setFocusToBlock(blockElement1);
+
+ triggerMouseMove(blockElement2, 10, 10);
+ expect(editor.currentHoveredBlock.id).toBe(blockElement2.id);
+
+ triggerMouseMove(editorElement, 0, 0);
+ expect(editor.currentHoveredBlock).toBeNull();
+ });
+
+ it('should update focused blocks on mouseup', () => {
+ const blockElement1 = editorElement.querySelector('#paragraph1') as HTMLElement;
+ const contentElement1 = blockElement1.querySelector('.e-block-content') as HTMLElement;
+ const blockElement2 = editorElement.querySelector('#paragraph2') as HTMLElement;
+ const contentElement2 = blockElement2.querySelector('.e-block-content') as HTMLElement;
+ editor.setFocusToBlock(blockElement1);
+
+ blockElement2.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
+ expect(editor.currentFocusedBlock.id).toBe(blockElement2.id);
+ });
+
+ it('should update focused blocks on arrow keys', (done) => {
+ setTimeout(() => {
+ const blockElement1 = editorElement.querySelector('#paragraph1') as HTMLElement;
+ const contentElement1 = blockElement1.querySelector('.e-block-content') as HTMLElement;
+ const blockElement2 = editorElement.querySelector('#paragraph2') as HTMLElement;
+ const contentElement2 = blockElement2.querySelector('.e-block-content') as HTMLElement;
+ editor.setFocusToBlock(blockElement2);
+ setCursorPosition(contentElement1, 3);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', code: 'ArrowUp' }));
+ setTimeout(() => {
+ expect(editor.currentFocusedBlock.id).toBe(blockElement1.id);
+ setCursorPosition(contentElement2, 3);
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', code: 'ArrowDown' }));
+ setTimeout(() => {
+ expect(editor.currentFocusedBlock.id).toBe(blockElement2.id);
+ done();
+ }, 300);
+ }, 300);
+ }, 200);
+ });
+
+ it('should handle home and end key actions properly', () => {
+ const blockElement1 = editorElement.querySelector('#paragraph1') as HTMLElement;
+ const contentElement1 = blockElement1.querySelector('.e-block-content') as HTMLElement;
+ editor.setFocusToBlock(blockElement1);
+ setCursorPosition(contentElement1, 5);
+
+ //Trigger home key
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', code: 'Home' }));
+ expect(editor.currentFocusedBlock.id).toBe(blockElement1.id);
+ expect(editor.nodeSelection.getRange().startOffset).toBe(0);
+
+ //Trigger end key
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', code: 'End' }));
+ expect(editor.currentFocusedBlock.id).toBe(blockElement1.id);
+ expect(editor.nodeSelection.getRange().startOffset).toBe(contentElement1.textContent.length)
+ });
+
+ it('should exit callout on enter press in empty block', () => {
+ const blockElement1 = editorElement.querySelector('#calloutchild2') as HTMLElement;
+ const contentElement1 = blockElement1.querySelector('.e-block-content') as HTMLElement;
+ editor.setFocusToBlock(blockElement1);
+ setCursorPosition(contentElement1, contentElement1.textContent.length);
+
+ // On first enter, a new child block should be created
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter' }));
+ expect(editor.blocks[2].children.length).toBe(3);
+
+ // On second enter, the callout should be exited since the block is empty
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter' }));
+ expect(editor.blocks[2].children.length).toBe(2);
+ expect(editor.currentFocusedBlock.id).toBe(editor.blocks[3].id);
+ });
+
+ it('should expand the toggle block on enter press when content is empty', () => {
+ const blockElement1 = editorElement.querySelector('#paragraph1') as HTMLElement;
+ const contentElement1 = blockElement1.querySelector('.e-block-content') as HTMLElement;
+ editor.setFocusToBlock(blockElement1);
+ setCursorPosition(contentElement1, contentElement1.textContent.length);
+
+ editor.addBlock({
+ id: 'newtoggle',
+ type: 'ToggleParagraph'
+ }, 'paragraph1');
+
+ const newBlockElement = editorElement.querySelector('#newtoggle') as HTMLElement;
+ const newBlockContent = newBlockElement.querySelector('.e-block-content') as HTMLElement;
+
+ editor.setFocusToBlock(newBlockElement);
+ setCursorPosition(newBlockContent, 0);
+
+ // On enter, the toggle block should be expanded and focus should be moved to children block
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter' }));
+ expect(editor.blocks[1].isExpanded).toBe(true);
+ expect(editor.blocks[1].children.length).toBe(1);
+ expect(editor.currentFocusedBlock.id).toBe(editor.blocks[1].children[0].id);
+
+ // On second enter, the toggle should be exited since the block is empty
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter' }));
+ expect(editor.blocks[1].children.length).toBe(1);
+ expect(editor.currentFocusedBlock.id).toBe(editor.blocks[2].id);
+ });
+
+ it('should reduce indent on empty block while enter press', () => {
+ const blockElement1 = editorElement.querySelector('#paragraph1') as HTMLElement;
+ const contentElement1 = blockElement1.querySelector('.e-block-content') as HTMLElement;
+ editor.setFocusToBlock(blockElement1);
+ setCursorPosition(contentElement1, 5);
+
+ editor.blockAction.handleBlockIndentation({
+ blockIDs: ['paragraph1'],
+ shouldDecrease: false,
+ });
+ expect(editor.blocks[0].indent).toBe(1);
+
+ contentElement1.textContent = '';
+ setCursorPosition(contentElement1, 0);
+
+ // On enter, the indent should be reduced since the block is empty
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter' }));
+ expect(editor.blocks[0].indent).toBe(0);
+ });
+
+ });
+
+ describe('Other actions', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeAll(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'paragraph1', type: BlockType.BulletList, content: [{ id: 'paragraph1-content', type: ContentType.Text, content: 'Hello world' }] },
+ { id: 'paragraph2', type: BlockType.BulletList, content: [{ id: 'paragraph2-content', type: ContentType.Text, content: 'Paragraph 2' }] },
+ { id: 'paragraph3', type: BlockType.Paragraph,
+ content: [
+ { id: 'bold', type: ContentType.Text, content: 'Bold', styles: { bold: true } },
+ { id: 'italic', type: ContentType.Text, content: 'Italic', styles: { italic: true } },
+ ]
+ },
+ { id: 'paragraph4', type: BlockType.Paragraph,
+ content: [
+ { id: 'underline', type: ContentType.Text, content: 'Underline', styles: { underline: true } },
+ { id: 'strikethrough', type: ContentType.Text, content: 'Strikethrough', styles: { strikethrough: true } },
+ ]
+ },
+ ];
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterAll(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('should handle null values properly', (done) => {
+ const rangeSpy1 = spyOn(editor.nodeSelection, 'getRange').and.returnValue(null);
+ const blockElement1 = editorElement.querySelector('#paragraph1') as HTMLElement;
+ const contentElement1 = blockElement1.querySelector('.e-block-content') as HTMLElement;
+ editor.setFocusToBlock(blockElement1);
+
+ expect((editor as any).handleEditorSelection()).toBeUndefined();
+ expect((editor as any).handleLineBreaksOnBlock(blockElement1)).toBeUndefined();
+ rangeSpy1.calls.reset();
+
+ editor.readOnly = true;
+ expect((editor as any).handleMouseUpActions()).toBeUndefined();
+ expect((editor as any).handleMouseDownActions()).toBeUndefined();
+ editor.readOnly = false;
+
+ let isExecuted = false;
+ editor.keyActionExecuted = () => {
+ isExecuted = true;
+ };
+
+ spyOn(editor, 'notify').and.callFake(function () { done(); });
+ editorElement.dispatchEvent(new ClipboardEvent('copy'));
+ editorElement.dispatchEvent(new ClipboardEvent('paste'));
+ editorElement.dispatchEvent(new ClipboardEvent('cut'));
+
+ expect(isExecuted).toBe(true);
+
+ editor.getRange = jasmine.createSpy().and.returnValue({
+ commonAncestorContainer: editorElement
+ });
+ spyOn(editor.inlineToolbarModule, 'hideInlineToolbar').and.callThrough();
+ expect((editor as any).handleTextSelection());
+ expect(editor.inlineToolbarModule.hideInlineToolbar).toHaveBeenCalled();
+
+ setCursorPosition(contentElement1, 0);
+ const range = getSelectionRange();
+ expect((editor as any).handleArrowKeyActions({ key: 'ArrowUp' }, range, blockElement1 )).toBeUndefined();
+
+ done();
+ });
+
+ });
+});
\ No newline at end of file
diff --git a/controls/blockeditor/spec/managers/undo-redo-manager.spec.ts b/controls/blockeditor/spec/managers/undo-redo-manager.spec.ts
new file mode 100644
index 0000000000..fa804ddd31
--- /dev/null
+++ b/controls/blockeditor/spec/managers/undo-redo-manager.spec.ts
@@ -0,0 +1,305 @@
+import { createElement, remove } from '@syncfusion/ej2-base';
+import { BlockModel } from '../../src/blockeditor/models';
+import { BlockEditor, BlockType, ContentType, setCursorPosition, getBlockContentElement, BuiltInToolbar } from '../../src/index';
+import { createEditor } from '../common/util.spec';
+
+describe('Undo Redo Manager:', () => {
+ beforeAll(() => {
+ const isDef: any = (o: any) => o !== undefined && o !== null;
+ if (!isDef(window.performance)) {
+ console.log('Unsupported environment, window.performance.memory is unavailable');
+ pending(); // skips test (in Chai)
+ return;
+ }
+ });
+
+ function triggerUndo(editorElement: HTMLElement) : void {
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, code: 'KeyZ' }));
+ }
+
+ function triggerRedo(editorElement: HTMLElement) : void {
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'y', ctrlKey: true, code: 'KeyY' }));
+ }
+
+ function createMockClipboardEvent(type: string, clipboardData: any = {}): ClipboardEvent {
+ const event: any = {
+ type,
+ preventDefault: jasmine.createSpy(),
+ clipboardData: clipboardData,
+ bubbles: true,
+ cancelable: true
+ };
+ return event as ClipboardEvent;
+ }
+
+ describe('Main actions', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'paragraph1', type: BlockType.Paragraph, content: [{ id: 'paragraph1-content', type: ContentType.Text, content: 'Hello world 1' }] },
+ { id: 'paragraph2', type: BlockType.Paragraph, content: [{ id: 'paragraph2-content', type: ContentType.Text, content: 'Hello world 2' }] },
+ {
+ id: 'calloutblock',
+ type: 'Callout',
+ children: [
+ {
+ id: 'calloutchild1',
+ type: 'Paragraph',
+ content: [{ id: 'callout-child1-content', type: ContentType.Text, content: 'Callout child 1' }]
+ },
+ {
+ id: 'calloutchild2',
+ type: 'Paragraph',
+ content: [{ id: 'callout-child2-content', type: ContentType.Text, content: 'Callout child 2' }]
+ }
+ ]
+ },
+ {
+ id: 'toggleblock',
+ type: BlockType.ToggleParagraph,
+ content: [{ id: 'toggle-content-1', type: ContentType.Text, content: 'Click here to expand' }],
+ children: [
+ {
+ id: 'togglechild1',
+ type: BlockType.CheckList,
+ content: [{ type: ContentType.Text, content: 'Todo' }]
+ },
+ {
+ id: 'togglechild2',
+ type: BlockType.Paragraph,
+ content: [{ type: ContentType.Text, content: 'Toggle child 2' }]
+ }
+ ]
+ },
+ { id: 'paragraph3', type: BlockType.Paragraph, content: [{ id: 'paragraph3-content', type: ContentType.Text, content: 'Hello world 3' }] },
+ ];
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('formatting on user typing - undo redo', (done) => {
+ setTimeout(() => {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ const contentElement = getBlockContentElement(blockElement);
+ editor.setFocusToBlock(blockElement);
+
+ // Activate bold formatting
+ editorElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', code: 'KeyB', ctrlKey: true }));
+
+ // Simulate typing a character
+ contentElement.textContent = 'Hello worldx';
+
+ // Create a collapsed selection at the end of text
+ setCursorPosition(contentElement, contentElement.textContent.length);
+
+ editor.formattingAction.handleTypingWithActiveFormats();
+
+ // Last character should be bold now
+ expect(contentElement.querySelector('strong')).not.toBeNull();
+ expect(editor.blocks[0].content[1].styles.bold).toBe(true);
+
+ triggerUndo(editorElement);
+ expect(contentElement.querySelector('strong')).toBeNull();
+ expect(editor.blocks[0].content.length).toBe(1);
+
+ triggerRedo(editorElement);
+ expect(contentElement.querySelector('strong')).not.toBeNull();
+ expect(editor.blocks[0].content[1].styles.bold).toBe(true);
+ done();
+ }, 100);
+ });
+
+ it('move blocks within callout type - undo redo', () => {
+ const blockElement = editorElement.querySelector('#calloutchild2') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ editor.blockAction.moveBlock({
+ fromBlockIds: ['calloutchild2'], toBlockId: 'calloutchild1'
+ });
+ expect(editor.blocks[2].children.length).toBe(2);
+ expect(editor.blocks[2].children[0].id).toBe('calloutchild2');
+ expect(editor.blocks[2].children[1].id).toBe('calloutchild1');
+
+ triggerUndo(editorElement);
+ // expect(editor.blocks[2].children.length).toBe(2);
+ // expect(editor.blocks[2].children[0].id).toBe('calloutchild1');
+ // expect(editor.blocks[2].children[1].id).toBe('calloutchild2');
+
+ triggerRedo(editorElement);
+ // expect(editor.blocks[2].children.length).toBe(2);
+ // expect(editor.blocks[2].children[0].id).toBe('calloutchild2');
+ // expect(editor.blocks[2].children[1].id).toBe('calloutchild1');
+ });
+
+ it('Selective paste action - undo redo', (done) => {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+
+ editor.setSelection('paragraph1-content', 0, 13);
+ const copiedData = editor.clipboardAction.getClipboardPayload().blockeditorData;
+
+ const mockClipboard: any = {
+ setData: jasmine.createSpy(),
+ getData: (format: string) => {
+ if (format === 'text/blockeditor') {
+ return copiedData;
+ }
+ return '';
+ }
+ };
+
+ editor.clipboardAction.handleCopy(createMockClipboardEvent('copy', mockClipboard));
+
+ editor.setSelection('paragraph1-content', 1, 12);
+
+ editor.clipboardAction.handlePaste(createMockClipboardEvent('paste', mockClipboard));
+
+ setTimeout(() => {
+ const contentElement = getBlockContentElement(blockElement);
+ expect(editor.blocks[0].content.length).toBe(3);
+ expect(editor.blocks[0].content[0].content).toBe('H');
+ expect(editor.blocks[0].content[1].content).toBe('Hello world 1');
+ expect(editor.blocks[0].content[2].content).toBe('1');
+ expect(contentElement.childNodes.length).toBe(3);
+
+ triggerUndo(editorElement);
+ expect(editor.blocks[0].content.length).toBe(1);
+ expect(editor.blocks[0].content[0].content).toBe('Hello world 1');
+ expect(contentElement.childNodes.length).toBe(1);
+
+ triggerRedo(editorElement);
+ expect(editor.blocks[0].content.length).toBe(3);
+ expect(editor.blocks[0].content[0].content).toBe('H');
+ expect(editor.blocks[0].content[1].content).toBe('Hello world 1');
+ expect(editor.blocks[0].content[2].content).toBe('1');
+ expect(contentElement.childNodes.length).toBe(3);
+ done();
+ }, 200);
+ });
+
+ it('transform into image block - undo redo', (done) => {
+ const blockElement = editorElement.querySelector('#paragraph1') as HTMLElement;
+ const contentElement = blockElement.querySelector('.e-block-content') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ contentElement.textContent = '';
+ editor.updateContentOnUserTyping(blockElement);
+ setCursorPosition(contentElement, 0);
+ // Mock file blob for paste
+ const imageBlob = new Blob(['fake-image-data'], { type: 'image/png' });
+
+ editor.blockAction.imageRenderer.handleFilePaste(imageBlob).then(() => {
+ setTimeout(() => {
+ // Should transform the empty paragraph to image
+ expect(editor.blocks[0].type).toBe('Image');
+ expect(editorElement.querySelector('.e-image-block')).not.toBeNull();
+
+ triggerUndo(editorElement);
+ expect(editor.blocks[0].type).toBe('Paragraph');
+ expect(editorElement.querySelector('.e-image-block')).toBeNull();
+
+ triggerRedo(editorElement);
+ setTimeout(() => {
+ expect(editor.blocks[0].type).toBe('Image');
+ expect(editorElement.querySelector('.e-image-block')).not.toBeNull();
+
+ done();
+ }, 200);
+ }, 100);
+ })
+ });
+
+ it('Partial deletion inside callout type - Undo redo', (done) => {
+ setTimeout(() => {
+ const range = document.createRange();
+ const selection = document.getSelection();
+ const startBlockElement = editorElement.querySelector('#calloutchild1') as HTMLElement;
+ const startNode = startBlockElement.querySelector('.e-block-content').firstChild;
+ const startOffset = 8;
+ const endBlockElement = editorElement.querySelector('#calloutchild2') as HTMLElement;
+ const endNode = endBlockElement.querySelector('.e-block-content').firstChild;
+ const endOffset = 8;
+
+ range.setStart(startNode, startOffset);
+ range.setEnd(endNode, endOffset);
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ editor.setFocusToBlock(startBlockElement);
+
+ editor.element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', code: 'Backspace', bubbles: true }));
+ expect(editor.blocks[2].children.length).toBe(1);
+ expect(editor.blocks[2].children[0].type).toBe(BlockType.Paragraph);
+ expect(editor.blocks[2].children[0].content.length).toBe(1);
+ expect(editor.blocks[2].children[0].content[0].content).toBe('Callout child 2');
+ expect(getBlockContentElement(startBlockElement).childNodes[0].textContent).toBe('Callout child 2');
+
+ triggerUndo(editorElement);
+ expect(editor.blocks[2].children.length).toBe(2);
+ expect(editorElement.querySelector('#calloutblock').querySelectorAll('.e-block').length).toBe(2);
+
+ triggerRedo(editorElement);
+ expect(editor.blocks[2].children.length).toBe(1);
+ expect(editor.blocks[2].children[0].type).toBe(BlockType.Paragraph);
+ expect(editor.blocks[2].children[0].content.length).toBe(1);
+ expect(editor.blocks[2].children[0].content[0].content).toBe('Callout child 2');
+ expect(getBlockContentElement(startBlockElement).childNodes[0].textContent).toBe('Callout child 2');
+ done();
+ }, 200);
+ });
+
+ });
+
+ describe('Other actions', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+ const blocks: BlockModel[] = [
+ { id: 'paragraph1', type: BlockType.BulletList, content: [{ id: 'paragraph1-content', type: ContentType.Text, content: 'Hello world' }] },
+ { id: 'paragraph2', type: BlockType.BulletList, content: [{ id: 'paragraph2-content', type: ContentType.Text, content: 'Paragraph 2' }] },
+ { id: 'paragraph3', type: BlockType.Paragraph,
+ content: [
+ { id: 'bold', type: ContentType.Text, content: 'Bold', styles: { bold: true } },
+ { id: 'italic', type: ContentType.Text, content: 'Italic', styles: { italic: true } },
+ ]
+ },
+ { id: 'paragraph4', type: BlockType.Paragraph,
+ content: [
+ { id: 'underline', type: ContentType.Text, content: 'Underline', styles: { underline: true } },
+ { id: 'strikethrough', type: ContentType.Text, content: 'Strikethrough', styles: { strikethrough: true } },
+ ]
+ },
+ ];
+ editor = createEditor({ blocks: blocks });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ remove(editorElement);
+ });
+
+ it('should handle null values properly', () => {
+ expect((editor.undoRedoAction as any).restorePartialDeletion({ data: { deletedBlocks: [] }})).toBeUndefined();
+
+ expect((editor.undoRedoAction as any).createBlock({})).toBeUndefined();
+ });
+
+ });
+});
\ No newline at end of file
diff --git a/controls/blockeditor/spec/plugins/blockaction-menu.spec.ts b/controls/blockeditor/spec/plugins/blockaction-menu.spec.ts
new file mode 100644
index 0000000000..d39c2fc961
--- /dev/null
+++ b/controls/blockeditor/spec/plugins/blockaction-menu.spec.ts
@@ -0,0 +1,598 @@
+import { createElement } from '@syncfusion/ej2-base';
+import { BlockActionItemClickEventArgs, BlockActionItemModel, BlockActionMenuCloseEventArgs, BlockActionMenuOpenEventArgs, BlockEditor, BlockType, ContentType, getBlockContentElement } from '../../src/index';
+import { createEditor } from '../common/util.spec';
+
+describe('Block Action Menu', () => {
+ beforeAll(() => {
+ const isDef: any = (o: any) => o !== undefined && o !== null;
+ if (!isDef(window.performance)) {
+ console.log('Unsupported environment, window.performance.memory is unavailable');
+ pending();
+ return;
+ }
+ });
+
+ function triggerMouseMove(node: HTMLElement, x: number, y: number): void {
+ const event = new MouseEvent('mousemove', { bubbles: true, cancelable: true, clientX: x, clientY: y });
+ node.dispatchEvent(event);
+ }
+
+ describe('Default actions testing', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+
+ editor = createEditor({
+ blocks: [
+ {
+ id: 'paragraph1',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'content1', type: ContentType.Text, content: 'Test content 1' }
+ ]
+ },
+ {
+ id: 'paragraph2',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'content2', type: ContentType.Text, content: 'Test content 2' }
+ ]
+ },
+ ]
+ });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ document.body.removeChild(editorElement);
+ });
+
+ it('should open the popup on drag icon click', (done) => {
+ const blockElement = editor.element.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ triggerMouseMove(blockElement, 10, 10);
+ const dragIcon = editor.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ expect(dragIcon).not.toBeNull();
+ dragIcon.click();
+ setTimeout(() => {
+ expect(editor.blockActionMenuModule.popupObj.element.classList.contains('e-popup-open')).toBe(true);
+ done();
+ }, 200);
+ });
+
+ it('should duplicate the block properly', (done) => {
+ const blockElement = editor.element.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ triggerMouseMove(blockElement, 10, 10);
+ editor.blockActionMenuModule.toggleBlockActionPopup(false);
+ setTimeout(() => {
+ expect(editor.blockActionMenuModule.popupObj.element.classList.contains('e-popup-open')).toBe(true);
+ const popup = document.querySelector('.e-blockeditor-blockaction-popup');
+ (popup.querySelector('#duplicate') as HTMLElement).click();
+ expect(editor.blocks.length).toBe(3);
+ expect(editor.blocks[1].content[0].content).toBe('Test content 1');
+ expect(editor.blocks[0].id !== editor.blocks[1].id).toBe(true);
+ expect(blockElement.nextElementSibling.textContent).toBe('Test content 1');
+ expect(blockElement.id !== blockElement.nextElementSibling.id).toBe(true);
+ done();
+ }, 200);
+ });
+
+ it('should delete the block properly', (done) => {
+ const blockElement = editor.element.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ triggerMouseMove(blockElement, 10, 10);
+ editor.blockActionMenuModule.toggleBlockActionPopup(false);
+ setTimeout(() => {
+ expect(editor.blockActionMenuModule.popupObj.element.classList.contains('e-popup-open')).toBe(true);
+ const popup = document.querySelector('.e-blockeditor-blockaction-popup');
+ (popup.querySelector('#delete') as HTMLElement).click();
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.blocks[0].content[0].content).toBe('Test content 2');
+ expect(editor.element.querySelector('#paragraph1')).toBeNull();
+ done();
+ }, 200);
+ });
+
+ it('should delete the last block properly', (done) => {
+ const blockElement = editor.element.querySelector('#paragraph2') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ triggerMouseMove(blockElement, 10, 10);
+ editor.blockActionMenuModule.toggleBlockActionPopup(false);
+ setTimeout(() => {
+ expect(editor.blockActionMenuModule.popupObj.element.classList.contains('e-popup-open')).toBe(true);
+ const popup = document.querySelector('.e-blockeditor-blockaction-popup');
+ (popup.querySelector('#delete') as HTMLElement).click();
+ expect(editor.blocks.length).toBe(1);
+ expect(editor.blocks[0].content[0].content).toBe('Test content 1');
+ expect(editor.element.querySelector('#paragraph2')).toBeNull();
+ done();
+ }, 200);
+ });
+
+ it('should move down the block properly', (done) => {
+ const blockElement = editor.element.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ triggerMouseMove(blockElement, 10, 10);
+ editor.blockActionMenuModule.toggleBlockActionPopup(false);
+ setTimeout(() => {
+ expect(editor.blockActionMenuModule.popupObj.element.classList.contains('e-popup-open')).toBe(true);
+ const popup = document.querySelector('.e-blockeditor-blockaction-popup');
+
+ //Move down
+ (popup.querySelector('#movedown') as HTMLElement).click();
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[0].content[0].content).toBe('Test content 2');
+ expect(editor.blocks[1].content[0].content).toBe('Test content 1');
+
+ const allBlocks = editor.element.querySelectorAll('.e-block');
+ expect(allBlocks[0].id).toBe('paragraph2');
+ expect(allBlocks[1].id).toBe('paragraph1');
+ done();
+ }, 200);
+
+ });
+
+ it('should move up the block properly', (done) => {
+ const blockElement = editor.element.querySelector('#paragraph2') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ triggerMouseMove(blockElement, 10, 10);
+ editor.blockActionMenuModule.toggleBlockActionPopup(false);
+ setTimeout(() => {
+ expect(editor.blockActionMenuModule.popupObj.element.classList.contains('e-popup-open')).toBe(true);
+ const popup = document.querySelector('.e-blockeditor-blockaction-popup');
+
+ //Move down
+ (popup.querySelector('#moveup') as HTMLElement).click();
+ expect(editor.blocks.length).toBe(2);
+ expect(editor.blocks[0].content[0].content).toBe('Test content 2');
+ expect(editor.blocks[1].content[0].content).toBe('Test content 1');
+
+ const allBlocks = editor.element.querySelectorAll('.e-block');
+ expect(allBlocks[0].id).toBe('paragraph2');
+ expect(allBlocks[1].id).toBe('paragraph1');
+ done();
+ }, 200);
+
+ });
+
+ it('should trigger the action using shortcut key', () => {
+ const blockElement = editor.element.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+
+ //Trigger Ctrl + D to duplicate the block
+ editor.element.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyD', key: 'D', ctrlKey: true }));
+ expect(editor.blocks.length).toBe(3);
+ expect(editor.blocks[0].content[0].content).toBe('Test content 1');
+ expect(editor.blocks[1].content[0].content).toBe('Test content 1');
+ expect(editor.blocks[0].id !== editor.blocks[1].id).toBe(true);
+ expect(editor.blocks[2].content[0].content).toBe('Test content 2');
+
+ expect(blockElement.nextElementSibling.textContent).toBe('Test content 1');
+ expect(blockElement.id !== blockElement.nextElementSibling.id).toBe(true);
+ });
+
+ it('should display the tooltip properly', (done) => {
+ const blockElement = editor.element.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ triggerMouseMove(blockElement, 10, 10);
+ editor.blockActionMenuModule.toggleBlockActionPopup(false);
+ setTimeout(() => {
+ const popup = document.querySelector('.e-blockeditor-blockaction-popup');
+ expect(popup).not.toBeNull();
+
+ // Simulate hover on the item
+ const item1 = popup.querySelector('#duplicate') as HTMLElement;
+ item1.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
+
+ setTimeout(() => {
+ const tooltip1 = document.querySelector('.e-blockeditor-blockaction-tooltip');
+ expect(tooltip1).not.toBeNull();
+ expect(tooltip1.textContent).toContain('Duplicates a block');
+
+ // Simulate hover on the another item
+ const item2 = popup.querySelector('#delete') as HTMLElement;
+ item2.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
+
+ setTimeout(() => {
+ const tooltip2 = document.querySelector('.e-blockeditor-blockaction-tooltip');
+ expect(tooltip2).not.toBeNull();
+ expect(tooltip2.textContent).toContain('Deletes a block');
+ done();
+ }, 10);
+ }, 10);
+ }, 200);
+ });
+
+ it('should recalculate markers for list items on move', function (done) {
+ const blockElement = editor.element.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ const block1 = {
+ id: 'list1',
+ type: BlockType.NumberedList,
+ content: [
+ { type: ContentType.Text, content: 'List 1' }
+ ]
+ };
+ const block2 = {
+ id: 'list2',
+ type: BlockType.NumberedList,
+ content: [
+ { type: ContentType.Text, content: 'List 2' }
+ ]
+ }
+ editor.addBlock(block1);
+ editor.addBlock(block2);
+ const listBlock = editor.element.querySelector('#list1') as HTMLElement;
+ editor.setFocusToBlock(listBlock);
+ editor.currentHoveredBlock = listBlock;
+ editor.element.dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowDown', key: 'ArrowDown', ctrlKey: true, shiftKey: true }));
+ expect(getBlockContentElement(editor.element.querySelector('#list1')).style.getPropertyValue('list-style-type')).toBe('"2. "');
+ expect(getBlockContentElement(editor.element.querySelector('#list2')).style.getPropertyValue('list-style-type')).toBe('"1. "');
+ done();
+ });
+ });
+
+ describe('Events and Custom items', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+ let isOpened = false;
+ let isClosed = false;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+
+ editor = createEditor({
+ blocks: [
+ {
+ id: 'paragraph1',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'content1', type: ContentType.Text, content: 'Test content 1' }
+ ]
+ },
+ {
+ id: 'paragraph2',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'content2', type: ContentType.Text, content: 'Test content 2' }
+ ]
+ },
+ ],
+ blockActionsMenu: {
+ enableTooltip: false,
+ items: [
+ {
+ id: 'highlight-action',
+ label: 'Highlight Block',
+ iconCss: 'e-icons e-highlight',
+ tooltip: 'Highlight this block'
+ },
+ {
+ id: 'copy-content-action',
+ label: 'Copy Content',
+ iconCss: 'e-icons e-copy',
+ tooltip: 'Copy block content to clipboard'
+ },
+ {
+ id: 'block-info-action',
+ label: 'Block Info',
+ tooltip: 'Show block information'
+ },
+ {
+ id: 'duplicate',
+ label: 'Duplicate',
+ }
+ ]
+ }
+ });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ isOpened = false;
+ isClosed = false;
+ }
+ document.body.removeChild(editorElement);
+ });
+
+ it('should render custom items properly', (done) => {
+ setTimeout(() => {
+ const popup = document.querySelector('.e-blockeditor-blockaction-popup');
+ expect(popup).not.toBeNull();
+ expect(popup.querySelectorAll('li').length).toBe(4);
+ done();
+ }, 200);
+ });
+
+ it('should not render tooltip when enableTooltip is false', (done) => {
+ setTimeout(() => {
+ expect((editor.blockActionMenuModule as any).blockActionTooltip).toBeUndefined();
+ done();
+ }, 200);
+ });
+
+ it('should trigger open and close events', (done) => {
+ editor.blockActionsMenu.open = (args: BlockActionMenuOpenEventArgs) => {
+ isOpened = true;
+ },
+ editor.blockActionsMenu.close = (args: BlockActionMenuCloseEventArgs) => {
+ isClosed = true;
+ },
+ editor.blockActionMenuModule.toggleBlockActionPopup(false);
+ expect(isOpened).toBe(true);
+
+ editor.blockActionMenuModule.toggleBlockActionPopup(true);
+ expect(isClosed).toBe(true);
+ done();
+ });
+
+ it('should cancel open event', (done) => {
+ const popup = document.querySelector('.e-blockeditor-blockaction-popup');
+ editor.blockActionsMenu.open = (args: BlockActionMenuOpenEventArgs) => {
+ args.cancel = true;
+ },
+ editor.blockActionMenuModule.toggleBlockActionPopup(false);
+ expect(popup.classList.contains('e-popup-open')).toBe(false);
+ done();
+ });
+
+ it('should cancel close event', (done) => {
+ const popup = document.querySelector('.e-blockeditor-blockaction-popup');
+ editor.blockActionsMenu.close = (args: BlockActionMenuCloseEventArgs) => {
+ args.cancel = true;
+ },
+ editor.blockActionMenuModule.toggleBlockActionPopup(false);
+ setTimeout(() => {
+ editor.blockActionMenuModule.toggleBlockActionPopup(true);
+ setTimeout(() => {
+ expect(popup.classList.contains('e-popup-open')).toBe(true);
+ done();
+ }, 100);
+ }, 100);
+ });
+
+ it('should cancel itemClick event', (done) => {
+ const popup = document.querySelector('.e-blockeditor-blockaction-popup');
+ editor.blockActionsMenu.itemClick = (args: BlockActionItemClickEventArgs) => {
+ args.cancel = true;
+ },
+ (editor.blockActionMenuModule as any).handleBlockActionMenuSelect(
+ {
+ event: undefined,
+ item: { id: 'duplicate', label: 'Duplicate' },
+ element: popup.querySelector('.e-blockeditor-blockaction-item li:last-child') as HTMLElement
+ });
+ expect(popup.querySelectorAll('li').length).toBe(4);
+ done();
+ });
+ });
+
+ describe('Disable scenarios testing', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+
+ editor = createEditor({
+ blocks: [
+ {
+ id: 'paragraph1',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'content1', type: ContentType.Text, content: 'Test content 1' }
+ ]
+ },
+ {
+ id: 'paragraph2',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'content2', type: ContentType.Text, content: 'Test content 2' }
+ ]
+ },
+ {
+ id: 'callout-block',
+ type: 'Callout',
+ children: [
+ {
+ id: 'callout-children',
+ type: BlockType.Paragraph,
+ content: [{
+ id: 'callout-content',
+ type: ContentType.Text,
+ content: 'Important: Block Editor supports various content types including Text, Link, Code, Mention, and Label.',
+ styles: {
+ bold: true
+ }
+ }]
+ }
+ ]
+ },
+ {
+ id: 'paragraph3',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'content2', type: ContentType.Text, content: 'Test content 3' }
+ ]
+ },
+ ]
+ });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ }
+ document.body.removeChild(editorElement);
+ });
+
+ it('should disable move up for first block', (done) => {
+ const blockElement = editor.element.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ triggerMouseMove(blockElement, 10, 10);
+ editor.blockActionMenuModule.toggleBlockActionPopup(false);
+ setTimeout(() => {
+ expect(editor.blockActionMenuModule.popupObj.element.classList.contains('e-popup-open')).toBe(true);
+ const popup = document.querySelector('.e-blockeditor-blockaction-popup');
+
+ expect((popup.querySelector('#moveup') as HTMLElement).classList.contains('e-disabled')).toBe(true);
+ done();
+ });
+
+ });
+
+ it('should disable move down for last block', (done) => {
+ const blockElement = editor.element.querySelector('#paragraph3') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ triggerMouseMove(blockElement, 10, 10);
+ editor.blockActionMenuModule.toggleBlockActionPopup(false);
+ setTimeout(() => {
+ expect(editor.blockActionMenuModule.popupObj.element.classList.contains('e-popup-open')).toBe(true);
+ const popup = document.querySelector('.e-blockeditor-blockaction-popup');
+
+ expect((popup.querySelector('#movedown') as HTMLElement).classList.contains('e-disabled')).toBe(true);
+ done();
+ });
+
+ });
+
+ it('should enable both move up and move down for normal block', (done) => {
+ const blockElement = editor.element.querySelector('#callout-block') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ triggerMouseMove(blockElement, 10, 10);
+ editor.blockActionMenuModule.toggleBlockActionPopup(false);
+ setTimeout(() => {
+ expect(editor.blockActionMenuModule.popupObj.element.classList.contains('e-popup-open')).toBe(true);
+ const popup = document.querySelector('.e-blockeditor-blockaction-popup');
+
+ expect((popup.querySelector('#moveup') as HTMLElement).classList.contains('e-disabled')).toBe(false);
+ expect((popup.querySelector('#movedown') as HTMLElement).classList.contains('e-disabled')).toBe(false);
+ done();
+ });
+ });
+
+ it('should disable both move up and move down for children block', function (done) {
+ var blockElement = editor.element.querySelector('#callout-children') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ triggerMouseMove(blockElement, 50, 10);
+ editor.currentHoveredBlock = blockElement;
+ editor.blockActionMenuModule.toggleBlockActionPopup(false);
+ setTimeout(function () {
+ expect(editor.blockActionMenuModule.popupObj.element.classList.contains('e-popup-open')).toBe(true);
+ var popup = document.querySelector('.e-blockeditor-blockaction-popup');
+ expect(popup.querySelector('#moveup').classList.contains('e-disabled')).toBe(true);
+ expect(popup.querySelector('#movedown').classList.contains('e-disabled')).toBe(true);
+ done();
+ });
+ });
+
+ it('should return when blockelement is null', function (done) {
+ (editor.blockActionMenuModule as any).toggleDisabledItems(null);
+ done();
+ });
+
+ it('should disable all items when multiple blocks are selected', function (done) {
+ var blockElement = editor.element.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ triggerMouseMove(blockElement, 10, 10);
+ editor.selectAllBlocks();
+ (editor.blockActionMenuModule as any).toggleDisabledItems(editor.element.querySelector('#paragraph1'));
+ var popup = document.querySelector('.e-blockeditor-blockaction-popup');
+ expect(popup.querySelector('#moveup').classList.contains('e-disabled')).toBe(true);
+ expect(popup.querySelector('#movedown').classList.contains('e-disabled')).toBe(true);
+ expect(popup.querySelector('#delete').classList.contains('e-disabled')).toBe(true);
+ expect(popup.querySelector('#duplicate').classList.contains('e-disabled')).toBe(true);
+ done();
+ });
+
+ it('should disable moveup firstblock when using shortcut key', function () {
+ var blockElement = editor.element.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ editor.element.dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowUp', key: 'ArrowUp', ctrlKey: true, shiftKey: true }));
+ expect(editor.blocks[0].content[0].content).toBe('Test content 1');
+ });
+ });
+
+ describe('On property change', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+
+ editor = createEditor({
+ blocks: [
+ {
+ id: 'paragraph1',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'content1', type: ContentType.Text, content: 'Test content 1' }
+ ]
+ }
+ ]
+ });
+ editor.appendTo('#editor');
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ editor = undefined;
+ }
+ document.body.removeChild(editorElement);
+ });
+
+ it('should not open popup when enable is set to false', (done) => {
+ editor.blockActionsMenu.enable = false;
+ const blockElement = editor.element.querySelector('#paragraph1') as HTMLElement;
+ editor.setFocusToBlock(blockElement);
+ triggerMouseMove(blockElement, 10, 10);
+ editor.blockActionMenuModule.toggleBlockActionPopup(false);
+ setTimeout(() => {
+ expect(editor.blockActionMenuModule.popupObj.element.classList.contains('e-popup-open')).toBe(false);
+ done();
+ });
+ });
+
+ it('should update popup width and height', (done) => {
+ editor.blockActionsMenu.popupWidth = '200px';
+ editor.blockActionsMenu.popupHeight = '200px';
+ setTimeout(() => {
+ expect(editor.blockActionMenuModule.popupObj.element.style.width).toBe('200px');
+ expect(editor.blockActionMenuModule.popupObj.element.style.height).toBe('200px');
+ done()
+ }, 200);
+ });
+
+ it('should update items dynamically', (done) => {
+ const items: BlockActionItemModel[] = [
+ { id: 'custom1', label: 'Custom Item 1', iconCss: 'e-icons e-copy' },
+ { id: 'custom2', label: 'Custom Item 2', iconCss: 'e-icons e-paste' }
+ ];
+ editor.blockActionsMenu.items = items;
+ setTimeout(() => {
+ const popup = document.querySelector('.e-blockeditor-blockaction-popup');
+ expect(popup.querySelectorAll('li').length).toBe(2);
+ expect(popup.querySelector('#custom1') !== null).toBe(true);
+ expect(popup.querySelector('#custom2') !== null).toBe(true);
+ done();
+ }, 200);
+ });
+ });
+
+});
diff --git a/controls/blockeditor/spec/plugins/clipboard-cleanup.spec.ts b/controls/blockeditor/spec/plugins/clipboard-cleanup.spec.ts
new file mode 100644
index 0000000000..2b8d24a7d8
--- /dev/null
+++ b/controls/blockeditor/spec/plugins/clipboard-cleanup.spec.ts
@@ -0,0 +1,2719 @@
+import { createElement } from '@syncfusion/ej2-base';
+import { BlockEditor, BlockType, ContentType } from '../../src/index';
+import { ClipboardCleanupModule } from '../../src/blockeditor/plugins/index';
+import { createEditor } from '../common/util.spec';
+
+describe('Clipboard Cleanup Module', () => {
+ let editor: BlockEditor;
+ let editorElement: HTMLElement;
+ let cleanupModule: ClipboardCleanupModule;
+
+ beforeEach(() => {
+ editorElement = createElement('div', { id: 'editor' });
+ document.body.appendChild(editorElement);
+
+ editor = createEditor({
+ blocks: [
+ {
+ id: 'paragraph1',
+ type: BlockType.Paragraph,
+ content: [
+ { id: 'content1', type: ContentType.Text, content: 'Test content' }
+ ]
+ }
+ ]
+ });
+
+ editor.appendTo('#editor');
+ cleanupModule = new ClipboardCleanupModule(editor);
+ });
+
+ afterEach(() => {
+ if (editor) {
+ editor.destroy();
+ }
+ document.body.removeChild(editorElement);
+ cleanupModule = null;
+ });
+
+ describe('cleanupPaste method', () => {
+ it('should handle plain text formatting when isPlainText is true', () => {
+ const plainText = 'Line 1\nLine 2\nLine 3';
+ editor.pasteSettings.plainText = true;
+ const result = cleanupModule.cleanupPaste({
+ plainText: plainText
+ });
+
+ expect(result).toBeDefined();
+ expect(result.indexOf('Line 1')).toBeGreaterThan(-1);
+ expect(result.indexOf('Line 2')).toBeGreaterThan(-1);
+ expect(result.indexOf('Line 3')).toBeGreaterThan(-1);
+ });
+
+ it('should detect MS Word content and clean it', () => {
+ const msWordHtml = 'Test Heading This is some bold text and italic text from Word.
1. Item 1
2. Item 2
';
+
+ const result = cleanupModule.cleanupPaste({
+ html: msWordHtml
+ });
+
+ expect(result).toBeDefined();
+ expect(result.indexOf('MsoNormal')).toBe(-1); // Should remove MS Word classes
+ expect(result).toContain('Test Heading');
+ expect(result.indexOf('')).toBe(-1); // Should remove comments
+ expect(result.indexOf('bold ')).toBeGreaterThan(-1); // Should preserve formatting
+ expect(result.indexOf('italic ')).toBeGreaterThan(-1);
+ expect(result.indexOf(' {
+ const html = 'This is a test paragraph with styling .
';
+
+ const result = cleanupModule.cleanupPaste({
+ html: html,
+ });
+
+ expect(result).toBeDefined();
+ expect(result.indexOf(' ';
+
+ const result = cleanupModule.cleanupPaste({
+ html: html
+ });
+
+ expect(result).toBeDefined();
+ expect(result.indexOf(''
+ }];
+
+ const html = renderContentAsHTML(content);
+ expect(html).not.toContain('
+ * ```
+ **/
+@NotifyPropertyChanges
+export class BlockEditor extends Component implements INotifyPropertyChanged {
+
+ /**
+ * Specifies the height of the editor.
+ * This property sets the height of the editor, which can be a string or number.
+ *
+ * @default '100%'
+ */
+ @Property('100%')
+ public height: string | number;
+
+ /**
+ * Specifies the width of the editor.
+ * This property sets the width of the editor, which can be a string or number.
+ *
+ * @default '100%'
+ */
+ @Property('100%')
+ public width: string | number;
+
+ /**
+ * Specifies a custom CSS class to apply to the editor.
+ * This property allows for additional styling by applying a custom CSS class.
+ *
+ * @default ''
+ */
+ @Property('')
+ public cssClass: string;
+
+ /**
+ * Specifies the locale for localization.
+ * This property sets the language and regional settings for the editor.
+ *
+ * @default ''
+ */
+ @Property('')
+ public locale: string;
+
+ /**
+ * Specifies custom keyboard shortcuts configuration.
+ * This property allows the definition of custom keyboard shortcuts for editor commands.
+ *
+ * @default null
+ */
+ @Property(null)
+ public keyConfig: { [key: string]: string };
+
+ /**
+ * Specifies the maximum size of the undo/redo stack.
+ * This property determines how many actions are stored for undo and redo functionality.
+ * With a default value of 30, it allows users to revert up to 30 operations.
+ *
+ * @default 30
+ */
+ @Property(30)
+ public undoRedoStack: number;
+
+ /**
+ * Specifies whether the editor is in read-only mode.
+ * This property prevents users from editing the content when set to true.
+ *
+ * @default false
+ */
+ @Property(false)
+ public readOnly: boolean;
+
+ /**
+ * Specifies whether HTML encoding is enabled.
+ * This property determines if the content will be encoded to escape special HTML characters.
+ *
+ * @default false
+ */
+ @Property(false)
+ public enableHtmlEncode: boolean;
+
+ /**
+ * Specifies whether the HTML sanitizer is enabled.
+ * This property determines if the HTML content will be sanitized to remove potentially harmful tags and attributes.
+ *
+ * @default true
+ */
+ @Property(true)
+ public enableHtmlSanitizer: boolean;
+
+ /**
+ * Specifies whether drag and drop functionality is enabled for the blocks.
+ * This property enables or disables drag-and-drop operations within the block editor.
+ *
+ * @default true
+ */
+ @Property(true)
+ public enableDragAndDrop: boolean;
+
+ /**
+ * Specifies whether URLs should automatically have "https://" added if the user does not include it.
+ * If disabled, URLs will be entered as-is, without any protocol prepends.
+ * This can be useful for internal links or specific use cases where the protocol is not required.
+ *
+ * @default true
+ */
+ @Property(true)
+ public enableAutoHttps: boolean;
+
+ /**
+ * Specifies an array of block models representing the content of the editor.
+ * This property holds the various blocks that make up the editor's content.
+ *
+ * @default []
+ */
+ @Collection([], Block)
+ public blocks: BlockModel[];
+
+ /**
+ * Specifies an array of user models representing the list of users.
+ * This property holds user details such as name, ID, and other properties.
+ *
+ * @default []
+ */
+ @Collection([], User)
+ public users: UserModel[];
+
+ /**
+ * Specifies configuration options for editor commands.
+ * This property allows customization of command behaviors within the editor.
+ *
+ * @default {}
+ */
+ @Complex({}, CommandMenuSettings)
+ public commandMenu: CommandMenuSettingsModel;
+
+ /**
+ * Specifies settings for the formatting toolbar.
+ * This property configures the toolbar that provides text formatting options.
+ *
+ * @default {}
+ */
+ @Complex({}, InlineToolbarSettings)
+ public inlineToolbar: InlineToolbarSettingsModel;
+
+ /**
+ * Specifies the configuration settings for the block actions menu.
+ * This property allows customization of the actions menu within the editor.
+ *
+ * @default {}
+ */
+ @Complex({}, BlockActionMenuSettings)
+ public blockActionsMenu: BlockActionMenuSettingsModel;
+
+ /**
+ * Specifies settings for the context menu.
+ * This property configures the context menu options that appear on right-click actions.
+ *
+ * @default {}
+ */
+ @Complex({}, ContextMenuSettings)
+ public contextMenu: ContextMenuSettingsModel;
+
+ /**
+ * Configures settings related to pasting content in the editor.
+ * This property utilizes the PasteSettingsModel to specify various options and behaviors for paste operations.
+ *
+ * @default {}
+ */
+ @Complex({}, PasteSettings)
+ public pasteSettings: PasteSettingsModel;
+
+ /**
+ * Configures settings related to label popup in the editor.
+ * This property utilizes the LabelSettingsModel to specify various options and behaviors for paste operations.
+ *
+ * @default {}
+ */
+ @Complex({}, LabelSettings)
+ public labelSettings: LabelSettingsModel;
+
+ /* Events */
+
+ /**
+ * Event triggered after the Blockeditor is rendered completely.
+ *
+ * @event created
+ */
+ @Event()
+ public created: EmitType;
+
+ /**
+ * Event triggered when the content of the block editor is changed.
+ * This event provides details about the changes made to the content.
+ *
+ * @event contentChanged
+ */
+ @Event()
+ public contentChanged: EmitType;
+
+ /**
+ * Event triggered when the selection in the block editor changes.
+ * This event provides details about the new selection state.
+ *
+ * @event selectionChanged
+ */
+ @Event()
+ public selectionChanged: EmitType;
+
+ /**
+ * Event triggered when an undo or redo operation is performed in the block editor.
+ * This event provides details about the undo/redo action that was executed.
+ *
+ * @event undoRedoPerformed
+ */
+ @Event()
+ public undoRedoPerformed: EmitType;
+
+ /**
+ * Event triggered when a block is added to the block editor.
+ * This event provides details about the newly added block.
+ *
+ * @event blockAdded
+ */
+ @Event()
+ public blockAdded: EmitType;
+
+ /**
+ * Event triggered when a block is removed from the block editor.
+ * This event provides details about the block being removed.
+ *
+ * @event blockRemoved
+ */
+ @Event()
+ public blockRemoved: EmitType;
+
+ /**
+ * Event triggered when a block is moved within the block editor.
+ * This event provides details about the moved block.
+ *
+ * @event blockMoved
+ */
+ @Event()
+ public blockMoved: EmitType;
+
+ /**
+ * Event triggered during the dragging operation of a block.
+ * This event provides details about the drag operation.
+ *
+ * @event blockDrag
+ */
+ @Event()
+ public blockDrag: EmitType;
+
+ /**
+ * Event triggered when the drag operation for a block starts.
+ * This event provides details about the initial stage of the drag.
+ *
+ * @event blockDragStart
+ */
+ @Event()
+ public blockDragStart: EmitType;
+
+ /**
+ * Event triggered when a block is dropped after a drag operation.
+ * This event provides details about the block drop action.
+ *
+ * @event blockDrop
+ */
+ @Event()
+ public blockDrop: EmitType;
+
+ /**
+ * Event triggered when the block editor gains focus.
+ * This event provides details about the focus action.
+ *
+ * @event focus
+ */
+ @Event()
+ public focus: EmitType;
+
+ /**
+ * Event triggered when the block editor loses focus.
+ * This event provides details about the blur action.
+ *
+ * @event blur
+ */
+ @Event()
+ public blur: EmitType;
+
+ /**
+ * Event triggered when a key action (both built-in and custom) is executed in the block editor component.
+ * This event provides detailed information about the executed key action, including the key combination,
+ * the action performed, whether the action was triggered by a custom key configuration, and the platform.
+ *
+ * @event keyActionExecuted
+ */
+ @Event()
+ public keyActionExecuted: EmitType;
+
+ /**
+ * Event triggered before a paste operation occurs in the block editor.
+ * This event allows interception or modification of the pasted content.
+ *
+ * @event beforePaste
+ */
+ @Event()
+ public beforePaste: EmitType;
+
+ /**
+ * Event triggered after a paste operation occurs in the block editor.
+ * This event provides details about the pasted content.
+ *
+ * @event afterPaste
+ */
+ @Event()
+ public afterPaste: EmitType;
+
+
+ /* Renderers */
+ /** @hidden */
+ public popupRenderer: PopupRenderer;
+
+ /** @hidden */
+ public mentionRenderer: MentionRenderer;
+
+ /** @hidden */
+ public tooltipRenderer: TooltipRenderer;
+
+ /** @hidden */
+ public menubarRenderer: MenuBarRenderer;
+
+ /* Plugins */
+ /** @hidden */
+ public inlineContentInsertionModule: InlineContentInsertionModule;
+ /** @hidden */
+ public slashCommandModule: SlashCommandModule;
+ /** @hidden */
+ public inlineToolbarModule: InlineToolbarModule;
+ /** @hidden */
+ public contextMenuModule: ContextMenuModule;
+ /** @hidden */
+ public blockActionMenuModule: BlockActionMenuModule;
+
+ /** @hidden */
+ public nodeSelection: NodeSelection;
+
+ /** @hidden */
+ public linkModule: LinkModule;
+
+ /* Actions */
+ /** @hidden */
+ public blockAction: BlockAction;
+
+ /** @hidden */
+ public formattingAction: FormattingAction;
+
+ /** @hidden */
+ public listBlockAction: ListBlockAction;
+
+ /** @hidden */
+ public dragAndDropAction: DragAndDropAction;
+
+ /** @hidden */
+ public blockEditorMethods: BlockEditorMethods;
+
+ /** @hidden */
+ public clipboardAction: ClipboardAction;
+
+ /* Objects */
+ private userMenuObj: Mention;
+ private labelMenuObj: Mention;
+ private addIconTooltip: Tooltip;
+ private dragIconTooltip: Tooltip;
+
+ /* Variables */
+ /** @hidden */
+ public overlayContainer: HTMLElement;
+
+ /** @hidden */
+ public floatingIconContainer!: HTMLElement;
+
+ /** @hidden */
+ public currentHoveredBlock: HTMLElement;
+
+ /** @hidden */
+ public currentFocusedBlock: HTMLElement;
+
+ /** @hidden */
+ public isPopupOpenedOnAddIconClick: boolean;
+
+ /** @hidden */
+ public blockWrapper: HTMLElement;
+
+ /** @hidden */
+ public blocksInternal: BlockModel[];
+
+ /** @hidden */
+ public keyCommandMap: Map;
+
+ private defaultKeyConfig: Record = {
+ bold: 'ctrl+b',
+ italic: 'ctrl+i',
+ underline: 'ctrl+u',
+ strikethrough: 'ctrl+shift+x',
+ link: 'ctrl+k',
+ print: 'ctrl+p'
+ };
+ /** @hidden */
+ public l10n: L10n;
+ private updateTimer: ReturnType;
+ /** @hidden */
+ public isEntireEditorSelected: boolean
+ /** @hidden */
+ public undoRedoAction: UndoRedoAction;
+ /** @hidden */
+ public previousSelection: IUndoRedoSelection | undefined = undefined;
+
+ /**
+ * Constructor for creating the component
+ *
+ * @param {BlockEditorModel} options - Specifies the BlockEditorModel model.
+ * @param {string | HTMLElement} element - Specifies the element to render as component.
+ * @private
+ */
+ public constructor(options?: BlockEditorModel, element?: string | HTMLElement) {
+ super(options, element);
+ }
+
+ /**
+ * Initialize the event handler
+ *
+ * @private
+ * @returns {void}
+ */
+ protected preRender(): void {
+ if (!this.element.id) { this.element.id = getUniqueID('e-' + this.getModuleName()); }
+ }
+
+ protected getDirective(): string {
+ return 'EJS-BLOCKEDITOR';
+ }
+
+ /**
+ * To get component name.
+ *
+ * @returns {string} - It returns the current module name.
+ * @private
+ */
+ public getModuleName(): string {
+ return 'blockeditor';
+ }
+
+ /**
+ * Get the properties to be maintained in the persisted state.
+ *
+ * @private
+ * @returns {string} - It returns the persisted data.
+ */
+ protected getPersistData(): string {
+ return this.addOnPersist([]);
+ }
+
+ protected render(): void {
+ this.initialize();
+ }
+
+ private initialize(): void {
+ this.updateInternalValues();
+ this.initializeLocale();
+ this.intializeEngines();
+ this.initializeKeyBindings();
+ this.setDimension();
+ this.setCssClass();
+ this.updateEditorReadyOnlyState();
+ this.populateUniqueIds(this.blocksInternal);
+ this.renderBlockWrapper();
+ this.initializeMentionModules();
+ this.renderBlocks(this.blocksInternal);
+ if (!this.overlayContainer) {
+ this.createOverlayContainer();
+ }
+ if (!this.floatingIconContainer) {
+ this.createFloatingIcons();
+ }
+ if (this.enableDragAndDrop) {
+ this.dragAndDropAction.wireDragEvents();
+ }
+ this.wireGlobalEvents();
+ this.applyRtlSettings();
+ }
+
+ private initializeLocale(): void {
+ this.l10n = new L10n(this.getModuleName(), {
+ paragraph: 'Write something or ‘/’ for commands.',
+ heading1: 'Heading 1',
+ heading2: 'Heading 2',
+ heading3: 'Heading 3',
+ heading4: 'Heading 4',
+ toggleParagraph: 'Toggle Paragraph',
+ toggleHeading1: 'Toggle Heading 1',
+ toggleHeading2: 'Toggle Heading 2',
+ toggleHeading3: 'Toggle Heading 3',
+ toggleHeading4: 'Toggle Heading 4',
+ bulletList: 'Add item',
+ numberedList: 'Add item',
+ checkList: 'Todo',
+ quote: 'Write a quote',
+ callout: 'Write a callout',
+ addIconTooltip: 'Click to insert below',
+ dragIconTooltipActionMenu: 'Click to open',
+ dragIconTooltip: '(Hold to drag)',
+ insertLink: 'Insert Link',
+ linkText: 'Text',
+ linkTextPlaceholder: 'Link text',
+ linkUrl: 'URL',
+ linkUrlPlaceholder: 'https://example.com',
+ linkTitle: 'Title',
+ linkTitlePlaceholder: 'Link title',
+ linkOpenInNewWindow: 'Open in new window',
+ linkInsert: 'Insert',
+ linkRemove: 'Remove',
+ linkCancel: 'Cancel',
+ codeCopyTooltip: 'Copy code'
+ }, this.locale);
+ }
+
+ private intializeEngines(): void {
+ this.blockEditorMethods = new BlockEditorMethods(this);
+ this.nodeSelection = new NodeSelection(this);
+ this.popupRenderer = new PopupRenderer(this);
+ this.menubarRenderer = new MenuBarRenderer(this);
+ this.mentionRenderer = new MentionRenderer(this);
+ this.tooltipRenderer = new TooltipRenderer(this);
+ this.blockAction = new BlockAction(this);
+ this.formattingAction = new FormattingAction(this);
+ this.listBlockAction = new ListBlockAction(this, this.blockAction);
+ this.dragAndDropAction = new DragAndDropAction(this);
+ this.undoRedoAction = new UndoRedoAction(this);
+ this.clipboardAction = new ClipboardAction(this);
+ this.inlineContentInsertionModule = new InlineContentInsertionModule(this);
+ this.linkModule = new LinkModule(this);
+ this.inlineToolbarModule = new InlineToolbarModule(this);
+ this.blockActionMenuModule = new BlockActionMenuModule(this);
+ this.contextMenuModule = new ContextMenuModule(this);
+ }
+
+ private updateInternalValues(): void {
+ this.blocksInternal = this.blocks.slice();
+ }
+
+ private setDimension(): void {
+ this.element.style.width = !isNOU(this.width) ? formatUnit(this.width) : this.element.style.width;
+ this.element.style.height = !isNOU(this.height) ? formatUnit(this.height) : this.element.style.height;
+ }
+
+ private setCssClass(): void {
+ if (this.cssClass) { this.element.classList.add(this.cssClass); }
+ }
+
+ private updateLocale(): void {
+ this.l10n.setLocale(this.locale);
+ // Manually update placeholder for current focused block alone, rest will be updated on further focus
+ if (this.currentFocusedBlock) {
+ this.togglePlaceholder(this.currentFocusedBlock, true);
+ }
+ this.UpdateFloatingIconTooltipContent();
+ this.notify('locale-changed', {});
+ }
+
+ private applyRtlSettings(): void {
+ this.element.classList.toggle('e-rtl', this.enableRtl);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const rtlTargets: any = [
+ this.userMenuObj,
+ this.labelMenuObj,
+ this.addIconTooltip,
+ this.dragIconTooltip,
+ this.contextMenuModule.contextMenuObj
+ ];
+
+ for (const target of rtlTargets) {
+ if (target) {
+ target.enableRtl = this.enableRtl;
+ }
+ }
+ this.notify('rtl-changed', {});
+ }
+
+ private updateEditorReadyOnlyState(): void {
+ const defaultNonEditableElements: string[] = ['e-checkmark', 'e-callout-icon', 'e-toggle-icon', 'e-be-hr'];
+ let editableElements: HTMLElement[] = Array.from(this.element.querySelectorAll(`[contenteditable='${this.readOnly}']`));
+ editableElements = editableElements.filter((element: HTMLElement) => {
+ return !defaultNonEditableElements.some((className: string) => element.classList.contains(className));
+ });
+ editableElements.forEach((element: HTMLElement) => { element.contentEditable = (!this.readOnly).toString(); });
+ }
+
+ private wireGlobalEvents(): void {
+ EventHandler.add(document, 'selectionchange', this.handleEditorSelection, this);
+ EventHandler.add(document, 'scroll', this.handleScrollActions, this);
+ EventHandler.add(this.element, 'scroll', this.handleScrollActions, this);
+ EventHandler.add(document, 'click', this.handleDocumentClickActions, this);
+ EventHandler.add(document, 'mousemove', this.handleMouseMoveActions, this);
+ EventHandler.add(this.element, 'mouseup', this.handleMouseUpActions, this);
+ EventHandler.add(this.element, 'mousedown', this.handleMouseDownActions, this);
+ EventHandler.add(this.element, 'input', this.handleEditorInputActions, this);
+ EventHandler.add(this.element, 'keyup', this.handleKeyupActions, this);
+ EventHandler.add(this.element, 'keydown', this.handleKeydownActions, this);
+ EventHandler.add(this.element, 'click', this.handleEditorClickActions, this);
+ EventHandler.add(this.element, 'copy', this.clipboardActionHandler, this);
+ EventHandler.add(this.element, 'cut', this.clipboardActionHandler, this);
+ EventHandler.add(this.element, 'paste', this.clipboardActionHandler, this);
+ EventHandler.add(this.blockWrapper, 'focus', this.handleEditorFocusActions, this);
+ EventHandler.add(this.blockWrapper, 'blur', this.handleEditorBlurActions, this);
+ }
+
+ private createOverlayContainer(): void {
+ this.overlayContainer = this.createElement('div', { className: 'e-blockeditor-overlay-container' });
+ this.element.appendChild(this.overlayContainer);
+ }
+
+ // public getCurrentUser(): UserModel {
+ // return this.users.find((user: UserModel) => user.id === this.currentUserId);
+ // }
+
+ public populateUniqueIds(blocks: BlockModel[], parentBlockId?: string): void {
+ /* eslint-disable */
+ const prevOnChange: boolean = (this as any).isProtectedOnChange;
+ (this as any).isProtectedOnChange = true;
+ blocks.forEach((block: BlockModel) => {
+ if (!block.id) { block.id = generateUniqueId('block'); }
+ if (parentBlockId) { block.parentId = parentBlockId; }
+ block.content && block.content.forEach((content: ContentModel) => {
+ if (!content.id) { content.id = generateUniqueId('content'); }
+ if (!content.stylesApplied) { content.stylesApplied = []; }
+ if (content.styles) {
+ const styles: StyleModel = sanitizeStyles(content.styles);
+ content.stylesApplied = Object.keys(styles).filter((style: string) => (content.styles as any)[`${style}`]);
+ }
+ });
+
+ // Recursively process child blocks
+ if (block.children && block.children.length > 0) {
+ this.populateUniqueIds(block.children, block.id);
+ }
+ });
+ (this as any).isProtectedOnChange = prevOnChange;
+ /* eslint-enable */
+ }
+
+ private checkIsEntireEditorSelected(): boolean {
+ const selection: Selection = this.nodeSelection.getSelection();
+ if (!selection || selection.rangeCount === 0) {
+ return false;
+ }
+ const range: Range = getSelectionRange();
+ if (!range) { return false; }
+ let firstBlockElement: HTMLElement = this.blockWrapper.firstElementChild as HTMLElement;
+ let lastBlockElement: HTMLElement = this.blockWrapper.lastElementChild as HTMLElement;
+ if (isChildrenTypeBlock(firstBlockElement.getAttribute('data-block-type'))) {
+ firstBlockElement = firstBlockElement.querySelector('.e-block') as HTMLElement;
+ }
+ if (isChildrenTypeBlock(lastBlockElement.getAttribute('data-block-type'))) {
+ lastBlockElement = lastBlockElement.querySelector('.e-block:last-child') as HTMLElement;
+ }
+ const firstBlockContent: HTMLElement = getBlockContentElement(this.blockWrapper.firstElementChild as HTMLElement);
+ const lastBlockContent: HTMLElement = getBlockContentElement(this.blockWrapper.lastElementChild as HTMLElement);
+ const startContainer: Node = range.startContainer;
+ const endContainer: Node = range.endContainer;
+ const isFirstBlockEmpty: boolean = firstBlockContent.textContent.trim() === '';
+ const isLastBlockEmpty: boolean = lastBlockContent.textContent.trim() === '';
+ const firstBlockStartNode: ChildNode = firstBlockContent.childNodes[0];
+ const lastBlockEndNode: ChildNode = lastBlockContent.childNodes[lastBlockContent.childNodes.length - 1];
+
+ // Selection performed using selectAll method
+ if (startContainer.nodeType === Node.ELEMENT_NODE && endContainer.nodeType === Node.ELEMENT_NODE &&
+ (startContainer as HTMLElement).classList.contains('e-block-container-wrapper') &&
+ (endContainer as HTMLElement).classList.contains('e-block-container-wrapper')) {
+ return true;
+ }
+
+ const isEqualsStartContainer: boolean = (
+ firstBlockStartNode && firstBlockStartNode.contains(startContainer) ||
+ isFirstBlockEmpty && firstBlockElement.contains(startContainer)
+ );
+ const isEqualsEndContainer: boolean = (
+ lastBlockEndNode && lastBlockEndNode.contains(endContainer) ||
+ isLastBlockEmpty && lastBlockElement.contains(endContainer)
+ );
+ return (isEqualsStartContainer &&
+ isEqualsEndContainer &&
+ range.startOffset === 0 &&
+ range.endOffset === endContainer.textContent.length);
+ }
+
+ private initializeKeyBindings(): void {
+ const config: { [key: string]: string } = { ...this.defaultKeyConfig, ...this.keyConfig };
+ const map: Map = new Map();
+
+ for (const command in config) {
+ if (Object.prototype.hasOwnProperty.call(config, command)) {
+ const keyCombo: string = config[`${command}`].toLowerCase().replace(/\s+/g, '');
+ map.set(keyCombo, command);
+ }
+ }
+ this.keyCommandMap = map;
+ }
+
+ private handleEditorSelection(e: Event): void {
+ const range: Range = this.nodeSelection.getRange();
+ if (!range) { return; }
+ const isMoreThanSingleSelection: boolean = (range.startContainer !== range.endContainer || range.startOffset !== range.endOffset);
+ if (isMoreThanSingleSelection && this.element.contains(range.commonAncestorContainer)) {
+ this.isEntireEditorSelected = this.checkIsEntireEditorSelected();
+ }
+ }
+
+ private handleScrollActions(e: Event): void {
+ this.hideFloatingIcons();
+ if (this.linkModule) {
+ this.linkModule.hideLinkPopup();
+ }
+ if (this.blockActionMenuModule) {
+ this.blockActionMenuModule.toggleBlockActionPopup(true);
+ }
+ if (this.inlineToolbarModule) {
+ this.inlineToolbarModule.hideInlineToolbar(e);
+ }
+ }
+
+ private handleMouseMoveActions(e: MouseEvent): void {
+ const target: HTMLElement = e.target as HTMLElement;
+ const blockElement: HTMLElement = target.closest('.e-block') as HTMLElement;
+ if (this.contextMenuModule.isPopupOpen() ||
+ this.blockActionMenuModule.isPopupOpen()) {
+ return;
+ }
+ if (blockElement) {
+ if (blockElement !== this.currentHoveredBlock) {
+ if (this.currentHoveredBlock) {
+ this.hideFloatingIcons();
+ }
+ this.currentHoveredBlock = blockElement;
+ this.showFloatingIcons(this.currentHoveredBlock);
+ }
+ }
+ else if (this.currentHoveredBlock) {
+ if (this.floatingIconContainer && !this.floatingIconContainer.contains(e.target as HTMLElement)) {
+ this.hideFloatingIcons();
+ this.currentHoveredBlock = null;
+ }
+ }
+ }
+
+ private handleEditorInputActions(e: Event): void {
+ this.notify('input', e);
+ if (this.isEntireEditorSelected) {
+ const allBlocks: BlockModel[] = this.blocksInternal.map((block: BlockModel) => deepClone(sanitizeBlock(block)));
+
+ this.setFocusToBlock(this.blockWrapper.firstElementChild as HTMLElement);
+ this.showFloatingIcons(this.currentFocusedBlock);
+ const prevOnChange: boolean = this.isProtectedOnChange;
+ this.isProtectedOnChange = true;
+ this.blocksInternal.splice(1);
+ this.blockAction.updatePropChangesToModel();
+ this.isProtectedOnChange = prevOnChange;
+ this.isEntireEditorSelected = false;
+
+ this.undoRedoAction.pushToUndoStack({
+ action: 'multipleBlocksDeleted',
+ oldBlockModel: this.blocksInternal[0],
+ data: {
+ deletedBlocks: allBlocks,
+ deletionType: DeletionType.Entire
+ }
+ });
+ }
+ if (this.inlineToolbarModule) {
+ this.inlineToolbarModule.hideInlineToolbar(e);
+ }
+ this.togglePlaceholder(this.currentFocusedBlock, true);
+ this.hideDragIconForEmptyBlock(this.currentFocusedBlock);
+ const blockContent: HTMLElement = getBlockContentElement(this.currentFocusedBlock);
+ if (blockContent && blockContent.textContent.length <= 1) {
+ this.showFloatingIcons(this.currentFocusedBlock);
+ }
+ this.filterSlashCommandOnUserInput();
+ /* Handling where user activates any formatting using keyboard(eg.ctrl+b) and starts typing */
+ if ((this.formattingAction.activeInlineFormats && this.formattingAction.activeInlineFormats.size > 0)
+ || this.formattingAction.lastRemovedFormat) {
+ const isFormattingPerformed: boolean = this.formattingAction.handleTypingWithActiveFormats();
+ if (isFormattingPerformed) { return; }
+ }
+ this.throttleContentUpdate(e);
+ }
+
+ private handleDocumentClickActions(e: MouseEvent): void {
+ // hide if the click is outside the editor and floating icon container
+ if (!this.element.contains(e.target as HTMLElement)
+ && (this.floatingIconContainer && !this.floatingIconContainer.contains(e.target as HTMLElement))) {
+ this.hideFloatingIcons();
+ }
+ this.isEntireEditorSelected = false;
+ this.notify('documentClick', e);
+ this.togglePopupsOnDocumentClick(e);
+ }
+
+ private handleEditorFocusActions(e: Event): void {
+ setTimeout(() => {
+ const range: Range = getSelectionRange();
+ if (!range || !this.currentFocusedBlock) { return; }
+ const eventArgs: FocusEventArgs = {
+ event: e,
+ blockId: this.currentFocusedBlock.id,
+ selectionRange: [range.startOffset, range.endOffset]
+ };
+ this.trigger('focus', eventArgs);
+ }, 200);
+ }
+
+ private handleEditorBlurActions(e: Event): void {
+ const eventArgs: BlurEventArgs = {
+ event: e,
+ blockId: this.currentFocusedBlock.id
+ };
+ this.trigger('blur', eventArgs);
+ }
+
+ private handleEditorClickActions(e: MouseEvent): void {
+ this.notify('editorClick', e);
+ }
+
+ private handleKeyupActions(e: KeyboardEvent): void {
+ this.notify('keyup', e);
+ }
+
+ private handleKeydownActions(e: KeyboardEvent): void {
+ this.previousSelection = captureSelectionState();
+ this.notify('keydown', e);
+ const commandPopupElement: HTMLElement = document.querySelector('.e-mention.e-popup.e-blockeditor-command-menu') as HTMLElement;
+ const userMentionPopupElement: HTMLElement = document.querySelector('.e-mention.e-popup.e-blockeditor-user-menu') as HTMLElement;
+ const labelMentionPopupElement: HTMLElement = document.querySelector('.e-mention.e-popup.e-blockeditor-label-menu') as HTMLElement;
+ const isArrowKeys: boolean = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].indexOf(e.key) !== -1;
+ const isIndentingKeys: boolean = ['Tab', 'Shift'].indexOf(e.key) !== -1;
+ const isControlKey: boolean = e.ctrlKey || e.metaKey;
+ const isShiftKey: boolean = e.shiftKey;
+ const isEscapeKey: boolean = e.key === 'Escape';
+ const isLeftRightArrows: boolean = ['ArrowLeft', 'ArrowRight'].indexOf(e.key) !== -1;
+ const isUpDownArrows: boolean = ['ArrowUp', 'ArrowDown'].indexOf(e.key) !== -1;
+ const blockModel: BlockModel = getBlockModelById(this.currentFocusedBlock.id, this.blocksInternal);
+ if (!blockModel || (isControlKey && isUpDownArrows && isShiftKey)) {
+ return;
+ }
+ const selectedBlocks: BlockModel[] = this.getSelectedBlocks();
+ const isSelectiveDeletions: boolean = this.isEntireEditorSelected || (selectedBlocks && selectedBlocks.length > 1);
+ const notAllowedTypes: string[] = ['Code', 'Table', 'Image'];
+
+ if (isEscapeKey || (!isControlKey && isArrowKeys && !isShiftKey)) {
+ this.inlineToolbarModule.hideInlineToolbar(e);
+ }
+ else if ((isControlKey && isLeftRightArrows && isShiftKey)) {
+ const inlineTbarPopup: HTMLElement = document.querySelector('.e-blockeditor-inline-toolbar-popup');
+ const isInlineTbarOpen: boolean = inlineTbarPopup && inlineTbarPopup.classList.contains('e-popup-open');
+ if (!isInlineTbarOpen) {
+ setTimeout(() => {
+ const range: Range = getSelectionRange();
+ this.inlineToolbarModule.showInlineToolbar(range, e);
+ });
+ }
+ }
+
+ if (this.mentionRenderer.isPopupOpen ||
+ (commandPopupElement && commandPopupElement.classList.contains('e-popup-open')) ||
+ (userMentionPopupElement && userMentionPopupElement.classList.contains('e-popup-open')) ||
+ (labelMentionPopupElement && labelMentionPopupElement.classList.contains('e-popup-open')) ||
+ notAllowedTypes.indexOf(blockModel.type) !== -1) {
+ return;
+ }
+
+ this.listBlockAction.handleListTriggerKey(e, this.currentFocusedBlock, blockModel);
+ if (blockModel && isListTypeBlock(blockModel.type) && !isSelectiveDeletions) {
+ this.listBlockAction.handleListKeyActions(e, this.currentFocusedBlock);
+ if (!isArrowKeys && !isIndentingKeys) { return; }
+ }
+
+ this.handleBlockKeyActions(e);
+ }
+
+ private handleMouseUpActions(e: MouseEvent): void {
+ if (this.readOnly) { return; }
+ const blockElement: HTMLElement = (e.target as HTMLElement).closest('.e-block') as HTMLElement;
+ if (blockElement && (this.currentFocusedBlock !== blockElement)) {
+ this.togglePlaceholder(this.currentFocusedBlock, false);
+ this.setFocusToBlock(blockElement);
+ this.togglePlaceholder(this.currentFocusedBlock, true);
+ this.showFloatingIcons(this.currentFocusedBlock);
+ }
+ setTimeout(() => {
+ this.handleTextSelection(e);
+ });
+ this.notify('mouseup', e);
+ }
+
+ private handleMouseDownActions(e: MouseEvent): void {
+ this.isEntireEditorSelected = false;
+ if (this.readOnly) { return; }
+ const blockElement: HTMLElement = (e.target as HTMLElement).closest('.e-block') as HTMLElement;
+ if (blockElement && (this.currentFocusedBlock !== blockElement)) {
+ if (blockElement.innerText.length === 0) {
+ setCursorPosition(getBlockContentElement(blockElement), 0);
+ }
+ }
+ }
+
+ private throttleContentUpdate(e: Event): void {
+ clearTimeout(this.updateTimer);
+ this.updateTimer = setTimeout(() => {
+ const target: HTMLElement = this.currentFocusedBlock as HTMLElement;
+ this.updateContentOnUserTyping(target, e);
+ }, 100);
+ }
+
+ public updateContentOnUserTyping(blockElement: HTMLElement, e?: Event): void {
+ if (!blockElement) { return; }
+
+ const range: Range = getSelectionRange();
+
+ const block: BlockModel = getBlockModelById(blockElement.id, this.blocksInternal);
+ if (!block) { return; }
+
+ const blockIndex: number = getBlockIndexById(block.id, this.blocksInternal);
+ const previousBlock: BlockModel = deepClone(sanitizeBlock(block));
+ let contentElement: HTMLElement = getBlockContentElement(blockElement);
+ if (!contentElement) { return; }
+
+ const toggleBlock: HTMLElement = blockElement.closest('.e-toggle-block') as HTMLElement;
+ if (toggleBlock) {
+ const toggleHeader: HTMLElement = findClosestParent(range.startContainer, '.e-toggle-header');
+ if (toggleHeader) {
+ contentElement = toggleHeader.querySelector('.e-block-content');
+ }
+ }
+
+ const prevOnChange: boolean = this.isProtectedOnChange;
+ this.isProtectedOnChange = true;
+ if (!block.content || contentElement.childNodes.length === 0) {
+ block.content = [];
+ }
+ let previousContent: ContentModel;
+ let newContentId: string = '';
+ contentElement.childNodes.forEach((node: ChildNode) => {
+ if (node.nodeType === Node.TEXT_NODE) {
+ const text: string = node.textContent;
+ if (text) {
+ if (block.content.length === 0) {
+ block.content = [{ id: node.parentElement.id || generateUniqueId('content'), content: text }];
+ contentElement.id = newContentId = block.content[0].id;
+ } else {
+ const isAroundSpecialElement: boolean = isNodeAroundSpecialElements(node);
+ if (isAroundSpecialElement) {
+ const clonedNode: Node = node.cloneNode(true);
+ const index: number = Array.from(contentElement.childNodes).indexOf(node);
+ block.content.splice(index, 0, {
+ id: generateUniqueId('content'),
+ content: text
+ });
+ this.blockAction.triggerWholeContentUpdate(block, block.content);
+ const span: HTMLElement = wrapNodeWithTag(clonedNode, 'span');
+ span.id = newContentId = block.content[parseInt(index.toString(), 10)].id;
+ contentElement.insertBefore(span, node);
+ contentElement.removeChild(node);
+ }
+ else {
+ previousContent = block.content[0];
+ block.content[0].content = text;
+ newContentId = block.content[0].id;
+ }
+ }
+ }
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
+ const element: HTMLElement = node as HTMLElement;
+ const text: string = element.innerText;
+ if (element.classList.contains('e-label-chip') || element.classList.contains('e-user-chip')) {
+ return;
+ }
+ if (text || element.childNodes.length > 0) {
+ const existingContent: ContentModel = block.content.find((c: ContentModel) => c.id === element.id);
+ if (existingContent) {
+ previousContent = existingContent;
+ existingContent.content = text;
+ newContentId = existingContent.id;
+ } else {
+ const newId: string = newContentId = element.id || generateUniqueId('content');
+ block.content.push({ id: newId, content: text });
+ this.blockAction.triggerWholeContentUpdate(block, block.content);
+ }
+ }
+ }
+ });
+ if (block.type !== 'Code') {
+ this.cleanUpStaleContents(block, contentElement);
+ }
+ this.isProtectedOnChange = prevOnChange;
+ const clonedBlock: BlockModel = deepClone(sanitizeBlock(block));
+ this.notify('contentChanged', { oldBlockModel: previousBlock, updatedBlockModel: clonedBlock });
+ const eventArgs: ContentChangedEventArgs = {
+ event: e,
+ // user: this.getCurrentUser(),
+ previousContent: previousContent,
+ content: block.content.find((c: ContentModel) => c.id === newContentId)
+ };
+ this.trigger('contentChanged', eventArgs);
+ }
+
+ private cleanUpStaleContents(block: BlockModel, contentElement: HTMLElement): void {
+ const idAttributes: string [] = ['id', 'data-label-id', 'data-user-id'];
+ const domContentIds: Set = new Set();
+
+ for (const node of Array.from(contentElement.childNodes)) {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ const el: HTMLElement = node as HTMLElement;
+
+ for (const attr of idAttributes) {
+ const value: string | null = el.getAttribute(attr);
+ if (value) {
+ domContentIds.add(value);
+ }
+ }
+ }
+ else if (node.nodeType === Node.TEXT_NODE) {
+ const parentEl: HTMLElement = node.parentElement;
+ if (parentEl) {
+ domContentIds.add(parentEl.id);
+ }
+ }
+ }
+ const currentContent: ContentModel[] = block.content.filter((c: ContentModel) => domContentIds.has(c.id));
+ const contentRemoved: boolean = currentContent.length !== block.content.length;
+ if (contentRemoved) {
+ this.blockAction.triggerWholeContentUpdate(block, currentContent);
+ }
+ }
+
+ private filterSlashCommandOnUserInput(): void {
+ if (this.mentionRenderer.isPopupOpen &&
+ this.currentFocusedBlock &&
+ this.currentFocusedBlock.innerText &&
+ this.isPopupOpenedOnAddIconClick) {
+ const rect: DOMRect | ClientRect = getElementRect(this.currentFocusedBlock);
+ const xOffset: number = rect.left;
+ const yOffset: number = rect.top + this.currentFocusedBlock.offsetHeight;
+ this.slashCommandModule.filterCommands(this.currentFocusedBlock.innerText, xOffset, yOffset);
+ }
+ }
+
+ private renderBlockWrapper(): void {
+ this.blockWrapper = this.createElement('div', { className: 'e-block-container-wrapper' });
+ this.element.appendChild(this.blockWrapper);
+ this.blockAction.createDefaultEmptyBlock();
+ }
+
+ public reRenderBlockContent(block: BlockModel): void {
+ if (!block) { return; }
+ const blockElement: HTMLElement = this.getBlockElementById(block.id);
+ if (!blockElement) { return; }
+ const contentElement: HTMLElement = getBlockContentElement(blockElement);
+ if (!contentElement) { return; }
+
+ contentElement.innerHTML = '';
+ this.blockAction.contentRenderer.renderContent(block, contentElement);
+ }
+
+ public renderBlocks(blocks: BlockModel[]): void {
+ if (blocks.length <= 0) {
+ return;
+ }
+ let lastBlockElement: HTMLElement; // Track the last block for caret positioning
+
+ blocks.forEach((block: BlockModel) => {
+ const blockElement: HTMLElement = this.blockAction.createBlockElement(block);
+ this.insertBlockIntoDOM(blockElement);
+ this.togglePlaceholder(blockElement, false);
+ lastBlockElement = blockElement;
+ if (isListTypeBlock(block.type)) {
+ this.listBlockAction.updateListItemMarkers(blockElement);
+ }
+ if (isChildrenTypeBlock(block.type) && block.children.length > 0) {
+ block.children.forEach((childBlock: BlockModel) => {
+ if (isListTypeBlock(childBlock.type)) {
+ const childBlockElement: HTMLElement = blockElement.querySelector('#' + childBlock.id);
+ this.listBlockAction.updateListItemMarkers(childBlockElement);
+ }
+ });
+ }
+ });
+
+ if (lastBlockElement) {
+ if (lastBlockElement.classList.contains('e-callout-block')) {
+ lastBlockElement = lastBlockElement.querySelector('.e-callout-content').lastChild as HTMLElement;
+ }
+ // Wait for DOM updates and set focus and position the caret at the end
+ requestAnimationFrame(() => {
+ this.setFocusToBlock(lastBlockElement);
+ this.togglePlaceholder(this.currentFocusedBlock, true);
+ const position: number = lastBlockElement ? lastBlockElement.textContent.length : 0;
+ setCursorPosition(getBlockContentElement(lastBlockElement), position);
+ blocks.forEach((block: BlockModel) => {
+ if (block.type === 'CheckList') {
+ if (this.blockAction && this.blockAction.listRenderer) {
+ this.blockAction.listRenderer.toggleCheckedState(block, block.isChecked);
+ }
+ }
+ });
+ });
+ }
+ }
+
+ private insertBlockIntoDOM(blockElement: HTMLElement, afterElement?: HTMLElement): void {
+ if (afterElement) {
+ this.blockWrapper.insertBefore(blockElement, afterElement.nextSibling);
+ } else {
+ this.blockWrapper.appendChild(blockElement);
+ }
+ }
+
+ private initializeMentionModules(): void {
+ this.slashCommandModule = new SlashCommandModule(this);
+ this.initializeUserMention();
+ this.initializeLabelContent();
+ }
+
+ private initializeUserMention(): void {
+ const mentionDataSource: UserModel[] = (this.users).map((user: UserModel) => {
+ const name: string = user.user.trim();
+ const initials: string = getUserInitials(name);
+ const bgColor: string = user.avatarBgColor || getAutoAvatarColor(user.id);
+ const avatarUrl: string = user.avatarUrl || '';
+
+ return {
+ id: user.id,
+ user: name,
+ avatarUrl: avatarUrl,
+ avatarBgColor: bgColor,
+ initials
+ };
+ });
+
+ const mentionArgs: IMentionRenderOptions = {
+ element: this.blockWrapper,
+ itemTemplate: '${if(avatarUrl)}
${else}
${initials}
${/if}
',
+ displayTemplate: getUserMentionDisplayTemplate(),
+ dataSource: mentionDataSource,
+ popupWidth: '200px',
+ cssClass: 'e-blockeditor-user-menu e-blockeditor-mention-menu',
+ fields: { text: 'user', value: 'id' },
+ change: this.handleInlineContentInsertion.bind(this),
+ beforeOpen: (args: PopupEventArgs) => {
+ args.cancel = this.users.length === 0;
+ }
+ };
+ this.userMenuObj = this.mentionRenderer.renderMention(mentionArgs);
+ }
+
+ private initializeLabelContent(): void {
+ let items: LabelItemModel[];
+ if (this.labelSettings.labelItems.length > 0) {
+ items = sanitizeLabelItems(this.labelSettings.labelItems);
+ }
+ else {
+ items = getLabelMenuItems();
+ const prevOnChange: boolean = this.isProtectedOnChange;
+ this.isProtectedOnChange = true;
+ this.labelSettings.labelItems = items;
+ this.isProtectedOnChange = prevOnChange;
+ }
+
+ const mentionArgs: IMentionRenderOptions = {
+ element: this.blockWrapper,
+ mentionChar: this.labelSettings.triggerChar,
+ itemTemplate: '',
+ displayTemplate: getLabelMentionDisplayTemplate(),
+ dataSource: items,
+ popupWidth: '200px',
+ cssClass: 'e-blockeditor-label-menu e-blockeditor-mention-menu',
+ fields: { text: 'text', value: 'id', groupBy: 'groupHeader', iconCss: 'iconCss' },
+ change: this.handleInlineContentInsertion.bind(this)
+ };
+ this.labelMenuObj = this.mentionRenderer.renderMention(mentionArgs);
+ }
+
+ private handleInlineContentInsertion(args: MentionChangeEventArgs): void {
+ args.e.preventDefault();
+ args.e.stopPropagation();
+ this.mentionRenderer.cleanMentionArtifacts(this.currentFocusedBlock);
+ const contentType: ContentType = (args.value.toString().indexOf('e-user-mention-item-template')) > 0 ? ContentType.Mention : ContentType.Label;
+ const mentionChar: string = contentType === ContentType.Mention ? '@' : this.labelSettings.triggerChar;
+ this.mentionRenderer.removeMentionQueryKeysFromModel(mentionChar);
+ const options: IInlineContentInsertionArgs = {
+ block: getBlockModelById(this.currentFocusedBlock.id, this.blocksInternal),
+ blockElement: this.currentFocusedBlock,
+ range: getSelectionRange().cloneRange(),
+ contentType: contentType,
+ itemData: args.itemData
+ };
+ this.notify('inline-content-inserted', options);
+ }
+
+ public handleBlockTransformation(args: ITransformBlockArgs): void {
+ const { block, blockElement, newBlockType, isUndoRedoAction } = args;
+ const rangePath: RangePath = this.mentionRenderer.nodeSelection.getStoredBackupRange();
+ this.mentionRenderer.cleanMentionArtifacts(blockElement, true);
+ cleanCheckmarkElement(blockElement);
+ this.mentionRenderer.removeMentionQueryKeysFromModel('/', args.isUndoRedoAction);
+ const specialTypes: string[] = ['Divider', 'ToggleParagraph', 'ToggleHeading1', 'ToggleHeading2', 'ToggleHeading3',
+ 'ToggleHeading4', 'Callout', 'Table', 'Code'];
+ const isClosestCallout: HTMLElement = findClosestParent(blockElement, '.e-callout-block');
+ const isClosestToggle: HTMLElement = findClosestParent(blockElement, '.e-toggle-block');
+ let transformedElement: HTMLElement = blockElement;
+ const isSpecialType: boolean = (specialTypes.indexOf(newBlockType) > -1) || (specialTypes.indexOf(block.type) > -1);
+ if (isSpecialType && ((blockElement.textContent.length > 0) || (isClosestCallout || isClosestToggle))) {
+ transformedElement = this.blockAction.addNewBlock({
+ targetBlock: isClosestCallout ? isClosestCallout : isClosestToggle ? isClosestToggle : blockElement,
+ blockType: newBlockType
+ });
+ }
+ else {
+ this.blockAction.transformBlock({
+ block: block,
+ blockElement: blockElement,
+ newBlockType: newBlockType,
+ isUndoRedoAction: isUndoRedoAction
+ });
+ }
+ // Add a new paragraph block after the transformed block if it is a special type block.
+ if (isSpecialType && !isUndoRedoAction) {
+ const contentElement: HTMLElement = this.createElement('p', {
+ className: 'e-block-content',
+ innerHTML: ' ', // Added to hide placeholder initially
+ attrs: {
+ contenteditable: 'true'
+ }
+ });
+ this.blockAction.addNewBlock({
+ targetBlock: transformedElement,
+ blockType: 'Paragraph',
+ contentElement: contentElement,
+ preventUIUpdate: true
+ });
+ }
+ const contentElement: HTMLElement = getBlockContentElement(transformedElement);
+ this.togglePlaceholder(transformedElement, true);
+ if (transformedElement.getAttribute('data-block-type') === 'Callout') {
+ const firstChild: HTMLElement = transformedElement.querySelector('.e-block') as HTMLElement;
+ this.setFocusToBlock(firstChild);
+ }
+ if (rangePath && rangePath.endContainer && contentElement) {
+ const offset: number = getAbsoluteOffset(contentElement, rangePath.endContainer, rangePath.endOffset);
+ setCursorPosition(contentElement, offset);
+ }
+ const prevAdjacent: HTMLElement = getAdjacentBlock(transformedElement, 'previous');
+ this.listBlockAction.recalculateMarkersForListItems();
+ this.showFloatingIcons(transformedElement);
+ this.blockAction.updatePropChangesToModel();
+ }
+
+ private handleTextSelection(e: Event): void {
+ const range: Range = this.nodeSelection.getRange();
+ if (!range || range.toString().trim().length === 0) {
+ this.inlineToolbarModule.hideInlineToolbar(e);
+ return;
+ }
+ const previousRange: Range = this.nodeSelection.getStoredRange();
+ const selectionArgs: SelectionChangedEventArgs = {
+ event: e,
+ // user: this.users.find((user: UserModel) => user.id === this.currentUserId),
+ range: [range.startOffset, range.endOffset],
+ previousRange: previousRange ? [previousRange.startOffset, previousRange.endOffset] : null
+ };
+ this.trigger('selectionChanged', selectionArgs);
+ this.nodeSelection.storeCurrentRange();
+ const rect: DOMRect | ClientRect = range.getBoundingClientRect();
+
+ if (range && rect) {
+ const parentBlock: HTMLElement = getParentBlock(range.commonAncestorContainer as HTMLElement);
+ if (parentBlock && parentBlock.classList.contains('e-block')) {
+ this.inlineToolbarModule.showInlineToolbar(range, e);
+ } else {
+ this.inlineToolbarModule.hideInlineToolbar(e);
+ }
+ }
+ }
+
+ private togglePopupsOnDocumentClick(event: MouseEvent): void {
+ const inlineTbarPopup: HTMLElement = document.querySelector('.e-blockeditor-inline-toolbar-popup');
+ const blockActionPopup: HTMLElement = document.querySelector('.e-blockeditor-blockaction-popup');
+ const isInlineTbarOpen: boolean = inlineTbarPopup && inlineTbarPopup.classList.contains('e-popup-open');
+ const isBlockActionOpen: boolean = blockActionPopup && blockActionPopup.classList.contains('e-popup-open');
+ if (!this.inlineToolbarModule.popupObj.element.contains(event.target as Node) && isInlineTbarOpen) {
+ this.inlineToolbarModule.hideInlineToolbar(event);
+ }
+ if (!this.blockActionMenuModule.popupObj.element.contains(event.target as Node) && isBlockActionOpen) {
+ this.blockActionMenuModule.toggleBlockActionPopup(true, event);
+ }
+ }
+
+ private createFloatingIcons(): void {
+ this.floatingIconContainer = this.createElement('div', { className: 'e-floating-icons' });
+ const addIcon: HTMLElement = this.createElement('span', { className: 'e-floating-icon e-icons e-block-add-icon' });
+ EventHandler.add(addIcon, 'click', this.handleAddIconClick, this);
+ const dragIcon: HTMLElement = this.createElement('span', { className: 'e-floating-icon e-icons e-block-drag-icon', attrs: { draggable: 'true' } });
+ EventHandler.add(dragIcon, 'click', this.handleDragIconClick, this);
+ this.floatingIconContainer.appendChild(addIcon);
+ this.floatingIconContainer.appendChild(dragIcon);
+ this.floatingIconContainer.style.position = 'absolute';
+ this.floatingIconContainer.style.display = 'none';
+ this.floatingIconContainer.style.pointerEvents = 'none';
+ document.body.appendChild(this.floatingIconContainer);
+ this.renderFloatingIconTooltips();
+ }
+
+ private renderFloatingIconTooltips(): void {
+ this.addIconTooltip = this.tooltipRenderer.renderTooltip({
+ element: this.floatingIconContainer,
+ target: '.e-block-add-icon',
+ position: 'TopCenter',
+ showTipPointer: true,
+ windowCollision: true,
+ cssClass: 'e-be-floating-icon-tooltip',
+ content: this.getTooltipContent('add')
+ });
+
+ this.dragIconTooltip = this.tooltipRenderer.renderTooltip({
+ element: this.floatingIconContainer,
+ target: '.e-block-drag-icon',
+ position: 'TopCenter',
+ showTipPointer: true,
+ windowCollision: true,
+ cssClass: 'e-be-floating-icon-tooltip',
+ content: this.getTooltipContent('drag')
+ });
+ }
+
+ private getTooltipContent(iconType: 'add' | 'drag'): HTMLElement {
+ if (iconType === 'add') {
+ const bold: HTMLElement = document.createElement('b');
+ bold.textContent = this.l10n.getConstant('addIconTooltip');
+ return bold;
+ }
+
+ const container: HTMLElement = document.createElement('div');
+ container.innerHTML = `
+ ${this.l10n.getConstant('dragIconTooltipActionMenu')}
+ ${this.l10n.getConstant('dragIconTooltip')}
+ `;
+ return container;
+ }
+
+ private UpdateFloatingIconTooltipContent(): void {
+ if (this.addIconTooltip) {
+ this.addIconTooltip.content = this.getTooltipContent('add');
+ this.addIconTooltip.dataBind();
+ }
+ if (this.dragIconTooltip) {
+ this.dragIconTooltip.content = this.getTooltipContent('drag');
+ this.dragIconTooltip.dataBind();
+ }
+ }
+
+ private isFullyVisibleInEditor(blockElement: HTMLElement): boolean {
+ const editorRect: DOMRect | ClientRect = this.element.getBoundingClientRect();
+ const blockRect: DOMRect | ClientRect = blockElement.getBoundingClientRect();
+
+ return (
+ blockRect.top >= editorRect.top &&
+ blockRect.bottom <= editorRect.bottom
+ );
+ }
+
+ public showFloatingIcons(target: HTMLElement): void {
+ if (this.readOnly) { return; }
+ let blockElement: HTMLElement = target;
+ this.hideDragIconForEmptyBlock(blockElement);
+ const calloutContent: HTMLElement = blockElement.closest('.e-callout-content') as HTMLElement;
+ const isToggleBlock: boolean = blockElement.classList.contains('e-toggle-block');
+ if ((calloutContent && blockElement === calloutContent.firstElementChild) || !this.isFullyVisibleInEditor(blockElement)) {
+ // Do not show floating icons for the first child of a callout content block
+ this.hideFloatingIcons();
+ return;
+ }
+ this.floatingIconContainer.style.display = 'flex';
+ const floatingIconRect: DOMRect | ClientRect = this.floatingIconContainer.getBoundingClientRect();
+ const blockType: string = blockElement.getAttribute('data-block-type').toLowerCase();
+
+ blockElement = isToggleBlock ? blockElement.querySelector('.e-toggle-header') as HTMLElement : blockElement;
+ const rect: DOMRect | ClientRect = getElementRect(blockElement);
+ const styles: CSSStyleDeclaration = window.getComputedStyle(blockElement);
+ const marginTop: number = parseFloat(styles.marginTop) || 0;
+ const marginLeft: number = parseFloat(styles.marginLeft) || 0;
+ const paddingTop: number = parseFloat(styles.paddingTop) || 0;
+ const paddingLeft: number = parseFloat(styles.paddingLeft) || 0;
+ const additionalOffsetsForHeadings: number = (rect.height / 2 - (floatingIconRect.height / 2));
+ let topOffset: number = rect.top + window.scrollY + marginTop;
+ topOffset = ((blockType === 'heading1' || blockType.endsWith('heading1'))
+ || (blockType === 'heading2') || blockType.endsWith('heading2'))
+ ? (topOffset + additionalOffsetsForHeadings) : (topOffset + paddingTop);
+ const leftOffset: number = rect.left + window.scrollX - marginLeft;
+ const adjustedLeft: number = leftOffset + paddingLeft - (floatingIconRect.width + 5);
+ this.floatingIconContainer.style.top = `${topOffset}px`;
+ this.floatingIconContainer.style.left = `${adjustedLeft}px`;
+ this.floatingIconContainer.style.pointerEvents = 'auto';
+ }
+
+ private hideDragIconForEmptyBlock(target: HTMLElement): void {
+ const dragIcon: HTMLElement = this.floatingIconContainer.querySelector('.e-block-drag-icon') as HTMLElement;
+ dragIcon.style.display = 'flex';
+ const ignoredTypes: string[] = ['Code', 'Callout', 'Table', 'Divider', 'Toggle', 'Image'];
+ const blockType: string = target.getAttribute('data-block-type');
+ const isIgnoredtype: boolean = blockType && ignoredTypes.indexOf(blockType) !== -1;
+ const contentElement: HTMLElement = getBlockContentElement(target);
+ if (!isIgnoredtype && (contentElement && !contentElement.textContent)) {
+ dragIcon.style.display = 'none';
+ }
+ }
+
+ private hideFloatingIcons(): void {
+ this.floatingIconContainer.style.display = 'none';
+ this.currentHoveredBlock = null;
+ }
+
+ private handleDragIconClick(e: MouseEvent): void {
+ if (!this.blockActionsMenu.enable) { return; }
+ const block: HTMLElement = this.currentHoveredBlock;
+ const popupElement: HTMLElement = document.querySelector('.e-blockeditor-blockaction-popup');
+ const isPopupOpen: boolean = popupElement.classList.contains('e-popup-open');
+ this.popupRenderer.adjustPopupPositionRelativeToTarget(block, this.blockActionMenuModule.popupObj);
+ this.blockActionMenuModule.toggleBlockActionPopup(isPopupOpen, e);
+ }
+
+ private handleAddIconClick(): void {
+ let block: HTMLElement = this.currentHoveredBlock;
+ if ((this.currentHoveredBlock.innerText.length > 0) || (isNonContentEditableBlock(block.getAttribute('data-block-type')))) {
+ block = this.blockAction.addNewBlock({
+ targetBlock: this.currentHoveredBlock
+ });
+ }
+ else {
+ this.blockAction.setFocusAndUIForNewBlock(block);
+ }
+ if (this.slashCommandModule) {
+ this.isPopupOpenedOnAddIconClick = true;
+ this.slashCommandModule.showPopup();
+ }
+ }
+
+ private handleBlockKeyActions(event: KeyboardEvent): void {
+ const range: Range = getSelectionRange();
+ const blockElement: HTMLElement = this.currentFocusedBlock;
+ const blockModel: BlockModel = getBlockModelById(blockElement.id, this.blocksInternal);
+ const blockType: string = blockElement.getAttribute('data-block-type');
+ const contentElement: HTMLElement = getBlockContentElement(blockElement);
+ switch (event.key) {
+ case 'ArrowUp':
+ case 'ArrowDown':
+ case 'ArrowLeft':
+ case 'ArrowRight':
+ this.handleArrowKeyActions(event, range, blockElement);
+ this.isEntireEditorSelected = false;
+ break;
+ case 'Enter':
+ this.inlineToolbarModule.hideInlineToolbar();
+ if (event.shiftKey) {
+ this.handleLineBreaksOnBlock(blockElement);
+ event.preventDefault();
+ }
+ else {
+ this.handleEnterKeyAction(event);
+ this.isEntireEditorSelected = false;
+ event.preventDefault();
+ }
+ break;
+ case 'Backspace': {
+ this.inlineToolbarModule.hideInlineToolbar();
+ const isDeletionPerformed: boolean = this.handleSelectiveDeletions(event);
+ if (!isDeletionPerformed && (blockType === 'Divider' || isAtStartOfBlock(contentElement))) {
+ this.blockAction.deleteBlockAtCursor({ blockElement: this.currentFocusedBlock, mergeDirection: 'previous' });
+ this.isEntireEditorSelected = false;
+ event.preventDefault();
+ }
+ break;
+ }
+ case 'Delete': {
+ this.inlineToolbarModule.hideInlineToolbar();
+ const isDeletionPerformed: boolean = this.handleSelectiveDeletions(event);
+ if (!isDeletionPerformed && (blockType === 'Divider' || isAtEndOfBlock(contentElement))) {
+ this.blockAction.deleteBlockAtCursor({ blockElement: this.currentFocusedBlock, mergeDirection: 'next' });
+ this.isEntireEditorSelected = false;
+ event.preventDefault();
+ }
+ break;
+ }
+ case 'Tab':
+ case 'Shift+Tab': {
+ const selectedBlocks: BlockModel[] = this.getSelectedBlocks();
+ const blockIDs: string[] = selectedBlocks.map((block: BlockModel) => block.id);
+ this.blockAction.handleBlockIndentation({
+ blockIDs,
+ shouldDecrease: event.shiftKey
+ });
+ this.isEntireEditorSelected = false;
+ event.preventDefault();
+ break;
+ }
+ case 'Home':
+ case 'End': {
+ if (!event.shiftKey) {
+ this.handleHomeEndKeyActions(event, blockElement);
+ }
+ break;
+ }
+ }
+ }
+
+ private handleHomeEndKeyActions(event: KeyboardEvent, blockElement: HTMLElement): void {
+ const contentElement: HTMLElement = getBlockContentElement(blockElement);
+ const isHomeKey: boolean = event.key === 'Home';
+
+ setCursorPosition(contentElement, isHomeKey ? 0 : contentElement.textContent.length);
+ }
+
+ public handleSelectiveDeletions(event: KeyboardEvent): boolean {
+ const selectedBlocks: BlockModel[] = this.getSelectedBlocks();
+ this.isEntireEditorSelected = this.checkIsEntireEditorSelected();
+ if (this.isEntireEditorSelected) {
+ this.handleEntireBlockDeletion(event);
+ return true;
+ }
+ else if (selectedBlocks && selectedBlocks.length > 1) {
+ this.handleMultipleBlockDeletion(selectedBlocks, event.key === 'Backspace' ? 'previous' : 'next');
+ event.preventDefault();
+ return true;
+ }
+ return false;
+ }
+
+ private handleEntireBlockDeletion(event: KeyboardEvent): void {
+ const prevFocusedBlockid: string = this.currentFocusedBlock.id;
+ const allBlocks: BlockModel[] = this.blocksInternal.map((block: BlockModel) => deepClone(sanitizeBlock(block)));
+ this.blocksInternal = [];
+ const newlyInsertedBlock: BlockModel = this.blockAction.createDefaultEmptyBlock(true);
+
+ this.undoRedoAction.pushToUndoStack({
+ action: 'multipleBlocksDeleted',
+ oldBlockModel: newlyInsertedBlock,
+ data: {
+ deletedBlocks: allBlocks,
+ deletionType: DeletionType.Entire,
+ cursorBlockId: prevFocusedBlockid
+ }
+ });
+
+ this.isEntireEditorSelected = false;
+ event.preventDefault();
+ }
+
+ handleMultipleBlockDeletion(
+ selectedBlocks: BlockModel[],
+ direction: 'previous' | 'next' = 'previous',
+ isUndoRedoAction?: boolean
+ ): boolean {
+ const prevFocusedBlockid: string = this.currentFocusedBlock ? this.currentFocusedBlock.id : '';
+ const selectedClones: BlockModel[] = selectedBlocks.map((block: BlockModel) => deepClone(sanitizeBlock(block)));
+ const firstBlock: BlockModel = selectedBlocks[0];
+ const lastBlock: BlockModel = selectedBlocks[selectedBlocks.length - 1];
+ const firstBlockElement: HTMLElement = this.getBlockElementById(firstBlock.id);
+ const lastBlockElement: HTMLElement = this.getBlockElementById(lastBlock.id);
+ const range: Range = getSelectionRange();
+
+ if (!range || !firstBlockElement || !lastBlockElement) { return false; }
+
+ // 1. Delete all middle blocks
+ for (let i: number = 1; i < selectedBlocks.length - 1; i++) {
+ this.blockAction.deleteBlock(
+ {
+ blockElement: this.getBlockElementById(selectedBlocks[parseInt(i.toString(), 10)].id),
+ isUndoRedoAction: true
+ }
+ );
+ }
+
+ const firstBlockContent: HTMLElement = getBlockContentElement(firstBlockElement);
+ const firstSplit: ISplitContent = this.blockAction.splitContent(firstBlockContent, range.startContainer, range.startOffset);
+ this.updateAndCleanContentModels(firstBlock, firstSplit, 'keepBefore');
+
+ const lastBlockContent: HTMLElement = getBlockContentElement(lastBlockElement);
+ const lastSplit: ISplitContent = this.blockAction.splitContent(lastBlockContent, range.endContainer, range.endOffset);
+ this.updateAndCleanContentModels(lastBlock, lastSplit, 'keepAfter');
+
+ firstBlockContent.innerHTML = '';
+ firstBlockContent.appendChild(firstSplit.beforeFragment);
+ lastBlockContent.innerHTML = '';
+ lastBlockContent.appendChild(lastSplit.afterFragment);
+
+ this.blockAction.deleteBlockAtCursor({
+ blockElement: direction === 'previous' ? lastBlockElement : firstBlockElement,
+ mergeDirection: direction,
+ isUndoRedoAction: true
+ });
+
+ if (!isUndoRedoAction) {
+ this.undoRedoAction.pushToUndoStack({
+ action: 'multipleBlocksDeleted',
+ data: {
+ deletedBlocks: selectedClones,
+ deletionType: DeletionType.Partial,
+ direction: direction,
+ firstBlockIndex: getBlockIndexById(firstBlock.id, this.blocksInternal),
+ cursorBlockId: prevFocusedBlockid
+ }
+ });
+ }
+
+ return true;
+ }
+
+ private updateAndCleanContentModels(
+ block: BlockModel,
+ splitContent: ISplitContent,
+ mode: 'keepBefore' | 'keepAfter'
+ ): void {
+ const newContentModels: ContentModel[] = [];
+ const beforeFragmentNodes: ChildNode[] = Array.from(splitContent.beforeFragment.childNodes);
+ const afterFragmentNodes: ChildNode[] = Array.from(splitContent.afterFragment.childNodes);
+ const blockElement: HTMLElement = this.getBlockElementById(block.id);
+
+ const range: Range = getSelectionRange();
+ const splitNode: Node = mode === 'keepBefore' ? range.startContainer : range.endContainer;
+ const splitOffset: number = mode === 'keepBefore' ? range.startOffset : range.endOffset;
+ const contentElementOfSplitNode: HTMLElement = getClosestContentElementInDocument(splitNode);
+ const isContentFoundInCollection: (element: Node, collection: ChildNode[]) => boolean =
+ (element: Node, collection: ChildNode[]) => {
+ return collection.some((node: Node) => {
+ return (node.contains(element) || node === element || (node as HTMLElement).id === (element as HTMLElement).id);
+ });
+ };
+ const prevOnChange: boolean = this.isProtectedOnChange;
+ this.isProtectedOnChange = true;
+ block.content.forEach((content: ContentModel) => {
+ let isSplitted: boolean = false;
+ const contentEl: HTMLElement = getContentElementBasedOnId(content, blockElement);
+
+ const isCurrentContentIntersectsNode: boolean = range.intersectsNode(contentEl);
+ const isCurrentContentFoundInAfterNodes: boolean = isContentFoundInCollection(contentEl, afterFragmentNodes);
+ if (mode === 'keepBefore' && isCurrentContentFoundInAfterNodes && isCurrentContentIntersectsNode) {
+ return;
+ }
+ if (contentEl === contentElementOfSplitNode) {
+ content.content = mode === 'keepBefore'
+ ? splitNode.textContent.substring(0, splitOffset)
+ : (splitNode.textContent.substring(splitOffset) || '');
+ if (mode === 'keepAfter') {
+ const splittedNodeId: string = afterFragmentNodes.length && (afterFragmentNodes[0].nodeType === Node.ELEMENT_NODE
+ ? (afterFragmentNodes[0] as HTMLElement).id : content.id);
+ content.id = splittedNodeId;
+ }
+ isSplitted = true;
+ }
+ const isCurrentContentFoundInBeforeNodes: boolean = isContentFoundInCollection(contentEl, beforeFragmentNodes);
+ if (!isSplitted && mode === 'keepAfter' && isCurrentContentFoundInBeforeNodes && isCurrentContentIntersectsNode) {
+ return;
+ }
+ if (content.content.trim()) {
+ newContentModels.push(content);
+ }
+ });
+ block.content = newContentModels;
+ this.isProtectedOnChange = prevOnChange;
+ }
+
+ private handleEnterKeyAction(event: KeyboardEvent): void {
+ const blockType: string = this.currentFocusedBlock.getAttribute('data-block-type');
+ const calloutBlock: HTMLElement = this.currentFocusedBlock.closest('.e-callout-block') as HTMLElement;
+ const toggleBlock: HTMLElement = this.currentFocusedBlock.closest('.e-toggle-block') as HTMLElement;
+ if (calloutBlock) {
+ this.handleChildrenBlockExit('.e-callout-block', '.e-callout-content');
+ }
+ else if (toggleBlock) {
+ const range: Range = getSelectionRange();
+ const blockModel: BlockModel = getBlockModelById(toggleBlock.id, this.blocksInternal);
+ const toggleHeader: HTMLElement = findClosestParent(range.startContainer, '.e-toggle-header');
+ const toggleContent: HTMLElement = toggleBlock.querySelector('.e-toggle-content');
+ if (toggleContent && toggleHeader && toggleContent.textContent === '') {
+ this.blockAction.toggleRenderer.updateToggleBlockExpansion(this.currentFocusedBlock, !blockModel.isExpanded);
+ setCursorPosition(toggleContent.querySelector('.e-block-content'), 0);
+ this.setFocusToBlock(this.currentFocusedBlock.querySelector('.e-block'));
+ return;
+ }
+ this.handleChildrenBlockExit('.e-toggle-block', '.e-toggle-content');
+ }
+ const isEmpty: boolean = this.currentFocusedBlock.textContent.trim() === '';
+ const blockModel: BlockModel = getBlockModelById(this.currentFocusedBlock.id, this.blocksInternal);
+ if (isEmpty && blockModel.indent > 0) {
+ blockModel.indent--;
+ this.blockAction.updateBlockIndentAttribute(this.currentFocusedBlock, blockModel.indent);
+ this.showFloatingIcons(this.currentFocusedBlock);
+ this.blockAction.updatePropChangesToModel();
+ }
+ else if (blockType !== BlockType.Code) {
+ this.splitAndCreateNewBlockAtCursor();
+ }
+ }
+
+ private handleLineBreaksOnBlock(blockElement: HTMLElement, isUndoRedoAction?: boolean): void {
+ const blockModel: BlockModel = getBlockModelById(blockElement.id, this.blocksInternal);
+ const oldContentClone: ContentModel[] = deepClone(sanitizeContent(blockModel.content));
+ const contentElement: HTMLElement = getBlockContentElement(blockElement);
+ const range: Range = this.nodeSelection.getRange();
+ if (!range) { return; }
+ const absoluteOffset: number = getAbsoluteOffset(contentElement, range.startContainer, range.startOffset);
+ // Find the affected content in range
+ const closestContentElement: HTMLElement = getClosestContentElementInDocument(range.startContainer);
+ const contentModel: ContentModel = blockModel.content.find((content: ContentModel) => {
+ return content.id === closestContentElement.id;
+ });
+
+ // Update the \n at correct place in the content model
+ contentModel.content = contentModel.content.substring(0, range.startOffset) + '\n' + contentModel.content.substring(range.startOffset);
+ this.reRenderBlockContent(blockModel);
+ setCursorPosition(contentElement, absoluteOffset + 1);
+ const newContentClone: ContentModel[] = deepClone(sanitizeContent(blockModel.content));
+ if (!isUndoRedoAction) {
+ this.undoRedoAction.pushToUndoStack({
+ action: 'lineBreakAdded',
+ oldContents: oldContentClone,
+ newContents: newContentClone,
+ data: {
+ blockId: blockModel.id
+ }
+ });
+ }
+ }
+
+ private handleChildrenBlockExit(parentSelector: string, contentSelector: string, deleteDirection: 'previous' | 'next' = 'previous'): boolean {
+ const parentBlock: HTMLElement = (this.currentFocusedBlock.closest(parentSelector) as HTMLElement);
+ const contentElement: HTMLElement = parentBlock ? parentBlock.querySelector(contentSelector) : null;
+ if (parentBlock && contentElement &&
+ (this.currentFocusedBlock.textContent.trim() === '') &&
+ (contentElement.lastElementChild === this.currentFocusedBlock)) {
+ this.blockAction.deleteBlockAtCursor({ blockElement: this.currentFocusedBlock, mergeDirection: deleteDirection });
+ this.currentFocusedBlock = parentBlock;
+ return true;
+ }
+ return false;
+ }
+
+ private handleArrowKeyActions(event: KeyboardEvent, range: Range, blockElement: HTMLElement): void {
+ const blockContentLength: number = blockElement.textContent.length;
+ const key: string = event.key;
+ const isUp: boolean = key === 'ArrowUp';
+ const isDown: boolean = key === 'ArrowDown';
+ const isLeft: boolean = key === 'ArrowLeft';
+ const isRight: boolean = key === 'ArrowRight';
+ const isAtStart: boolean = range.startOffset === 0 && range.endOffset === 0;
+ const isAtEnd: boolean = range.startOffset === blockContentLength && range.endOffset === blockContentLength;
+ const adjacentBlock: HTMLElement = getAdjacentBlock(blockElement, (isUp || isLeft) ? 'previous' : 'next');
+ if (!adjacentBlock) { return; }
+ const isAdjacentEmpty: boolean = adjacentBlock.textContent.length === 0;
+ //Only prevent default behaviour when cursor at the ends, otherwise let the browser's default behaviour take over
+ const isMovingAdjacentBlock: boolean = (isAtStart && (isLeft)) || (isAtEnd && (isRight)) || ((isUp || isDown) && isAdjacentEmpty);
+ if (isMovingAdjacentBlock) {
+ event.preventDefault();
+ this.moveCursorToAdjacentBlock(adjacentBlock, key);
+ }
+ else {
+ setTimeout(() => {
+ if (!isMovingAdjacentBlock) {
+ const range: Range = getSelectionRange();
+ const currentBlock: HTMLElement = (range.startContainer.parentElement.closest('.e-block') as HTMLElement);
+ if (currentBlock !== this.currentFocusedBlock) {
+ this.togglePlaceholder(this.currentFocusedBlock, false);
+ this.setFocusToBlock(currentBlock);
+ this.showFloatingIcons(this.currentFocusedBlock);
+ }
+ }
+ });
+ }
+ }
+
+ private moveCursorToAdjacentBlock(adjacentBlock: HTMLElement, key: string): void {
+ const isMovingLeft: boolean = key === 'ArrowLeft';
+ const targetPosition: number = isMovingLeft ? adjacentBlock.textContent.length : 0;
+ this.togglePlaceholder(this.currentFocusedBlock, false);
+ this.setFocusToBlock(adjacentBlock);
+ setCursorPosition(getBlockContentElement(adjacentBlock), targetPosition);
+ this.togglePlaceholder(this.currentFocusedBlock, true);
+ this.showFloatingIcons(this.currentFocusedBlock);
+ }
+
+ public splitAndCreateNewBlockAtCursor(args?: IAddBlockArgs): void {
+ const blockElement: HTMLElement = (args && args.isUndoRedoAction) ? args.targetBlock : this.currentFocusedBlock;
+ const blockModel: BlockModel = getBlockModelById(blockElement.id, this.blocksInternal);
+ const contentElement: HTMLElement = getBlockContentElement(blockElement);
+
+ // Split the block at the cursor position and get the before and after fragments
+ const splitContent: ISplitContent = this.blockAction.splitBlockAtCursor(blockElement, args);
+ const isTextNode: boolean =
+ isNOU(splitContent.beforeFragment.lastChild) ||
+ (splitContent.beforeFragment.lastChild.nodeType === Node.TEXT_NODE);
+ //Track beforeFragment's last child for new content model creation
+ const lastChild: HTMLElement = isTextNode ? contentElement : splitContent.beforeFragment.lastChild as HTMLElement;
+
+ //Update the current block with the before fragment content
+ contentElement.innerHTML = '';
+ if (splitContent.beforeFragment.textContent !== '') {
+ contentElement.appendChild(splitContent.beforeFragment);
+ }
+ const prevOnChange: boolean = this.isProtectedOnChange;
+ this.isProtectedOnChange = true;
+ //Before clearing current block model, store the after block content to create new block with it
+ const afterBlockContents: ContentModel[] = this.getContentModelForFragment(
+ splitContent.afterFragment,
+ blockModel,
+ lastChild
+ );
+ this.isProtectedOnChange = prevOnChange;
+ this.blockAction.updateContentChangesToModel(blockElement, contentElement);
+ const curBlockType: string = blockModel.type;
+ const isListType: boolean = isListTypeBlock(curBlockType);
+ //Create a new block with the after fragment content and insert it after the current block
+ if (isNOU(args)) {
+ this.blockAction.addNewBlock({
+ blockType: isListType ? curBlockType : BlockType.Paragraph,
+ targetBlock: blockElement,
+ contentElement: splitContent.afterFragment,
+ contentModel: afterBlockContents,
+ splitOffset: splitContent.splitOffset,
+ lastChild: lastChild
+ });
+ }
+ else if (args.isUndoRedoAction) {
+ this.blockAction.addNewBlock({
+ targetBlock: args.targetBlock,
+ blockType: args.blockType,
+ blockID: args.blockID,
+ contentModel: args.contentModel,
+ isUndoRedoAction: args.isUndoRedoAction,
+ contentElement: args.contentElement
+ });
+ }
+ }
+
+ public getContentModelForFragment(
+ fragment: DocumentFragment,
+ blockModel: BlockModel,
+ referenceNode: Node
+ ): ContentModel[] {
+ const newContents: ContentModel[] = [];
+ fragment.childNodes.forEach((node: Node) => {
+ if (node.nodeType === Node.ELEMENT_NODE && (node instanceof HTMLElement)) {
+ const content: ContentModel = blockModel.content.find((content: ContentModel) => content.id === node.id);
+ if (content) {
+ content.content = node.textContent;
+ newContents.push(content);
+ }
+ else {
+ /*
+ On Enter in middle of a formatted element, we clone the previous model,
+ and reuse it to preserve formatting (e.g., split 'Hello ' into 'He' and 'llo').
+ */
+ const previousContent: ContentModel = blockModel.content.find((content: ContentModel) => {
+ return content.id === (referenceNode as HTMLElement).id;
+ });
+ const [sanitizedContent]: ContentModel[] = sanitizeContent([previousContent]);
+ const newContent: ContentModel = deepClone(sanitizedContent) as ContentModel;
+ newContent.id = node.id;
+ newContent.content = node.textContent;
+ newContents.push(newContent);
+ }
+ }
+ else if (node.nodeType === Node.TEXT_NODE) {
+ newContents.push({ id: generateUniqueId('content'), content: node.textContent });
+ }
+ });
+ return newContents;
+ }
+
+ public setFocusToBlock(block: HTMLElement): void {
+ if (block) {
+ block.focus();
+ this.currentFocusedBlock = block;
+ }
+ }
+
+ public togglePlaceholder(blockElement: HTMLElement, isFocused: boolean): void {
+ const blockModel: BlockModel = getBlockModelById(blockElement.id, this.blocksInternal);
+ if (!blockModel) { return; }
+ const blockType: string = blockElement.getAttribute('data-block-type');
+ const placeholderValue: string = this.getPlaceholderValue(blockType, blockModel.placeholder);
+ const contentEle: HTMLElement = getBlockContentElement(blockElement);
+ const isEmptyContent: boolean = isElementEmpty(contentEle);
+ contentEle.setAttribute('placeholder', isEmptyContent && isFocused ? placeholderValue : '');
+ if (isEmptyContent && blockType !== 'Code') {
+ clearBreakTags(contentEle);
+ }
+ }
+
+ public renderTemplate(block: BlockModel, templateElement: HTMLElement): void {
+ const templateName: string = block.id + 'template';
+ this.clearTemplate([templateName]);
+ const templateFunction: Function = getTemplateFunction(block.template);
+ append(templateFunction({}, this, templateName, 'template', this.isStringTemplate), templateElement);
+ this.renderReactTemplates();
+ }
+
+ public serializeValue(value: string): string {
+ if (!isNOU(value)) {
+ if (this.enableHtmlEncode) {
+ value = sanitizeHelper(decode(value), this.enableHtmlSanitizer);
+ value = encode(value);
+ } else {
+ value = sanitizeHelper(value, this.enableHtmlSanitizer);
+ }
+ }
+ return value;
+ }
+
+ /**
+ * Gets the placeholder value for the given block element.
+ *
+ * @param {BlockType | string} blockType - The type of the block.
+ * @param {string} blockPlaceholder - The placeholder value for the block.
+ * @returns {string} The placeholder value for the given block type.
+ * @hidden
+ */
+ public getPlaceholderValue(blockType: BlockType | string, blockPlaceholder: string): string {
+ if (blockPlaceholder && blockPlaceholder !== '') { return blockPlaceholder; }
+ const constant: string = blockType.charAt(0).toLowerCase() + blockType.slice(1);
+ return this.l10n.getConstant(constant);
+ }
+
+ /* Section Public methods */
+
+ /**
+ * Adds a new block to the editor
+ *
+ * @param {BlockModel} block - The block model to add
+ * @param {string} targetId - The ID of the target block to insert the new block. If not provided, the block will be appended to the end of the editor.
+ * @param {boolean} isAfter - Specifies whether to insert the new block after the target block. Default is false.
+ * @returns {void}
+ */
+ public addBlock(block: BlockModel, targetId?: string, isAfter?: boolean): void {
+ this.blockEditorMethods.addBlock(block, targetId, isAfter);
+ }
+
+ /**
+ * Removes a block from the editor
+ *
+ * @param {string} blockId - ID of the block to remove
+ * @returns {void}
+ */
+ public removeBlock(blockId: string): void {
+ this.blockEditorMethods.removeBlock(blockId);
+ }
+
+ /**
+ * Gets a block by ID
+ *
+ * @param {string} blockId - ID of the block to retrieve
+ * @returns {BlockModel | null} - The block model or null if not found
+ */
+ public getBlock(blockId: string): BlockModel | null {
+ return this.blockEditorMethods.getBlock(blockId);
+ }
+
+ /**
+ * Moves a block to a new position
+ *
+ * @param {string} fromBlockId - ID of the block to move
+ * @param {string} toBlockId - ID of the target block to move to
+ * @returns {void}
+ */
+ public moveBlock(fromBlockId: string, toBlockId: string): void {
+ this.blockEditorMethods.moveBlock(fromBlockId, toBlockId);
+ }
+
+ /**
+ * Updates block properties
+ *
+ * @param {string} blockId - ID of the block to update
+ * @param {Partial} properties - Properties to update
+ * @returns {boolean} True if update was successful
+ */
+ public updateBlock(blockId: string, properties: Partial): boolean {
+ return this.blockEditorMethods.updateBlock(blockId, properties);
+ }
+
+ /**
+ * Enables one or more toolbar items.
+ * This method allows the specified toolbar items to be enabled.
+ *
+ * @param {string | string[]} itemId - The id(s) of the toolbar item(s) to enable.
+ * @returns {void}
+ */
+ public enableToolbarItems(itemId: string | string[]): void {
+ this.blockEditorMethods.enableDisableToolbarItems(itemId, true);
+ }
+
+ /**
+ * Disables one or more toolbar items.
+ * This method allows the specified toolbar items to be disabled.
+ *
+ * @param {string | string[]} itemId - The id(s) of the toolbar item(s) to disable.
+ * @returns {void}
+ */
+ public disableToolbarItems(itemId: string | string[]): void {
+ this.blockEditorMethods.enableDisableToolbarItems(itemId, false);
+ }
+
+ /**
+ * Executes the specified toolbar action on the editor.
+ *
+ * @param {string} action - The action to execute.
+ * @param {value} value - The value required if any (Optional).
+ * @returns {void}
+ */
+ public executeToolbarAction(action: BuiltInToolbar, value?: string): void {
+ this.blockEditorMethods.executeToolbarAction(action, value);
+ }
+
+ /**
+ * Sets the selection range within a content.
+ * This method selects content within the specified element using a start and end index.
+ *
+ * @param {string} contentId - The ID of the content element.
+ * @param {number} startIndex - The starting index of the selection.
+ * @param {number} endIndex - The ending index of the selection.
+ * @returns {void}
+ */
+ public setSelection(contentId: string, startIndex: number, endIndex: number): void {
+ this.blockEditorMethods.setSelection(contentId, startIndex, endIndex);
+ }
+
+ /**
+ * Sets cursor position
+ *
+ * @param {string} blockId - ID of the target block
+ * @param {number} position - Character offset position
+ * @returns {void}
+ */
+ public setCursorPosition(blockId: string, position: number): void {
+ this.blockEditorMethods.setCursorPosition(blockId, position);
+ }
+
+ /**
+ * Gets the block from current selection
+ *
+ * @returns {BlockModel | null} - The block model or null if not found
+ */
+ public getSelectedBlocks(): BlockModel[] | null {
+ return this.blockEditorMethods.getSelectedBlocks();
+ }
+
+ /**
+ * Gets current selection range
+ *
+ * @returns {Range | null} Current selection range or null
+ */
+ public getRange(): Range | null {
+ return this.blockEditorMethods.getRange();
+ }
+
+ /**
+ * Selects the given range
+ *
+ * @param {Range} range - DOM Range object to select
+ * @returns {void}
+ */
+ public selectRange(range: Range): void {
+ this.blockEditorMethods.selectRange(range);
+ }
+
+ /**
+ * Selects an entire block
+ *
+ * @param {string} blockId - ID of the block to select
+ * @returns {void}
+ */
+ public selectBlock(blockId: string): void {
+ this.blockEditorMethods.selectBlock(blockId);
+ }
+
+ /**
+ * Selects all blocks in the editor.
+ *
+ * @returns {void}
+ */
+ public selectAllBlocks(): void {
+ this.blockEditorMethods.selectAllBlocks();
+ }
+
+ /**
+ * Focuses the editor
+ *
+ * @returns {void}
+ */
+ public focusIn(): void {
+ this.blockEditorMethods.focusIn();
+ }
+
+ /**
+ * Removes focus from the editor
+ *
+ * @returns {void}
+ */
+ public focusOut(): void {
+ this.blockEditorMethods.focusOut();
+ }
+
+ /**
+ * Gets total block count
+ *
+ * @returns {number} Number of blocks in editor
+ */
+ public getBlockCount(): number {
+ return this.blockEditorMethods.getBlockCount();
+ }
+
+ /**
+ * Prints all the block data.
+ *
+ * @returns {void}
+ */
+ public print(): void {
+ this.blockEditorMethods.print();
+ }
+
+ /**
+ * Retrieves data from the editor as JSON.
+ * If a block ID is provided, returns the data of that specific block; otherwise returns all content.
+ *
+ * @param {string} blockId - Optional ID of the block to retrieve
+ * @returns {any} The JSON representation of the editor data
+ */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ public getDataAsJson(blockId?: string): any {
+ return this.blockEditorMethods.getDataAsJson(blockId);
+ }
+
+ /**
+ * Retrieves data from the editor as HTML.
+ * If a block ID is provided, returns the data of that specific block; otherwise returns all content.
+ *
+ * @param {string} blockId - Optional ID of the block to retrieve
+ * @returns {string} The HTML representation of the editor data
+ */
+ public getDataAsHtml(blockId?: string): string {
+ return this.blockEditorMethods.getDataAsHtml(blockId);
+ }
+
+ public getBlockElementById(blockId: string): HTMLElement | null {
+ return this.blockWrapper.querySelector(`#${blockId}`);
+ }
+
+ private clipboardActionHandler(e: KeyboardEvent): void {
+ let isActionExecuted: boolean = false;
+ const prop: string = e.type.toLowerCase();
+ switch (prop) {
+ case 'cut':
+ this.notify(events.cut, e);
+ isActionExecuted = true;
+ break;
+ case 'copy':
+ this.notify(events.copy, e);
+ isActionExecuted = true;
+ break;
+ case 'paste':
+ this.notify(events.paste, e);
+ isActionExecuted = true;
+ break;
+ }
+ if (isActionExecuted && this.keyActionExecuted) {
+ const normalizedKey: string = prop === 'cut' ? 'ctrl+x' : prop === 'copy' ? 'ctrl+c' : 'ctrl+v';
+ this.trigger('keyActionExecuted', {
+ keyCombination: normalizedKey, action: prop
+ });
+ }
+ }
+
+ public getCurrentFocusedBlockModel(): BlockModel {
+ if (!this.currentFocusedBlock) { return null; }
+ return getBlockModelById(this.currentFocusedBlock.id, this.blocksInternal);
+ }
+
+ private unWireGlobalEvents(): void {
+ EventHandler.remove(document, 'selectionchange', this.handleEditorSelection);
+ EventHandler.remove(document, 'scroll', this.handleScrollActions);
+ EventHandler.remove(this.element, 'scroll', this.handleScrollActions);
+ EventHandler.remove(document, 'click', this.handleDocumentClickActions);
+ EventHandler.remove(document, 'mousemove', this.handleMouseMoveActions);
+ EventHandler.remove(this.element, 'mouseup', this.handleMouseUpActions);
+ EventHandler.remove(this.element, 'mousedown', this.handleMouseDownActions);
+ EventHandler.remove(this.element, 'input', this.handleEditorInputActions);
+ EventHandler.remove(this.element, 'keydown', this.handleKeydownActions);
+ EventHandler.remove(this.element, 'click', this.handleEditorClickActions);
+ EventHandler.remove(this.element, 'copy', this.clipboardActionHandler);
+ EventHandler.remove(this.element, 'cut', this.clipboardActionHandler);
+ EventHandler.remove(this.element, 'paste', this.clipboardActionHandler);
+ EventHandler.remove(this.blockWrapper, 'focus', this.handleEditorFocusActions);
+ EventHandler.remove(this.blockWrapper, 'blur', this.handleEditorBlurActions);
+ EventHandler.remove((this.floatingIconContainer.firstChild as HTMLElement), 'click', this.handleAddIconClick);
+ }
+
+ protected removeAndNullify(element: HTMLElement): void {
+ if (element) {
+ if (!isNOU(element.parentNode)) {
+ remove(element);
+ } else {
+ element.innerHTML = '';
+ }
+ }
+ }
+
+ private destroyFloatingIconTooltips(): void {
+ if (this.addIconTooltip) {
+ this.tooltipRenderer.destroyTooltip(this.addIconTooltip);
+ this.addIconTooltip = null;
+ }
+ if (this.dragIconTooltip) {
+ this.tooltipRenderer.destroyTooltip(this.dragIconTooltip);
+ this.dragIconTooltip = null;
+ }
+ }
+
+ private destroyBlockEditor(): void {
+ const properties: string [] = [
+ 'floatingIconContainer',
+ 'currentHoveredBlock',
+ 'currentFocusedBlock',
+ 'blockWrapper',
+ 'overlayContainer'
+ ];
+
+ for (const prop of properties) {
+ const element: keyof BlockEditor = prop as keyof BlockEditor;
+ this.removeAndNullify(this[element as keyof BlockEditor]);
+ (this[element as keyof BlockEditor] as HTMLElement) = null;
+ }
+ }
+
+ public destroy(): void {
+ if (this.isDestroyed) {
+ return;
+ }
+ this.unWireGlobalEvents();
+ if (this.enableDragAndDrop) {
+ this.dragAndDropAction.destroy();
+ }
+ if (this.undoRedoAction) {
+ this.undoRedoAction.destroy();
+ }
+ if (!isNOU(this.updateTimer)) {
+ clearInterval(this.updateTimer);
+ this.updateTimer = null;
+ }
+ this.notify(events.destroy, {});
+
+ this.popupRenderer = null;
+ this.mentionRenderer = null;
+ this.menubarRenderer = null;
+
+ this.inlineToolbarModule = null;
+ this.inlineContentInsertionModule = null;
+ this.slashCommandModule = null;
+ this.contextMenuModule = null;
+ this.blockActionMenuModule = null;
+ this.linkModule = null;
+ this.nodeSelection = null;
+
+ this.blockAction = null;
+ this.formattingAction = null;
+ this.listBlockAction = null;
+ this.blockEditorMethods = null;
+
+ this.blocksInternal = null;
+ this.keyCommandMap = null;
+ this.defaultKeyConfig = null;
+ this.l10n = null;
+
+ if (this.userMenuObj) {
+ this.userMenuObj.destroy();
+ }
+ if (this.labelMenuObj) {
+ this.labelMenuObj.destroy();
+ }
+ this.destroyFloatingIconTooltips();
+ this.blockAction = null;
+ this.dragAndDropAction = null;
+ this.undoRedoAction = null;
+ this.updateTimer = null;
+ this.blocksInternal = null;
+ this.destroyBlockEditor();
+ this.isRendered = false;
+ super.destroy();
+ }
+
+ /**
+ * Called if any of the property value is changed.
+ *
+ * @param {BlockEditorModel} newProp - Specifies new properties
+ * @param {BlockEditorModel} oldProp - Specifies old properties
+ * @returns {void}
+ * @hidden
+ */
+ /* eslint-disable */
+ public onPropertyChanged(newProp: BlockEditorModel, oldProp?: BlockEditorModel): void {
+ const prevProp: BlockEditorModel = oldProp;
+ if (!prevProp) { return; }
+ for (const prop of Object.keys(newProp)) {
+ switch (prop) {
+ case 'width':
+ case 'height':
+ this.setDimension();
+ break;
+ case 'cssClass':
+ this.setCssClass();
+ break;
+ case 'locale':
+ this.updateLocale();
+ break;
+ case 'enableRtl':
+ this.applyRtlSettings();
+ break;
+ case 'readOnly':
+ this.updateEditorReadyOnlyState();
+ break;
+ case 'keyConfig':
+ this.initializeKeyBindings();
+ break;
+ case 'enableDragAndDrop':
+ if (this.enableDragAndDrop) {
+ this.dragAndDropAction.wireDragEvents();
+ }
+ else {
+ this.dragAndDropAction.unwireDragEvents();
+ }
+ break;
+ case 'enableAutoHttps':
+ this.linkModule.handleAutoHttps();
+ break;
+ case 'commandMenu':
+ this.notify(events.moduleChanged, { module: 'slashCommand', newProp: newProp, oldProp: oldProp });
+ break;
+ case 'inlineToolbar':
+ this.notify(events.moduleChanged, { module: 'inlineToolbar', newProp: newProp, oldProp: oldProp });
+ break;
+ case 'blockActionsMenu':
+ this.notify(events.moduleChanged, { module: 'blockActionsMenu', newProp: newProp, oldProp: oldProp });
+ break;
+ case 'contextMenu':
+ this.notify(events.moduleChanged, { module: 'contextMenu', newProp: newProp, oldProp: oldProp });
+ break;
+ case 'blocks':
+ this.blockAction.handleBlockPropertyChanges({ newProp: newProp, oldProp: oldProp });
+ break;
+ }
+ }
+ }
+ /* eslint-enable */
+}
diff --git a/controls/blockeditor/src/blockeditor/base/constant.ts b/controls/blockeditor/src/blockeditor/base/constant.ts
new file mode 100644
index 0000000000..b8b9bb989f
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/base/constant.ts
@@ -0,0 +1,14 @@
+/* Events */
+
+export const events: { [key: string]: string } = {
+ keydown: 'keydown',
+ moduleChanged: 'moduleChanged',
+ inlineToolbarCreated: 'inlineToolbarCreated',
+ inlineToolbarItemClick: 'inlineToolbarItemClick',
+ inlineToolbarBeforeOpen: 'inlineToolbarBeforeOpen',
+ formattingPerformed: 'formatting-performed',
+ cut: 'cut',
+ copy: 'copy',
+ paste: 'paste',
+ destroy: 'destroy'
+};
diff --git a/controls/blockeditor/src/blockeditor/base/enums.ts b/controls/blockeditor/src/blockeditor/base/enums.ts
new file mode 100644
index 0000000000..5102db33fb
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/base/enums.ts
@@ -0,0 +1,172 @@
+/**
+ * Enum representing the different block types available in the block editor component.
+ * Each block type corresponds to a specific content format that can be used to create structured documents.
+ */
+export enum BlockType {
+ /**
+ * Represents a text block.
+ * This block type is used for plain text content.
+ */
+ Paragraph = 'Paragraph',
+
+ /**
+ * Represents a heading block.
+ * This block type is used for headings (H1).
+ */
+ Heading1 = 'Heading1',
+
+ /**
+ * Represents a heading block.
+ * This block type is used for headings (H2).
+ */
+ Heading2 = 'Heading2',
+
+ /**
+ * Represents a heading block.
+ * This block type is used for headings (H3).
+ */
+ Heading3 = 'Heading3',
+
+ /**
+ * Represents a heading block.
+ * This block type is used for headings (H4).
+ */
+ Heading4 = 'Heading4',
+
+ /**
+ * Represents a checklist block.
+ * This block type is used for creating interactive to-do lists.
+ */
+ CheckList = 'CheckList',
+
+ /**
+ * Represents a bullet list block.
+ * This block type is used for unordered lists.
+ */
+ BulletList = 'BulletList',
+
+ /**
+ * Represents a numbered list block.
+ * This block type is used for ordered lists.
+ */
+ NumberedList = 'NumberedList',
+
+ /**
+ * Represents a code block.
+ * This block type is used to display formatted code with syntax highlighting.
+ */
+ Code = 'Code',
+
+ /**
+ * Represents a quote block.
+ * This block type is used to display quotations or excerpts from a text.
+ */
+ Quote = 'Quote',
+
+ /**
+ * Represents a callout block.
+ * This block type is used to highlight important information or warnings.
+ */
+ Callout = 'Callout',
+
+ /**
+ * Represents a divider block.
+ * This block type is used to insert horizontal dividers to separate sections of content.
+ */
+ Divider = 'Divider',
+
+ /**
+ * Represents a toggle paragraph block.
+ * This block type is used to display paragraphs that can be expanded or collapsed.
+ */
+ ToggleParagraph = 'ToggleParagraph',
+
+ /**
+ * Represents a toggle heading 1 block.
+ * This block type is used to display top-level headings that can be expanded or collapsed.
+ */
+ ToggleHeading1 = 'ToggleHeading1',
+
+ /**
+ * Represents a toggle heading 2 block.
+ * This block type is used to display second-level headings that can be expanded or collapsed.
+ */
+ ToggleHeading2 = 'ToggleHeading2',
+
+ /**
+ * Represents a toggle heading 3 block.
+ * This block type is used to display third-level headings that can be expanded or collapsed.
+ */
+ ToggleHeading3 = 'ToggleHeading3',
+
+ /**
+ * Represents a toggle heading 4 block.
+ * This block type is used to display fourth-level headings that can be expanded or collapsed.
+ */
+ ToggleHeading4 = 'ToggleHeading4',
+
+ /**
+ * Represents an image block.
+ * This block type is used to display images.
+ */
+ Image = 'Image',
+
+ /**
+ * Represents a template block.
+ * This block type is used for predefined templates.
+ */
+ Template = 'Template'
+}
+
+/**
+ * Defines the type of content a block can hold.
+ * This enum represents various content formats supported in the editor.
+ */
+export enum ContentType {
+ /**
+ * Represents plain text content.
+ */
+ Text = 'Text',
+
+ /**
+ * Represents a hyperlink.
+ */
+ Link = 'Link',
+
+ /**
+ * Represents a code snippet.
+ */
+ Code = 'Code',
+
+ /**
+ * Represents a user mention.
+ */
+ Mention = 'Mention',
+
+ /**
+ * Represents a label or tag.
+ */
+ Label = 'Label'
+}
+
+/**
+ * Enum representing the built in items for inline toolbar.
+ */
+export enum BuiltInToolbar {
+ Bold = 'Bold',
+ Italic = 'Italic',
+ Underline = 'Underline',
+ Strikethrough = 'Strikethrough',
+ Color = 'Color',
+ BgColor = 'BgColor',
+ Superscript = 'Superscript',
+ Subscript = 'Subscript',
+ Uppercase = 'Uppercase',
+ Lowercase = 'Lowercase',
+ Custom = 'Custom'
+}
+
+export enum DeletionType {
+ Partial = 'partial',
+ Entire = 'entire'
+}
diff --git a/controls/blockeditor/src/blockeditor/base/eventargs.ts b/controls/blockeditor/src/blockeditor/base/eventargs.ts
new file mode 100644
index 0000000000..ff126960b8
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/base/eventargs.ts
@@ -0,0 +1,801 @@
+import { CommandItemModel, BlockModel, ToolbarItemModel, UserModel, BlockActionItemModel, ContentModel, ContextMenuItemModel } from '../models/index';
+
+
+/**
+ * This event is triggered when the command menu opens.
+ *
+ */
+export interface CommandMenuOpenEventArgs {
+ /**
+ * Specifies the list of command items to be displayed in the command menu.
+ *
+ * @default null
+ */
+ commands: CommandItemModel[];
+
+ /**
+ * Specifies the native browser event associated with the opening of the command menu.
+ *
+ * @default null
+ */
+ event: Event;
+
+ /**
+ * Specifies whether the event should be canceled. `true` to prevent opening the command menu.
+ *
+ * @default false
+ */
+ cancel: boolean;
+}
+
+/**
+ * This event is triggered when the command menu closes.
+ *
+ */
+export interface CommandMenuCloseEventArgs {
+ /**
+ * Specifies the list of command items that were displayed in the command menu.
+ *
+ * @default null
+ */
+ commands: CommandItemModel[];
+
+ /**
+ * Specifies the native browser event associated with the closing of the command menu.
+ *
+ * @default null
+ */
+ event: Event;
+
+ /**
+ * Specifies whether the event should be canceled. `true` to prevent closing the command menu.
+ *
+ * @default false
+ */
+ cancel: boolean;
+}
+
+/**
+ * This event is triggered when a query is typed in the command menu and filtering of commands occurs.
+ *
+ */
+export interface CommandQueryFilteringEventArgs {
+ /**
+ * Specifies the list of command items after filtering based on the query.
+ *
+ * @default null
+ */
+ commands: CommandItemModel[];
+
+ /**
+ * Specifies the query text that was typed by the user.
+ *
+ * @default ''
+ */
+ text: string;
+
+ /**
+ * Specifies the native browser event associated with the query filtering action.
+ *
+ * @default null
+ */
+ event: Event;
+
+ /**
+ * Specifies whether the event should be canceled. `true` to prevent the filtering.
+ *
+ * @default false
+ */
+ cancel: boolean;
+}
+
+/**
+ * This event is triggered when a command item is clicked in the command menu.
+ *
+ */
+export interface CommandItemClickedEventArgs {
+ /**
+ * Specifies the command item that was clicked.
+ *
+ * @default null
+ */
+ command: CommandItemModel;
+
+ /**
+ * Specifies the HTML element associated with the clicked command item.
+ *
+ * @default null
+ */
+ element: HTMLElement;
+
+ /**
+ * Specifies whether the click was made by the user (`true`) or programmatically (`false`).
+ *
+ * @default false
+ */
+ isInteracted: boolean;
+
+ /**
+ * Specifies the native browser event associated with the command item click.
+ *
+ * @default null
+ */
+ event: Event;
+
+ /**
+ * Specifies whether the event should be canceled. `true` to prevent the default click action.
+ *
+ * @default false
+ */
+ cancel: boolean;
+}
+
+/**
+ * This event is triggered when the toolbar is opened.
+ *
+ */
+export interface ToolbarOpenEventArgs {
+ /**
+ * Specifies the list of toolbar items to be displayed.
+ *
+ * @default null
+ */
+ items: ToolbarItemModel[];
+
+ /**
+ * Specifies the native browser event associated with the opening of the toolbar.
+ *
+ * @default null
+ */
+ event: Event;
+
+ /**
+ * Specifies whether the event should be canceled. `true` to prevent opening the toolbar.
+ *
+ * @default false
+ */
+ cancel: boolean;
+}
+
+/**
+ * This event is triggered when the toolbar is closed.
+ *
+ */
+export interface ToolbarCloseEventArgs {
+ /**
+ * Specifies the list of toolbar items that were displayed.
+ *
+ * @default null
+ */
+ items: ToolbarItemModel[];
+
+ /**
+ * Specifies the native browser event associated with the closing of the toolbar.
+ *
+ * @default null
+ */
+ event: Event;
+
+ /**
+ * Specifies whether the event should be canceled. `true` to prevent closing the toolbar.
+ *
+ * @default false
+ */
+ cancel: boolean;
+}
+
+/**
+ * This event is triggered when a toolbar item is clicked.
+ *
+ */
+export interface ToolbarItemClickedEventArgs {
+ /**
+ * Specifies the toolbar item that was clicked.
+ *
+ * @default null
+ */
+ item: ToolbarItemModel;
+
+ /**
+ * Specifies the native browser event associated with the toolbar item click.
+ *
+ * @default null
+ */
+ event: Event;
+
+ /**
+ * Specifies whether the click was made by the user (`true`) or programmatically (`false`).
+ *
+ * @default false
+ */
+ isInteracted: boolean;
+
+ /**
+ * Specifies whether the event should be canceled. `true` to prevent the click action.
+ *
+ * @default false
+ */
+ cancel: boolean;
+}
+
+/**
+ * Represents the event arguments for opening the block action menu.
+ *
+ */
+export interface BlockActionMenuOpenEventArgs {
+ /**
+ * Specifies the list of block action items in the menu.
+ *
+ * @default null
+ */
+ items: BlockActionItemModel[];
+
+ /**
+ * Represents the event that triggered the opening of the menu.
+ *
+ * @default null
+ */
+ event: Event;
+
+ /**
+ * Specifies whether to cancel the action.
+ * If true, the menu will not open.
+ *
+ * @default false
+ */
+ cancel: boolean;
+}
+
+/**
+ * Represents the event arguments for closing the block action menu.
+ *
+ */
+export interface BlockActionMenuCloseEventArgs {
+ /**
+ * Specifies the list of block action items in the menu.
+ *
+ * @default null
+ */
+ items: BlockActionItemModel[];
+
+ /**
+ * Represents the event that triggered the closing of the menu.
+ *
+ * @default null
+ */
+ event: Event;
+
+ /**
+ * Specifies whether to cancel the action.
+ * If true, the menu will not close.
+ *
+ * @default false
+ */
+ cancel: boolean;
+}
+
+/**
+ * Represents the event arguments for a block action item click event.
+ *
+ */
+export interface BlockActionItemClickEventArgs {
+ /**
+ * Specifies the block action item that was clicked.
+ *
+ * @default null
+ */
+ item: BlockActionItemModel;
+
+ /**
+ * Specifies the HTML element that triggered the click event.
+ *
+ * @default null
+ */
+ element: HTMLElement;
+
+ /**
+ * Specifies whether the item was directly interacted with.
+ *
+ * @default false
+ */
+ isInteracted: boolean;
+
+ /**
+ * Specifies whether to cancel the item click action.
+ *
+ * @default false
+ */
+ cancel: boolean;
+}
+
+/**
+ * Provides information about the event before the context menu opens.
+ */
+export interface ContextMenuBeforeOpenEventArgs {
+ /**
+ * Specifies the list of context menu items available in the menu.
+ *
+ * @default []
+ */
+ items: ContextMenuItemModel[];
+
+ /**
+ * Specifies the parent context menu item.
+ *
+ * @default null
+ */
+ parentItem: ContextMenuItemModel;
+
+ /**
+ * Specifies the native browser event associated with the opening of the context menu.
+ *
+ * @default null
+ */
+ event: Event;
+
+ /**
+ * Specifies whether the opening of the context menu should be canceled.
+ * If set to `true`, the menu will not be displayed.
+ *
+ * @default false
+ */
+ cancel: boolean;
+}
+
+/**
+ * Provides information about the event before the context menu closes.
+ */
+/* eslint-disable-next-line @typescript-eslint/no-empty-interface */
+export interface ContextMenuBeforeCloseEventArgs extends ContextMenuBeforeOpenEventArgs {}
+
+/**
+ * Provides information about the event when the context menu opens.
+ */
+export interface ContextMenuOpenEventArgs {
+ /**
+ * Specifies the list of context menu items available in the menu.
+ *
+ * @default []
+ */
+ items: ContextMenuItemModel[];
+
+ /**
+ * Specifies the parent context menu item.
+ *
+ * @default null
+ */
+ parentItem: ContextMenuItemModel;
+
+ /**
+ * Specifies the HTML element associated with the clicked menu item.
+ *
+ * @default null
+ */
+ element: HTMLElement;
+}
+
+/**
+ * Provides information about the event when the context menu closes.
+ */
+/* eslint-disable-next-line @typescript-eslint/no-empty-interface */
+export interface ContextMenuCloseEventArgs extends ContextMenuOpenEventArgs {}
+
+/**
+ * Provides information about the event when a context menu item is being clicked.
+ */
+export interface ContextMenuItemClickEventArgs {
+ /**
+ * Specifies the clicked context menu item.
+ *
+ * @default null
+ */
+ item: ContextMenuItemModel;
+
+ /**
+ * Specifies the native browser event associated with the item click.
+ *
+ * @default null
+ */
+ event: Event;
+
+ /**
+ * Specifies whether the action triggered by clicking the menu item should be canceled.
+ * If set to `true`, the menu action will not be executed.
+ *
+ * @default false
+ */
+ cancel: boolean;
+}
+
+/**
+ * Represents the event arguments for a block action content change event.
+ *
+ */
+export interface ContentChangedEventArgs {
+ /**
+ * Specifies the native event that triggered the content change.
+ */
+ event: Event;
+
+ /**
+ * Specifies the updated block content after the change.
+ *
+ * @default null
+ */
+ content: ContentModel;
+
+ /**
+ * Specifies the block content before the change.
+ *
+ * @default null
+ */
+ previousContent: ContentModel;
+}
+
+/**
+ * Represents the event arguments for a selection change event.
+ *
+ */
+export interface SelectionChangedEventArgs {
+ /**
+ * Specifies the native event that triggered the selection change.
+ */
+ event: Event;
+
+ /**
+ * Specifies the new selection range, represented as an array with [start, end] indexes.
+ *
+ * @default null
+ */
+ range: [number, number];
+
+ /**
+ * Specifies the previous selection range, represented as an array with [start, end] indexes.
+ *
+ * @default null
+ */
+ previousRange: [number, number];
+}
+
+/**
+ * Represents the arguments for an undo/redo event.
+ *
+ */
+export interface UndoRedoEventArgs {
+ /**
+ * Specifies whether the action is an undo or redo.
+ *
+ * @default false
+ */
+ isUndo: boolean;
+
+ /**
+ * Specifies the current block model after the undo/redo action.
+ *
+ * @default null
+ */
+ content: BlockModel;
+
+ /**
+ * Specifies the block model before the undo/redo action.
+ *
+ * @default null
+ */
+ previousContent: BlockModel;
+}
+
+/**
+ * This event is triggered when a new block is added to the editor.
+ *
+ */
+export interface BlockAddedEventArgs {
+ /**
+ * Specifies the block model that was added.
+ *
+ * @default null
+ */
+ block: BlockModel;
+
+ /**
+ * Specifies the ID of the parent block (if any).
+ *
+ * @default ''
+ */
+ parentID: string;
+
+ /**
+ * Specifies the index where the block was added.
+ *
+ * @default -1
+ */
+ index: number;
+
+ /**
+ * Indicates if the block was added via paste action.
+ *
+ * @default false
+ */
+ isPasted: boolean;
+
+ /**
+ * Indicates whether the user directly interacted with the block (e.g., adding it manually).
+ * If `false`, the block was added programmatically.
+ *
+ * @default false
+ */
+ isInteracted: boolean;
+}
+
+/*
+ * This event is triggered when a block is removed from the editor.
+ *
+ */
+export interface BlockRemovedEventArgs {
+ /**
+ * Specifies the block model that was removed.
+ *
+ * @default null
+ */
+ block: BlockModel;
+
+ /**
+ * Specifies the ID of the parent block (if any).
+ *
+ * @default ''
+ */
+ parentID: string;
+
+ /**
+ * Specifies the index of the block that was removed.
+ *
+ * @default -1
+ */
+ index: number;
+
+ /**
+ * Indicates whether the user directly interacted with the block (e.g., removing it manually).
+ * If `false`, the block was removed programmatically.
+ *
+ * @default false
+ */
+ isInteracted: boolean;
+}
+
+/*
+ * This event is triggered when a block is moved from one location to another within the editor.
+ *
+ */
+export interface BlockMovedEventArgs {
+ /**
+ * Specifies the block models that was moved.
+ *
+ * @default null
+ */
+ blocks: BlockModel[];
+
+ /**
+ * Specifies the ID of the parent block where the block was moved to.
+ *
+ * @default ''
+ */
+ parentID: string;
+
+ /**
+ * Specifies the ID of the parent blocks from which the block was moved.
+ *
+ * @default ''
+ */
+ previousParentID: string[];
+
+ /**
+ * Specifies the new index of the block after it was moved.
+ *
+ * @default -1
+ */
+ index: number;
+
+ /**
+ * Specifies the previous index of the blocks before it was moved.
+ *
+ * @default -1
+ */
+ previousIndex: number[];
+
+ /**
+ * Indicates whether the user directly interacted with the block (e.g., moving it manually).
+ * If `false`, the block was moved programmatically.
+ *
+ * @default false
+ */
+ isInteracted: boolean;
+}
+
+/*
+ * This event is triggered when a block is dragged, before it is dropped in the block editor.
+ *
+ */
+export interface BlockDragEventArgs {
+ /**
+ * Specifies the block models that is being dragged.
+ *
+ * @default null
+ */
+ blocks: BlockModel[];
+
+ /**
+ * Specifies the index of the blocks from which the drag started.
+ *
+ * @default -1
+ */
+ fromIndex: number[];
+
+ /**
+ * Specifies the index where the block is intended to be dropped.
+ *
+ * @default -1
+ */
+ dropIndex: number;
+
+ /**
+ * Specifies the native event (e.g., mouse or drag event) that triggered the block drag action.
+ *
+ * @default null
+ */
+ event: Event;
+
+ /**
+ * Specifies the target HTML element where the block is being dragged from.
+ *
+ * @default null
+ */
+ target: HTMLElement;
+
+ /**
+ * Specifies whether the drag action should be canceled.
+ *
+ * @default false
+ */
+ cancel: boolean;
+}
+
+/*
+ * This event is triggered when a block is dropped onto another block or area in the block editor.
+ *
+ */
+export interface BlockDropEventArgs {
+ /**
+ * Specifies the block models that was dropped.
+ *
+ * @default null
+ */
+ blocks: BlockModel[];
+
+ /**
+ * Specifies the index of the blocks from where it was dragged before the drop.
+ *
+ * @default -1
+ */
+ fromIndex: number[];
+
+ /**
+ * Specifies the index of the block where the drop occurred.
+ *
+ * @default -1
+ */
+ dropIndex: number;
+
+ /**
+ * Specifies the native event (e.g., mouse or drag event) that triggered the block drop action.
+ *
+ * @default null
+ */
+ event: Event;
+
+ /**
+ * Specifies the target HTML element where the block was dropped.
+ *
+ * @default null
+ */
+ target: HTMLElement;
+}
+
+/**
+ * This event is triggered when a block or the block editor gains focus.
+ *
+ */
+export interface FocusEventArgs {
+ /**
+ * The native event (e.g., mouse, keyboard) that triggered the focus action.
+ *
+ * @default null
+ */
+ event: Event;
+
+ /**
+ * The unique identifier of the block that currently has focus.
+ *
+ * @default ''
+ */
+ blockId: string;
+
+ /**
+ * The range of the selection within the block. The first value is the starting index, and the second value is the ending index.
+ *
+ * @default [0, 0]
+ */
+ selectionRange: [number, number];
+}
+
+
+/**
+ * This event is triggered when the block editor loses focus.
+ *
+ */
+export interface BlurEventArgs {
+ /**
+ * The native event (e.g., mouse, keyboard) that triggered the blur action.
+ *
+ * @default null
+ */
+ event: Event;
+
+ /**
+ * The unique identifier of the block that currently had focus or selection when the blur event occurs.
+ *
+ * @default ''
+ */
+ blockId: string;
+}
+
+/**
+ * Represents the event arguments for a key action execution event.
+ *
+ */
+export interface KeyActionExecutedEventArgs {
+ /**
+ * Specifies the key combination that triggered the action (e.g., 'Ctrl+Alt+1').
+ *
+ * @default ''
+ */
+ keyCombination: string;
+
+ /**
+ * Specifies the action that was executed based on the key combination.
+ *
+ * @default ''
+ */
+ action: string;
+}
+
+/**
+ * Represents the event arguments for paste event.
+ *
+ */
+export interface BeforePasteEventArgs {
+ /**
+ * Specifies whether the paste action should be canceled.
+ *
+ * @default false
+ */
+ cancel: boolean;
+
+ /**
+ * Contains the content being pasted.
+ *
+ * @default ''
+ */
+ content: string;
+}
+
+/**
+ * Represents the event arguments for paste event.
+ *
+ */
+export interface AfterPasteEventArgs {
+ /**
+ * Contains the content that was pasted.
+ *
+ * @default ''
+ */
+ content: string;
+}
diff --git a/controls/blockeditor/src/blockeditor/base/index.ts b/controls/blockeditor/src/blockeditor/base/index.ts
new file mode 100644
index 0000000000..5da2b94842
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/base/index.ts
@@ -0,0 +1,8 @@
+/**
+ * Blockeditor Base exports
+ */
+export * from './blockeditor';
+export * from './blockeditor-model';
+export * from './enums';
+export * from './eventargs';
+export * from './interface';
diff --git a/controls/blockeditor/src/blockeditor/base/interface.ts b/controls/blockeditor/src/blockeditor/base/interface.ts
new file mode 100644
index 0000000000..425fcda022
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/base/interface.ts
@@ -0,0 +1,295 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { EmitType } from '@syncfusion/ej2-base';
+import { FieldSettingsModel as MentionFieldSettingsModel, FilteringEventArgs, MentionChangeEventArgs, PopupEventArgs, SelectEventArgs } from '@syncfusion/ej2-dropdowns';
+import { ClickEventArgs, ItemModel, MenuItemModel, OverflowMode, MenuEventArgs, Orientation, BeforeOpenCloseMenuEventArgs, OpenCloseMenuEventArgs } from '@syncfusion/ej2-navigations';
+import { FieldSettingsModel as MenuFieldSettingsModel } from '@syncfusion/ej2-navigations/common';
+import { DropDownButtonModel } from '@syncfusion/ej2-splitbuttons';
+import { BlockModel, LabelItemModel, StyleModel, UserModel, ContentModel, Block } from '../models/index';
+import { ContentType, BlockType, DeletionType } from './enums';
+import { Position, TooltipEventArgs } from '@syncfusion/ej2-popups';
+
+export type SubCommand = 'Link';
+
+export interface LinkData {
+ text?: string;
+ url?: string;
+ title?: string
+ openInNewWindow?: boolean;
+ shouldRemoveLink?: boolean;
+}
+
+export interface ISplitContent {
+ beforeFragment: DocumentFragment,
+ afterFragment: DocumentFragment,
+ splitOffset: number
+}
+
+export interface IPopupRenderOptions {
+ width?: string | number
+ height?: string | number
+ element?: string | HTMLElement
+ content?: string | HTMLElement
+ relateTo?: string | HTMLElement
+}
+
+export interface IDropDownButtonArgs {
+ instance?: DropDownButtonModel,
+ element?: string | HTMLElement,
+ inlineClass?: string,
+ type?: 'color' | 'bgColor',
+}
+
+export interface IMenubarRenderOptions {
+ target?: string
+ element?: string | HTMLUListElement
+ template?: string | Function
+ orientation?: Orientation
+ showItemOnClick?: boolean
+ items: MenuItemModel[]
+ itemTemplate?: string | Function
+ fields?: MenuFieldSettingsModel,
+ select?: EmitType
+ beforeOpen?: EmitType
+ open?: EmitType
+ beforeClose?: EmitType
+ close?: EmitType
+}
+
+export interface IToolbarRenderOptions {
+ element?: string | HTMLElement
+ items: ItemModel[]
+ width?: string | number
+ overflowMode?: OverflowMode
+ enablePersistence?: boolean,
+ enableRtl?: boolean,
+ clicked?: EmitType,
+ created?: EmitType
+}
+
+export interface IMentionRenderOptions {
+ element?: string | HTMLElement,
+ mentionChar?: string,
+ dataSource?: any,
+ cssClass?: string,
+ highlight?: boolean,
+ fields?: MentionFieldSettingsModel,
+ itemTemplate?: string,
+ displayTemplate?: string,
+ popupWidth?: string
+ popupHeight?: string,
+ beforeOpen?: EmitType,
+ beforeClose?: EmitType,
+ opened?: EmitType,
+ closed?: EmitType,
+ select?: EmitType,
+ change?: EmitType,
+ filtering?: EmitType
+}
+
+export interface ITooltipRenderOptions {
+ element?: string | HTMLElement
+ content?: string | HTMLElement | Function
+ container?: string | HTMLElement;
+ target?: string
+ position?: Position
+ showTipPointer?: boolean
+ windowCollision?: boolean
+ cssClass?: string
+ beforeRender?: EmitType
+}
+
+export interface ExecCommandOptions {
+ command?: keyof StyleModel,
+ subCommand?: SubCommand,
+ value?: string | LinkData,
+ isFormattingOnUserTyping?: boolean
+}
+
+export interface IInlineContentInsertionArgs {
+ node?: HTMLElement | Node
+ range?: Range
+ contentType?: ContentType
+ block?: BlockModel
+ blockElement?: HTMLElement,
+ itemData?: UserModel | LabelItemModel
+}
+
+export interface RangePath {
+ startContainer?: Node;
+ startOffset?: number;
+ endContainer?: Node;
+ endOffset?: number;
+ parentElement?: HTMLElement
+}
+
+export interface CommentRange {
+ startContainerPath: number[];
+ startOffset: number;
+ endContainerPath: number[];
+ endOffset: number;
+ textContent: string;
+}
+
+export interface IDeleteBlockArgs {
+ blockElement: HTMLElement
+ isUndoRedoAction?: boolean
+ mergeDirection?: 'previous' | 'next'
+ splitOffset?: number
+ lastChild?: HTMLElement
+ contentElement?: Node
+ isMethod?: boolean
+}
+
+export interface IAddBlockArgs {
+ block?: BlockModel
+ targetBlock?: HTMLElement
+ isAfter?: boolean
+ blockType?: string | BlockType
+ contentElement?: Node,
+ contentModel?: ContentModel[]
+ blockID?: string
+ isUndoRedoAction?: boolean
+ splitOffset?: number
+ lastChild?: HTMLElement,
+ preventUIUpdate?: boolean
+}
+
+export interface IAddBulkBlocksArgs {
+ blocks?: BlockModel[]
+ targetBlockId?: string
+ isUndoRedoAction?: boolean
+ insertionType?: 'blocks' | 'block'
+ oldBlockModel?: BlockModel
+ clipboardBlocks?: BlockModel[]
+ pastedBlocks?: BlockModel[]
+ isPastedAtStart?: boolean
+ isSelectivePaste?: boolean
+}
+
+export interface IMoveBlock {
+ blockIds?: string[];
+ fromIndex?: number[];
+ toIndex?: number;
+ fromParentId?: string[];
+ toParentId?: string;
+ isMovedUp?: boolean;
+ toBlockId?: string;
+}
+
+export interface IData {
+ blockId?: string;
+}
+
+export interface ITransform extends IData {
+ block?: BlockModel;
+ oldBlockType?: string;
+ newBlockType?: string;
+}
+
+export interface IDelete extends IData {
+ currentIndex?: number;
+}
+
+export interface IAdd extends IData {
+ currentIndex?: number;
+ lastChild?: HTMLElement;
+ splitOffset?: number;
+ contentElement?: Node;
+}
+export interface IUndoRedoSelection {
+ startBlockId: string;
+ endBlockId: string;
+ startContainerPath: number[];
+ endContainerPath: number[];
+ startOffset: number;
+ endOffset: number;
+ isCollapsed: boolean;
+}
+export interface IUndoRedoState {
+ oldBlockModel?: BlockModel;
+ updatedBlockModel?: BlockModel;
+ oldContentModel?: ContentModel;
+ newContentModel?: ContentModel;
+ oldContents?: ContentModel[]
+ newContents?: ContentModel[]
+ action?: string;
+ data?: IData | IMoveBlock | IIndentBlockArgs | IDelete | IAdd | ITransform | IMultipleBlockDeletion | IClipboardPasteUndoRedo,
+ undoSelection?: IUndoRedoSelection;
+ redoSelection?: IUndoRedoSelection;
+ isFormattingOnUserTyping?: boolean
+}
+
+export interface IIndentBlockArgs {
+ blockIDs?: string[];
+ shouldDecrease?: boolean;
+ isUndoRedoAction?: boolean;
+}
+
+export interface IClipboardPasteUndoRedo {
+ type?: 'blocks' | 'block' | 'content'
+ clipboardData?: {
+ blocks?: BlockModel[]
+ }
+ blocks?: BlockModel[]
+ oldContent?: ContentModel[]
+ newContent?: ContentModel[]
+ targetBlockId?: string
+ isPastedAtStart?: boolean
+ isSelectivePaste?: boolean
+}
+
+export interface IMultipleBlockDeletion {
+ deletedBlocks: BlockModel[]
+ deletionType: DeletionType
+ direction?: 'previous' | 'next'
+ firstBlockIndex?: number
+ cursorBlockId?: string;
+}
+
+
+export interface IMoveBlockArgs {
+ fromBlockIds?: string[];
+ toBlockId?: string;
+ fromIndex?: number[];
+ toIndex?: number;
+ fromParentId?: string[];
+ toParentId?: string;
+ isInteracted?: boolean;
+ isUndoRedoAction?: boolean;
+ isUndoing?: boolean;
+ isMovedUp?: boolean;
+}
+
+export interface ITransformBlockArgs {
+ block?: BlockModel;
+ blockElement?: HTMLElement;
+ newBlockType?: string;
+ isUndoRedoAction?: boolean;
+}
+
+export interface IPasteCleanupArgs {
+ e?: ClipboardEvent;
+ html?: string;
+ plainText?: string;
+ isPlainText?: boolean;
+ keepFormat?: boolean;
+ allowedStyles?: string[];
+ deniedTags?: string[];
+ isFromMsWord?: boolean;
+ onSucess?: (data: string) => void;
+}
+
+export interface IClipboardPayload {
+ e?: ClipboardEvent
+ html?: string;
+ text?: string;
+ blockeditorData?: string;
+ file?: File | Blob;
+}
+
+export interface IFromBlockArgs {
+ blockId?: string;
+ model?: BlockModel;
+ parent?: BlockModel;
+ index?: number
+}
diff --git a/controls/blockeditor/src/blockeditor/index.ts b/controls/blockeditor/src/blockeditor/index.ts
new file mode 100644
index 0000000000..73164a3bfa
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/index.ts
@@ -0,0 +1,5 @@
+export * from './base/index';
+export * from './actions/index';
+export * from './models/index';
+export * from './renderer/index';
+export * from './utils/index';
diff --git a/controls/blockeditor/src/blockeditor/models/block/block-model.d.ts b/controls/blockeditor/src/blockeditor/models/block/block-model.d.ts
new file mode 100644
index 0000000000..e26943dc9e
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/block/block-model.d.ts
@@ -0,0 +1,116 @@
+import { ChildProperty, Property, Collection, Complex } from '@syncfusion/ej2-base';import { BlockType } from '../../base/enums';import { Content } from '../content/content';import { ContentModel } from '../content/index';import { CodeSettings } from './code-settings';import { ImageSettings } from './image-settings';import { CodeSettingsModel, ImageSettingsModel } from './index';
+
+/**
+ * Interface for a class Block
+ */
+export interface BlockModel {
+
+ /**
+ * Specifies the unique identifier for the block.
+ * This property is used to uniquely identify each block.
+ *
+ * @default ''
+ */
+ id?: string;
+
+ /**
+ * Specifies the unique identifier of the parent block.
+ * This property is used to establish a hierarchical relationship between parent and child blocks.
+ *
+ * @default ''
+ */
+ parentId?: string;
+
+ /**
+ * Specifies the type of the block, which can be a string or a predefined BlockType.
+ * This property determines the type of content the block holds.
+ *
+ * @default 'Paragraph'
+ */
+ type?: string | BlockType;
+
+ /**
+ * Specifies placeholder text to display when the block is empty.
+ * This property provides a hint to the user about what to write.
+ *
+ * @default ''
+ */
+ placeholder?: string;
+
+ /**
+ * Specifies the content of the block, which can vary based on the block type.
+ * This property holds the actual content of the block.
+ *
+ * @default []
+ */
+ content?: ContentModel[];
+
+ /**
+ * Specifies the indent for the block.
+ * This property is used to specify indent for each block.
+ *
+ * @default 0
+ */
+ indent?: number;
+
+ /**
+ * Represents the child blocks of the current block.
+ * This property contains an array of block models which are considered
+ * as children of the current block, allowing for hierarchical structures.
+ *
+ * @default []
+ */
+ children?: BlockModel[];
+
+ /**
+ * Specifies whether the block is expanded or collapsed.
+ * This property controls the visibility of child blocks within a hierarchical structure.
+ *
+ * @default false
+ */
+ isExpanded?: boolean;
+
+ /**
+ * Specifies the checked state for the block.
+ * This property is applicable for blocks that support a checked state, such as checklist.
+ *
+ * @default false
+ */
+ isChecked?: boolean;
+
+ /**
+ * Specifies the CSS class applied to the block.
+ * Allows custom styling by associating one or more CSS class names with the block.
+ *
+ * @default ''
+ */
+ cssClass?: string;
+
+ /**
+ * Defines the template content for the block.
+ *
+ * @default ''
+ * @angularType string | object | HTMLElement
+ * @reactType string | function | JSX.Element | HTMLElement
+ * @vueType string | function | HTMLElement
+ * @aspType string
+ */
+ template?: string | HTMLElement | Function;
+
+ /**
+ * Specifies the code block configuration associated with this block.
+ * This property defines settings such as language, code content, theme, and syntax highlighting preferences.
+ *
+ * @default {}
+ */
+ codeSettings?: CodeSettingsModel;
+
+ /**
+ * Specifies the image block configuration associated with this block.
+ * This property defines settings such as save format, upload URLs, size constraints, display mode, and read-only preferences.
+ *
+ * @default {}
+ */
+ imageSettings?: ImageSettingsModel;
+
+}
\ No newline at end of file
diff --git a/controls/blockeditor/src/blockeditor/models/block/block.ts b/controls/blockeditor/src/blockeditor/models/block/block.ts
new file mode 100644
index 0000000000..b52c46fc43
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/block/block.ts
@@ -0,0 +1,146 @@
+import { ChildProperty, Property, Collection, Complex } from '@syncfusion/ej2-base';
+import { BlockType } from '../../base/enums';
+import { Content } from '../content/content';
+import { BlockModel } from './block-model';
+import { ContentModel } from '../content/index';
+import { CodeSettings } from './code-settings';
+import { ImageSettings } from './image-settings';
+import { CodeSettingsModel, ImageSettingsModel } from './index';
+
+/**
+ * Defines the properties of block.
+ */
+export class Block extends ChildProperty{
+
+ /**
+ * Specifies the unique identifier for the block.
+ * This property is used to uniquely identify each block.
+ *
+ * @default ''
+ */
+ @Property('')
+ public id: string;
+
+ /**
+ * Specifies the unique identifier of the parent block.
+ * This property is used to establish a hierarchical relationship between parent and child blocks.
+ *
+ * @default ''
+ */
+ @Property('')
+ public parentId: string;
+
+ /**
+ * Specifies the type of the block, which can be a string or a predefined BlockType.
+ * This property determines the type of content the block holds.
+ *
+ * @default 'Paragraph'
+ */
+ @Property('Paragraph')
+ public type: string | BlockType;
+
+ /**
+ * Specifies placeholder text to display when the block is empty.
+ * This property provides a hint to the user about what to write.
+ *
+ * @default ''
+ */
+ @Property('')
+ public placeholder: string;
+
+ /**
+ * Specifies the content of the block, which can vary based on the block type.
+ * This property holds the actual content of the block.
+ *
+ * @default []
+ */
+ @Collection([], Content)
+ public content: ContentModel[];
+
+ /**
+ * Specifies the indent for the block.
+ * This property is used to specify indent for each block.
+ *
+ * @default 0
+ */
+ @Property(0)
+ public indent: number;
+
+ /**
+ * Represents the child blocks of the current block.
+ * This property contains an array of block models which are considered
+ * as children of the current block, allowing for hierarchical structures.
+ *
+ * @default []
+ */
+ @Collection([], Block)
+ public children: BlockModel[];
+
+ /**
+ * Specifies whether the block is expanded or collapsed.
+ * This property controls the visibility of child blocks within a hierarchical structure.
+ *
+ * @default false
+ */
+ @Property(false)
+ public isExpanded: boolean;
+
+ /**
+ * Specifies the checked state for the block.
+ * This property is applicable for blocks that support a checked state, such as checklist.
+ *
+ * @default false
+ */
+ @Property(false)
+ public isChecked: boolean;
+
+ /**
+ * Specifies the CSS class applied to the block.
+ * Allows custom styling by associating one or more CSS class names with the block.
+ *
+ * @default ''
+ */
+ @Property('')
+ public cssClass: string;
+
+ /**
+ * Defines the template content for the block.
+ *
+ * @default ''
+ * @angularType string | object | HTMLElement
+ * @reactType string | function | JSX.Element | HTMLElement
+ * @vueType string | function | HTMLElement
+ * @aspType string
+ */
+ @Property('')
+ public template: string | HTMLElement | Function;
+
+ /**
+ * Specifies the code block configuration associated with this block.
+ * This property defines settings such as language, code content, theme, and syntax highlighting preferences.
+ *
+ * @default {}
+ */
+ @Complex({}, CodeSettings)
+ public codeSettings: CodeSettingsModel;
+
+ /**
+ * Specifies the image block configuration associated with this block.
+ * This property defines settings such as save format, upload URLs, size constraints, display mode, and read-only preferences.
+ *
+ * @default {}
+ */
+ @Complex({}, ImageSettings)
+ public imageSettings: ImageSettingsModel;
+
+ /**
+ * @param {Object} prop - Gets the property of block.
+ * @param {boolean} muteOnChange - Gets the boolean value of muteOnChange.
+ * @returns {void}
+ * @private
+ */
+ public setProperties(prop: Object, muteOnChange: boolean): void {
+ super.setProperties(prop, muteOnChange);
+ }
+
+}
diff --git a/controls/blockeditor/src/blockeditor/models/block/blockaction-item-model.d.ts b/controls/blockeditor/src/blockeditor/models/block/blockaction-item-model.d.ts
new file mode 100644
index 0000000000..5802717025
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/block/blockaction-item-model.d.ts
@@ -0,0 +1,54 @@
+import { Property, ChildProperty } from '@syncfusion/ej2-base';
+
+/**
+ * Interface for a class BlockActionItem
+ */
+export interface BlockActionItemModel {
+
+ /**
+ * Specifies unique identifier of the action item.
+ *
+ * @default ''
+ */
+ id?: string;
+
+ /**
+ * Specifies the display label of the action item.
+ *
+ * @default ''
+ */
+ label?: string;
+
+ /**
+ * Specifies the CSS class for the action icon.
+ * This allows styling customization of the menu items.
+ *
+ * @default ''
+ */
+ iconCss?: string;
+
+ /**
+ * Specifies whether the action item is disabled.
+ * When set to `true`, the action item will be unavailable for selection and execution.
+ *
+ * @default false
+ */
+ disabled?: boolean;
+
+ /**
+ * Specifies the tooltip for the action item.
+ * This is an optional description shown on hover.
+ *
+ * @default ''
+ */
+ tooltip?: string;
+
+ /**
+ * Specifies the keyboard shortcut for the action item.
+ * This allows users to trigger the action using a specific key combination.
+ *
+ * @default ''
+ */
+ shortcut?: string;
+
+}
\ No newline at end of file
diff --git a/controls/blockeditor/src/blockeditor/models/block/blockaction-item.ts b/controls/blockeditor/src/blockeditor/models/block/blockaction-item.ts
new file mode 100644
index 0000000000..4195214b43
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/block/blockaction-item.ts
@@ -0,0 +1,59 @@
+import { Property, ChildProperty } from '@syncfusion/ej2-base';
+
+/**
+ * Represents BlockActionItem in the block editor component.
+ */
+export class BlockActionItem extends ChildProperty {
+
+ /**
+ * Specifies unique identifier of the action item.
+ *
+ * @default ''
+ */
+ @Property('')
+ public id: string;
+
+ /**
+ * Specifies the display label of the action item.
+ *
+ * @default ''
+ */
+ @Property('')
+ public label: string;
+
+ /**
+ * Specifies the CSS class for the action icon.
+ * This allows styling customization of the menu items.
+ *
+ * @default ''
+ */
+ @Property('')
+ public iconCss: string;
+
+ /**
+ * Specifies whether the action item is disabled.
+ * When set to `true`, the action item will be unavailable for selection and execution.
+ *
+ * @default false
+ */
+ @Property(false)
+ public disabled: boolean;
+
+ /**
+ * Specifies the tooltip for the action item.
+ * This is an optional description shown on hover.
+ *
+ * @default ''
+ */
+ @Property('')
+ public tooltip: string;
+
+ /**
+ * Specifies the keyboard shortcut for the action item.
+ * This allows users to trigger the action using a specific key combination.
+ *
+ * @default ''
+ */
+ @Property('')
+ public shortcut: string;
+}
diff --git a/controls/blockeditor/src/blockeditor/models/block/blockaction-menu-settings-model.d.ts b/controls/blockeditor/src/blockeditor/models/block/blockaction-menu-settings-model.d.ts
new file mode 100644
index 0000000000..cafd356ec8
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/block/blockaction-menu-settings-model.d.ts
@@ -0,0 +1,67 @@
+import { Collection, EmitType, Event, Property, ChildProperty } from '@syncfusion/ej2-base';import { BlockActionMenuOpenEventArgs, BlockActionMenuCloseEventArgs, BlockActionItemClickEventArgs } from '../../base/eventargs';import { BlockActionItem } from './blockaction-item';import { BlockActionItemModel } from './index';
+
+/**
+ * Interface for a class BlockActionMenuSettings
+ */
+export interface BlockActionMenuSettingsModel {
+
+ /**
+ * Specifies whether the block actions menu is enabled.
+ * If set to `false`, the menu will not be displayed.
+ *
+ * @default true
+ */
+ enable?: boolean;
+
+ /**
+ * Specifies the action items in the block actions menu.
+ * This defines the set of commands that appear when the menu is opened.
+ *
+ * @default []
+ */
+ items?: BlockActionItemModel[];
+
+ /**
+ * Specifies the event triggered when the block actions menu opens.
+ *
+ * @event open
+ */
+ open?: EmitType;
+
+ /**
+ * Specifies the event triggered when the block actions menu closes.
+ *
+ * @event close
+ */
+ close?: EmitType;
+
+ /**
+ * Specifies the event triggered when an item is being selected from the menu.
+ *
+ * @event itemClick
+ */
+ itemClick?: EmitType;
+
+ /**
+ * Specifies the popup width for the action menu.
+ *
+ * @default '230px'
+ */
+ popupWidth?: string;
+
+ /**
+ * Specifies the popup height for the action menu.
+ *
+ * @default 'auto'
+ */
+ popupHeight?: string;
+
+ /**
+ * Specifies whether the tooltip is enabled for the block action menu.
+ * If set to `true`, tooltips will be displayed based on the `tooltip` property of the action item.
+ *
+ * @default true
+ */
+ enableTooltip?: boolean;
+
+}
\ No newline at end of file
diff --git a/controls/blockeditor/src/blockeditor/models/block/blockaction-menu-settings.ts b/controls/blockeditor/src/blockeditor/models/block/blockaction-menu-settings.ts
new file mode 100644
index 0000000000..9d7e6e5412
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/block/blockaction-menu-settings.ts
@@ -0,0 +1,76 @@
+import { Collection, EmitType, Event, Property, ChildProperty } from '@syncfusion/ej2-base';
+import { BlockActionMenuOpenEventArgs, BlockActionMenuCloseEventArgs, BlockActionItemClickEventArgs } from '../../base/eventargs';
+import { BlockActionItem } from './blockaction-item';
+import { BlockActionItemModel } from './index';
+
+/**
+ * Represents BlockActionMenuSettings in the block editor component.
+ */
+export class BlockActionMenuSettings extends ChildProperty{
+ /**
+ * Specifies whether the block actions menu is enabled.
+ * If set to `false`, the menu will not be displayed.
+ *
+ * @default true
+ */
+ @Property(true)
+ public enable: boolean;
+
+ /**
+ * Specifies the action items in the block actions menu.
+ * This defines the set of commands that appear when the menu is opened.
+ *
+ * @default []
+ */
+ @Collection([], BlockActionItem)
+ public items: BlockActionItemModel[];
+
+ /**
+ * Specifies the event triggered when the block actions menu opens.
+ *
+ * @event open
+ */
+ @Event()
+ public open: EmitType;
+
+ /**
+ * Specifies the event triggered when the block actions menu closes.
+ *
+ * @event close
+ */
+ @Event()
+ public close: EmitType;
+
+ /**
+ * Specifies the event triggered when an item is being selected from the menu.
+ *
+ * @event itemClick
+ */
+ @Event()
+ public itemClick: EmitType;
+
+ /**
+ * Specifies the popup width for the action menu.
+ *
+ * @default '230px'
+ */
+ @Property('230px')
+ public popupWidth: string;
+
+ /**
+ * Specifies the popup height for the action menu.
+ *
+ * @default 'auto'
+ */
+ @Property('auto')
+ public popupHeight: string;
+
+ /**
+ * Specifies whether the tooltip is enabled for the block action menu.
+ * If set to `true`, tooltips will be displayed based on the `tooltip` property of the action item.
+ *
+ * @default true
+ */
+ @Property(true)
+ public enableTooltip: boolean;
+}
diff --git a/controls/blockeditor/src/blockeditor/models/block/code-settings-model.d.ts b/controls/blockeditor/src/blockeditor/models/block/code-settings-model.d.ts
new file mode 100644
index 0000000000..959ad4f1f6
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/block/code-settings-model.d.ts
@@ -0,0 +1,25 @@
+import { Property, ChildProperty } from '@syncfusion/ej2-base';
+import {CodeLanguageModel} from "./code-settings";
+
+/**
+ * Interface for a class CodeSettings
+ */
+export interface CodeSettingsModel {
+
+ /**
+ * Specifies the languages available for syntax highlighting.
+ * This is an array of objects, each containing a language value and a label.
+ *
+ * @default []
+ */
+ languages?: CodeLanguageModel[];
+
+ /**
+ * Specifies the default language to use for syntax highlighting.
+ * This is the language that will be selected by default in the language selector dropdown.
+ *
+ * @default 'javascript'
+ */
+ defaultLanguage?: string;
+
+}
\ No newline at end of file
diff --git a/controls/blockeditor/src/blockeditor/models/block/code-settings.ts b/controls/blockeditor/src/blockeditor/models/block/code-settings.ts
new file mode 100644
index 0000000000..1c9b73f5f5
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/block/code-settings.ts
@@ -0,0 +1,47 @@
+import { Property, ChildProperty } from '@syncfusion/ej2-base';
+
+/**
+ * Interface for a class Comment
+ */
+export interface CodeLanguageModel {
+
+ /**
+ * Specifies the language value used for syntax highlighting.
+ * For example, 'javascript', 'python', 'html', etc.
+ *
+ * @default ''
+ */
+ language?: string;
+
+ /**
+ * Specifies the label to display in the language selector dropdown.
+ * This is typically a user-friendly name corresponding to the language.
+ *
+ * @default ''
+ */
+ label?: string;
+
+}
+
+/**
+ * Represents CodeSettings in the block editor component.
+ */
+export class CodeSettings extends ChildProperty{
+ /**
+ * Specifies the languages available for syntax highlighting.
+ * This is an array of objects, each containing a language value and a label.
+ *
+ * @default []
+ */
+ @Property([])
+ public languages: CodeLanguageModel[];
+
+ /**
+ * Specifies the default language to use for syntax highlighting.
+ * This is the language that will be selected by default in the language selector dropdown.
+ *
+ * @default 'javascript'
+ */
+ @Property('javascript')
+ public defaultLanguage: string;
+}
diff --git a/controls/blockeditor/src/blockeditor/models/block/image-settings-model.d.ts b/controls/blockeditor/src/blockeditor/models/block/image-settings-model.d.ts
new file mode 100644
index 0000000000..8e15b9b636
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/block/image-settings-model.d.ts
@@ -0,0 +1,105 @@
+import { Property, ChildProperty } from '@syncfusion/ej2-base';
+import {SaveFormat} from "./image-settings";
+
+/**
+ * Interface for a class ImageSettings
+ */
+export interface ImageSettingsModel {
+
+ /**
+ * Specifies the format to save the image.
+ * Accepts either 'base64' for inline image encoding or 'blob' for binary object representation.
+ *
+ * @default 'base64'
+ */
+ saveFormat?: SaveFormat;
+
+ /**
+ * Specifies the image path.
+ *
+ * @default ''
+ */
+ src?: string;
+
+ /**
+ * Specifies the allowed image file types that can be uploaded.
+ * Common types include '.jpg', '.jpeg', and '.png'.
+ *
+ * @default ['.jpg', '.jpeg', '.png']
+ */
+ allowedTypes?: string[];
+
+ /**
+ * Specifies the display width of the image.
+ * Can be defined in pixels or percentage.
+ *
+ * @default ''
+ */
+
+ width?: string;
+
+ /**
+ * Specifies the display height of the image.
+ * Can be defined in pixels or percentage.
+ *
+ * @default ''
+ */
+ height?: string;
+
+ /**
+ * Specifies the minimum width of the image in pixels or as a string unit.
+ * Prevents the image from being resized below this value.
+ *
+ * @default 40
+ */
+
+ minWidth?: string | number;
+
+ /**
+ * Specifies the maximum width of the image in pixels or as a string unit.
+ * Prevents the image from being resized beyond this value.
+ *
+ * @default ''
+ */
+ maxWidth?: string | number;
+
+ /**
+ * Specifies the minimum height of the image in pixels or as a string unit.
+ * Prevents the image from being resized below this value.
+ *
+ * @default 40
+ */
+ minHeight?: string | number;
+
+ /**
+ * Specifies the maximum height of the image in pixels or as a string unit.
+ * Prevents the image from being resized beyond this value.
+ *
+ * @default ''
+ */
+ maxHeight?: string | number;
+
+ /**
+ * Specifies the alternative text to be displayed when the image cannot be loaded.
+ *
+ * @default ''
+ */
+ altText?: string;
+
+ /**
+ * Specifies one or more CSS classes to be applied to the image element.
+ * Useful for applying custom styles or themes.
+ *
+ * @default ''
+ */
+ cssClass?: string;
+
+ /**
+ * Specifies whether the image is in read-only mode.
+ * In read-only mode, editing or removing the image is not allowed.
+ *
+ * @default false
+ */
+ readOnly?: boolean;
+
+}
\ No newline at end of file
diff --git a/controls/blockeditor/src/blockeditor/models/block/image-settings.ts b/controls/blockeditor/src/blockeditor/models/block/image-settings.ts
new file mode 100644
index 0000000000..1655f43d54
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/block/image-settings.ts
@@ -0,0 +1,121 @@
+import { Property, ChildProperty } from '@syncfusion/ej2-base';
+
+/**
+ * Specifies the formats available for saving images.
+ * Options include saving as Base64 or Blob.
+ *
+ */
+export type SaveFormat = 'Base64' | 'Blob';
+
+/**
+ * Defines the settings available for rendering an Image block.
+ */
+export class ImageSettings extends ChildProperty {
+ /**
+ * Specifies the format to save the image.
+ * Accepts either 'base64' for inline image encoding or 'blob' for binary object representation.
+ *
+ * @default 'base64'
+ */
+ @Property('base64')
+ public saveFormat: SaveFormat;
+
+ /**
+ * Specifies the image path.
+ *
+ * @default ''
+ */
+ @Property('')
+ public src: string;
+
+ /**
+ * Specifies the allowed image file types that can be uploaded.
+ * Common types include '.jpg', '.jpeg', and '.png'.
+ *
+ * @default ['.jpg', '.jpeg', '.png']
+ */
+ @Property(['.jpg', '.jpeg', '.png'])
+ public allowedTypes: string[];
+
+ /**
+ * Specifies the display width of the image.
+ * Can be defined in pixels or percentage.
+ *
+ * @default ''
+ */
+
+ @Property('')
+ public width: string;
+
+ /**
+ * Specifies the display height of the image.
+ * Can be defined in pixels or percentage.
+ *
+ * @default ''
+ */
+ @Property('')
+ public height: string;
+
+ /**
+ * Specifies the minimum width of the image in pixels or as a string unit.
+ * Prevents the image from being resized below this value.
+ *
+ * @default 40
+ */
+
+ @Property(40)
+ public minWidth: string | number;
+
+ /**
+ * Specifies the maximum width of the image in pixels or as a string unit.
+ * Prevents the image from being resized beyond this value.
+ *
+ * @default ''
+ */
+ @Property('')
+ public maxWidth: string | number;
+
+ /**
+ * Specifies the minimum height of the image in pixels or as a string unit.
+ * Prevents the image from being resized below this value.
+ *
+ * @default 40
+ */
+ @Property(40)
+ public minHeight: string | number;
+
+ /**
+ * Specifies the maximum height of the image in pixels or as a string unit.
+ * Prevents the image from being resized beyond this value.
+ *
+ * @default ''
+ */
+ @Property('')
+ public maxHeight: string | number;
+
+ /**
+ * Specifies the alternative text to be displayed when the image cannot be loaded.
+ *
+ * @default ''
+ */
+ @Property('')
+ public altText: string;
+
+ /**
+ * Specifies one or more CSS classes to be applied to the image element.
+ * Useful for applying custom styles or themes.
+ *
+ * @default ''
+ */
+ @Property('')
+ public cssClass: string;
+
+ /**
+ * Specifies whether the image is in read-only mode.
+ * In read-only mode, editing or removing the image is not allowed.
+ *
+ * @default false
+ */
+ @Property(false)
+ public readOnly: boolean;
+}
diff --git a/controls/blockeditor/src/blockeditor/models/block/index.ts b/controls/blockeditor/src/blockeditor/models/block/index.ts
new file mode 100644
index 0000000000..e44aa9a859
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/block/index.ts
@@ -0,0 +1,13 @@
+export * from './block';
+export * from './blockaction-menu-settings';
+export * from './blockaction-item';
+
+export * from './code-settings';
+export * from './image-settings';
+
+export * from './block-model';
+export * from './blockaction-menu-settings-model';
+export * from './blockaction-item-model';
+
+export * from './code-settings-model';
+export * from './image-settings-model';
diff --git a/controls/blockeditor/src/blockeditor/models/common/index.ts b/controls/blockeditor/src/blockeditor/models/common/index.ts
new file mode 100644
index 0000000000..3c07cb1c43
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/common/index.ts
@@ -0,0 +1,9 @@
+export * from './paste-settings';
+export * from './user';
+export * from './label-item';
+export * from './label-settings';
+
+export * from './paste-settings-model';
+export * from './user-model';
+export * from './label-item-model';
+export * from './label-settings-model';
diff --git a/controls/blockeditor/src/blockeditor/models/common/label-item-model.d.ts b/controls/blockeditor/src/blockeditor/models/common/label-item-model.d.ts
new file mode 100644
index 0000000000..53c678dbf5
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/common/label-item-model.d.ts
@@ -0,0 +1,46 @@
+import { Property, ChildProperty } from '@syncfusion/ej2-base';
+
+/**
+ * Interface for a class LabelItem
+ */
+export interface LabelItemModel {
+
+ /**
+ * Specifies the unique identifier for the label.
+ *
+ * @default ''
+ */
+ id?: string;
+
+ /**
+ * Specifies the display text for the label.
+ *
+ * @default ''
+ */
+ text?: string;
+
+ /**
+ * Specifies the group header for the label.
+ * This is used to categorize labels within the editor.
+ *
+ * @default ''
+ */
+ groupHeader?: string;
+
+ /**
+ * Specifies the color of the label.
+ * This can be used to visually distinguish labels.
+ *
+ * @default ''
+ */
+ labelColor?: string;
+
+ /**
+ * Specifies the CSS class for the label's icon.
+ * This can be used to define custom label icons which appears near the label text.
+ *
+ * @default ''
+ */
+ iconCss?: string;
+
+}
\ No newline at end of file
diff --git a/controls/blockeditor/src/blockeditor/models/common/label-item.ts b/controls/blockeditor/src/blockeditor/models/common/label-item.ts
new file mode 100644
index 0000000000..93e40fecf7
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/common/label-item.ts
@@ -0,0 +1,50 @@
+import { Property, ChildProperty } from '@syncfusion/ej2-base';
+
+/**
+ * Represents LabelItem in the block editor component.
+ */
+export class LabelItem extends ChildProperty {
+
+ /**
+ * Specifies the unique identifier for the label.
+ *
+ * @default ''
+ */
+ @Property('')
+ public id: string;
+
+ /**
+ * Specifies the display text for the label.
+ *
+ * @default ''
+ */
+ @Property('')
+ public text: string;
+
+ /**
+ * Specifies the group header for the label.
+ * This is used to categorize labels within the editor.
+ *
+ * @default ''
+ */
+ @Property('')
+ public groupHeader: string;
+
+ /**
+ * Specifies the color of the label.
+ * This can be used to visually distinguish labels.
+ *
+ * @default ''
+ */
+ @Property('')
+ public labelColor: string;
+
+ /**
+ * Specifies the CSS class for the label's icon.
+ * This can be used to define custom label icons which appears near the label text.
+ *
+ * @default ''
+ */
+ @Property('')
+ public iconCss: string;
+}
diff --git a/controls/blockeditor/src/blockeditor/models/common/label-settings-model.d.ts b/controls/blockeditor/src/blockeditor/models/common/label-settings-model.d.ts
new file mode 100644
index 0000000000..590c3f17b5
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/common/label-settings-model.d.ts
@@ -0,0 +1,26 @@
+import { ChildProperty, Collection, Property } from '@syncfusion/ej2-base';import { LabelItem } from './label-item';import { LabelItemModel } from './index';
+
+/**
+ * Interface for a class LabelSettings
+ */
+export interface LabelSettingsModel {
+
+ /**
+ * Specifies the label items for the label popup.
+ * This property is an array of LabelItemModel instances defining label-related options.
+ * By default, predefined labels are provided.
+ *
+ * @default []
+ */
+ labelItems?: LabelItemModel[];
+
+ /**
+ * Specifies the trigger character for labels.
+ * This property defines the character that triggers the label popup to open.
+ * By default, the trigger character is set to '$'.
+ *
+ * @default '$'
+ */
+ triggerChar?: string;
+
+}
\ No newline at end of file
diff --git a/controls/blockeditor/src/blockeditor/models/common/label-settings.ts b/controls/blockeditor/src/blockeditor/models/common/label-settings.ts
new file mode 100644
index 0000000000..d792f284d8
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/common/label-settings.ts
@@ -0,0 +1,30 @@
+import { ChildProperty, Collection, Property } from '@syncfusion/ej2-base';
+import { LabelItem } from './label-item';
+import { LabelItemModel } from './index';
+
+/**
+ * Configures settings related to Label popup in the editor.
+ * This property utilizes the LabelSettingsModel to specify various options and behaviors for paste operations.
+ */
+export class LabelSettings extends ChildProperty {
+
+ /**
+ * Specifies the label items for the label popup.
+ * This property is an array of LabelItemModel instances defining label-related options.
+ * By default, predefined labels are provided.
+ *
+ * @default []
+ */
+ @Collection([], LabelItem)
+ public labelItems: LabelItemModel[];
+
+ /**
+ * Specifies the trigger character for labels.
+ * This property defines the character that triggers the label popup to open.
+ * By default, the trigger character is set to '$'.
+ *
+ * @default '$'
+ */
+ @Property('$')
+ public triggerChar: string;
+}
diff --git a/controls/blockeditor/src/blockeditor/models/common/paste-settings-model.d.ts b/controls/blockeditor/src/blockeditor/models/common/paste-settings-model.d.ts
new file mode 100644
index 0000000000..e66de66375
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/common/paste-settings-model.d.ts
@@ -0,0 +1,40 @@
+import { ChildProperty, Property } from '@syncfusion/ej2-base';
+
+/**
+ * Interface for a class PasteSettings
+ */
+export interface PasteSettingsModel {
+
+ /**
+ * Specifies the allowed styles when pasting content.
+ * This property holds an array of styles that can be applied to pasted content.
+ *
+ * @default ['font-weight', 'font-style', 'text-decoration', 'text-transform']
+ */
+ allowedStyles?: string[];
+
+ /**
+ * Specifies the tags that are denied when pasting content.
+ * This property holds an array of tags that should be removed from pasted content.
+ *
+ * @default []
+ */
+ deniedTags?: string[];
+
+ /**
+ * Specifies whether to keep the formatting of pasted content.
+ * This property determines if the formatting (e.g., bold, italics) should be preserved.
+ *
+ * @default true
+ */
+ keepFormat?: boolean;
+
+ /**
+ * Specifies whether to paste as plain text.
+ * This property removes any formatting from the pasted content and pastes only the raw text.
+ *
+ * @default false
+ */
+ plainText?: boolean;
+
+}
\ No newline at end of file
diff --git a/controls/blockeditor/src/blockeditor/models/common/paste-settings.ts b/controls/blockeditor/src/blockeditor/models/common/paste-settings.ts
new file mode 100644
index 0000000000..a971b9e0b0
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/common/paste-settings.ts
@@ -0,0 +1,44 @@
+import { ChildProperty, Property } from '@syncfusion/ej2-base';
+
+/**
+ * Configures settings related to pasting content in the editor.
+ * This property utilizes the PasteSettingsModel to specify various options and behaviors for paste operations.
+ */
+export class PasteSettings extends ChildProperty {
+
+ /**
+ * Specifies the allowed styles when pasting content.
+ * This property holds an array of styles that can be applied to pasted content.
+ *
+ * @default ['font-weight', 'font-style', 'text-decoration', 'text-transform']
+ */
+ @Property(['font-weight', 'font-style', 'text-decoration', 'text-transform'])
+ public allowedStyles: string[];
+
+ /**
+ * Specifies the tags that are denied when pasting content.
+ * This property holds an array of tags that should be removed from pasted content.
+ *
+ * @default []
+ */
+ @Property([])
+ public deniedTags: string[];
+
+ /**
+ * Specifies whether to keep the formatting of pasted content.
+ * This property determines if the formatting (e.g., bold, italics) should be preserved.
+ *
+ * @default true
+ */
+ @Property(true)
+ public keepFormat: boolean;
+
+ /**
+ * Specifies whether to paste as plain text.
+ * This property removes any formatting from the pasted content and pastes only the raw text.
+ *
+ * @default false
+ */
+ @Property(false)
+ public plainText: boolean;
+}
diff --git a/controls/blockeditor/src/blockeditor/models/common/user-model.d.ts b/controls/blockeditor/src/blockeditor/models/common/user-model.d.ts
new file mode 100644
index 0000000000..f3695792ab
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/common/user-model.d.ts
@@ -0,0 +1,56 @@
+import { ChildProperty, Property } from '@syncfusion/ej2-base';
+
+/**
+ * Interface for a class User
+ */
+export interface UserModel {
+
+ /**
+ * Specifies the unique identifier for the user.
+ * This property is used to uniquely identify each user in the editor.
+ *
+ * @default ''
+ */
+ id?: string;
+
+ /**
+ * Specifies the name of the user.
+ * This property stores the name of the user associated with the block.
+ *
+ * @default 'Default'
+ */
+ user?: string;
+
+ /**
+ * Specifies the URL of the user's avatar image.
+ * This property holds the URL that points to the user's avatar image.
+ *
+ * @default ''
+ */
+ avatarUrl?: string;
+
+ /**
+ * Specifies the background color of the user's avatar.
+ * This property defines the background color for the avatar and can also be used as the cursor color in collaborative editing.
+ *
+ * @default ''
+ */
+ avatarBgColor?: string;
+
+ /**
+ * Specifies the CSS class applied to the user block.
+ * Allows custom styling by associating one or more CSS class names with the user.
+ *
+ * @default ''
+ */
+ cssClass?: string;
+
+ /**
+ * Specifies the range of selected text or block for the user.
+ * This property defines the start and end positions of the user's selection
+ *
+ * @default null
+ */
+ selectionRange?: [number, number];
+
+}
\ No newline at end of file
diff --git a/controls/blockeditor/src/blockeditor/models/common/user.ts b/controls/blockeditor/src/blockeditor/models/common/user.ts
new file mode 100644
index 0000000000..4233aabd4d
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/common/user.ts
@@ -0,0 +1,61 @@
+import { ChildProperty, Property } from '@syncfusion/ej2-base';
+
+/**
+ * Represents a user model for a block in the block editor component.
+ */
+export class User extends ChildProperty {
+
+ /**
+ * Specifies the unique identifier for the user.
+ * This property is used to uniquely identify each user in the editor.
+ *
+ * @default ''
+ */
+ @Property('')
+ public id: string;
+
+ /**
+ * Specifies the name of the user.
+ * This property stores the name of the user associated with the block.
+ *
+ * @default 'Default'
+ */
+ @Property('Default')
+ public user: string;
+
+ /**
+ * Specifies the URL of the user's avatar image.
+ * This property holds the URL that points to the user's avatar image.
+ *
+ * @default ''
+ */
+ @Property('')
+ public avatarUrl: string;
+
+ /**
+ * Specifies the background color of the user's avatar.
+ * This property defines the background color for the avatar and can also be used as the cursor color in collaborative editing.
+ *
+ * @default ''
+ */
+ @Property('')
+ public avatarBgColor: string;
+
+ /**
+ * Specifies the CSS class applied to the user block.
+ * Allows custom styling by associating one or more CSS class names with the user.
+ *
+ * @default ''
+ */
+ @Property('')
+ public cssClass: string;
+
+ /**
+ * Specifies the range of selected text or block for the user.
+ * This property defines the start and end positions of the user's selection
+ *
+ * @default null
+ */
+ @Property(null)
+ public selectionRange: [number, number];
+}
diff --git a/controls/blockeditor/src/blockeditor/models/content/content-model.d.ts b/controls/blockeditor/src/blockeditor/models/content/content-model.d.ts
new file mode 100644
index 0000000000..c98a318b3c
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/content/content-model.d.ts
@@ -0,0 +1,66 @@
+import { ChildProperty, Complex, Property } from '@syncfusion/ej2-base';import { StyleModel, LinkSettingsModel } from './index';import { LinkSettings } from './link-settings';import { Style } from './style';import { ContentType } from '../../base/enums';
+
+/**
+ * Interface for a class Content
+ */
+export interface ContentModel {
+
+ /**
+ * Specifies the unique identifier for the block.
+ *
+ * For standard types, this acts as the unique identifier of the content.
+ * For special types like `Label` or `Mention`, this should be set to the corresponding item ID
+ * from the datasource to render the resolved content.
+ *
+ * @default ''
+ */
+ id?: string;
+
+ /**
+ * Defines the type of content for the block.
+ * It can be text, link, code, mention, or label.
+ *
+ * @default 'Text'
+ */
+ type?: ContentType;
+
+ /**
+ * Specifies the actual content of the block.
+ *
+ * @default ''
+ */
+ content?: string;
+
+ /**
+ * Specifies style attributes for the block.
+ * This property is an object of StyleModel instances defining text formatting options.
+ *
+ * @default {}
+ */
+ styles?: StyleModel;
+
+ /**
+ * Specifies a hyperlink associated with the block.
+ * If the block represents a link, this property holds the URL.
+ *
+ * @default {}
+ */
+ linkSettings?: LinkSettingsModel;
+
+ /**
+ * @hidden
+ * Tracks the order of styles applied to the content.
+ *
+ * @default []
+ */
+ stylesApplied?: string[];
+
+ /**
+ * @hidden
+ * Internal data identifier for label or mention type.
+ *
+ * @default ''
+ */
+ dataId?: string;
+
+}
\ No newline at end of file
diff --git a/controls/blockeditor/src/blockeditor/models/content/content.ts b/controls/blockeditor/src/blockeditor/models/content/content.ts
new file mode 100644
index 0000000000..5f3d84ba0c
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/content/content.ts
@@ -0,0 +1,76 @@
+import { ChildProperty, Complex, Property } from '@syncfusion/ej2-base';
+import { StyleModel, LinkSettingsModel } from './index';
+import { LinkSettings } from './link-settings';
+import { Style } from './style';
+import { ContentType } from '../../base/enums';
+
+/**
+ * Defines the properties of block.
+ */
+export class Content extends ChildProperty {
+
+ /**
+ * Specifies the unique identifier for the block.
+ *
+ * For standard types, this acts as the unique identifier of the content.
+ * For special types like `Label` or `Mention`, this should be set to the corresponding item ID
+ * from the datasource to render the resolved content.
+ *
+ * @default ''
+ */
+ @Property('')
+ public id: string;
+
+ /**
+ * Defines the type of content for the block.
+ * It can be text, link, code, mention, or label.
+ *
+ * @default 'Text'
+ */
+ @Property('Text')
+ public type: ContentType;
+
+ /**
+ * Specifies the actual content of the block.
+ *
+ * @default ''
+ */
+ @Property('')
+ public content: string;
+
+ /**
+ * Specifies style attributes for the block.
+ * This property is an object of StyleModel instances defining text formatting options.
+ *
+ * @default {}
+ */
+ @Complex({}, Style)
+ public styles: StyleModel;
+
+ /**
+ * Specifies a hyperlink associated with the block.
+ * If the block represents a link, this property holds the URL.
+ *
+ * @default {}
+ */
+ @Complex({}, LinkSettings)
+ public linkSettings: LinkSettingsModel;
+
+ /**
+ * @hidden
+ * Tracks the order of styles applied to the content.
+ *
+ * @default []
+ */
+ @Property([])
+ public stylesApplied: string[];
+
+ /**
+ * @hidden
+ * Internal data identifier for label or mention type.
+ *
+ * @default ''
+ */
+ @Property('')
+ public dataId: string;
+}
diff --git a/controls/blockeditor/src/blockeditor/models/content/index.ts b/controls/blockeditor/src/blockeditor/models/content/index.ts
new file mode 100644
index 0000000000..199d0bdbe0
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/content/index.ts
@@ -0,0 +1,7 @@
+export * from './content';
+export * from './link-settings';
+export * from './style';
+
+export * from './content-model';
+export * from './link-settings-model';
+export * from './style-model';
diff --git a/controls/blockeditor/src/blockeditor/models/content/link-settings-model.d.ts b/controls/blockeditor/src/blockeditor/models/content/link-settings-model.d.ts
new file mode 100644
index 0000000000..ad09e458b5
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/content/link-settings-model.d.ts
@@ -0,0 +1,24 @@
+import { Property, ChildProperty } from '@syncfusion/ej2-base';
+
+/**
+ * Interface for a class LinkSettings
+ */
+export interface LinkSettingsModel {
+
+ /**
+ * Specifies the URL of the link.
+ * This is the destination where the link will navigate to when clicked.
+ *
+ * @default ''
+ */
+ url?: string;
+
+ /**
+ * Specifies whether the link should open in a new window/tab.
+ * If set to true, the link will open in a new window/tab, otherwise it will open in the same window.
+ *
+ * @default true
+ */
+ openInNewWindow?: boolean;
+
+}
\ No newline at end of file
diff --git a/controls/blockeditor/src/blockeditor/models/content/link-settings.ts b/controls/blockeditor/src/blockeditor/models/content/link-settings.ts
new file mode 100644
index 0000000000..211af08cb7
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/content/link-settings.ts
@@ -0,0 +1,24 @@
+import { Property, ChildProperty } from '@syncfusion/ej2-base';
+
+/**
+ * Represents LinkSettings in the block editor component.
+ */
+export class LinkSettings extends ChildProperty{
+ /**
+ * Specifies the URL of the link.
+ * This is the destination where the link will navigate to when clicked.
+ *
+ * @default ''
+ */
+ @Property('')
+ public url: string;
+
+ /**
+ * Specifies whether the link should open in a new window/tab.
+ * If set to true, the link will open in a new window/tab, otherwise it will open in the same window.
+ *
+ * @default true
+ */
+ @Property(true)
+ public openInNewWindow: boolean;
+}
diff --git a/controls/blockeditor/src/blockeditor/models/content/style-model.d.ts b/controls/blockeditor/src/blockeditor/models/content/style-model.d.ts
new file mode 100644
index 0000000000..67c3fcd0f3
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/content/style-model.d.ts
@@ -0,0 +1,85 @@
+import { ChildProperty, Property } from '@syncfusion/ej2-base';
+
+/**
+ * Interface for a class Style
+ */
+export interface StyleModel {
+
+ /**
+ * Specifies whether the text is bold.
+ *
+ * @default false
+ */
+ bold?: boolean;
+
+ /**
+ * Specifies whether the text is italicized.
+ *
+ * @default false
+ */
+ italic?: boolean;
+
+ /**
+ * Specifies whether the text is underlined.
+ *
+ * @default false
+ */
+ underline?: boolean;
+
+ /**
+ * Specifies whether the text has a strikethrough effect.
+ *
+ * @default false
+ */
+ strikethrough?: boolean;
+
+ /**
+ * Specifies the text color in a HEX or RGBA format.
+ *
+ * @default ''
+ */
+ color?: string;
+
+ /**
+ * Specifies the background color for the text.
+ *
+ * @default ''
+ */
+ bgColor?: string;
+
+ /**
+ * Specifies whether the text is displayed as superscript.
+ *
+ * @default false
+ */
+ superscript?: boolean;
+
+ /**
+ * Specifies whether the text is displayed as subscript.
+ *
+ * @default false
+ */
+ subscript?: boolean;
+
+ /**
+ * Converts the text to uppercase.
+ *
+ * @default false
+ */
+ uppercase?: boolean;
+
+ /**
+ * Converts the text to lowercase.
+ *
+ * @default false
+ */
+ lowercase?: boolean;
+
+ /**
+ * Specifies custom CSS styles for the text.
+ *
+ * @default ''
+ */
+ custom?: string;
+
+}
\ No newline at end of file
diff --git a/controls/blockeditor/src/blockeditor/models/content/style.ts b/controls/blockeditor/src/blockeditor/models/content/style.ts
new file mode 100644
index 0000000000..7b78a7552c
--- /dev/null
+++ b/controls/blockeditor/src/blockeditor/models/content/style.ts
@@ -0,0 +1,96 @@
+import { ChildProperty, Property } from '@syncfusion/ej2-base';
+
+/**
+ * Defines the style attributes applicable to a content block.
+ * This model specifies text formatting options such as bold, italic, underline, and colors.
+ */
+export class Style extends ChildProperty