diff --git a/packages/quill/src/blots/scroll.ts b/packages/quill/src/blots/scroll.ts
index 9975e12831..ef64771464 100644
--- a/packages/quill/src/blots/scroll.ts
+++ b/packages/quill/src/blots/scroll.ts
@@ -143,6 +143,13 @@ class Scroll extends ScrollBlot {
const last = renderBlocks.pop();
if (last == null) return;
+ const containerAttributes: {
+ name: string;
+ value: unknown;
+ index: number;
+ length?: number;
+ }[] = [];
+
this.batchStart();
const first = renderBlocks.shift();
@@ -155,24 +162,42 @@ class Scroll extends ScrollBlot {
first.type === 'block'
? first.delta
: new Delta().insert({ [first.key]: first.value });
- insertInlineContents(this, index, delta);
+ insertInlineContents(this, index, delta, containerAttributes);
const newlineCharLength = first.type === 'block' ? 1 : 0;
const lineEndIndex = index + delta.length() + newlineCharLength;
if (shouldInsertNewlineChar) {
this.insertAt(lineEndIndex - 1, '\n');
}
- const formats = bubbleFormats(this.line(index)[0]);
+ const firstLine = this.line(index)[0];
+ const formats = bubbleFormats(firstLine);
const attributes = AttributeMap.diff(formats, first.attributes) || {};
+ const firstLineOffset =
+ firstLine?.offset(firstLine.parent || this) || index;
Object.keys(attributes).forEach((name) => {
+ const format = this.scroll.query(name, Scope.BLOCK);
+ if (
+ format != null &&
+ (format as Function).prototype instanceof ContainerBlot
+ ) {
+ containerAttributes.push({
+ name,
+ value: attributes[name],
+ // index: lineEndIndex - 1,
+ index: firstLineOffset,
+ // length: delta.length(),
+ length: lineEndIndex - firstLineOffset,
+ });
+ return;
+ }
this.formatAt(lineEndIndex - 1, 1, name, attributes[name]);
});
-
index = lineEndIndex;
}
let [refBlot, refBlotOffset] = this.children.find(index);
if (renderBlocks.length) {
+ let blockOffset = index;
if (refBlot) {
refBlot = refBlot.split(refBlotOffset);
refBlotOffset = 0;
@@ -180,11 +205,42 @@ class Scroll extends ScrollBlot {
renderBlocks.forEach((renderBlock) => {
if (renderBlock.type === 'block') {
+ const attrs = { ...renderBlock.attributes };
+ const cAttributes: {
+ name: string;
+ value: unknown;
+ index: number;
+ length?: number;
+ }[] = [];
+ Object.keys(attrs).forEach((name) => {
+ const format = this.scroll.query(name, Scope.BLOCK & Scope.BLOT);
+ if (format != null) {
+ if ((format as Function).prototype instanceof ContainerBlot) {
+ cAttributes.push({
+ name,
+ value: renderBlock.attributes[name],
+ index: blockOffset,
+ });
+ renderBlock.delta.forEach((op) => {
+ if (op.insert != null) {
+ delete op.attributes?.[name];
+ }
+ });
+ delete renderBlock.attributes[name];
+ }
+ }
+ });
const block = this.createBlock(
renderBlock.attributes,
refBlot || undefined,
);
insertInlineContents(block, 0, renderBlock.delta);
+ const l = block.length();
+ for (let i = 0; i < cAttributes.length; i += 1) {
+ cAttributes[i].length = l;
+ }
+ containerAttributes.push(...cAttributes);
+ blockOffset += l; // === 0 ? 1 : l;
} else {
const blockEmbed = this.create(
renderBlock.key,
@@ -194,6 +250,7 @@ class Scroll extends ScrollBlot {
Object.keys(renderBlock.attributes).forEach((name) => {
blockEmbed.format(name, renderBlock.attributes[name]);
});
+ blockOffset += blockEmbed.length();
}
});
}
@@ -202,11 +259,15 @@ class Scroll extends ScrollBlot {
const offset = refBlot
? refBlot.offset(refBlot.scroll) + refBlotOffset
: this.length();
- insertInlineContents(this, offset, last.delta);
+ insertInlineContents(this, offset, last.delta, containerAttributes);
}
this.batchEnd();
this.optimize();
+
+ containerAttributes.forEach(({ name, value, index, length }) => {
+ this.formatAt(index, length != null ? length : 1, name, value);
+ });
}
isEnabled() {
@@ -409,6 +470,12 @@ function insertInlineContents(
parent: ParentBlot,
index: number,
inlineContents: Delta,
+ containerAttributes?: {
+ name: string;
+ value: unknown;
+ index: number;
+ length?: number;
+ }[],
) {
inlineContents.reduce((index, op) => {
const length = Op.length(op);
@@ -433,6 +500,21 @@ function insertInlineContents(
}
}
Object.keys(attributes).forEach((key) => {
+ if (containerAttributes != null) {
+ const format = parent.scroll.query(key, Scope.BLOCK);
+ if (
+ format != null &&
+ (format as Function).prototype instanceof ContainerBlot
+ ) {
+ containerAttributes.push({
+ name: key,
+ value: attributes[key],
+ index,
+ length,
+ });
+ return;
+ }
+ }
parent.formatAt(index, length, key, attributes[key]);
});
return index + length;
diff --git a/packages/quill/src/core/editor.ts b/packages/quill/src/core/editor.ts
index b6391a7cd4..faa8bef3ce 100644
--- a/packages/quill/src/core/editor.ts
+++ b/packages/quill/src/core/editor.ts
@@ -1,5 +1,11 @@
import { cloneDeep, isEqual, merge } from 'lodash-es';
-import { LeafBlot, EmbedBlot, Scope, ParentBlot } from 'parchment';
+import {
+ ContainerBlot,
+ LeafBlot,
+ EmbedBlot,
+ Scope,
+ ParentBlot,
+} from 'parchment';
import type { Blot } from 'parchment';
import Delta, { AttributeMap, Op } from 'quill-delta';
import Block, { BlockEmbed, bubbleFormats } from '../blots/block.js';
@@ -32,6 +38,14 @@ class Editor {
const normalizedDelta = normalizeDelta(delta);
const deleteDelta = new Delta();
const normalizedOps = splitOpLines(normalizedDelta.ops.slice());
+
+ const containerAttributes: {
+ name: string;
+ value: unknown;
+ index: number;
+ length?: number;
+ }[] = [];
+
normalizedOps.reduce((index, op) => {
const length = Op.length(op);
let attributes = op.attributes || {};
@@ -101,6 +115,19 @@ class Editor {
}
}
Object.keys(attributes).forEach((name) => {
+ const format = this.scroll.query(name, Scope.BLOCK);
+ if (
+ format != null &&
+ (format as Function).prototype instanceof ContainerBlot
+ ) {
+ containerAttributes.push({
+ name,
+ value: attributes[name],
+ index,
+ length,
+ });
+ return;
+ }
this.scroll.formatAt(index, length, name, attributes[name]);
});
const prependedLength = isImplicitNewlinePrepended ? 1 : 0;
@@ -119,6 +146,11 @@ class Editor {
}, 0);
this.scroll.batchEnd();
this.scroll.optimize();
+
+ containerAttributes.forEach(({ name, value, index, length }) => {
+ this.scroll.formatAt(index, length != null ? length : 1, name, value);
+ });
+
return this.update(normalizedDelta);
}
diff --git a/packages/quill/test/unit/modules/table.spec.ts b/packages/quill/test/unit/modules/table.spec.ts
index 89840d1ba8..2c8e7eb77d 100644
--- a/packages/quill/test/unit/modules/table.spec.ts
+++ b/packages/quill/test/unit/modules/table.spec.ts
@@ -215,4 +215,271 @@ describe('Table Module', () => {
);
});
});
+
+ describe('className formats', () => {
+ const setupWithHtml = (html: string) => {
+ Quill.register({ 'modules/table': Table }, true);
+
+ class CustomTableRow extends TableRow {
+ static create(value: string) {
+ const node = super.create() as HTMLElement;
+ if (value) {
+ node.className = value;
+ }
+ return node;
+ }
+
+ static formats(domNode: HTMLElement) {
+ return domNode.className;
+ }
+
+ formats() {
+ const formats = CustomTableRow.formats(this.domNode);
+ return { [CustomTableRow.blotName]: formats };
+ }
+
+ format(name: string, value: string) {
+ if (name === CustomTableRow.blotName) {
+ if (value == null || value === '') {
+ this.domNode.removeAttribute('class');
+ } else {
+ this.domNode.className = value;
+ }
+ }
+ }
+ }
+
+ class CustomTableContainer extends TableContainer {
+ static create(value: string) {
+ const node = super.create() as HTMLElement;
+ if (value) {
+ node.className = value;
+ }
+ return node;
+ }
+
+ static formats(domNode: HTMLElement) {
+ return domNode.className;
+ }
+
+ formats() {
+ const formats = CustomTableContainer.formats(this.domNode);
+ return { [CustomTableContainer.blotName]: formats };
+ }
+
+ format(name: string, value: string) {
+ if (name === CustomTableContainer.blotName) {
+ if (value == null || value === '') {
+ this.domNode.removeAttribute('class');
+ } else {
+ this.domNode.className = value;
+ }
+ }
+ }
+ }
+
+ const container = document.body.appendChild(
+ document.createElement('div'),
+ );
+ container.innerHTML = normalizeHTML(html);
+ const quill = new Quill(container, {
+ modules: { table: true },
+ registry: createRegistry([
+ TableBody,
+ TableCell,
+ CustomTableContainer,
+ CustomTableRow,
+ ]),
+ });
+ return quill;
+ };
+
+ test('table with class names', () => {
+ const quill = setupWithHtml(
+ `
+
+
+ | data |
+ more data |
+
+
+
`,
+ );
+ expect(quill.root).toEqualHTML(
+ `
+
+
+
+ | data |
+ more data |
+
+
+
+ `,
+ { ignoreAttrs: ['data-row'] },
+ );
+ });
+
+ test('table with class names varient 2', () => {
+ const quill = setupWithHtml(
+ `
+
+
+ | data |
+ more data |
+
+
+
|
+
|
+
+
+ | data2 |
+ more data2 |
+
+
+
`,
+ );
+ expect(quill.root).toEqualHTML(
+ `
+
+
+
+ | data |
+ more data |
+
+
+
|
+
|
+
+
+ | data2 |
+ more data2 |
+
+
+
+ `,
+ { ignoreAttrs: ['data-row'] },
+ );
+ quill.setSelection(0);
+ const tableModule = quill.getModule('table') as Table;
+ const [, row] = tableModule.getTable();
+ const index = row?.length();
+
+ quill.formatLine(index as number, 1, 'table-row', 'new-class-name');
+ expect(quill.root).toEqualHTML(
+ `
+
+
+
+ | data |
+ more data |
+
+
+
|
+
|
+
+
+ | data2 |
+ more data2 |
+
+
+
+ `,
+ { ignoreAttrs: ['data-row'] },
+ );
+ quill.formatLine(
+ 0,
+ 1,
+ 'table-container',
+ 'new-table-class other-table-class',
+ );
+ expect(quill.root).toEqualHTML(
+ `
+
+
+
+ | data |
+ more data |
+
+
+
|
+
|
+
+
+ | data2 |
+ more data2 |
+
+
+
+ `,
+ { ignoreAttrs: ['data-row'] },
+ );
+ quill.formatLine(index as number, 1, 'table-container', '');
+ expect(quill.root).toEqualHTML(
+ `
+
+
+
+ | data |
+ more data |
+
+
+
|
+
|
+
+
+ | data2 |
+ more data2 |
+
+
+
+ `,
+ { ignoreAttrs: ['data-row'] },
+ );
+ });
+
+ test('table as first line', () => {
+ const content = `
+
+
+
+ | Märkus: Detsembris ei ole Kaarli kirikus palvusi esmaspäeviti, sest kirikus toimuvad kontsertid. Selle asemel palvetame Dominiiklaste kabelis (Müürivahe 33). Ka proov kell 17 sealsamas. Ja 5. jaanuaril 2025 läheb elu edasi Kaarli kirikus. |
+
+
+
+
+
+ Ühispalvus igal esmaspäeval kell 18:00. Enne palvust kell 17:00 lauluproov ja ettevalmistused. Igaüks on teretulnud! Ootame lauljaid juurde!
+ Asukoht: Tallinna Kaarli kirik
+ Korraldaja: EELK Tallinna Kaarli kogudus
+ Kontakt: Annely Neame (5267825)
+ N.B.: Juulis, augustis ja detsembris Kaarli kirikus palvusi esmaspäeviti ei ole.
`;
+ const quill = setupWithHtml(content);
+ expect(quill.root.innerHTML).toBe(normalizeHTML(content));
+ });
+
+ test.only('updateContents with class names', () => {
+ const quill = setupWithHtml('
');
+ quill.updateContents(
+ new Delta()
+ .retain(quill.getLength())
+ .insert('\n\n', { table: 'row-lo87' })
+ .insert('\n\n', { table: 'row-54yt', 'table-row': 'custom-row' }),
+ );
+ expect(quill.root.innerHTML).toEqualHTML(
+ normalizeHTML(`
+
+
+
+
+ `),
+ { ignoreAttrs: ['data-row'] },
+ );
+ });
+ });
});