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.
- wagtail_tinymce_table-0.2.1.dist-info/METADATA +429 -0
- wagtail_tinymce_table-0.2.1.dist-info/RECORD +11 -0
- wagtail_tinymce_table-0.2.1.dist-info/WHEEL +5 -0
- wagtail_tinymce_table-0.2.1.dist-info/top_level.txt +1 -0
- wagtailtinymce/__init__.py +3 -0
- wagtailtinymce/apps.py +8 -0
- wagtailtinymce/blocks.py +62 -0
- wagtailtinymce/core/__init__.py +0 -0
- wagtailtinymce/core/table_block.py +220 -0
- wagtailtinymce/static/wagtailtinymce/js/tinymce-adapter.js +99 -0
- wagtailtinymce/widgets.py +35 -0
|
@@ -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 @@
|
|
|
1
|
+
wagtailtinymce
|
wagtailtinymce/apps.py
ADDED
wagtailtinymce/blocks.py
ADDED
|
@@ -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)
|