Skip to content

Vista previa en vivo

Claude edited this page Oct 26, 2026 · 6 revisions

Vista previa en vivo

English · Español

El panel central muestra las hojas tal como se imprimirán: una página por hoja de anverso, cada carta colocada exactamente donde dice el PagePlan, con miniaturas que Rust renderiza bajo demanda por la misma tubería de imagen que el PDF. React solo pinta cajas y etiquetas <img>; nunca calcula la maquetación ni procesa píxeles.

Código fuente: src/features/preview/ (preview-pane.tsx, page-view.tsx, slot-view.tsx, slot-menu.tsx, acciones-carta.ts, arrastrar-ficheros.ts, mover-a-dialog.tsx, visibilidad.tsx), src/lib/image-url.ts, src/stores/plan-store.ts, src/stores/ui-store.ts, src-tauri/src/protocol.rs, src-tauri/src/render.rs.

Hojas

Cómo consume la vista previa el PagePlan

flowchart LR
    DS["deck-store (cards, settings)"] -->|"100 ms de espera"| PS["plan-store (compute_page_plan)"]
    PS -->|"plan.pages (solo anversos)"| PP["PreviewPane"]
    PP --> PV["PageView por hoja"]
    PV --> SV["SlotView por Slot"]
    SV -->|"urlMiniatura(...)"| IMG["img src = pbimg://.../r/..."]
    IMG --> PR["protocol.rs: Ruta::Render"]
    PR --> M["AppState::miniatura (caché renders)"]
    M --> PIPE["imaging::pipeline::render"]
Loading
  • PreviewPane lee usePlan (el plan y el último error), useDeck (cartas, ajustes, reverso por defecto) y useUi (zoom, selección, indicadores). Muestra solo plan.pages.filter(p => p.side === "front"). Las hojas de reversos existen en el plan (y en el PDF) pero no son páginas de la vista previa; «Ver reversos» sustituye, en cambio, la imagen de cada casilla de anverso por el reverso que llevará.
  • Escala: escala = zoom * PX_POR_MM, con PX_POR_MM = 96 / 25.4 (píxeles CSS por mm al 100 %). Una hoja mide widthMm * escala por heightMm * escala; una casilla se posiciona en trim.x * escala, trim.y * escala. No existe ninguna otra geometría en la pantalla. Véase Motor de maquetación para saber por qué.
  • Las celdas vacías de la última hoja salen de plan.cells (o de plan.bigCells si la última hoja es de cartas grandes) menos los valores index de casilla ya ocupados (vacias en preview-pane.tsx). Cada una es un botón que abre «Añadir carta» (la primera lleva además el rótulo); la carta va al final del mazo.
  • Los avisos del plan (dfcWithoutBack, missingDefaultBack, bigCardsDoNotFit) y el error del plan se muestran como una barra ámbar bajo la barra de herramientas. El tooltip de la barra de herramientas dice que la vista previa es aproximada y que el PDF es exacto.

Componentes de hoja y de casilla

PageView (page-view.tsx)

Un componente memo por hoja: una cabecera «Página n de N» con un botón de eliminar las cartas de la página, y un div de hoja blanca del tamaño del papel. Dentro: un SlotView por cada Slot (con clave cardId:face), las celdas vacías y, si cutGuides.enabled y la página tiene guías, una superposición SVG con viewBox en milímetros (0 0 widthMm heightMm) para que Page.guides se dibuje sin conversión; el grosor del trazo es widthPx * 25.4 / 96 mm. El elemento de la hoja se pasa a useVisible (más abajo); conImagen={visible} decide si las casillas montan <img>.

SlotView (slot-view.tsx)

Un componente memo. Estructura:

  1. Una caja posicionada de forma absoluta en el trim de la casilla (left/top/width/height = mm x escala). Es el asa de arrastre, el destino de soltado, el objetivo del clic, y lleva data-carta=<cardId> (sirve para encontrar la carta bajo el cursor cuando se sueltan ficheros desde el sistema operativo).
  2. Dentro, una caja de recorte en el rectángulo bleed de la casilla (desplazada respecto a trim), de modo que el sangrado compartido con las vecinas se corta exactamente como lo hace el PDF.
  3. Dentro de esa, el <img>, de tamaño (trim.w + 2*full) x (trim.h + 2*full) con full = slot.bleedFullMm, colocado en (trim.x - full - bleed.x). Esto refleja dibujar_casilla de pdf/compose.rs (el renderizado lleva el sangrado completo en los cuatro lados; el recorte conserva lo que permite el plan).
  4. Superposiciones en el marco del corte: anillo de selección, anillo de hover, anillo de soltado, insignias (arriba a la izquierda), icono de candado (arriba a la derecha) y una barra de información (nombre, set, número de coleccionista, idioma cuando no es inglés) si «Info de carta» está activada.

