From 59d869d1edcbc9b748e3521f7d12ac7f592b6d04 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Tue, 9 Jun 2026 18:04:48 +0530
Subject: [PATCH] feat: Ability to resize table column width (#14611)
---
.../shared/helpers/MessageFormatter.js | 9 +-
.../helpers/specs/MessageFormatter.spec.js | 10 +++
lib/custom_markdown_renderer.rb | 82 ++++++++++++++++++-
package.json | 2 +-
pnpm-lock.yaml | 10 +--
spec/lib/custom_markdown_renderer_spec.rb | 53 ++++++++++++
6 files changed, 157 insertions(+), 9 deletions(-)
diff --git a/app/javascript/shared/helpers/MessageFormatter.js b/app/javascript/shared/helpers/MessageFormatter.js
index eb8fecf01..85ce67bc6 100644
--- a/app/javascript/shared/helpers/MessageFormatter.js
+++ b/app/javascript/shared/helpers/MessageFormatter.js
@@ -63,6 +63,13 @@ const createMarkdownInstance = (linkify = true) => {
});
};
+// Help center article tables persist column widths as an internal
+// `` comment before the table. It exists only for the
+// editor's markdown round-trip and must never surface as text — markdown-it runs
+// with `html: false`, which would otherwise escape it into a visible comment in
+// rendered/plain output (e.g. dashboard search snippets). Strip it on the way in.
+const COLWIDTHS_MARKER_REGEX = /\r?\n?/g;
+
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
const TWITTER_HASH_REGEX = /(^|\s)#(\w+)/g;
@@ -75,7 +82,7 @@ class MessageFormatter {
isAPrivateNote = false,
linkify = true
) {
- this.message = message || '';
+ this.message = (message || '').replace(COLWIDTHS_MARKER_REGEX, '');
this.isAPrivateNote = isAPrivateNote;
this.isATweet = isATweet;
this.linkify = linkify;
diff --git a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
index 12b84085c..3350399eb 100644
--- a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
+++ b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
@@ -126,6 +126,16 @@ describe('#MessageFormatter', () => {
});
});
+ describe('help center table colwidth marker', () => {
+ it('strips the internal colwidths marker from rendered output', () => {
+ const message =
+ '\n| A | B |\n| --- | --- |\n| 1 | 2 |';
+ const formatter = new MessageFormatter(message);
+ expect(formatter.formattedMessage).not.toContain('cw-colwidths');
+ expect(formatter.plainText).not.toContain('cw-colwidths');
+ });
+ });
+
describe('#sanitize', () => {
it('sanitizes markup and removes all unnecessary elements', () => {
const message =
diff --git a/lib/custom_markdown_renderer.rb b/lib/custom_markdown_renderer.rb
index 665d4c80c..fa191f5ed 100644
--- a/lib/custom_markdown_renderer.rb
+++ b/lib/custom_markdown_renderer.rb
@@ -9,9 +9,32 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
@embed_regexes ||= config.transform_values { |embed_config| Regexp.new(embed_config['regex']) }
end
+ # Matches columnResizing({ cellMinWidth: 50 }) in @chatwoot/prosemirror-schema
+ # so cells without an explicit colwidth render the same minimum here as in the editor.
+ TABLE_CELL_MIN_WIDTH_PX = 50
+ COLWIDTHS_COMMENT = //
+
+ # The article editor serializes column widths as a `` HTML
+ # comment immediately before each resized table. Capture it (emitting nothing) so the
+ # next `table` can size itself; any other raw HTML keeps its default rendering.
+ def html(node)
+ match = node.string_content.match(COLWIDTHS_COMMENT)
+ return super unless match
+
+ @pending_colwidths = match[1].split(',').map(&:to_i)
+ end
+
def table(node)
- out('
')
- super
+ widths = @pending_colwidths
+ @pending_colwidths = nil
+
+ if sized_widths?(widths)
+ out(table_wrapper_open(widths))
+ out(inject_table_sizing(capture_html { super(node) }, widths))
+ else
+ out('
')
+ super
+ end
out('
')
end
@@ -47,6 +70,61 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
private
+ def sized_widths?(widths)
+ widths.is_a?(Array) && widths.any? { |w| w.to_i.positive? }
+ end
+
+ def fully_sized?(widths)
+ widths.all? { |w| w.to_i.positive? }
+ end
+
+ # Fully-sized tables hug their exact width so the card doesn't trail empty space;
+ # partial tables stay a plain full-width card so flexible columns can expand.
+ def table_wrapper_open(widths)
+ return '
' unless fully_sized?(widths)
+
+ %(
)
+ end
+
+ # Let the gem render the whole table, then splice a
and sizing style
+ # into the opening tag. Delegating the row/cell/tbody/alignment markup to
+ # super keeps this working across commonmarker upgrades.
+ # `!important` overrides the portal's `[&_table]:!min-w-full` Tailwind rule.
+ def inject_table_sizing(html, widths)
+ opening = %(\n#{colgroup_html(widths)})
+ html.sub(/]*>\n?/, opening)
+ end
+
+ # Capture everything `super` writes by swapping the renderer's output buffer.
+ def capture_html
+ original = @stream
+ @stream = StringIO.new(+'')
+ yield
+ @stream.string
+ ensure
+ @stream = original
+ end
+
+ # Total table width: each column's saved width, or the cell min for unsized ones.
+ def total_width(widths)
+ widths.sum { |w| w.to_i.positive? ? w.to_i : TABLE_CELL_MIN_WIDTH_PX }
+ end
+
+ # Fully sized → lock to the exact total (min-width too, so a narrow saved width
+ # beats the portal's `[&_table]:!min-w-full`). Partial → `max(100%, total)` fills
+ # the container (flexible columns) yet scrolls when the sized columns exceed it.
+ def table_sizing_style(widths)
+ total = total_width(widths)
+ return "table-layout: fixed; min-width: max(100%, #{total}px) !important;" unless fully_sized?(widths)
+
+ "table-layout: fixed; width: #{total}px !important; min-width: #{total}px !important;"
+ end
+
+ def colgroup_html(widths)
+ cols = widths.map { |w| w.to_i.positive? ? %() : '' }
+ "#{cols.join}\n"
+ end
+
def extract_image_width(src)
query = URI.parse(src).query
raw = query && CGI.parse(query)['cw_image_width']&.first
diff --git a/package.json b/package.json
index d8527051d..41313c8b3 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
"@amplitude/analytics-browser": "^2.11.10",
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
- "@chatwoot/prosemirror-schema": "1.3.17",
+ "@chatwoot/prosemirror-schema": "1.3.19",
"@chatwoot/utils": "^0.0.55",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a4b61061c..68e667953 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -25,8 +25,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
- specifier: 1.3.17
- version: 1.3.17
+ specifier: 1.3.19
+ version: 1.3.19
'@chatwoot/utils':
specifier: ^0.0.55
version: 0.0.55
@@ -458,8 +458,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
- '@chatwoot/prosemirror-schema@1.3.17':
- resolution: {integrity: sha512-n78ZfMIzSrylImIN5cjCeEdTJ8ub0JtCybwUlqFyOyLy3ZzAZpOHvCSo+w/KmV4dCgOH2mBmYlxBQ9Rww+e0Rw==}
+ '@chatwoot/prosemirror-schema@1.3.19':
+ resolution: {integrity: sha512-LbATIAeTzclvbIK6WjtrGUO37AtMWkzCJi+s/KpUIp81TORhc0fHnclGT3353AbxeH6dF51/4hFTTsmF+ziqGA==}
'@chatwoot/utils@0.0.55':
resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==}
@@ -5128,7 +5128,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
- '@chatwoot/prosemirror-schema@1.3.17':
+ '@chatwoot/prosemirror-schema@1.3.19':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.7.1
diff --git a/spec/lib/custom_markdown_renderer_spec.rb b/spec/lib/custom_markdown_renderer_spec.rb
index 28c5e069c..6484f2e6c 100644
--- a/spec/lib/custom_markdown_renderer_spec.rb
+++ b/spec/lib/custom_markdown_renderer_spec.rb
@@ -258,6 +258,59 @@ describe CustomMarkdownRenderer do
end
end
+ describe '#table' do
+ def render_table(markdown)
+ doc = CommonMarker.render_doc(markdown, :DEFAULT, [:table])
+ described_class.new.render(doc)
+ end
+
+ let(:plain_table) { "| A | B |\n| --- | --- |\n| 1 | 2 |\n" }
+
+ it 'renders a table without column widths when no marker is present' do
+ output = render_table(plain_table)
+ expect(output).to include('')
+ expect(output).not_to include('colgroup')
+ expect(output).not_to include('cw-colwidths')
+ end
+
+ context 'when every column has a saved width' do
+ it 'lays the table out at the total width with a sized colgroup' do
+ output = render_table("\n#{plain_table}")
+ # Wrapper hugs the table; min-width is set alongside width so a narrow saved width beats min-w-full.
+ expect(output).to include('')
+ expect(output).to include('
')
+ expect(output).to include('')
+ end
+ end
+
+ context 'when only some columns have a saved width' do
+ it 'fills the container so unsized columns stay flexible, floored at the sized total' do
+ output = render_table("\n#{plain_table}")
+ # max(100%, 200px): fills the container (flexible) but scrolls if the sized columns exceed it.
+ expect(output).to include('table-layout: fixed; min-width: max(100%, 200px) !important;')
+ expect(output).to include('')
+ # No exact-width lock on the wrapper or table — the table must be free to expand.
+ expect(output).to include('')
+ expect(output).to include('width: 400px !important;')
+ expect(output).to include('')
+ expect(output.scan('colgroup').length).to eq(2)
+ end
+
+ it 'does not emit the marker comment into the rendered html' do
+ expect(render_table("\n#{plain_table}")).not_to include('cw-colwidths')
+ end
+ end
+
describe '#image' do
it 'renders width in px with responsive cap and auto height' do
markdown = ''