wagtail-tinymce-table 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,429 @@
1
+ Metadata-Version: 2.4
2
+ Name: wagtail-tinymce-table
3
+ Version: 0.2.1
4
+ Summary: TinyMCE-powered StreamField blocks for Wagtail, including a table block with Excel paste support.
5
+ License: MIT
6
+ Project-URL: Repository, https://github.com/ogcio/wagtail-tinymce-table
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: wagtail>=4.0
10
+ Requires-Dist: django-tinymce>=5.0
11
+ Requires-Dist: bleach>=6.0
12
+ Requires-Dist: beautifulsoup4>=4.12
13
+ Requires-Dist: lxml>=4.9
14
+ Requires-Dist: wagtail-localize>=1.5
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest; extra == "dev"
17
+ Requires-Dist: pytest-django; extra == "dev"
18
+
19
+ # wagtail-tinymce
20
+
21
+ A set of [Wagtail](https://wagtail.org/) StreamField blocks that embed a [TinyMCE](https://www.tiny.cloud/) editor directly inside the page editor. The headline feature is `TinyMCETableBlock`: a fully-featured table block that lets editors paste tables straight from Excel or Google Sheets, create header-only or footer-only tables, and merge cells — all without needing Wagtail's built-in table block.
22
+
23
+ ---
24
+
25
+ ## Features
26
+
27
+ - **Paste from Excel / Google Sheets** — TinyMCE's clipboard handling converts spreadsheet data to clean HTML tables automatically.
28
+ - **Header rows (`<thead>`) and footer rows (`<tfoot>`)** — a custom *Footer Row* toolbar button toggles the selected row between `<tbody>` and `<tfoot>`. Header rows use TinyMCE's built-in *Row Header* button.
29
+ - **Header-less tables** — nothing forces a header row; plain body tables work fine.
30
+ - **Cell merging and splitting** — `colspan`/`rowspan` via the built-in *Merge Cells* and *Split Cell* buttons.
31
+ - **`wagtail-localize` integration** — `TinyMCETableBlock` implements `get_translatable_segments` and `restore_translated_segments` so table content is fully translatable, including header cells (`<th>`), footer cells, empty cells, and merged cells.
32
+ - **HTML sanitization** — all output is passed through [bleach](https://bleach.readthedocs.io/) with a strict allowlist before being stored.
33
+ - **Customisable** — override `custom_mce_config`, `allowed_tags`, `allowed_attributes`, or pass `menubar_options` / `toolbar_options` per block instance.
34
+
35
+ ---
36
+
37
+ ## Requirements
38
+
39
+ | Package | Minimum version |
40
+ |---|---|
41
+ | Python | 3.9 |
42
+ | Django | 4.2 |
43
+ | Wagtail | 4.0 |
44
+ | django-tinymce | 5.0 |
45
+ | bleach | 6.0 |
46
+ | beautifulsoup4 | 4.12 |
47
+ | lxml | 4.9 |
48
+
49
+ `wagtail-localize` ≥ 1.5 is a **required** dependency and is installed automatically.
50
+
51
+ ---
52
+
53
+ ## Installation
54
+
55
+ `wagtail-localize` is a **required** dependency — it is always installed automatically. No extra flags are needed.
56
+
57
+ ### From PyPI
58
+
59
+ ```bash
60
+ pip install wagtail-tinymce
61
+ ```
62
+
63
+ ### From Git (`master` branch — TinyMCE 7, django-tinymce ≥ 5.0)
64
+
65
+ ```bash
66
+ pip install git+https://github.com/ogcio/wagtail-tinymce-table.git@master
67
+ ```
68
+
69
+ In a `requirements.txt` file (PEP 508):
70
+
71
+ ```text
72
+ wagtail-tinymce @ git+https://github.com/ogcio/wagtail-tinymce-table.git@master
73
+ ```
74
+
75
+ ### Legacy version (TinyMCE 6, django-tinymce ≥ 3.5)
76
+
77
+ If your project requires (TinyMCE 6), install the tag instead:
78
+
79
+ ```bash
80
+ pip install git+https://github.com/ogcio/wagtail-tinymce-table.git@v0.1.0
81
+ ```
82
+
83
+ In a `requirements.txt` file (PEP 508):
84
+
85
+ ```text
86
+ wagtail-tinymce @ git+https://github.com/ogcio/wagtail-tinymce-table.git@v0.1.0
87
+ ```
88
+
89
+ ---
90
+
91
+ Add `"wagtailtinymce"` and `"tinymce"` to `INSTALLED_APPS`:
92
+
93
+ ```python
94
+ INSTALLED_APPS = [
95
+ ...
96
+ "tinymce",
97
+ "wagtailtinymce",
98
+ ]
99
+ ```
100
+
101
+ Add a minimal TinyMCE configuration to your Django settings (required by `django-tinymce`):
102
+
103
+ ```python
104
+ TINYMCE_DEFAULT_CONFIG = {}
105
+ ```
106
+
107
+ Add `STATIC_URL` if it is not already set:
108
+
109
+ ```python
110
+ STATIC_URL = "/static/"
111
+ ```
112
+
113
+ Run `collectstatic` so the Wagtail telepath adapter script is served:
114
+
115
+ ```bash
116
+ python manage.py collectstatic
117
+ ```
118
+
119
+ ---
120
+
121
+ ## Quick start
122
+
123
+ ### Complete working example
124
+
125
+ The snippets below show every file you need to add or edit in a standard Wagtail project to get a table block working end-to-end.
126
+
127
+ #### 1. `settings.py`
128
+
129
+ ```python
130
+ INSTALLED_APPS = [
131
+ # --- Wagtail core ---
132
+ "wagtail.contrib.forms",
133
+ "wagtail.contrib.redirects",
134
+ "wagtail.embeds",
135
+ "wagtail.sites",
136
+ "wagtail.users",
137
+ "wagtail.snippets",
138
+ "wagtail.documents",
139
+ "wagtail.images",
140
+ "wagtail.search",
141
+ "wagtail.admin",
142
+ "wagtail",
143
+ # --- Django ---
144
+ "django.contrib.admin",
145
+ "django.contrib.auth",
146
+ "django.contrib.contenttypes",
147
+ "django.contrib.sessions",
148
+ "django.contrib.messages",
149
+ "django.contrib.staticfiles",
150
+ # --- Third-party ---
151
+ "tinymce", # django-tinymce must come before wagtailtinymce
152
+ "wagtailtinymce", # this package
153
+ ]
154
+
155
+ # Required by django-tinymce (may be an empty dict if you rely on block-level config)
156
+ TINYMCE_DEFAULT_CONFIG = {}
157
+ ```
158
+
159
+ #### 2. `myapp/models.py`
160
+
161
+ ```python
162
+ from wagtail.models import Page
163
+ from wagtail.fields import StreamField
164
+ from wagtail.admin.panels import FieldPanel
165
+
166
+ from wagtailtinymce.core.table_block import TinyMCETableBlock
167
+
168
+
169
+ class TableDemoPage(Page):
170
+ """A page that contains one or more TinyMCE table blocks."""
171
+
172
+ body = StreamField(
173
+ [
174
+ ("table", TinyMCETableBlock()),
175
+ ],
176
+ blank=True,
177
+ use_json_field=True,
178
+ verbose_name="Page body",
179
+ )
180
+
181
+ content_panels = Page.content_panels + [
182
+ FieldPanel("body"),
183
+ ]
184
+
185
+ class Meta:
186
+ verbose_name = "Table demo page"
187
+ ```
188
+
189
+ #### 3. `myapp/templates/myapp/table_demo_page.html`
190
+
191
+ ```html
192
+ {% extends "base.html" %}
193
+ {% load wagtailcore_tags %}
194
+
195
+ {% block content %}
196
+ <article>
197
+ <h1>{{ page.title }}</h1>
198
+
199
+ {% for block in page.body %}
200
+ {% if block.block_type == "table" %}
201
+ {# The block value is already sanitised HTML — render it directly #}
202
+ <div class="table-wrapper">
203
+ {{ block.value }}
204
+ </div>
205
+ {% endif %}
206
+ {% endfor %}
207
+ </article>
208
+ {% endblock %}
209
+ ```
210
+
211
+ > **Tip:** use `{% include_block page.body %}` instead of the manual loop if you do not need to wrap individual blocks in extra markup.
212
+
213
+ #### 4. Optional: add basic table styles
214
+
215
+ The block stores a plain `<table>` element. Add CSS so it displays nicely:
216
+
217
+ ```css
218
+ /* static/css/content.css (load this in your base template) */
219
+ .table-wrapper {
220
+ overflow-x: auto; /* horizontal scroll on small screens */
221
+ }
222
+
223
+ .table-wrapper table {
224
+ border-collapse: collapse;
225
+ width: 100%;
226
+ }
227
+
228
+ .table-wrapper th,
229
+ .table-wrapper td {
230
+ border: 1px solid #d1d5db;
231
+ padding: 0.5rem 0.75rem;
232
+ text-align: left;
233
+ vertical-align: top;
234
+ }
235
+
236
+ .table-wrapper thead th {
237
+ background-color: #f3f4f6;
238
+ font-weight: 600;
239
+ }
240
+
241
+ .table-wrapper tfoot td {
242
+ background-color: #f9fafb;
243
+ font-style: italic;
244
+ }
245
+ ```
246
+
247
+ ---
248
+
249
+ ### Table block (minimal)
250
+
251
+ ```python
252
+ from wagtail.models import Page
253
+ from wagtail.fields import StreamField
254
+ from wagtail.admin.panels import FieldPanel
255
+ from wagtailtinymce.core.table_block import TinyMCETableBlock
256
+
257
+ class MyPage(Page):
258
+ body = StreamField(
259
+ [("table", TinyMCETableBlock())],
260
+ blank=True,
261
+ use_json_field=True,
262
+ )
263
+
264
+ content_panels = Page.content_panels + [
265
+ FieldPanel("body"),
266
+ ]
267
+ ```
268
+
269
+ Render the block in a template as you would any other StreamField block:
270
+
271
+ ```html
272
+ {% load wagtailcore_tags %}
273
+ {% include_block page.body %}
274
+ ```
275
+
276
+ ### Generic TinyMCE block
277
+
278
+ `TinyMCEBlock` is the base class. Use it directly when you need a full rich-text editor without the table-specific toolbar:
279
+
280
+ ```python
281
+ from wagtailtinymce.blocks import TinyMCEBlock
282
+
283
+ class MyPage(Page):
284
+ body = StreamField(
285
+ [("rich_text", TinyMCEBlock())],
286
+ blank=True,
287
+ use_json_field=True,
288
+ )
289
+ ```
290
+
291
+ ---
292
+
293
+ ## Customisation
294
+
295
+ ### Per-instance toolbar / menubar
296
+
297
+ Pass `toolbar_options` or `menubar_options` when declaring the block:
298
+
299
+ ```python
300
+ TinyMCETableBlock(
301
+ toolbar_options="bold italic | table tablemergecells",
302
+ menubar_options="", # hide the menubar
303
+ )
304
+ ```
305
+
306
+ ### Subclassing for a project-wide config
307
+
308
+ ```python
309
+ from wagtailtinymce.core.table_block import TinyMCETableBlock
310
+
311
+ class MyTableBlock(TinyMCETableBlock):
312
+ custom_mce_config = {
313
+ **TinyMCETableBlock.custom_mce_config,
314
+ "language": "ga", # Irish
315
+ "content_css": "/static/css/editor.css",
316
+ }
317
+
318
+ allowed_tags = TinyMCETableBlock.allowed_tags + ["figure", "figcaption"]
319
+ ```
320
+
321
+ ### Disabling sanitization
322
+
323
+ Set `sanitize_input=False` if you need to allow arbitrary HTML (only do this when the editor is trusted):
324
+
325
+ ```python
326
+ TinyMCETableBlock(sanitize_input=False)
327
+ ```
328
+
329
+ ---
330
+
331
+ ## Toolbar reference
332
+
333
+ The default `TinyMCETableBlock` toolbar groups are:
334
+
335
+ | Group | Buttons |
336
+ |---|---|
337
+ | Formatting | `bold` `italic` `link` `unlink` |
338
+ | Table structure | `table` `tablecaption` `tablecolheader` `tablerowheader` `tablefooterrow`* |
339
+ | Cell operations | `tablecellprops` `tablemergecells` `tablesplitcells` |
340
+ | Row operations | `tableinsertrowbefore` `tableinsertrowafter` `tabledeleterow` |
341
+ | Column operations | `tableinsertcolbefore` `tableinsertcolafter` `tabledeletecol` |
342
+
343
+ \* `tablefooterrow` is a custom button added by this package. Clicking it moves the selected row into `<tfoot>` (or back to `<tbody>` if it is already a footer row).
344
+
345
+ ---
346
+
347
+ ## `wagtail-localize` integration
348
+
349
+ When `wagtail-localize` is installed, `TinyMCETableBlock` implements the segment protocol so translators see each non-empty cell as an individual string segment.
350
+
351
+ **Behaviour:**
352
+ - Empty cells are skipped and do not consume a segment index.
353
+ - Duplicate cell values are extracted once and restored to all matching cells.
354
+ - `<th>` (header) cells, `<tbody>` cells, and `<tfoot>` cells are all included.
355
+ - Cells that contain a nested table are skipped entirely.
356
+ - Merged cells (`colspan`/`rowspan`) are treated as a single cell.
357
+
358
+ No configuration is needed. `wagtail-localize` is installed automatically with the package, and Wagtail Localize will pick up the segments automatically.
359
+
360
+ ---
361
+
362
+ ## Project structure
363
+
364
+ ```
365
+ wagtailtinymce/
366
+ ├── __init__.py # version
367
+ ├── apps.py # Django AppConfig
368
+ ├── blocks.py # TinyMCEBlock (base class)
369
+ ├── widgets.py # WagtailTinyMCE widget + telepath adapter
370
+ ├── core/
371
+ │ └── table_block.py # TinyMCETableBlock
372
+ └── static/
373
+ └── wagtailtinymce/js/
374
+ └── tinymce-adapter.js # Wagtail telepath registration
375
+ ```
376
+
377
+ ---
378
+
379
+ ## Running the tests
380
+
381
+ ```bash
382
+ pip install "wagtail-tinymce[dev]" # adds pytest + pytest-django
383
+ pytest
384
+ ```
385
+
386
+ The test suite has **75 tests** covering:
387
+
388
+ - `_replace_cell_text` helper (simple and compound cell paths)
389
+ - `TinyMCETableBlock` configuration (allowed tags, toolbar, TinyMCE `setup` callback)
390
+ - `get_translatable_segments` (empty cells, duplicates, `<th>`, `<tfoot>`, merged cells, round-trip)
391
+ - `restore_translated_segments` (index correctness, compound cells, `<tfoot>`, duplicates)
392
+ - `TinyMCEBlock.sanitize` (XSS, allowed tags/attributes, inline formatting)
393
+ - `TinyMCEBlock.value_from_form` (`SafeData`, bypass mode)
394
+ - `WagtailTinyMCE` widget (config merging and overrides)
395
+ - `WagtailTinyMCEAdapter` (telepath registration)
396
+
397
+ ---
398
+
399
+ ## Changelog
400
+
401
+ ### 0.2.1
402
+
403
+ #### Bug fixes
404
+ - **Header rows now produce `<th>` elements.** The default TinyMCE `table_header_type` value `"section"` moved rows into `<thead>` but kept `<td>` cells. Setting it to `"sectionCells"` correctly converts cells to `<th>` as well.
405
+ - **Table `<caption>` text is now included in translatable segments.** The caption is extracted before row cells and restored in the same position, so it appears correctly in the Wagtail Localize translation UI.
406
+
407
+ ### 0.2.0
408
+
409
+ #### Breaking changes
410
+ - **Requires `django-tinymce >= 5.0`** (previously 3.5). This bundles **TinyMCE 7.8** instead of TinyMCE 6.x. If you cannot upgrade `django-tinymce`, pin to the `v0.1.0` git tag.
411
+ - **`wagtail-localize >= 1.5` is now a required dependency** (previously optional).
412
+
413
+ #### Packaging
414
+ - Fixed build backend from `setuptools.backends.legacy:build` to `setuptools.build_meta` for compatibility with older setuptools versions.
415
+ - Fixed `package-dir` mapping so `pip install git+…` correctly installs the `wagtailtinymce` module (previously the package was silently installed empty).
416
+
417
+ ### 0.1.0
418
+
419
+ - Initial release.
420
+ - `TinyMCEBlock` and `TinyMCETableBlock` StreamField blocks.
421
+ - Excel / Google Sheets paste support via TinyMCE's clipboard plugin.
422
+ - Custom *Footer Row* toolbar button for `<tfoot>` support.
423
+ - `wagtail-localize` segment extraction and restoration, with fixes for empty cells, merged cells with rich content, and `<th>` cells.
424
+
425
+ ---
426
+
427
+ ## Licence
428
+
429
+ MIT
@@ -0,0 +1,11 @@
1
+ wagtailtinymce/__init__.py,sha256=ncxeznUBDHenWtbEyPJ4VenzNNIfq839P0jhX7wFIBM,87
2
+ wagtailtinymce/apps.py,sha256=vr03BhRm6N3kPCWWGNVp_aSt9rpdpovRCYy38QkV04s,226
3
+ wagtailtinymce/blocks.py,sha256=Eb6WdqqRga_3_xekD4AT6-qLtsDMDdmIoFRRUeqUEzk,1978
4
+ wagtailtinymce/widgets.py,sha256=50meYYKzQesqEuRcr2DtTbyPWUrE2gHlXb2VRYr3Ubc,1038
5
+ wagtailtinymce/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ wagtailtinymce/core/table_block.py,sha256=9f9c4LHNNaHWNgejXnoT-iJ-1hwFoyiEoY5TCpVf6uE,8754
7
+ wagtailtinymce/static/wagtailtinymce/js/tinymce-adapter.js,sha256=dT8nfi8C6bjmzijktnRkhNxiP9FpZs46RtVmmuVAndI,3932
8
+ wagtail_tinymce_table-0.2.1.dist-info/METADATA,sha256=cAUwxyE3c7LmqqdyUOX_bD-MQrVwLkNuhIG_VrJMNN0,12627
9
+ wagtail_tinymce_table-0.2.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
10
+ wagtail_tinymce_table-0.2.1.dist-info/top_level.txt,sha256=UX9zRg-30w36MuCz91wFbPLFdIGFybSGso0_zUUtdd4,15
11
+ wagtail_tinymce_table-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ wagtailtinymce
@@ -0,0 +1,3 @@
1
+ default_app_config = "wagtailtinymce.apps.WagtailTinyMCEConfig"
2
+
3
+ __version__ = "0.2.1"
wagtailtinymce/apps.py ADDED
@@ -0,0 +1,8 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class WagtailTinyMCEConfig(AppConfig):
5
+ name = "wagtailtinymce"
6
+ label = "wagtailtinymce"
7
+ verbose_name = "Wagtail TinyMCE"
8
+ default_auto_field = "django.db.models.BigAutoField"
@@ -0,0 +1,62 @@
1
+ import bleach
2
+ from django import forms
3
+ from django.utils.safestring import mark_safe
4
+ from wagtail.blocks import RawHTMLBlock
5
+
6
+ from .widgets import WagtailTinyMCE
7
+
8
+
9
+ class TinyMCEBlock(RawHTMLBlock):
10
+
11
+ # Set to override all or part of the default tinymce config
12
+ # from TINYMCE_DEFAULT_CONFIG
13
+ mce_config = None
14
+
15
+ # Set to override sanitization options passed to bleach
16
+ # when sanitize_input=True
17
+ allowed_tags = None
18
+ allowed_attributes = None
19
+ allowed_styles = None
20
+
21
+ def __init__(
22
+ self,
23
+ required=False,
24
+ help_text=None,
25
+ menubar_options=None,
26
+ toolbar_options=None,
27
+ sanitize_input=True,
28
+ validators=(),
29
+ **kwargs,
30
+ ):
31
+ # ``menubar_options`` and ``toolbar_options`` are provided as a means to
32
+ # customize those parts of the tinymce config for specific block instances
33
+ self.menubar_options = menubar_options
34
+ self.toolbar_options = toolbar_options
35
+ self.sanitize_input = sanitize_input
36
+
37
+ super().__init__(required=required, help_text=help_text, validators=validators, **kwargs)
38
+
39
+ # RawHTMLBlock initializes the field in the init function so we have
40
+ # to reinitialize as a WagtailTinyMCE widget.
41
+ # https://github.com/wagtail/wagtail/blob/main/wagtail/core/blocks/field_block.py#L706
42
+ self.field = forms.CharField(
43
+ widget=WagtailTinyMCE(
44
+ menubar_options=self.menubar_options,
45
+ toolbar_options=self.toolbar_options,
46
+ mce_config=self.custom_mce_config,
47
+ )
48
+ )
49
+
50
+ def sanitize(self, value):
51
+ return bleach.clean(
52
+ value,
53
+ attributes=self.allowed_attributes,
54
+ tags=self.allowed_tags,
55
+ strip=True,
56
+ strip_comments=True,
57
+ )
58
+
59
+ def value_from_form(self, value):
60
+ if self.sanitize_input:
61
+ value = self.sanitize(value)
62
+ return mark_safe(value) # nosec
File without changes
@@ -0,0 +1,220 @@
1
+ from wagtail_localize.segments import StringSegmentValue
2
+
3
+ from bs4 import BeautifulSoup, NavigableString
4
+
5
+ from wagtailtinymce.blocks import TinyMCEBlock
6
+
7
+ __all__ = [
8
+ "TinyMCETableBlock",
9
+ ]
10
+
11
+ # JavaScript function (serialised as a string) that registers a custom TinyMCE
12
+ # toolbar button which toggles the currently selected row between <tbody> and
13
+ # <tfoot>. The JS adapter in tinymce-adapter.js evals any setup value that
14
+ # contains a "(" character, so a plain arrow-function expression is sufficient.
15
+ _TFOOT_SETUP = (
16
+ "(editor) => {"
17
+ " editor.ui.registry.addButton('tablefooterrow', {"
18
+ " text: 'Footer Row',"
19
+ " tooltip: 'Toggle selected row as table footer',"
20
+ " onAction: () => {"
21
+ " const node = editor.selection.getNode();"
22
+ " const row = editor.dom.getParent(node, 'tr');"
23
+ " if (!row) return;"
24
+ " const parent = row.parentNode;"
25
+ " const table = editor.dom.getParent(row, 'table');"
26
+ " if (!table) return;"
27
+ " if (parent.tagName.toLowerCase() === 'tfoot') {"
28
+ " let tbody = table.querySelector('tbody');"
29
+ " if (!tbody) { tbody = editor.dom.create('tbody'); table.appendChild(tbody); }"
30
+ " tbody.appendChild(row);"
31
+ " if (!parent.hasChildNodes()) parent.remove();"
32
+ " } else {"
33
+ " let tfoot = table.querySelector('tfoot');"
34
+ " if (!tfoot) { tfoot = editor.dom.create('tfoot'); table.appendChild(tfoot); }"
35
+ " tfoot.appendChild(row);"
36
+ " if (!parent.hasChildNodes()) parent.remove();"
37
+ " }"
38
+ " }"
39
+ " });"
40
+ "}"
41
+ )
42
+
43
+
44
+ def _replace_cell_text(cell_tag, translated_text):
45
+ """Replace the visible text of a BeautifulSoup cell tag with *translated_text*.
46
+
47
+ Two cases:
48
+ - Simple cell: the tag has a single NavigableString child (``tag.string`` is
49
+ not None). We call ``replace_with`` directly on that string.
50
+ - Compound cell: the tag contains child elements (e.g. ``<p>``, ``<strong>``).
51
+ ``tag.string`` is None for such tags in BeautifulSoup. We clear the tag and
52
+ insert the translated plain text as a new NavigableString, which intentionally
53
+ drops inner formatting because the translation system operates on plain text.
54
+ """
55
+ if cell_tag.string is not None:
56
+ cell_tag.string.replace_with(translated_text)
57
+ else:
58
+ cell_tag.clear()
59
+ cell_tag.append(NavigableString(translated_text))
60
+
61
+
62
+ class TinyMCETableBlock(TinyMCEBlock):
63
+ custom_mce_config = {
64
+ "plugins": "table link",
65
+ "menubar": "",
66
+ # tablefooterrow is a custom button registered via the setup callback below.
67
+ "toolbar": (
68
+ "bold italic link unlink"
69
+ " | table tablecaption tablecolheader tablerowheader tablefooterrow"
70
+ " | tablecellprops tablemergecells tablesplitcells"
71
+ " | tableinsertrowbefore tableinsertrowafter tabledeleterow"
72
+ " | tableinsertcolbefore tableinsertcolafter tabledeletecol"
73
+ ),
74
+ "table_appearance_options": False,
75
+ "table_default_attributes": {},
76
+ "table_default_styles": {"border-collapse": "collapse", "width": "100%"},
77
+ "table_sizing_mode": "relative",
78
+ "table_advtab": False,
79
+ "table_cell_advtab": False,
80
+ "table_row_advtab": False,
81
+ # "sectionCells" moves the row into <thead> AND converts <td> → <th>.
82
+ # The default "section" only moves the row but keeps <td> elements,
83
+ # which is why header rows appeared without <th> markup.
84
+ "table_header_type": "sectionCells",
85
+ "language": "en",
86
+ "license_key": "gpl",
87
+ "setup": _TFOOT_SETUP,
88
+ }
89
+
90
+ class Meta:
91
+ label = "Table"
92
+ icon = "table"
93
+
94
+ allowed_tags = [
95
+ "table",
96
+ "thead",
97
+ "tbody",
98
+ "tfoot",
99
+ "br",
100
+ "colgroup",
101
+ "col",
102
+ "caption",
103
+ "tr",
104
+ "th",
105
+ "td",
106
+ "p",
107
+ "strong",
108
+ "b",
109
+ "em",
110
+ "i",
111
+ "a",
112
+ ]
113
+
114
+ allowed_attributes = {
115
+ "table": ["class", "id", "style"],
116
+ "col": ["span", "class"],
117
+ "th": [
118
+ "align",
119
+ "valign",
120
+ "class",
121
+ "scope",
122
+ "abbr",
123
+ "colspan",
124
+ "rowspan",
125
+ "headers",
126
+ ],
127
+ "td": ["align", "valign", "class", "colspan", "rowspan", "headers"],
128
+ "a": ["class", "href", "target", "title"],
129
+ }
130
+
131
+ allowed_styles = ["width", "border-collapse"]
132
+
133
+ def get_translatable_segments(self, data, **kwargs):
134
+ duplicate_elements = []
135
+ segments = []
136
+ soup = BeautifulSoup(data, features="lxml")
137
+ tables = soup.find_all("table")
138
+ col = 0
139
+ for table in tables:
140
+ # <caption> is a direct child of <table>, not inside any <tr>, so it
141
+ # must be handled explicitly before the row loop.
142
+ caption = table.find("caption")
143
+ if caption is not None:
144
+ text = caption.get_text(separator=" ").strip()
145
+ if text and text not in duplicate_elements:
146
+ segments.append(StringSegmentValue("", text, order=col))
147
+ duplicate_elements.append(text)
148
+ col += 1
149
+
150
+ rows = table.find_all("tr")
151
+ for row in rows:
152
+ # Include both <td> (body/footer cells) and <th> (header cells).
153
+ cells = row.find_all(["td", "th"])
154
+ for elem in cells:
155
+ text = elem.get_text(separator=" ").strip()
156
+ # Skip empty cells and cells that contain nested tables.
157
+ # The truthiness guard (bool(text)) ensures the first empty
158
+ # cell is not mistakenly added as a segment, which would
159
+ # shift every subsequent segment index by one during restore.
160
+ if text and text not in duplicate_elements and not elem.find("table"):
161
+ segments.append(StringSegmentValue("", text, order=col))
162
+ duplicate_elements.append(text)
163
+ col += 1
164
+
165
+ return segments
166
+
167
+ def sort_segment(self, segments) -> list:
168
+ seg_dict = {}
169
+ first_list = []
170
+ for s in segments:
171
+ seg_dict[s.order] = s
172
+ first_list.append(s.order)
173
+ first_list.sort()
174
+ return [seg_dict[elem] for elem in first_list]
175
+
176
+ def restore_translated_segments(self, block_value, segments):
177
+ duplicate_elements = {}
178
+ soup = BeautifulSoup(block_value, "html.parser")
179
+ tables = soup.find_all("table")
180
+ cell = 0
181
+ sorted_segment = self.sort_segment(segments)
182
+
183
+ if tables:
184
+ for table in tables:
185
+ # Restore caption before processing rows, mirroring extraction order.
186
+ caption = table.find("caption")
187
+ if caption is not None:
188
+ text = caption.get_text(separator=" ").strip()
189
+ if text:
190
+ try:
191
+ translated = sorted_segment[cell].string.data
192
+ if text not in duplicate_elements and text not in duplicate_elements.values():
193
+ duplicate_elements[text] = translated
194
+ _replace_cell_text(caption, translated)
195
+ cell += 1
196
+ elif text not in duplicate_elements.values():
197
+ _replace_cell_text(caption, duplicate_elements[text])
198
+ except Exception as e:
199
+ print(e)
200
+
201
+ rows = table.find_all("tr")
202
+ for row in rows:
203
+ # Mirror the same cell selector used in get_translatable_segments.
204
+ cells = row.find_all(["td", "th"])
205
+ for ele in cells:
206
+ text = ele.get_text(separator=" ").strip()
207
+ if not text or ele.find("table"):
208
+ continue
209
+ try:
210
+ translated = sorted_segment[cell].string.data
211
+ if text not in duplicate_elements and text not in duplicate_elements.values():
212
+ duplicate_elements[text] = translated
213
+ _replace_cell_text(ele, translated)
214
+ cell += 1
215
+ elif text not in duplicate_elements.values():
216
+ _replace_cell_text(ele, duplicate_elements[text])
217
+ except Exception as e:
218
+ print(e)
219
+
220
+ return str(soup)
@@ -0,0 +1,99 @@
1
+ /* eslint-disable func-names */
2
+ (function() {
3
+ function initializeTinyMCE(el) {
4
+ // This function is copied from:
5
+ // https://github.com/jazzband/django-tinymce/blob/master/tinymce/static/django_tinymce/init_tinymce.js
6
+
7
+ // initTinyMCE is not reachable outside of django-tinymce.
8
+ // The alternative is to import admin/js/jquery.init.js. However, that breaks a lot more things in Wagtail editor.
9
+ // Relevant issue: https://github.com/jazzband/django-tinymce/issues/385
10
+ if (el.closest('.empty-form') === null) { // Don't do empty inlines
11
+ const mce_conf = JSON.parse(el.dataset.mceConf);
12
+
13
+ // There is no way to pass a JavaScript function as an option
14
+ // because all options are serialized as JSON.
15
+ const fns = [
16
+ 'color_picker_callback',
17
+ 'file_browser_callback',
18
+ 'file_picker_callback',
19
+ 'images_dataimg_filter',
20
+ 'images_upload_handler',
21
+ 'paste_postprocess',
22
+ 'paste_preprocess',
23
+ 'setup',
24
+ 'urlconverter_callback',
25
+ ];
26
+ fns.forEach((fn_name) => {
27
+ if (typeof mce_conf[fn_name] !== 'undefined') {
28
+ if (mce_conf[fn_name].includes('(')) {
29
+ // This is disabled since we are copying it from the django_tinymce package.
30
+ // eslint-disable-next-line no-eval
31
+ mce_conf[fn_name] = eval(`(${ mce_conf[fn_name] })`);
32
+ } else {
33
+ mce_conf[fn_name] = window[mce_conf[fn_name]];
34
+ }
35
+ }
36
+ });
37
+
38
+ const {id} = el;
39
+ if ('elements' in mce_conf && mce_conf.mode === 'exact') {
40
+ mce_conf.elements = id;
41
+ }
42
+ // The following eslint-disable-enxt-line no-undef
43
+ // are set because those variables are defined via the JS files added
44
+ // by the TinyMCE widget.
45
+ if (el.dataset.mceGzConf && typeof tinyMCE_GZ !== 'undefined') {
46
+ // eslint-disable-next-line no-undef
47
+ tinyMCE_GZ.init(JSON.parse(el.dataset.mceGzConf));
48
+ }
49
+ // eslint-disable-next-line no-undef
50
+ if (!tinyMCE.editors[id]) {
51
+ // eslint-disable-next-line no-undef
52
+ tinyMCE.init(mce_conf);
53
+ }
54
+ }
55
+ }
56
+
57
+ function WagtailTinyMCE(html, config) {
58
+ this.html = html;
59
+ this.baseConfig = config;
60
+ }
61
+
62
+ WagtailTinyMCE.prototype.render = function(placeholder, name, id, initialState) {
63
+ placeholder.outerHTML = this.html.replace(/__NAME__/g, name).replace(/__ID__/g, id);
64
+
65
+ const element = document.getElementById(id);
66
+ element.value = initialState;
67
+
68
+ initializeTinyMCE(element);
69
+
70
+ // define public API functions for the widget:
71
+ // https://docs.wagtail.io/en/latest/reference/streamfield/widget_api.html
72
+ return {
73
+ idForLabel: null,
74
+ getValue() {
75
+ return element.value;
76
+ },
77
+ getState() {
78
+ return element.value;
79
+ },
80
+ setState() {
81
+ throw new Error('WagtailTinyMCE.setState is not implemented');
82
+ },
83
+ getTextLabel(opts) {
84
+ if (!element.value) return '';
85
+ const maxLength = opts && opts.maxLength;
86
+ const result = element.value;
87
+ if (maxLength && result.length > maxLength) {
88
+ return `${result.substring(0, maxLength - 1) }…`;
89
+ }
90
+ return result;
91
+ },
92
+ focus() {
93
+ this.element.focus();
94
+ }
95
+ };
96
+ };
97
+
98
+ window.telepath.register('wagtailtinymce.widgets.WagtailTinyMCE', WagtailTinyMCE);
99
+ })();
@@ -0,0 +1,35 @@
1
+ from tinymce.widgets import TinyMCE
2
+ from wagtail.telepath import register
3
+ from wagtail.utils.widgets import WidgetWithScript
4
+ from wagtail.widget_adapters import WidgetAdapter
5
+
6
+
7
+ class WagtailTinyMCE(WidgetWithScript, TinyMCE):
8
+ def __init__(
9
+ self,
10
+ content_language=None,
11
+ attrs=None,
12
+ menubar_options=None,
13
+ toolbar_options=None,
14
+ mce_config=None,
15
+ ):
16
+ mce_config = mce_config or {}
17
+ if menubar_options is not None:
18
+ mce_config["menubar"] = menubar_options
19
+ if toolbar_options is not None:
20
+ mce_config["toolbar"] = toolbar_options
21
+ super().__init__(content_language, attrs, mce_config)
22
+
23
+
24
+ class WagtailTinyMCEAdapter(WidgetAdapter):
25
+ js_constructor = "wagtailtinymce.widgets.WagtailTinyMCE"
26
+
27
+ class Media:
28
+ js = [
29
+ # This is the JS file that will run every time an
30
+ # TinyMCEBlock is added.
31
+ "wagtailtinymce/js/tinymce-adapter.js",
32
+ ]
33
+
34
+
35
+ register(WagtailTinyMCEAdapter(), WagtailTinyMCE)