From 0e3498fd207627e3f8aa5dac2a98beed1b2eb4f5 Mon Sep 17 00:00:00 2001 From: 8lurry Date: Sun, 16 Nov 2025 18:12:52 +0600 Subject: [PATCH 1/3] container: enable formatting support on ContainerBlot - Collect inherited formats from parent Blots during insertion - Apply formatting using formatAt after insertion completes - Update parchment logic to correctly process ContainerBlot formats --- packages/quill/src/blots/scroll.ts | 83 ++++++- .../quill/test/unit/modules/table.spec.ts | 223 ++++++++++++++++++ 2 files changed, 303 insertions(+), 3 deletions(-) diff --git a/packages/quill/src/blots/scroll.ts b/packages/quill/src/blots/scroll.ts index 9975e12831..75afd7a62b 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,7 +162,7 @@ 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) { @@ -165,14 +172,27 @@ class Scroll extends ScrollBlot { const formats = bubbleFormats(this.line(index)[0]); const attributes = AttributeMap.diff(formats, first.attributes) || {}; 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, + length: delta.length(), + }); + 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 +200,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 +245,7 @@ class Scroll extends ScrollBlot { Object.keys(renderBlock.attributes).forEach((name) => { blockEmbed.format(name, renderBlock.attributes[name]); }); + blockOffset += blockEmbed.length(); } }); } @@ -202,11 +254,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 +465,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 +495,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/test/unit/modules/table.spec.ts b/packages/quill/test/unit/modules/table.spec.ts index 89840d1ba8..254b1dfe0b 100644 --- a/packages/quill/test/unit/modules/table.spec.ts +++ b/packages/quill/test/unit/modules/table.spec.ts @@ -215,4 +215,227 @@ 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( + ` + + + + + + +
datamore data
`, + ); + expect(quill.root).toEqualHTML( + ` + + + + + + + +
datamore data
+ `, + { ignoreAttrs: ['data-row'] }, + ); + }); + + test('table with class names varient 2', () => { + const quill = setupWithHtml( + ` + + + + + + + + + + + + + + +
datamore data


data2more data2
`, + ); + expect(quill.root).toEqualHTML( + ` + + + + + + + + + + + + + + + +
datamore data


data2more 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( + ` + + + + + + + + + + + + + + + +
datamore data


data2more data2
+ `, + { ignoreAttrs: ['data-row'] }, + ); + quill.formatLine( + 0, + 1, + 'table-container', + 'new-table-class other-table-class', + ); + expect(quill.root).toEqualHTML( + ` + + + + + + + + + + + + + + + +
datamore data


data2more data2
+ `, + { ignoreAttrs: ['data-row'] }, + ); + quill.formatLine(index as number, 1, 'table-container', ''); + expect(quill.root).toEqualHTML( + ` + + + + + + + + + + + + + + + +
datamore data


data2more data2
+ `, + { ignoreAttrs: ['data-row'] }, + ); + }); + }); }); From 179bb0015b37350a031225ceb200e4fd6d119778 Mon Sep 17 00:00:00 2001 From: 8lurry Date: Wed, 24 Dec 2025 12:11:55 +0600 Subject: [PATCH 2/3] fix: format's index, length at first line with container --- packages/quill/src/blots/scroll.ts | 13 ++++++++---- .../quill/test/unit/modules/table.spec.ts | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/quill/src/blots/scroll.ts b/packages/quill/src/blots/scroll.ts index 75afd7a62b..ef64771464 100644 --- a/packages/quill/src/blots/scroll.ts +++ b/packages/quill/src/blots/scroll.ts @@ -169,8 +169,11 @@ class Scroll extends ScrollBlot { 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 ( @@ -180,8 +183,10 @@ class Scroll extends ScrollBlot { containerAttributes.push({ name, value: attributes[name], - index: lineEndIndex - 1, - length: delta.length(), + // index: lineEndIndex - 1, + index: firstLineOffset, + // length: delta.length(), + length: lineEndIndex - firstLineOffset, }); return; } @@ -235,7 +240,7 @@ class Scroll extends ScrollBlot { cAttributes[i].length = l; } containerAttributes.push(...cAttributes); - blockOffset += l === 0 ? 1 : l; + blockOffset += l; // === 0 ? 1 : l; } else { const blockEmbed = this.create( renderBlock.key, diff --git a/packages/quill/test/unit/modules/table.spec.ts b/packages/quill/test/unit/modules/table.spec.ts index 254b1dfe0b..33b59be410 100644 --- a/packages/quill/test/unit/modules/table.spec.ts +++ b/packages/quill/test/unit/modules/table.spec.ts @@ -437,5 +437,25 @@ describe('Table Module', () => { { 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)); + }); }); }); From b692520e6c94d9ce250a788850eb7b32f1c1d43e Mon Sep 17 00:00:00 2001 From: 8lurry Date: Tue, 30 Dec 2025 14:40:35 +0600 Subject: [PATCH 3/3] update quill.updateContents to account for container attributes --- packages/quill/src/core/editor.ts | 34 ++++++++++++++++++- .../quill/test/unit/modules/table.spec.ts | 24 +++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) 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 33b59be410..2c8e7eb77d 100644 --- a/packages/quill/test/unit/modules/table.spec.ts +++ b/packages/quill/test/unit/modules/table.spec.ts @@ -457,5 +457,29 @@ describe('Table Module', () => { 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'] }, + ); + }); }); });