commit - 0bb0d5a98d03a51974236b5e5f6b8acc2d5118a0
commit + 51d10b6fa8a8b60a714a894943377221cc93e435
blob - bc9e19614b8f5e059f11d5fdd957c0e251747b72
blob + 63304b2e41e165570a3cebe1e71b59a1842dc2b6
--- nezzontli_ctl/preview.py
+++ nezzontli_ctl/preview.py
return {m.group(1): m.group(2) for m in _KV_RE.finditer(raw)}
+def split_paragraphs_with_offsets(chunk):
+ """[(párrafo, offset_en_chunk), ...] separados por 1+ líneas en blanco,
+ salteando los que quedan vacíos. Un tramo largo de texto plano (sin
+ imágenes/fórmulas en el medio) se anclaba como un solo widget gigante
+ para el sync del scroll — recién al llegar a la SIGUIENTE fórmula/
+ imagen (si la había) se volvía a mover el preview. Sin más fórmulas
+ después, se quedaba pegado ahí el resto del documento. Con un anchor
+ por párrafo, el sync tiene puntos de referencia repartidos parejo en
+ todo el texto, no solo donde hay imágenes/fórmulas."""
+ parts = []
+ cursor = 0
+ for m in re.finditer(r"\n{2,}", chunk):
+ para = chunk[cursor:m.start()]
+ if para.strip():
+ parts.append((para, cursor))
+ cursor = m.end()
+ tail = chunk[cursor:]
+ if tail.strip():
+ parts.append((tail, cursor))
+ return parts
+
+
def _strip_wrapper_macro(tex, name):
pattern = re.compile(r"\\" + name + r"\{([^{}]*)\}")
while pattern.search(tex):
blob - f2346d8626a6f324198ff677526b52e90a3cb0e2
blob + 449b1de96f14e7e358f0990b628145392eff0e71
--- nezzontli_ctl/screens/editor.py
+++ nezzontli_ctl/screens/editor.py
def _start_rebuild(self, text: str) -> None:
self.run_worker(self._rebuild_preview(text), exclusive=True, group="preview")
- async def _render_segment(self, segment) -> list:
- """Renderiza un segmento a 0+ widgets. Corre en paralelo para todos
+ async def _render_segment(self, segment, display_text: str) -> list:
+ """Renderiza un segmento a [(widget, línea_o_None), ...]. `None`
+ para widgets que no deben ser su propio punto de anclaje del sync
+ (ej. la caption debajo de una imagen). Corre en paralelo para todos
los segmentos vía asyncio.gather (cada render_math/resolve_image
pesado va a un hilo aparte, así una fórmula lenta no bloquea a las
demás ni a la UI)."""
kind = segment[0]
+
if kind == "text":
- _, chunk, _start = segment
- return [Markdown(chunk)] if chunk.strip() else []
+ _, chunk, start = segment
+ entries = []
+ for para, offset_in_chunk in preview_mod.split_paragraphs_with_offsets(chunk):
+ line = display_text.count("\n", 0, start + offset_in_chunk)
+ entries.append((Markdown(para), line))
+ return entries
if kind == "math":
- _, tex, is_display, _start = segment
+ _, tex, is_display, start = segment
+ line = display_text.count("\n", 0, start)
png_path = await asyncio.to_thread(preview_mod.render_math, tex, is_display)
if png_path is not None and AutoImage is not None:
- return [AutoImage(str(png_path), classes="preview-math")]
- return [Static(f"$$ {tex} $$", classes="preview-fallback")]
+ return [(AutoImage(str(png_path), classes="preview-math"), line)]
+ return [(Static(f"$$ {tex} $$", classes="preview-fallback"), line)]
if kind == "image":
- _, ref, alt, _start = segment
+ _, ref, alt, start = segment
+ line = display_text.count("\n", 0, start)
img_path = await asyncio.to_thread(preview_mod.resolve_image, ref)
if img_path is not None and AutoImage is not None:
- widgets = [AutoImage(str(img_path), classes="preview-image")]
+ entries = [(AutoImage(str(img_path), classes="preview-image"), line)]
else:
- widgets = [Static(f"[img no encontrada: {ref}]", classes="preview-fallback")]
+ entries = [(Static(f"[img no encontrada: {ref}]", classes="preview-fallback"), line)]
if alt:
- widgets.append(Static(alt, classes="preview-caption"))
- return widgets
+ entries.append((Static(alt, classes="preview-caption"), None))
+ return entries
return []
async def _rebuild_preview(self, text: str) -> None:
"""Tokeniza `text` y remonta el preview como una mezcla de widgets
Markdown (texto normal, con el LaTeX inline ya aproximado a Unicode
- en el lugar) e Image (fórmulas en bloque $$...$$ renderizadas a PNG,
- imágenes locales del repo o remotas por URL)."""
+ en el lugar, un widget por párrafo) e Image (fórmulas en bloque
+ $$...$$ renderizadas a PNG, imágenes locales del repo o remotas por
+ URL)."""
preview_scroll = self.query_one("#editor-preview-scroll", VerticalScroll)
display_text, segments = await asyncio.to_thread(preview_mod.tokenize, text)
)
rendered_groups = await asyncio.gather(
- *(self._render_segment(segment) for segment in segments)
+ *(self._render_segment(segment, display_text) for segment in segments)
)
widgets = []
anchors = []
- for segment, group in zip(segments, rendered_groups):
- line = display_text.count("\n", 0, segment[-1])
- for widget in group:
+ last_anchored_line = None
+ for group in rendered_groups:
+ for widget, line in group:
widgets.append(widget)
- anchors.append((line, widget))
+ # Un anchor por línea de origen distinta, apuntando al
+ # PRIMER widget que aparece ahí (ej. la imagen, no su
+ # caption debajo; o la primera foto de una gallery con 20
+ # fotos en la misma línea) — anclar cada widget hacía que
+ # el scroll saltara al ÚLTIMO en vez de al primero.
+ if line is not None and line != last_anchored_line:
+ anchors.append((line, widget))
+ last_anchored_line = line
await preview_scroll.remove_children()
await preview_scroll.mount_all(widgets or [Markdown("")])