Skip to content

Commit

Permalink
Table content normalisation
Browse files Browse the repository at this point in the history
* Table heading row and column detection
* Common spreadsheet patterns
* Caption detection (heading)
  • Loading branch information
sfnelson committed Mar 12, 2024
1 parent f6fd41d commit 17944ad
Show file tree
Hide file tree
Showing 11 changed files with 492 additions and 22 deletions.
24 changes: 24 additions & 0 deletions app/assets/stylesheets/katalyst/content/editor/_table.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@use "variables" as *;

[data-content--editor--table-target="content"] {
position: relative;
min-height: 8rem;
Expand All @@ -15,4 +17,26 @@
&:has(table)::after {
content: unset;
}

table {
border-collapse: collapse;
max-width: 100%;
overflow: hidden;
}

th,
td {
border: 1px solid $grey;
padding: 0.25rem 0.5rem;
text-align: left;
vertical-align: top;
}

thead {
background-color: $grey;
}

tbody th {
background-color: $grey-light;
}
}
120 changes: 118 additions & 2 deletions app/helpers/katalyst/content/table_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,22 @@ module TableHelper
mattr_accessor(:sanitizer, default: Rails::HTML5::Sanitizer.safe_list_sanitizer.new)
mattr_accessor(:scrubber)

def sanitize_content_table(table)
# Normalize table content and render to an HTML string (un-sanitised).
#
# @param table [Katalyst::Content::Table]
# @param heading [Boolean] whether to add the heading as a caption
# @return [String] un-sanitised HTML as text
def normalize_content_table(table, heading: true)
TableNormalizer.new(table, heading:).to_html
end

# Sanitize table content and render to an HTML string (un-sanitised).
#
# @param content [String] un-sanitised HTML as text
# @return [ActiveSupport::SafeBuffer] sanitised HTML
def sanitize_content_table(content)
sanitizer.sanitize(
table.content.body.to_html,
content,
tags: content_table_allowed_tags,
attributes: content_table_allowed_attributes,
scrubber:,
Expand All @@ -24,6 +37,109 @@ def content_table_allowed_tags
def content_table_allowed_attributes
Katalyst::Content::Config.table_sanitizer_allowed_attributes
end

# rubocop:disable Rails/HelperInstanceVariable
class TableNormalizer
attr_reader :node, :heading_rows, :heading_columns

delegate_missing_to :@node

def initialize(table, heading: true)
@table = table
@heading = heading

root = ActionText::Fragment.from_html(table.content&.body&.to_html || "").source
@node = root.name == "table" ? root : root.at_css("table")

return unless @node

@heading_rows = @table.heading_rows.clamp(0..css("tr").length)
@heading_columns = @table.heading_columns.clamp(0..)
end

def set_caption!
return if at_css("caption")

prepend_child("<caption>#{@table.heading}</caption>")
end

# move cells between thead and tbody based on heading_rows
def set_header_rows!
rows = node.css("tr")

if heading_rows > thead.css("tr").count
rows.slice(0, heading_rows).reject { |row| row.parent == thead }.each do |row|
thead.add_child(row)
end
else
rows.slice(heading_rows, rows.length).reject { |row| row.parent == tbody }.reverse_each do |row|
tbody.prepend_child(row)
end
end
end

# convert cells between th and td based on heading_columns
def set_header_columns!
matrix = []

css("tr").each_with_index do |row, y|
row.css("td, th").each_with_index do |cell, x|
# step right until we find an empty cell
x += 1 until matrix.dig(y, x).nil?

# update the type of the cell based on the heading configuration
set_cell_type!(cell, y, x)

# record the coordinates that this cell occupies in the matrix
# e.g. colspan=2 rowspan=3 would occupy 6 cells
row_range(cell, y).each do |ty|
col_range(cell, x).each do |tx|
matrix[ty] ||= []
matrix[ty][tx] = cell.text
end
end
end
end

print matrix.inspect
end

def set_cell_type!(cell, row, col)
cell.name = col < heading_columns || row < heading_rows ? "th" : "td"
end

def to_html
return "" if @node.nil?

set_caption! if @heading && @table.heading.present? && !@table.heading_none?
set_header_rows!
set_header_columns!

@node.to_html
end

private

def thead
@thead ||= at_css("thead") || tbody.add_previous_sibling("<thead>").first
end

def tbody
@tbody ||= at_css("tbody") || add_child("<tbody>").last
end

def col_range(cell, from)
colspan = cell.attributes["colspan"]&.value&.to_i || 1
(from..).take(colspan)
end

def row_range(cell, from)
rowspan = cell.attributes["rowspan"]&.value&.to_i || 1
(from..).take(rowspan)
end
end

# rubocop:enable Rails/HelperInstanceVariable
end
end
end
19 changes: 13 additions & 6 deletions app/javascript/content/editor/table_controller.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Controller } from "@hotwired/stimulus";

export default class TableController extends Controller {
static targets = ["content", "input"];
static targets = ["content", "input", "update"];

constructor(config) {
super(config);
Expand All @@ -14,7 +14,7 @@ export default class TableController extends Controller {
attributes: true,
childList: true,
characterData: true,
subtree: true
subtree: true,
});
}

Expand All @@ -23,18 +23,25 @@ export default class TableController extends Controller {
}

change = (mutations) => {
this.inputTarget.value = this.contentTarget.innerHTML;
}
this.inputTarget.value = this.table.outerHTML;
};

update = () => {
this.element.closest("form").querySelector("button[data-action='update']").click();
}
this.updateTarget.click();
};

paste = (e) => {
e.preventDefault();

this.inputTarget.value = e.clipboardData.getData("text/html");

this.update();
};

/**
* @returns {HTMLTableElement} The table element from the content target
*/
get table() {
return this.contentTarget.querySelector("table");
}
}
23 changes: 20 additions & 3 deletions app/models/katalyst/content/table.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,23 @@ module Content
class Table < Item
has_rich_text :content

attribute :heading_rows, :integer
attribute :heading_columns, :integer

validates :content, presence: true

default_scope { with_rich_text_content }

after_initialize :set_defaults

def initialize_copy(source)
super

self.content = source.content&.body if source.content.is_a?(ActionText::RichText)
content.body = source.content&.body if source.content.is_a?(ActionText::RichText)
end

def self.permitted_params
super + %i[content]
super + %i[content heading_rows heading_columns]
end

def valid?(context = nil)
Expand All @@ -27,11 +32,23 @@ def to_plain_text
content.to_plain_text if visible?
end

def content=(value)
Tables::Importer.call(self, value)

set_defaults

content
end

private

def set_defaults
super
self.background = Item.config.backgrounds.first

if content.present? && (fragment = content.body.fragment)
self.heading_rows ||= fragment.find_all("thead > tr").count
self.heading_columns ||= fragment.find_all("tbody > tr:first-child > th").count
end
end
end
end
Expand Down
Loading

0 comments on commit 17944ad

Please sign in to comment.