Selección de la imagen:

  • Normal: slot.image con slot.applyEdits ? card.edits : null.
  • «Ver reversos» activado y la casilla no es de reverso: la imagen es card.back.image para los reversos dfc/custom, y deck.defaultBack en otro caso; no se aplican los retoques; la miniatura se trata como un reverso (back: true, de modo que el oscurecido de bordes solo sigue a applyBacks). Se muestra en su sitio, sin reflejar; es solo una ayuda visual.
  • Las cartas en blanco (source.kind === "blank") muestran una caja discontinua con un más; un clic normal (sin Ctrl/Mayús) selecciona la casilla y abre «Añadir carta» para llenarla (anadirEnHueco). Una casilla sin imagen muestra un icono de «imagen apagada» con el nombre de la carta.
  • loading="lazy", decoding="async", draggable={false}.
  • Para las cartas diseñadas, slot.image es la miniatura guardada del diseño: diseno::vista::aplicar (src-tauri/src/diseno/vista.rs) pinta una miniatura de 1000 px, la codifica como JPEG de calidad 92 y la guarda en el almacén de blobs con ImageOrigin::Designed (guardar_miniatura). La vista previa muestra esa miniatura por la misma ruta /r/, mientras que el PDF vuelve a pintar desde el diseño al tamaño de impresión (Exportación, Capa de aplicación del diseñador).

Insignias (badges) y marcas

insignias(card) en src/lib/cartas.ts devuelve entradas { texto, tono, ayuda } que se pintan como pequeñas insignias de colores con un tooltip:

Insignia Tono Cuándo
etiqueta de la marca (VARIANTE / OPCIONAL / BANQUILLO) marca (violeta) DeckCard.marca está definida (MARCAS en cartas.ts)
PROXY / BORRADOR (BORRADOR en las cadenas fuente) info / aviso la carta tiene un diseño con estado ready / en otro caso (no hay insignias de imagen de Scryfall para las cartas diseñadas)
CUSTOM, CUSTOM <-> info imagen propia, con o sin carta de Scryfall vinculada
↩ EN aviso meta.langFallback: el idioma pedido no existía y se recurrió al inglés
LOWRES aviso imageStatus === "lowres"
PLACEHOLDER, MISSING error Scryfall no tiene una imagen real

Las marcas se ponen desde el menú de la casilla (submenú «Marca», marcarCartas). La impresión no se ve afectada; la regla cuentan excluye las cartas marcadas del tamaño del mazo, del recuento de copias, de las estadísticas y de la lista exportada (véase Estadísticas y Exportación). Las insignias se ocultan mientras «Ver reversos» está activado.

Desmontar las imágenes de las hojas ocultas

Decisión: las imágenes se montan solo para las hojas cercanas al viewport. Motivo: un mazo de 20 hojas son 180 imágenes grandes; mantenerlas todas decodificadas hace que WebKit retenga bitmaps enormes, y Rust renderizaría cada miniatura aunque nunca se viese. Al desmontar, WebKit puede liberar los bitmaps y Rust procesa solo lo visible (el comentario del protocolo dice lo mismo).

useVisible(el) en visibilidad.tsx: un IntersectionObserver con root = el elemento viewport del área de scroll y rootMargin: "100% 0px" (un viewport por encima y otro por debajo). La raíz viene de un contexto, RaizScroll, que PreviewPane rellena pasando onViewportChange={setViewport} al ScrollArea compartido (src/components/ui/scroll-area.tsx). Invariante: la raíz DEBE ser el viewport del ScrollArea, no el documento. El propio documento nunca hace scroll (solo lo hace el viewport del ScrollArea), así que con la raíz por defecto todas las hojas contarían siempre como visibles. Las cajas de las casillas (destinos de soltado) están siempre montadas; solo el <img> depende de conImagen.

El scroll debe usar el ScrollArea compartido (nunca overflow-auto), y se prueba en WebKitGTK, no en Chrome (Arquitectura del frontend).

Miniaturas: protocolo pbimg://

