wagtail-visual-editor 0.1.0__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 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,10 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class WagtailVisualEditorConfig(AppConfig):
5
+ name = "wagtail_visual_editor"
6
+ label = "wagtail_visual_editor"
7
+ verbose_name = "Wagtail visual editor"
8
+
9
+ def ready(self):
10
+ from . import checks # noqa: F401 (registra el system check)
@@ -0,0 +1,27 @@
1
+ from django.core.checks import Warning, register
2
+
3
+ # Rango de Wagtail probado: [mínima, siguiente minor sin probar). Va en sincronía con pyproject.toml.
4
+ WAGTAIL_TESTED = ((7, 4), (7, 5))
5
+
6
+
7
+ def version_warnings(version):
8
+ """Avisos si la versión de Wagtail (tupla) está fuera del rango probado. pip se puede saltear el límite."""
9
+ low, high = WAGTAIL_TESTED
10
+ if low <= tuple(version[:2]) < high:
11
+ return []
12
+ return [
13
+ Warning(
14
+ f"wagtail-visual-editor no está probado con Wagtail {version[0]}.{version[1]} "
15
+ f"(probado: >={low[0]}.{low[1]},<{high[0]}.{high[1]}).",
16
+ hint="El editor usa la API JS interna del StreamField: probalo antes de usarlo o instalá una "
17
+ "versión de wagtail-visual-editor compatible (ver la tabla del README).",
18
+ id="wagtail_visual_editor.W001",
19
+ )
20
+ ]
21
+
22
+
23
+ @register()
24
+ def check_wagtail_version(app_configs, **kwargs):
25
+ from wagtail import VERSION
26
+
27
+ return version_warnings(VERSION)
@@ -0,0 +1,62 @@
1
+ from urllib.parse import urlparse
2
+
3
+ from django.urls import reverse
4
+ from wagtail.admin.panels import FieldPanel, Panel
5
+
6
+ from . import __version__
7
+
8
+
9
+ class VisualStreamPanel(FieldPanel):
10
+ """StreamField editado sobre el preview del frontend.
11
+
12
+ El widget nativo queda oculto pero activo: es la fuente de verdad y lo que se envía al guardar.
13
+ """
14
+
15
+ class BoundPanel(FieldPanel.BoundPanel):
16
+ template_name = "wagtail_visual_editor/visual_stream_panel.html"
17
+
18
+ def get_context_data(self, parent_context=None):
19
+ context = super().get_context_data(parent_context)
20
+ page = self.instance
21
+ if page.pk:
22
+ preview_url = reverse("wagtailadmin_pages:preview_on_edit", args=[page.pk])
23
+ site = page.get_site()
24
+ else:
25
+ # Página nueva: todavía no está en el árbol, así que el preview y el Site salen del padre
26
+ parent = self.form.parent_page
27
+ preview_url = reverse(
28
+ "wagtailadmin_pages:preview_on_add",
29
+ args=[page._meta.app_label, page._meta.model_name, parent.pk],
30
+ )
31
+ site = parent.get_site()
32
+ context["preview_url"] = preview_url
33
+ context["field_name"] = self.bound_field.html_name
34
+ # El preview headless corre en el frontend del Site: solo se aceptan mensajes de ese origen
35
+ context["frontend_origin"] = site.root_url if site else ""
36
+ context["asset_version"] = __version__ # cache-busting de JS/CSS al actualizar la app
37
+ return context
38
+
39
+
40
+ class SocialCardPreviewPanel(Panel):
41
+ """Vista previa de cómo se ve la página al compartirla (WhatsApp, LinkedIn, X), en vivo.
42
+
43
+ Lee del formulario seo_title/title, search_description y og_image. La imagen por defecto (cuando no hay
44
+ og_image) la da la página con get_social_preview_defaults(site) -> {"image_url": ...}.
45
+ """
46
+
47
+ class BoundPanel(Panel.BoundPanel):
48
+ template_name = "wagtail_visual_editor/social_card_preview_panel.html"
49
+
50
+ def get_context_data(self, parent_context=None):
51
+ context = super().get_context_data(parent_context)
52
+ page = self.instance
53
+ site = page.get_site() if page.pk else self.form.parent_page.get_site()
54
+ defaults = page.get_social_preview_defaults(site) if hasattr(page, "get_social_preview_defaults") else {}
55
+ context["domain"] = urlparse(site.root_url).netloc if site else ""
56
+ # URL completa en vivo = URL del padre + slug del formulario. La raíz del Site no tiene padre con URL.
57
+ parent = page.get_parent() if page.pk else self.form.parent_page
58
+ context["parent_url"] = (parent.full_url or "") if parent else ""
59
+ context["page_url"] = (page.full_url or "") if page.pk else ""
60
+ context["default_image_url"] = defaults.get("image_url") or ""
61
+ context["asset_version"] = __version__
62
+ return context
@@ -0,0 +1,84 @@
1
+ """Valores de ejemplo para cualquier bloque, generados a partir de su estructura.
2
+
3
+ Sirven de preview por defecto en el selector de bloques (y para poblar páginas demo). Un bloque que
4
+ necesite un preview mejor puede definir su propio Meta.preview_value.
5
+ """
6
+
7
+ from itertools import count
8
+
9
+ from wagtail import blocks as b
10
+ from wagtail.documents import get_document_model
11
+ from wagtail.documents.blocks import DocumentChooserBlock
12
+ from wagtail.images import get_image_model
13
+ from wagtail.images.blocks import ImageChooserBlock
14
+ from wagtail.models import Site
15
+ from wagtail.snippets.blocks import SnippetChooserBlock
16
+
17
+ TEXT_BY_NAME = {"title": "Título de ejemplo", "eyebrow": "Eyebrow", "tab_title": "Tab", "text": "Texto de ejemplo"}
18
+ BLANK_NAMES = {"component_id"}
19
+ RICH_TEXT = "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>"
20
+
21
+ # Textos únicos: hay componentes que usan el texto como key de React
22
+ _counter = count(1)
23
+
24
+
25
+ def _pk(model):
26
+ obj = model.objects.first()
27
+ return obj.pk if obj else None
28
+
29
+
30
+ def sample_value(block, name=""):
31
+ """Valor crudo (formato JSON del StreamField) de ejemplo para `block`."""
32
+ if isinstance(block, b.StaticBlock):
33
+ return None
34
+ if isinstance(block, b.StructBlock):
35
+ return {n: sample_value(c, n) for n, c in block.child_blocks.items()}
36
+ if isinstance(block, b.ListBlock):
37
+ size = min(3, block.meta.max_num or 3)
38
+ return [sample_value(block.child_block, name) for _ in range(max(size, block.meta.min_num or 0))]
39
+ if isinstance(block, b.StreamBlock):
40
+ first_name, first = next(iter(block.child_blocks.items()))
41
+ return [{"type": first_name, "value": sample_value(first, first_name)}]
42
+ if isinstance(block, ImageChooserBlock):
43
+ return _pk(get_image_model())
44
+ if isinstance(block, DocumentChooserBlock):
45
+ return _pk(get_document_model())
46
+ if isinstance(block, b.PageChooserBlock):
47
+ # La raíz del Site por defecto: siempre tiene URL (una página fuera de todo Site no)
48
+ site = Site.objects.filter(is_default_site=True).first()
49
+ return site.root_page_id if site else None
50
+ if isinstance(block, SnippetChooserBlock):
51
+ return _pk(block.target_model)
52
+ if type(block).__name__ == "VideoChooserBlock":
53
+ return None
54
+ if isinstance(block, b.ChoiceBlock):
55
+ return next((v for v, _ in block.field.choices if v), None)
56
+ if isinstance(block, b.EmailBlock):
57
+ return "demo@example.com"
58
+ if isinstance(block, b.URLBlock):
59
+ return "https://example.com"
60
+ if isinstance(block, (b.IntegerBlock, b.FloatBlock, b.DecimalBlock)):
61
+ return 42
62
+ if isinstance(block, b.BooleanBlock):
63
+ return False
64
+ if isinstance(block, (b.RichTextBlock, b.RawHTMLBlock)) or any(
65
+ kind in type(block).__name__ for kind in ("HTML", "RichText")
66
+ ):
67
+ return RICH_TEXT
68
+ if name in BLANK_NAMES:
69
+ return ""
70
+ if name == "anchor":
71
+ return f"seccion-{next(_counter)}"
72
+ label = TEXT_BY_NAME.get(name, name.replace("_", " ").capitalize() or "Texto")
73
+ return f"{label} {next(_counter)}"
74
+
75
+
76
+ class SamplePreviewMixin:
77
+ """Mixin para bloques: preview en el selector con valores de ejemplo, sin definir preview_value."""
78
+
79
+ is_previewable = True
80
+
81
+ def get_preview_value(self):
82
+ if hasattr(self.meta, "preview_value"):
83
+ return super().get_preview_value()
84
+ return self.to_python(sample_value(self))
@@ -0,0 +1,329 @@
1
+ /* Previews de cada red a partir de sus medidas y colores públicos (referencia: metatags.io).
2
+ Sirven para ver recortes de texto e imagen; no son pixel-perfect. Fondo claro fijo: así se ven en cada app. */
3
+ /* La columna del formulario tiene max-width 840px: la tab con las previews se abre hasta 1240px (entran
4
+ 3 columnas), pero los demás paneles (SEO, menús) siguen en 840px para que sus campos no queden muy anchos */
5
+ .w-form-width:has([data-social-cards]) {
6
+ max-width: 1240px;
7
+ }
8
+
9
+ .w-form-width:has([data-social-cards]) > .w-panel:not(:has([data-social-cards])) {
10
+ max-width: 840px;
11
+ }
12
+
13
+ /* Cada campo de un panel tiene su propio wrapper de 840px: solo el de las previews se abre */
14
+ .w-panel__wrapper:has(> [data-social-cards]) {
15
+ max-width: none;
16
+ }
17
+
18
+ /* Grilla responsive: tantas columnas de ≥300px como entren (una sola en pantallas angostas) */
19
+ .w-social-cards {
20
+ display: grid;
21
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
22
+ gap: 1.75rem 1.5rem;
23
+ align-items: start;
24
+ }
25
+
26
+ .w-social-cards > .help {
27
+ grid-column: 1 / -1;
28
+ margin: 0;
29
+ }
30
+
31
+ .w-social-cards__warnings:empty {
32
+ display: none;
33
+ }
34
+
35
+ .w-social-card {
36
+ min-width: 0;
37
+ }
38
+
39
+ .w-social-card h3 {
40
+ margin: 0 0 0.5rem;
41
+ font-size: 0.8125rem;
42
+ font-weight: 400;
43
+ opacity: 0.7;
44
+ }
45
+
46
+ /* Imagen compartida: proporción 1.91:1 (1200×630) salvo donde la red usa otra */
47
+ .w-sc-image {
48
+ aspect-ratio: 1.91 / 1;
49
+ background: #e5e7eb center / cover no-repeat;
50
+ }
51
+
52
+ .w-sc-image.is-empty {
53
+ background-image: repeating-linear-gradient(45deg, #e5e7eb 0 10px, #f3f4f6 10px 20px);
54
+ }
55
+
56
+ /* Google */
57
+ .w-sc-google {
58
+ max-width: 600px;
59
+ padding: 0.75rem 1rem;
60
+ border-radius: 0.5rem;
61
+ background: #fff;
62
+ font: 14px/20px Arial, sans-serif;
63
+ }
64
+
65
+ .w-sc-google__title {
66
+ display: block;
67
+ overflow: hidden;
68
+ color: #1a0dab;
69
+ font-size: 18px;
70
+ line-height: 21.6px;
71
+ text-overflow: ellipsis;
72
+ white-space: nowrap;
73
+ }
74
+
75
+ .w-sc-google__url {
76
+ display: flex;
77
+ gap: 0.375rem;
78
+ align-items: center;
79
+ color: #006621;
80
+ font-size: 14px;
81
+ line-height: 16px;
82
+ }
83
+
84
+ .w-sc-google__caret {
85
+ border: 5px solid transparent;
86
+ border-top-color: #006621;
87
+ border-bottom-width: 0;
88
+ }
89
+
90
+ .w-sc-google__description {
91
+ display: -webkit-box;
92
+ overflow: hidden;
93
+ color: #545454;
94
+ font-size: 13px;
95
+ line-height: 18.2px;
96
+ -webkit-box-orient: vertical;
97
+ -webkit-line-clamp: 2;
98
+ }
99
+
100
+ /* WhatsApp: globo de mensaje saliente con el preview arriba y el link abajo */
101
+ .w-sc-whatsapp {
102
+ max-width: 360px;
103
+ padding: 0.25rem;
104
+ border-radius: 0.5rem;
105
+ background: #d9fdd3;
106
+ font: 14px/19px system-ui, -apple-system, "Segoe UI", sans-serif;
107
+ color: #111b21;
108
+ }
109
+
110
+ .w-sc-whatsapp__preview {
111
+ overflow: hidden;
112
+ border-radius: 0.375rem;
113
+ background: #d1f4cc;
114
+ }
115
+
116
+ .w-sc-whatsapp__text {
117
+ display: flex;
118
+ flex-direction: column;
119
+ gap: 0.125rem;
120
+ padding: 0.375rem 0.625rem 0.5rem;
121
+ font-size: 13px;
122
+ }
123
+
124
+ .w-sc-whatsapp__text strong,
125
+ .w-sc-whatsapp__text span:not(.w-sc-whatsapp__domain) {
126
+ display: -webkit-box;
127
+ overflow: hidden;
128
+ -webkit-box-orient: vertical;
129
+ -webkit-line-clamp: 2;
130
+ }
131
+
132
+ .w-sc-whatsapp__text span:not(.w-sc-whatsapp__domain) {
133
+ color: #54656f;
134
+ }
135
+
136
+ .w-sc-whatsapp__domain {
137
+ color: #667781;
138
+ font-size: 12px;
139
+ }
140
+
141
+ .w-sc-whatsapp__link {
142
+ display: block;
143
+ overflow: hidden;
144
+ padding: 0.25rem 0.375rem 0.125rem;
145
+ color: #027eb5;
146
+ text-overflow: ellipsis;
147
+ white-space: nowrap;
148
+ }
149
+
150
+ /* Facebook */
151
+ .w-sc-facebook {
152
+ max-width: 500px;
153
+ border: 1px solid #dadde1;
154
+ background: #fff;
155
+ }
156
+
157
+ .w-sc-facebook__text {
158
+ display: flex;
159
+ flex-direction: column;
160
+ gap: 0.125rem;
161
+ padding: 10px 12px;
162
+ background: #f2f3f5;
163
+ color: #1d2129;
164
+ font-family: Helvetica, Arial, sans-serif;
165
+ }
166
+
167
+ .w-sc-facebook__domain {
168
+ color: #606770;
169
+ font-size: 12px;
170
+ text-transform: uppercase;
171
+ }
172
+
173
+ .w-sc-facebook__text strong {
174
+ overflow: hidden;
175
+ font-size: 16px;
176
+ font-weight: 600;
177
+ line-height: 20px;
178
+ text-overflow: ellipsis;
179
+ white-space: nowrap;
180
+ }
181
+
182
+ .w-sc-facebook__text span:last-child {
183
+ overflow: hidden;
184
+ color: #606770;
185
+ font-size: 14px;
186
+ line-height: 20px;
187
+ text-overflow: ellipsis;
188
+ white-space: nowrap;
189
+ }
190
+
191
+ /* X: el título va sobre la imagen, abajo a la izquierda; el dominio debajo de la tarjeta */
192
+ .w-sc-x {
193
+ max-width: 504px;
194
+ overflow: hidden;
195
+ border: 1px solid #cfd9de;
196
+ border-radius: 16px;
197
+ }
198
+
199
+ .w-sc-x__image {
200
+ position: relative;
201
+ }
202
+
203
+ .w-sc-x__title {
204
+ position: absolute;
205
+ bottom: 12px;
206
+ left: 12px;
207
+ max-width: calc(100% - 24px);
208
+ overflow: hidden;
209
+ padding: 0 8px;
210
+ border-radius: 4px;
211
+ background: rgba(0, 0, 0, 0.77);
212
+ color: #fff;
213
+ font: 13px/20px system-ui, -apple-system, "Segoe UI", sans-serif;
214
+ text-overflow: ellipsis;
215
+ white-space: nowrap;
216
+ }
217
+
218
+ .w-sc-x__from {
219
+ display: block;
220
+ margin-top: 0.25rem;
221
+ color: #71767b;
222
+ font: 13px/16px system-ui, -apple-system, "Segoe UI", sans-serif;
223
+ }
224
+
225
+ /* LinkedIn */
226
+ .w-sc-linkedin {
227
+ max-width: 552px;
228
+ border: 1px solid #dadde1;
229
+ background: #fff;
230
+ }
231
+
232
+ .w-sc-linkedin__text {
233
+ display: flex;
234
+ flex-direction: column;
235
+ padding: 8px 16px;
236
+ background: #f3f6f8;
237
+ font-family: -apple-system, system-ui, "Segoe UI", sans-serif;
238
+ }
239
+
240
+ .w-sc-linkedin__text strong {
241
+ overflow: hidden;
242
+ color: rgba(0, 0, 0, 0.9);
243
+ font-size: 14px;
244
+ font-weight: 600;
245
+ line-height: 20px;
246
+ text-overflow: ellipsis;
247
+ white-space: nowrap;
248
+ }
249
+
250
+ .w-sc-linkedin__text span {
251
+ color: rgba(0, 0, 0, 0.6);
252
+ font-size: 12px;
253
+ line-height: 16px;
254
+ }
255
+
256
+ /* Slack: barra gris a la izquierda, sitio, título en azul, descripción e imagen chica */
257
+ .w-sc-slack {
258
+ display: flex;
259
+ max-width: 600px;
260
+ padding: 0.5rem 0;
261
+ background: #fff;
262
+ color: #2c2d30;
263
+ font: 15px/22px system-ui, -apple-system, "Segoe UI", sans-serif;
264
+ }
265
+
266
+ .w-sc-slack__bar {
267
+ flex: none;
268
+ width: 4px;
269
+ border-radius: 8px;
270
+ background: #e8e8e8;
271
+ }
272
+
273
+ .w-sc-slack__body {
274
+ display: flex;
275
+ flex-direction: column;
276
+ padding: 0 12px;
277
+ }
278
+
279
+ .w-sc-slack__site {
280
+ color: #717274;
281
+ }
282
+
283
+ .w-sc-slack__title {
284
+ color: #0576b9;
285
+ }
286
+
287
+ .w-sc-slack__image {
288
+ width: 281px;
289
+ max-width: 100%;
290
+ margin-top: 0.5rem;
291
+ border-radius: 4px;
292
+ }
293
+
294
+ /* Pinterest: pin angosto, imagen con bordes redondeados y título en negrita */
295
+ .w-sc-pinterest {
296
+ width: 236px;
297
+ padding-bottom: 0.25rem;
298
+ border-radius: 8px;
299
+ background: #fff;
300
+ color: #333;
301
+ font: 12px/1.2 "Helvetica Neue", Helvetica, Arial, sans-serif;
302
+ }
303
+
304
+ .w-sc-pinterest__image {
305
+ border-radius: 8px;
306
+ }
307
+
308
+ .w-sc-pinterest__text {
309
+ display: flex;
310
+ gap: 0.5rem;
311
+ align-items: flex-start;
312
+ justify-content: space-between;
313
+ padding: 4px 8px;
314
+ }
315
+
316
+ .w-sc-pinterest__text strong {
317
+ display: -webkit-box;
318
+ overflow: hidden;
319
+ color: inherit;
320
+ font-size: 14px;
321
+ line-height: 16.8px;
322
+ -webkit-box-orient: vertical;
323
+ -webkit-line-clamp: 2;
324
+ }
325
+
326
+ .w-sc-pinterest__dots {
327
+ color: #8e8e8e;
328
+ letter-spacing: -1px;
329
+ }
@@ -0,0 +1,67 @@
1
+ // Previews de buscadores y redes del tab Promocionar: se redibujan con los valores actuales del formulario.
2
+ (() => {
3
+ const cards = document.querySelector('[data-social-cards]');
4
+ if (!cards) return;
5
+
6
+ const form = cards.closest('form');
7
+ const { defaultImage, parentUrl, pageUrl } = cards.dataset;
8
+ const warnings = cards.querySelector('[data-card-warnings]');
9
+ const field = (name) => form.elements.namedItem(name);
10
+ const setText = (selector, text) => {
11
+ for (const el of cards.querySelectorAll(selector)) el.textContent = text;
12
+ };
13
+
14
+ // Imagen elegida en og_image: se pide al admin el recorte que se comparte (1200×630). Mientras
15
+ // llega se muestra la miniatura del chooser, que ya está en el DOM.
16
+ const socialImages = new Map(); // id → URL del recorte
17
+ function chosenImage() {
18
+ const input = field('og_image');
19
+ if (!input?.value) return null;
20
+ const id = input.value;
21
+ if (!socialImages.has(id)) {
22
+ socialImages.set(id, null);
23
+ fetch(cards.dataset.imageUrl.replace('/0/', `/${id}/`))
24
+ .then((response) => response.json())
25
+ .then(({ url }) => {
26
+ socialImages.set(id, url);
27
+ render();
28
+ })
29
+ .catch(() => socialImages.delete(id));
30
+ }
31
+ return socialImages.get(id) ?? input.closest('.w-field__input')?.querySelector('img')?.src ?? null;
32
+ }
33
+
34
+ function pageAddress() {
35
+ const slug = field('slug')?.value.trim();
36
+ // La raíz del Site no tiene padre con URL: se muestra su propia URL
37
+ if (!parentUrl) return pageUrl;
38
+ return slug ? `${parentUrl.replace(/\/?$/, '/')}${slug}/` : parentUrl;
39
+ }
40
+
41
+ function render() {
42
+ // Mismas reglas que build_page_metadata en el backend
43
+ const title = field('seo_title')?.value.trim() || field('title')?.value.trim() || 'Sin título';
44
+ const description = field('search_description')?.value.trim() ?? '';
45
+ const image = chosenImage() || defaultImage;
46
+
47
+ setText('[data-card-title]', title);
48
+ setText('[data-card-description]', description);
49
+ setText('[data-card-url]', pageAddress());
50
+ for (const el of cards.querySelectorAll('[data-card-image]')) {
51
+ el.style.backgroundImage = image ? `url("${image}")` : '';
52
+ el.classList.toggle('is-empty', !image);
53
+ }
54
+
55
+ const missing = [];
56
+ if (!description) missing.push('sin descripción (buscadores y redes toman texto de la página)');
57
+ if (!image) missing.push('sin imagen (ni propia ni por defecto)');
58
+ warnings.textContent = missing.length ? `Atención: ${missing.join('; ')}.` : '';
59
+ }
60
+
61
+ form.addEventListener('input', render);
62
+ form.addEventListener('change', render);
63
+ // El chooser cambia la imagen sin disparar input/change en el formulario
64
+ const chooser = field('og_image')?.parentElement;
65
+ if (chooser) new MutationObserver(render).observe(chooser, { subtree: true, attributes: true, childList: true });
66
+ render();
67
+ })();