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 = '![Sample](https://example.com/image.jpg?cw_image_width=400px)'