src/lib/image-url.ts construye las URL; src-tauri/src/protocol.rs las sirve (registrado con register_asynchronous_uri_scheme_protocol en src-tauri/src/lib.rs). La base es convertFileSrc("", "pbimg") (pbimg://localhost/ en Linux y macOS, http://pbimg.localhost/ en Windows), con pbimg://localhost/ como alternativa.

Ruta Constructor en image-url.ts Respuesta Se usa para
/r/<sha256>/<params base64url>/<width> urlMiniatura(blob, params, width) JPEG Miniatura procesada de un blob (Ruta::Render)
/t/<sha256 of URL> urlBusqueda(huella) JPEG/PNG Miniatura de un resultado de búsqueda de Scryfall descargada por Rust y cacheada en renders/t/
/d/<huella>/<width> urlDiseno PNG Vista previa de un diseño registrado (design_register)
/c/<huella>/<width> urlCapa PNG El diseño sin la ilustración (design_overlay)
/b/<sha256>/<side> urlArte(blob, 1000 | 2000) imagen Una ilustración reducida del almacén de blobs
/x/<pack>/<n> urlExtra PNG Una imagen extra de un pack de marcos descargable
/f/<huella>.woff2 (raizProtocolo()) letra Letras de los temas instalados

leer_ruta valida de forma estricta: los hashes deben ser de 64 caracteres hex (es_huella), los anchos deben ser uno de ANCHOS = [146, 244, 488, 672, 745, 1000] (reflejado en image-url.ts), los parámetros como máximo de 2048 caracteres. Una entrada incorrecta da 400, las rutas desconocidas 404, los blobs que faltan 404 y los demás errores 500. Las respuestas correctas llevan Cache-Control: max-age=31536000, immutable: la URL contiene todo lo que cambia la imagen, así que se puede cachear para siempre.

Parámetros de /r/

PreviewParams (src-tauri/src/render.rs, exportado a TS): { edits, edge, back, bleedMm, bleedMode, fillCorners, card }, en JSON y luego base64url, construido por parametros(settings, edits, isBack, bleedMm). Rust decide si el oscurecido de bordes se aplica a esta imagen (origen y bandera de reverso) con el mismo edge_for que la exportación, de modo que la pantalla y el PDF no pueden discrepar. SlotView sustituye card por el tamaño de corte propio de la casilla para que las cartas grandes se rendericen a su tamaño. fillCorners es #[serde(default)], así que las URL que no lo llevan siguen siendo válidas.

Lado del servidor

  1. El manejador atender de ESQUEMA toma un permiso de AppState.renders (un tokio::Semaphore dimensionado a available_parallelism), de modo que una pantalla llena de miniaturas no pueda saturar la máquina.
  2. AppState::miniatura(blob, params, width) (render.rs): lee el blob, construye spec_de_miniatura (Width(width), JPEG calidad 85, edits, bleedMm) y busca app_cache_dir/renders/<spec.key()>.jpg; si no está, ejecuta imaging::pipeline::render y escribe la caché de forma atómica.
  3. El ancho pedido es el ancho del corte; la imagen real lleva además los píxeles de sangrado (round(bleedMm * px_per_mm) por lado).

Por qué existe un directorio de caché: renders/ guarda solo datos derivados, así que se puede borrar sin pérdida; las imágenes de los mazos viven en el almacén de blobs (Almacenamiento y blobs).

Elegir el ancho

anchoPara(mmCarta, escala, tope) en preview-pane.tsx: píxeles necesarios = cardWidthMm * escala * devicePixelRatio; se elige la entrada más pequeña de ANCHOS que los cubra, limitada por la preferencia del usuario «Calidad de la vista previa» (ui-store.calidadVista, 488 por defecto, se cambia en el panel de la lista). Un ancho distinto es una clave de caché distinta, así que al hacer zoom se vuelve a renderizar al nuevo tamaño de forma perezosa.

Zoom

useUi.zoom (0,8 por defecto, limitado a ZOOM_MIN = 0.4 .. ZOOM_MAX = 2.5, redondeado a 2 decimales, persistido en localStorage bajo la clave de las preferencias). Controles: botones de menos/más, un campo numérico (porcentaje), volver al 100 % y Ctrl/Cmd + rueda (useZoomConRueda): pasos del 5 %, los clics de la rueda del ratón cuentan como un paso, los trackpads acumulan hasta 40 unidades; el listener de la rueda no es pasivo para poder cancelar el zoom propio del navegador, y los desplazamientos de scroll se corrigen en requestAnimationFrame para que el punto bajo el cursor se quede quieto.

Selección, teclado, arrastrar y soltar

  • La selección vive en ui-store (seleccion: string[], ancla). seleccionarConClic (acciones-carta.ts): el clic selecciona una; Ctrl/Cmd alterna; Shift selecciona el rango desde el ancla en el orden del mazo (con Ctrl, lo añade a la selección). El doble clic abre «Editar carta» (Diálogo Editar carta). Las listas de estadísticas también pueden seleccionar cartas (seleccionarEnDeck).
  • Teclado (useTeclado, se ignora dentro de campos, diálogos y menús): Supr elimina la selección, Ctrl+D duplica, Ctrl+A selecciona todo, Escape la vacía.
  • Menú contextual (slot-menu.tsx, SlotMenu): actúa sobre la selección si la carta pulsada está en ella; si no, selecciona solo esa carta. Elementos: en una única casilla en blanco, «Añadir carta de Scryfall…» el primero (anadirEnHueco); Duplicar; Editar carta; Cambiar variante y Cambiar carta (cartas de Scryfall); Diseñar proxy / Editar diseño / Quitar diseño; Vincular las seleccionadas… y vincular/revincular (cartas propias); Idioma de la imagen; Marca; Reemplazar imagen; Cambiar reverso (elegir imagen / usar el reverso del deck); Exportar imagen (Frente… / Reverso…, exportarImagen: diálogo de guardar con filtros PNG, JPEG, WebP, TIFF y BMP, manda la extensión; véase Exportación); Mover antes / después / a posición (MoverADialog); Fijar/Desfijar; Eliminar. El menú se pinta con ContenidoContextual (src/components/contenido-contextual.tsx), que ignora pointerup y click durante los primeros 250 ms tras abrirse: el menú sale bajo el cursor (y se desplaza para caber en la ventana), así que al soltar el botón derecho que lo abrió una opción recibía el pointerup y Radix lo tomaba por un clic, ejecutando una acción sin querer. src/components/ui/ es de solo lectura, de ahí el envoltorio de ContextMenuContent; el teclado no se ve afectado. La hora de apertura la toma una referencia estable de callback (useCallback) que se ejecuta cuando Radix monta el nodo del contenido: el envoltorio se pinta con el menú aún cerrado (solo el contenido de Radix espera a abrirse), así que marcar la hora al pintarse dejaba la pausa sin efecto, y un callback nuevo en cada pintado la volvería a marcar. El menú de deck de la Biblioteca y los menús de la mesa de juego usan el mismo envoltorio (Biblioteca de mazos, Mesa de juego).
  • Toda mutación pasa por el store del mazo (setCartas), así que se autoguarda y se puede deshacer; después el plan se recalcula. Las operaciones puras sobre listas están en src/lib/deck-ops.ts (moverGrupo, duplicar, quitar, huecos, actualizar).
  • Arrastrar y soltar entre cartas usa @dnd-kit/core con un PointerSensor que necesita 6 px de movimiento. Cada SlotView es a la vez arrastrable y soltable (id = id de carta); las celdas vacías son soltables con id vacia:<x>:<y>. Al soltar, soltarSobre(ids, destino) mueve el grupo arrastrado (toda la selección si la carta arrastrada está en ella, mediante objetivo): los arrastres hacia delante caen después del destino, los arrastres hacia atrás antes, y una celda vacía los envía al final. Un DragOverlay muestra la miniatura de la carta ligeramente girada, con una insignia de cantidad para los grupos. Deducido: con «Pares DFC intercalados», el anverso y su casilla dfcFace comparten el mismo id de carta, así que ambos comparten un único id de arrastre/soltado; compruébalo antes de fiarte de los arrastres en ese modo.
  • Ficheros soltados desde el sistema operativo (arrastrar-ficheros.ts, useArrastrarFicheros): el onDragDropEvent de Tauri da posiciones en píxeles físicos; soltarFicheros convierte por devicePixelRatio y encuentra la carta bajo el cursor mediante [data-carta]; si se sueltan sobre una carta, la primera imagen sustituye el anverso de esa carta (reemplazarConFichero -> blob_import_files, la carta pasa a ser propia, el recorte y la rotación se restablecen); si se sueltan en otro sitio, todas las imágenes se añaden como cartas propias. Solo cuentan png/jpg/jpeg/webp (EXTENSIONES_IMAGEN).
  • «Añadir página» añade una página de cartas en blanco (anadirPagina(cols*rows)); «Auto ordenar» llama a arrange_cards (Motor de maquetación); la barra de herramientas tiene además el filtro de DFC, «Info de carta», «Ver reversos», «Estadísticas» y «Añadir carta».

Tests

src/features/preview/__tests__/acciones-carta.test.ts (semántica de mover/soltar, deshacer, selección, el orden automático aplica el orden de Rust), arrastrar-ficheros.test.ts (filtrado de extensiones, conversión de DPR, comportamiento al soltar, limpieza), src/stores/__tests__/plan-store.test.ts; Rust: tests de render.rs y protocol.rs.

Trampas conocidas

  • La vista previa muestra solo las hojas de anverso del plan; no busques hojas de reversos en el DOM.
  • Nunca calcules posiciones en los componentes: si algo está en el sitio equivocado, arregla layout::plan.
  • Una nueva ruta en protocol.rs debe validar su entrada como las demás (forma del hash, tamaños permitidos) porque es accesible desde el webview.
  • Si cambias algo que altere los píxeles renderizados, sube PIPELINE_VERSION (Imagen y PDF); si no, renders/ sirve ficheros obsoletos y la cabecera immutable mantiene las URL antiguas cacheadas en el webview.

Véase también

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally