diff --git a/blots/scroll.js b/blots/scroll.js
index 824a42299e..c32291fe4b 100644
--- a/blots/scroll.js
+++ b/blots/scroll.js
@@ -3,6 +3,7 @@ import Emitter from '../core/emitter';
import Block, { BlockEmbed } from './block';
import Break from './break';
import Container from './container';
+import { CellLine } from '../formats/table';
function isLine(blot) {
return blot instanceof Block || blot instanceof BlockEmbed;
@@ -44,13 +45,15 @@ class Scroll extends ScrollBlot {
const [last] = this.line(index + length);
super.deleteAt(index, length);
if (last != null && first !== last && offset > 0) {
- if (first instanceof BlockEmbed || last instanceof BlockEmbed) {
- this.optimize();
- return;
+ const isCrossCellDelete = (first instanceof CellLine || last instanceof CellLine)
+ && first.parent !== last.parent;
+ const includesEmbedBlock = first instanceof BlockEmbed || last instanceof BlockEmbed;
+
+ if (!includesEmbedBlock && !isCrossCellDelete) {
+ const ref = last.children.head instanceof Break ? null : last.children.head;
+ first.moveChildren(last, ref);
+ first.remove();
}
- const ref = last.children.head instanceof Break ? null : last.children.head;
- first.moveChildren(last, ref);
- first.remove();
}
this.optimize();
}
diff --git a/formats/table/index.js b/formats/table/index.js
index 900c65cefd..1c72018885 100644
--- a/formats/table/index.js
+++ b/formats/table/index.js
@@ -11,12 +11,18 @@ const CELL_IDENTITY_KEYS = ['row', 'cell'];
const TABLE_TAGS = ['TD', 'TH', 'TR', 'TBODY', 'THEAD', 'TABLE'];
const DATA_PREFIX = 'data-table-';
+function deleteChildrenAt(children, index, length) {
+ children.forEachAt(index, length, (child, offset, childLength) => {
+ child.deleteAt(offset, childLength);
+ });
+}
+
class CellLine extends Block {
static create(value) {
const node = super.create(value);
CELL_IDENTITY_KEYS.forEach((key) => {
const identityMarker = key === 'row' ? tableId : cellId;
- node.setAttribute(`${DATA_PREFIX}${key}`, value[key] ?? identityMarker());
+ node.setAttribute(`${DATA_PREFIX}${key}`, value?.[key] ?? identityMarker());
});
return node;
@@ -183,6 +189,10 @@ class BaseCell extends Container {
}
super.optimize(...args);
}
+
+ deleteAt(index, length) {
+ deleteChildrenAt(this.children, index, length);
+ }
}
BaseCell.tagName = ['TD', 'TH'];
@@ -210,6 +220,7 @@ class TableCell extends BaseCell {
TableCell.blotName = 'tableCell';
TableCell.className = 'ql-table-data-cell';
TableCell.dataAttribute = `${DATA_PREFIX}row`;
+TableCell.defaultChild = CellLine;
class TableHeaderCell extends BaseCell {
static create(value) {
@@ -236,6 +247,7 @@ TableHeaderCell.tagName = ['TH', 'TD'];
TableHeaderCell.className = 'ql-table-header-cell';
TableHeaderCell.blotName = 'tableHeaderCell';
TableHeaderCell.dataAttribute = `${DATA_PREFIX}header-row`;
+TableHeaderCell.defaultChild = HeaderCellLine;
class BaseRow extends Container {
checkMerge() {
@@ -316,6 +328,10 @@ class TableRow extends BaseRow {
this.childFormatName = 'table';
}
+
+ deleteAt(index, length) {
+ deleteChildrenAt(this.children, index, length);
+ }
}
TableRow.blotName = 'tableRow';
diff --git a/modules/keyboard.js b/modules/keyboard.js
index a8e14d86d1..82284efefc 100644
--- a/modules/keyboard.js
+++ b/modules/keyboard.js
@@ -309,7 +309,16 @@ class Keyboard extends Module {
const [prev] = this.quill.getLine(range.index - 1);
if (prev) {
const isPrevLineEmpty = prev.statics.blotName === 'block' && prev.length() <= 1;
- if (!isPrevLineEmpty) {
+ const isPrevLineTable = prev.statics.blotName.startsWith('table');
+ const isLineEmpty = line.statics.blotName === 'block' && line.length() <= 1;
+
+ if (isPrevLineTable) {
+ if (isLineEmpty) {
+ line.remove();
+ }
+ this.quill.setSelection(range.index - 1);
+ }
+ if (!isPrevLineEmpty && !isPrevLineTable) {
const curFormats = line.formats();
const prevFormats = this.quill.getFormat(range.index - 1, 1);
formats = AttributeMap.diff(curFormats, prevFormats) || {};
@@ -451,8 +460,9 @@ Keyboard.DEFAULTS = {
},
tab: {
key: 'tab',
- handler(range, context) {
- if (context.format.table) return true;
+ handler(range, { format }) {
+ if (format.tableCellLine || format.tableHeaderCellLine
+ || format.tableHeaderCell || format.table) return true;
this.quill.history.cutoff();
const delta = new Delta()
.retain(range.index)
diff --git a/test/functional/epic.js b/test/functional/epic.js
index c06e5e5b0c..e94dae1074 100644
--- a/test/functional/epic.js
+++ b/test/functional/epic.js
@@ -13,6 +13,10 @@ const P1 = 'Call me Ishmael. Some years ago—never mind how long precisely-havi
const P2 = 'There now is your insular city of the Manhattoes, belted round by wharves as Indian isles by coral reefs—commerce surrounds it with her surf. Right and left, the streets take you waterward. Its extreme downtown is the battery, where that noble mole is washed by waves, and cooled by breezes, which a few hours previous were out of sight of land. Look at the crowds of water-gazers there.';
const HOST = 'http://127.0.0.1:8080';
+function sanitizeTableHtml(html) {
+ return html.replace(/(<\w+)((\s+class\s*=\s*"[^"]*")|(\s+data-[\w-]+\s*=\s*"[^"]*"))*(\s*>)/gi, '$1$5');
+}
+
describe('quill', function () {
it('compose an epic', async function () {
const browser = await puppeteer.launch({
@@ -249,6 +253,210 @@ describe('quill', function () {
});
});
+describe('table header: ', function () {
+ it('cell should not be removed on typing if it is selected', async function () {
+ const browser = await puppeteer.launch({
+ headless: false,
+ });
+ const page = await browser.newPage();
+
+ await page.goto(`${HOST}/table_header.html`);
+ await page.waitForSelector('.ql-editor', { timeout: 10000 });
+
+ await page.click('[data-table-cell="3"]');
+
+ await page.keyboard.down('Shift');
+ await page.keyboard.press('ArrowLeft');
+ await page.keyboard.press('ArrowLeft');
+ await page.keyboard.up('Shift');
+
+ await page.keyboard.press('c');
+
+ const html = await page.$eval('.ql-editor', (e) => e.innerHTML);
+ const sanitizeHtml = sanitizeTableHtml(html);
+ expect(sanitizeHtml).toEqual(
+ `
+
+
+ `.replace(/\s/g, ''),
+ );
+ });
+});
+
+describe('table:', function () {
+ it('cell should not be removed on typing if it is selected', async function () {
+ const browser = await puppeteer.launch({
+ headless: false,
+ });
+ const page = await browser.newPage();
+
+ await page.goto(`${HOST}/table.html`);
+ await page.waitForSelector('.ql-editor', { timeout: 10000 });
+
+ await page.click('[data-table-cell="3"]');
+
+ await page.keyboard.down('Shift');
+ await page.keyboard.press('ArrowLeft');
+ await page.keyboard.press('ArrowLeft');
+ await page.keyboard.up('Shift');
+
+ await page.keyboard.press('c');
+
+ const html = await page.$eval('.ql-editor', (e) => e.innerHTML);
+ const sanitizeHtml = sanitizeTableHtml(html);
+ expect(sanitizeHtml).toEqual(
+ `
+
+
+ `.replace(/\s/g, ''),
+ );
+ });
+
+ it('backspace press on the position after table should remove an empty line and not add it to the cell', async function () {
+ const browser = await puppeteer.launch({
+ headless: false,
+ });
+ const page = await browser.newPage();
+
+ await page.goto(`${HOST}/table.html`);
+ await page.waitForSelector('.ql-editor', { timeout: 10000 });
+
+ await page.click('[data-table-cell="3"]');
+ await page.keyboard.press('ArrowRight');
+ await page.keyboard.press('Backspace');
+
+ const html = await page.$eval('.ql-editor', (e) => e.innerHTML);
+ const sanitizeHtml = sanitizeTableHtml(html);
+ expect(sanitizeHtml).toEqual(
+ `
+
+ `.replace(/\s/g, ''),
+ );
+ });
+
+ it('backspace in multiline cell should work as usual', async function () {
+ const browser = await puppeteer.launch({
+ headless: false,
+ });
+ const page = await browser.newPage();
+
+ await page.goto(`${HOST}/table.html`);
+ await page.waitForSelector('.ql-editor', { timeout: 10000 });
+
+ await page.click('[data-table-cell="3"]');
+ await page.keyboard.press('4');
+ await page.keyboard.press('Enter');
+ await page.keyboard.press('Backspace');
+ await page.keyboard.press('Backspace');
+
+ const html = await page.$eval('.ql-editor', (e) => e.innerHTML);
+ const sanitizeHtml = sanitizeTableHtml(html);
+ expect(sanitizeHtml).toEqual(
+ `
+
+
+ `.replace(/\s/g, ''),
+ );
+ });
+
+ it('backspace press on the position after table should only move a caret to cell if next line is not empty', async function () {
+ const browser = await puppeteer.launch({
+ headless: false,
+ });
+ const page = await browser.newPage();
+
+ await page.goto(`${HOST}/table.html`);
+ await page.waitForSelector('.ql-editor', { timeout: 10000 });
+
+ await page.click('[data-table-cell="3"]');
+ await page.keyboard.press('ArrowRight');
+ await page.keyboard.press('g');
+ await page.keyboard.press('ArrowLeft');
+ await page.keyboard.press('Backspace');
+ await page.keyboard.press('w');
+
+ const html = await page.$eval('.ql-editor', (e) => e.innerHTML);
+ const sanitizeHtml = sanitizeTableHtml(html);
+ expect(sanitizeHtml).toEqual(
+ `
+
+ g
+ `.replace(/\s/g, ''),
+ );
+ });
+
+ it('backspace press on the position after table should remove empty line and move caret to a cell if next line is empty', async function () {
+ const browser = await puppeteer.launch({
+ headless: false,
+ });
+ const page = await browser.newPage();
+
+ await page.goto(`${HOST}/table.html`);
+ await page.waitForSelector('.ql-editor', { timeout: 10000 });
+
+ await page.click('[data-table-cell="3"]');
+ await page.keyboard.press('ArrowRight');
+ await page.keyboard.press('Backspace');
+ await page.keyboard.press('w');
+
+ const html = await page.$eval('.ql-editor', (e) => e.innerHTML);
+ const sanitizeHtml = sanitizeTableHtml(html);
+ expect(sanitizeHtml).toEqual(
+ `
+
+ `.replace(/\s/g, ''),
+ );
+ });
+});
+
// Copy/paste emulation des not working on Mac. See https://github.com/puppeteer/puppeteer/issues/1313
if (!isMac) {
describe('List copy/pasting into table', function () {
diff --git a/test/functional/example/table.html b/test/functional/example/table.html
new file mode 100644
index 0000000000..183b004012
--- /dev/null
+++ b/test/functional/example/table.html
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+ DevExtreme-Quill Base Editing
+
+
+
+
+
+
+
+
+
+
diff --git a/test/functional/example/table_header.html b/test/functional/example/table_header.html
new file mode 100644
index 0000000000..9900314d16
--- /dev/null
+++ b/test/functional/example/table_header.html
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+ DevExtreme-Quill Base Editing
+
+
+
+
+
+
+
+
+
+
diff --git a/test/unit/modules/keyboard.js b/test/unit/modules/keyboard.js
index aa56494a71..61afbc82e6 100644
--- a/test/unit/modules/keyboard.js
+++ b/test/unit/modules/keyboard.js
@@ -1,4 +1,7 @@
import Keyboard, { SHORTKEY, normalize } from '../../../modules/keyboard';
+import { Range } from '../../../core/selection';
+import Quill from '../../../core/quill';
+import TableLite from '../../../modules/table/lite';
describe('Keyboard', function () {
describe('match', function () {
@@ -257,4 +260,245 @@ describe('Keyboard', function () {
quillMock.root.addEventListener = nativeAddEventListener;
});
});
+
+ describe('tab navigation on main table', function () {
+ const markup = `
+
+
+
+ head1
+ head2
+ head3
+
+
+
+
+ data1
+ data2
+ data3
+
+
+ data1
+ data2
+ data3
+
+
+
+ `;
+
+ it('should select next cell on tab click', function () {
+ const quill = this.initialize(Quill, markup, this.container, {
+ modules: {
+ table: true,
+ },
+ });
+ quill.setSelection(18);
+ const keydownEvent = new KeyboardEvent('keydown', {
+ key: 'tab',
+ });
+
+ quill.root.dispatchEvent(keydownEvent);
+ expect(quill.getSelection()).toEqual(new Range(24));
+ });
+
+ it('should select next cell in second row if cursor in the last cell of first row on tab click', function () {
+ const quill = this.initialize(Quill, markup, this.container, {
+ modules: {
+ table: true,
+ },
+ });
+ quill.setSelection(30);
+ const keydownEvent = new KeyboardEvent('keydown', {
+ key: 'tab',
+ });
+
+ quill.root.dispatchEvent(keydownEvent);
+ expect(quill.getSelection()).toEqual(new Range(36));
+ });
+
+ it('should select previous cell on tab + shift click', function () {
+ const quill = this.initialize(Quill, markup, this.container, {
+ modules: {
+ table: true,
+ },
+ });
+ quill.setSelection(31);
+ const keydownEvent = new KeyboardEvent('keydown', {
+ key: 'tab',
+ shiftKey: true,
+ });
+
+ quill.root.dispatchEvent(keydownEvent);
+ expect(quill.getSelection()).toEqual(new Range(29));
+ });
+
+ it('should select previous cell on first row if cursor in the first cell of second row on tab + shift click', function () {
+ const quill = this.initialize(Quill, markup, this.container, {
+ modules: {
+ table: true,
+ },
+ });
+ quill.setSelection(38);
+ const keydownEvent = new KeyboardEvent('keydown', {
+ key: 'tab',
+ shiftKey: true,
+ });
+
+ quill.root.dispatchEvent(keydownEvent);
+ expect(quill.getSelection()).toEqual(new Range(35));
+ });
+
+ it('should select next cell in header on tab click', function () {
+ const quill = this.initialize(Quill, markup, this.container, {
+ modules: {
+ table: true,
+ },
+ });
+ quill.setSelection(0);
+ const keydownEvent = new KeyboardEvent('keydown', {
+ key: 'tab',
+ });
+
+ quill.root.dispatchEvent(keydownEvent);
+ expect(quill.getSelection()).toEqual(new Range(6));
+ });
+
+ it('should select previous cell in header on tab + shift click', function () {
+ const quill = this.initialize(Quill, markup, this.container, {
+ modules: {
+ table: true,
+ },
+ });
+ quill.setSelection(8);
+ const keydownEvent = new KeyboardEvent('keydown', {
+ key: 'tab',
+ shiftKey: true,
+ });
+
+ quill.root.dispatchEvent(keydownEvent);
+ expect(quill.getSelection()).toEqual(new Range(5));
+ });
+ });
+
+ describe('tab navigation on lite table', function () {
+ beforeAll(function () {
+ Quill.register({ 'modules/table': TableLite }, true);
+ });
+ const markup = `
+
+
+
+ head1
+ head2
+ head3
+
+
+
+
+ data1
+ data2
+ data3
+
+
+ data1
+ data2
+ data3
+
+
+
+ `;
+
+ it('should select next cell on tab click', function () {
+ const quill = this.initialize(Quill, markup, this.container, {
+ modules: {
+ table: true,
+ },
+ });
+ quill.setSelection(18);
+ const keydownEvent = new KeyboardEvent('keydown', {
+ key: 'tab',
+ });
+
+ quill.root.dispatchEvent(keydownEvent);
+ expect(quill.getSelection()).toEqual(new Range(24));
+ });
+
+ it('should select next cell in second row if cursor in the last cell of first row on tab click', function () {
+ const quill = this.initialize(Quill, markup, this.container, {
+ modules: {
+ table: true,
+ },
+ });
+ quill.setSelection(30);
+ const keydownEvent = new KeyboardEvent('keydown', {
+ key: 'tab',
+ });
+
+ quill.root.dispatchEvent(keydownEvent);
+ expect(quill.getSelection()).toEqual(new Range(36));
+ });
+
+ it('should select previous cell on tab + shift click', function () {
+ const quill = this.initialize(Quill, markup, this.container, {
+ modules: {
+ table: true,
+ },
+ });
+ quill.setSelection(31);
+ const keydownEvent = new KeyboardEvent('keydown', {
+ key: 'tab',
+ shiftKey: true,
+ });
+
+ quill.root.dispatchEvent(keydownEvent);
+ expect(quill.getSelection()).toEqual(new Range(29));
+ });
+
+ it('should select previous cell on first row if cursor in the first cell of second row on tab + shift click', function () {
+ const quill = this.initialize(Quill, markup, this.container, {
+ modules: {
+ table: true,
+ },
+ });
+ quill.setSelection(38);
+ const keydownEvent = new KeyboardEvent('keydown', {
+ key: 'tab',
+ shiftKey: true,
+ });
+
+ quill.root.dispatchEvent(keydownEvent);
+ expect(quill.getSelection()).toEqual(new Range(35));
+ });
+
+ it('should select next cell in header on tab click', function () {
+ const quill = this.initialize(Quill, markup, this.container, {
+ modules: {
+ table: true,
+ },
+ });
+ quill.setSelection(0);
+ const keydownEvent = new KeyboardEvent('keydown', {
+ key: 'tab',
+ });
+
+ quill.root.dispatchEvent(keydownEvent);
+ expect(quill.getSelection()).toEqual(new Range(6));
+ });
+
+ it('should select previous cell in header on tab + shift click', function () {
+ const quill = this.initialize(Quill, markup, this.container, {
+ modules: {
+ table: true,
+ },
+ });
+ quill.setSelection(8);
+ const keydownEvent = new KeyboardEvent('keydown', {
+ key: 'tab',
+ shiftKey: true,
+ });
+
+ quill.root.dispatchEvent(keydownEvent);
+ expect(quill.getSelection()).toEqual(new Range(5));
+ });
+ });
});
diff --git a/test/unit/modules/table_main.js b/test/unit/modules/table_main.js
index 7b0765bf39..348717a472 100644
--- a/test/unit/modules/table_main.js
+++ b/test/unit/modules/table_main.js
@@ -1166,6 +1166,237 @@ describe('Table Module', function () {
true,
);
});
+
+ describe('deleteAt', function () {
+ it('should not remove a cell (T1062588)', function () {
+ this.quill.setSelection(0);
+ this.table.deleteRow();
+
+ this.quill.scroll.deleteAt(5, 3);
+ expect(this.quill.root).toEqualHTML(
+ `
+
+ `,
+ true,
+ );
+ });
+
+ it('should remove a cell line', function () {
+ const markup = `
+
+ `;
+ this.quill = this.initialize(Quill, markup, this.container, {
+ modules: {
+ table: true,
+ },
+ });
+
+ this.quill.scroll.deleteAt(2, 3);
+ expect(this.quill.root).toEqualHTML(
+ `
+
+ `,
+ true,
+ );
+ });
+
+ it('should not remove a cell if several cells are selected', function () {
+ this.quill.setSelection(0);
+ this.table.deleteRow();
+
+ this.quill.scroll.deleteAt(1, 7);
+ expect(this.quill.root).toEqualHTML(
+ `
+
+ `,
+ true,
+ );
+ });
+
+ it('should not remove a header cell if several cells are selected', function () {
+ const markup = `
+
+ `;
+ this.quill = this.initialize(Quill, markup, this.container, {
+ modules: {
+ table: true,
+ },
+ });
+
+ this.quill.scroll.deleteAt(3, 7);
+ expect(this.quill.root).toEqualHTML(
+ `
+
+ `,
+ true,
+ );
+ });
+
+ it('should not remove a row', function () {
+ this.quill.scroll.deleteAt(5, 12);
+ expect(this.quill.root).toEqualHTML(
+ `
+
+
+
+ a1
+ a2
+
+
+
+
+
+
+
+
+
+ `,
+ true,
+ );
+ });
+
+ it('should not move cells content if selection ends after table', function () {
+ const markup = `
+
+
+ `;
+ this.quill = this.initialize(Quill, markup, this.container, {
+ modules: {
+ table: true,
+ },
+ });
+ this.quill.scroll.deleteAt(2, 1);
+ expect(this.quill.root).toEqualHTML(
+ `
+
+
+ `,
+ true,
+ );
+ });
+
+ it('should not move header cells content if selection ends after table', function () {
+ const markup = `
+
+
+ `;
+ this.quill = this.initialize(Quill, markup, this.container, {
+ modules: {
+ table: true,
+ },
+ });
+ this.quill.scroll.deleteAt(2, 1);
+ expect(this.quill.root).toEqualHTML(
+ `
+
+
+ `,
+ true,
+ );
+ });
+
+ it('should remove a table', function () {
+ this.quill.scroll.deleteAt(0, 18);
+ expect(this.quill.root).toEqualHTML(
+ `
+
+ `,
+ true,
+ );
+ });
+ });
+
+ it('type should not remove a cell if it is selected', function () {
+ this.quill.updateContents(new Delta().retain(14).delete(5).insert('_INPUT_'), 'user');
+
+ expect(this.quill.root).toEqualHTML(
+ `
+
+
+
+ a1
+ a2
+ a3
+
+
+ b1
+ b2_INPUT_
+
+
+
+
+ `,
+ true,
+ );
+ });
});
describe('customize table', function () {