diff --git a/CHANGELOG.md b/CHANGELOG.md
index ca7f43a..f14d9f4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [unreleased]
+### Added
+
+- Added common styling options (#27)
+
## [0.4.5] - 2026-05-19
Note: this is the same as v0.4.2 - 0.4.4 with PyPI Trusted publishing fixes after updating GitHub Actions versions
@@ -50,7 +54,6 @@ Note: this is the same as v0.4.2 - 0.4.4 with PyPI Trusted publishing fixes afte
- Line breaks were stripped out ([#11](https://github.com/torchbox/wagtail-tinytableblock/pull/11))
-
## [0.3.1] - 2025-04-02
### Added
@@ -105,7 +108,6 @@ Note: this is the same as v0.4.2 - 0.4.4 with PyPI Trusted publishing fixes afte
Initial release
-
[unreleased]: https://github.com/torchbox/wagtail-tinytableblock/compare/v0.4.5...HEAD
[0.4.5]: https://github.com/torchbox/wagtail-tinytableblock/compare/v0.4.1...v0.4.5
[0.4.1]: https://github.com/torchbox/wagtail-tinytableblock/compare/v0.4.0...v0.4.1
diff --git a/README.md b/README.md
index 23e28c0..0336ed0 100644
--- a/README.md
+++ b/README.md
@@ -18,9 +18,11 @@ TinyTableBlock is a StreamField block powered by [TinyMCE](https://www.tiny.clou
Wagtail provides [`TableBlock`](https://docs.wagtail.org/en/stable/reference/contrib/table_block.html) and
[`TypedTableBlock`](https://docs.wagtail.org/en/stable/reference/contrib/typed_table_block.html)
which are good options if you want basic tables with some cell merging capability or StreamField-powered cell, but they have their limitations:
+
- `TableBlock` is using an old version of [handsontable](https://github.com/handsontable/handsontable/tree/6.2.2). It doesn't support multi-row header, column headers, nor pasting complex tables.
- `TypedTableBlock` gets complex quickly depending on the types of blocks you add, and pasting is limited to single cells.
-
+
Wagtail TinyTableBlock (this package) provides the TinyMCE table editor which has improved copy/paste, multi-row and column headers, external link support and more.
It does not currently support the Wagtail rich text [data format](https://docs.wagtail.org/en/stable/extending/rich_text_internals.html#data-format) for page and document links,
nor does it support embedding images.
@@ -84,13 +86,46 @@ class ContentBlocks(StreamBlock):
table_block = TinyTableBlock(enable_context_menu=True)
```
+### Configuring rich text features allowed in table cells
+
+You can customize which text formatting tools are available inside the table cells. By default, formatting features are turned **off** to keep table content clean. You can enable them per block or globally across your entire site.
+
+#### Supported features
+
+The following formatting identifiers can be passed to the configuration arrays:
+
+- `bold`
+- `italic`
+- `strikethrough`
+- `subscript`
+- `superscript`
+- `blockquote`
+
+#### Block configuration
+
+To enable per-block configuration, pass a `features` list directly to the `TinyTableBlock` definition in your `models.py`. This aligns directly with standard Wagtail [`RichTextField` formatting controls](https://docs.wagtail.org/en/stable/advanced_topics/customization/page_editing_interface.html#limiting-features-in-a-rich-text-field).
+
+
+#### Global configuration (Django settings)
+
+If you want to define a fallback list of formatting features for all tables across your website without repeating the code in every model, define `WAGTAIL_TINYTABLE` in your `settings.py`:
+
+```python
+# settings.py
+
+WAGTAIL_TINYTABLE = {
+ "features": ["bold", "italic", "strikethrough"]
+}
+```
+
+*Note: If no global settings are defined and no per-block features are provided, the features list defaults to empty (`[]`), disabling rich text formatting choices completely.*
+
### Content Security Policy
For [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) configuration guidance,
follow the [TinyMCE documentation](https://www.tiny.cloud/docs/tinymce/latest/security/#configuring-content-security-policy-csp-for-tinymce) with
the self-hosted option.
-
## Data representation
The table data is saved as a JSON-serialized dictionary with the following keys:
diff --git a/src/wagtail_tinytableblock/blocks.py b/src/wagtail_tinytableblock/blocks.py
index dd0b0b8..9423f7f 100644
--- a/src/wagtail_tinytableblock/blocks.py
+++ b/src/wagtail_tinytableblock/blocks.py
@@ -4,11 +4,13 @@
from typing import Any
from django import forms
+from django.conf import settings
from django.forms import Media
from django.utils.functional import cached_property
from wagtail.blocks import Block, FieldBlock, StructBlock
from wagtail.blocks.field_block import CharBlock, FieldBlockAdapter
+from .constants import TEXT_FEATURE_MAPPING
from .utils import html_table_to_dict
@@ -47,7 +49,9 @@ def value_from_form(self, value: str) -> dict:
try:
return dict(json.loads(value))
except (json.decoder.JSONDecodeError, TypeError, ValueError):
- return html_table_to_dict(value, allow_links=self.meta.allow_links)
+ return html_table_to_dict(
+ value, allow_links=self.meta.allow_links, features=self.meta.features
+ )
def value_for_form(self, value: dict | None) -> str:
return json.dumps(value)
@@ -67,9 +71,15 @@ class TinyTableBlockAdapter(FieldBlockAdapter):
def js_args(self, block) -> list:
the_args = super().js_args(block)
+ features = block.meta.features
+
+ # for tinymce we need both: tinymce and html representations
+ tinymce_features = [
+ feature for feature in TEXT_FEATURE_MAPPING if feature[0] in features
+ ]
the_args[2]["enableLinks"] = block.meta.allow_links
the_args[2]["enableContextMenu"] = block.meta.enable_context_menu
-
+ the_args[2]["features"] = tinymce_features
return the_args
@cached_property
@@ -97,16 +107,21 @@ def __init__(
*,
allow_links: bool = False,
enable_context_menu: bool = False,
+ features: list[str] = None,
**kwargs,
) -> None:
if local_blocks is None:
local_blocks = ()
+ if not features:
+ features = getattr(settings, "WAGTAIL_TINYTABLE", {}).get("features", [])
+
# Manually define the data block so we can pass on configuration kwargs.
data_block = TinyTableFieldBlock(
required=False,
allow_links=allow_links,
enable_context_menu=enable_context_menu,
+ features=features,
)
local_blocks = (*local_blocks, ("data", data_block))
@@ -117,3 +132,4 @@ class Meta:
template = "wagtail_tinytableblock/table_block.html"
allow_links = False
enable_context_menu = False
+ features = None
diff --git a/src/wagtail_tinytableblock/constants.py b/src/wagtail_tinytableblock/constants.py
new file mode 100644
index 0000000..b4468c5
--- /dev/null
+++ b/src/wagtail_tinytableblock/constants.py
@@ -0,0 +1,11 @@
+# Mapping of Wagtail's richtext features and TinyMCE button identifiers
+# to their corresponding html tags
+TEXT_FEATURE_MAPPING = [
+ # ("wagtail/tinymce", "html")
+ ("bold", "strong"),
+ ("italic", "em"),
+ ("strikethrough", "s"),
+ ("subscript", "sub"),
+ ("superscript", "sup"),
+ ("blockquote", "blockquote"),
+]
diff --git a/src/wagtail_tinytableblock/static/wagtail_tinytableblock/js/tiny-table-block.js b/src/wagtail_tinytableblock/static/wagtail_tinytableblock/js/tiny-table-block.js
index f54b918..f6f4f09 100644
--- a/src/wagtail_tinytableblock/static/wagtail_tinytableblock/js/tiny-table-block.js
+++ b/src/wagtail_tinytableblock/static/wagtail_tinytableblock/js/tiny-table-block.js
@@ -20,6 +20,14 @@ class TinyTableBlockDefinition extends window.wagtailStreamField.blocks.FieldBlo
valid_elements += ",a[href|rel|title|target]";
}
+ if (this.meta.features) {
+ const toolbar_buttons = this.meta.features.map(f => f[0]);
+ const elements = this.meta.features.map(f => f[1]);
+
+ toolbar = toolbar_buttons.join(" ") + " | " + toolbar;
+ valid_elements += "," + elements.join(",");
+ }
+
let contextmenu_never_use_native = true;
if (!this.meta.enableContextMenu) {
contextmenu = false;
diff --git a/src/wagtail_tinytableblock/utils.py b/src/wagtail_tinytableblock/utils.py
index 319bee2..badcd4a 100644
--- a/src/wagtail_tinytableblock/utils.py
+++ b/src/wagtail_tinytableblock/utils.py
@@ -6,6 +6,8 @@
from bs4 import BeautifulSoup
from nh3 import Cleaner
+from .constants import TEXT_FEATURE_MAPPING
+
if TYPE_CHECKING:
from bs4.element import Tag
@@ -13,7 +15,9 @@
Cell = Literal["td", "th"]
-def sanitise_html(content: str, *, allow_links: bool = False) -> str:
+def sanitise_html(
+ content: str, *, allow_links: bool = False, features: list[str] = None
+) -> str:
tags: set[str] = {"table", "tr", "th", "td", "thead", "tbody", "caption", "br"}
attributes: dict[str, set[str]] = {
"*": {"class"},
@@ -25,6 +29,14 @@ def sanitise_html(content: str, *, allow_links: bool = False) -> str:
tags |= {"a"}
attributes["a"] = {"href", "rel", "title", "target"}
+ if features:
+ feature_tags = {
+ html_tag
+ for text_feature, html_tag in TEXT_FEATURE_MAPPING
+ if text_feature in features
+ }
+ tags |= feature_tags
+
sanitizer = Cleaner(
tags=tags,
attributes=attributes,
@@ -105,7 +117,9 @@ def check_all_cells_are_empty(rows: list[list[dict[str, str | int]]]) -> bool:
return True
-def html_table_to_dict(content: str, *, allow_links: bool = False) -> dict:
+def html_table_to_dict(
+ content: str, *, allow_links: bool = False, features: list[str] = None
+) -> dict:
"""Take an HTML table and convert it to a dictionary.
The dictionary has the following structure:
@@ -113,7 +127,7 @@ def html_table_to_dict(content: str, *, allow_links: bool = False) -> dict:
- rows - a list of row lists, each containing the cell info
- html - the original html
"""
- content = sanitise_html(content, allow_links=allow_links)
+ content = sanitise_html(content, allow_links=allow_links, features=features)
soup = BeautifulSoup(content, "html.parser")
table = soup.find("table")
diff --git a/tests/test_blocks.py b/tests/test_blocks.py
index 3149e55..afebdc4 100644
--- a/tests/test_blocks.py
+++ b/tests/test_blocks.py
@@ -1,12 +1,13 @@
from unittest import skipIf
-from django.test import TestCase
+from django.test import TestCase, override_settings
from wagtail import VERSION as WAGTAIL_VERSION
+from wagtail.test.utils import WagtailTestUtils
-from wagtail_tinytableblock.blocks import TinyTableBlock
+from wagtail_tinytableblock.blocks import TinyTableBlock, TinyTableFieldBlock
-class BlockTestCase(TestCase):
+class BlockTestCase(WagtailTestUtils, TestCase):
@classmethod
def setUpTestData(cls):
cls.simple_table_data = {
@@ -78,3 +79,79 @@ def test_form_layout_includes_all_fields(self):
block = TinyTableBlock(allow_links=True)
form_children = block.get_form_layout().children
self.assertEqual(["title", "caption", "data"], form_children)
+
+ def test_features_are_disabled_by_default_when_no_param_provided(self):
+ html = """
+
+
+
+ | bold text |
+ Cell 2 |
+
+
+
+ """
+ block = TinyTableBlock()
+ data = block.child_blocks["data"].value_from_form(html)
+
+ rendered = block.render({"data": data})
+ self.assertNotInHTML("bold text", rendered)
+ self.assertNotInHTML("Cell 2", rendered)
+
+ def test_features_are_enabled_when_block_param_is_passed(self):
+ html = """
+
+
+
+ | bold text |
+ Cell 2 |
+
+
+
+ """
+ block = TinyTableBlock(features=["bold"])
+ data = block.child_blocks["data"].value_from_form(html)
+
+ rendered = block.render({"data": data})
+ self.assertInHTML("bold text", rendered)
+ self.assertNotInHTML("Cell 2", rendered)
+
+ @override_settings(WAGTAIL_TINYTABLE={"features": ["bold", "italic"]})
+ def test_features_load_from_global_settings_when_no_param_provided(self):
+ html = """
+
+
+
+ | bold text |
+ Cell 2 |
+
+
+
+ """
+ # field_block = TinyTableFieldBlock(allow_links=False, features=[])
+ block = TinyTableBlock()
+ data = block.child_blocks["data"].value_from_form(html)
+
+ rendered = block.render({"data": data})
+ self.assertInHTML("bold text", rendered)
+ self.assertInHTML("Cell 2", rendered)
+
+ @override_settings(WAGTAIL_TINYTABLE={"features": ["bold", "italic"]})
+ def test_features_param_takes_priority_over_global_settings(self):
+ html = """
+
+
+
+ | bold text |
+ Cell 2 |
+
+
+
+ """
+ # field_block = TinyTableFieldBlock(allow_links=False, features=[])
+ block = TinyTableBlock(features=["bold"])
+ data = block.child_blocks["data"].value_from_form(html)
+
+ rendered = block.render({"data": data})
+ self.assertInHTML("bold text", rendered)
+ self.assertNotInHTML("Cell 2", rendered)
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 07a0f46..5a8f65a 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -342,3 +342,53 @@ def test_sanitisation__no_links(self):
sanitised = sanitise_html(html)
self.assertEqual(sanitised.strip(), expected.strip())
+
+ def test_sanitisation__no_features(self):
+ html = """
+
+
+
+ Cell on on two lines |
+ Cell 2 |
+
+
+
+ """
+ expected = """
+
+
+
+ Cell on on two lines |
+ Cell 2 |
+
+
+
+ """
+
+ sanitised = sanitise_html(html, features=["bold"])
+ self.assertEqual(sanitised.strip(), expected.strip())
+
+ def test_sanitisation__with_features(self):
+ html = """
+
+
+
+ Cell on on two lines |
+ Cell 2 |
+
+
+
+ """
+ expected = """
+
+
+
+ Cell on on two lines |
+ Cell 2 |
+
+
+
+ """
+
+ sanitised = sanitise_html(html)
+ self.assertEqual(sanitised.strip(), expected.strip())