feat(apercu): rend ajustable le partage entre PDF et données
Le volet PDF était figé à 40 % de la fenêtre. C'est le vrai levier de lisibilité : élargir le volet augmente d'autant l'échelle du document, et vers 768 px l'ajustement à la largeur vaut 97 %, soit 100 % de fait. La poignée se glisse, la largeur est mémorisée et partagée entre l'écran d'extraction et celui d'édition, le double-clic revient au défaut — porté de 40 % à 50 % pour que la page tienne presque sans défiler. Un voile couvre la fenêtre pendant le glissement : sans lui l'<iframe> du PDF capte les évènements souris et le redimensionnement se fige dès qu'on la survole. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
117
frontend/src/components/VoletsAjustables.vue
Normal file
117
frontend/src/components/VoletsAjustables.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="conteneur" class="flex-1 flex overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="flex flex-col overflow-hidden"
|
||||||
|
:class="classeGauche"
|
||||||
|
:style="{ width: `${largeurGauche}%` }"
|
||||||
|
>
|
||||||
|
<slot name="gauche" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bande de saisie de 8 px pour un trait de 1 px : viser un filet d'un
|
||||||
|
pixel à la souris est pénible. Les marges négatives annulent sa
|
||||||
|
largeur dans le calcul du flux, pour que les deux volets se partagent
|
||||||
|
bien 100 % ; la bande déborde donc de 4 px sur chacun, d'où le z-10. -->
|
||||||
|
<div
|
||||||
|
class="group flex-shrink-0 w-2 -mx-1 z-10 flex justify-center cursor-col-resize"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
title="Glisser pour redimensionner — double-clic pour réinitialiser"
|
||||||
|
@mousedown.prevent="demarrerGlissement"
|
||||||
|
@dblclick="reinitialiser"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full w-px bg-gray-700 group-hover:bg-blue-500 transition-colors"
|
||||||
|
:class="{ '!bg-blue-500': glissement }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col overflow-hidden"
|
||||||
|
:class="classeDroite"
|
||||||
|
:style="{ width: `${100 - largeurGauche}%` }"
|
||||||
|
>
|
||||||
|
<slot name="droite" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Voile actif seulement pendant le glissement. Sans lui, l'<iframe> du
|
||||||
|
PDF avale les évènements souris dès que le curseur la survole et le
|
||||||
|
redimensionnement se fige en cours de route. -->
|
||||||
|
<div v-if="glissement" class="fixed inset-0 z-50 cursor-col-resize" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** Clé localStorage : la largeur choisie est propre à chaque écran. */
|
||||||
|
cleStockage: { type: String, required: true },
|
||||||
|
/** Largeur du volet gauche en %, au premier affichage et au double-clic. */
|
||||||
|
largeurDefaut: { type: Number, default: 40 },
|
||||||
|
largeurMin: { type: Number, default: 20 },
|
||||||
|
largeurMax: { type: Number, default: 80 },
|
||||||
|
/** Classes posées sur les conteneurs de volet (fond, bordure...). */
|
||||||
|
classeGauche: { type: String, default: '' },
|
||||||
|
classeDroite: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const conteneur = ref(null)
|
||||||
|
const glissement = ref(false)
|
||||||
|
|
||||||
|
/** Le stockage peut être indisponible (navigation privée, réglages) ou
|
||||||
|
contenir une valeur hors bornes si les props ont changé depuis. */
|
||||||
|
function largeurMemorisee() {
|
||||||
|
try {
|
||||||
|
const valeur = Number(localStorage.getItem(props.cleStockage))
|
||||||
|
if (Number.isFinite(valeur) && valeur >= props.largeurMin && valeur <= props.largeurMax) {
|
||||||
|
return valeur
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* stockage indisponible */
|
||||||
|
}
|
||||||
|
return props.largeurDefaut
|
||||||
|
}
|
||||||
|
|
||||||
|
const largeurGauche = ref(largeurMemorisee())
|
||||||
|
|
||||||
|
function memoriser(largeur) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(props.cleStockage, String(Math.round(largeur)))
|
||||||
|
} catch {
|
||||||
|
/* stockage indisponible */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function surDeplacement(evenement) {
|
||||||
|
const rect = conteneur.value?.getBoundingClientRect()
|
||||||
|
if (!rect?.width) return
|
||||||
|
const pourcentage = ((evenement.clientX - rect.left) / rect.width) * 100
|
||||||
|
largeurGauche.value = Math.min(props.largeurMax, Math.max(props.largeurMin, pourcentage))
|
||||||
|
}
|
||||||
|
|
||||||
|
function arreterGlissement() {
|
||||||
|
if (!glissement.value) return
|
||||||
|
glissement.value = false
|
||||||
|
document.removeEventListener('mousemove', surDeplacement)
|
||||||
|
document.removeEventListener('mouseup', arreterGlissement)
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
memoriser(largeurGauche.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function demarrerGlissement() {
|
||||||
|
glissement.value = true
|
||||||
|
document.addEventListener('mousemove', surDeplacement)
|
||||||
|
document.addEventListener('mouseup', arreterGlissement)
|
||||||
|
// Sans quoi le glissement sélectionne le texte des deux volets.
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function reinitialiser() {
|
||||||
|
largeurGauche.value = props.largeurDefaut
|
||||||
|
memoriser(props.largeurDefaut)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un démontage en plein glissement laisserait les écouteurs sur `document`.
|
||||||
|
onUnmounted(arreterGlissement)
|
||||||
|
</script>
|
||||||
@@ -68,9 +68,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Corps : Split view PDF + Données -->
|
<!-- Corps : Split view PDF + Données -->
|
||||||
<div v-else class="flex-1 flex overflow-hidden">
|
<VoletsAjustables
|
||||||
|
v-else
|
||||||
|
cle-stockage="apercuPdf.largeurVolet"
|
||||||
|
:largeur-defaut="50"
|
||||||
|
classe-droite="bg-gray-950"
|
||||||
|
>
|
||||||
<!-- Gauche : PDF Preview -->
|
<!-- Gauche : PDF Preview -->
|
||||||
<div class="w-2/5 border-r border-gray-700 flex flex-col">
|
<template #gauche>
|
||||||
<div class="flex-shrink-0 px-4 py-2 bg-gray-900 border-b border-gray-700">
|
<div class="flex-shrink-0 px-4 py-2 bg-gray-900 border-b border-gray-700">
|
||||||
<span class="card-title">Aperçu PDF</span>
|
<span class="card-title">Aperçu PDF</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,10 +96,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- Droite : Édition ou Tagging -->
|
<!-- Droite : Édition ou Tagging -->
|
||||||
<div class="w-3/5 flex flex-col bg-gray-950">
|
<template #droite>
|
||||||
<!-- Bandeau erreur de re-extraction -->
|
<!-- Bandeau erreur de re-extraction -->
|
||||||
<div v-if="reExtractError" class="flex-shrink-0 px-4 py-2 bg-red-500/10 border-b border-red-500/30 text-sm text-red-400">
|
<div v-if="reExtractError" class="flex-shrink-0 px-4 py-2 bg-red-500/10 border-b border-red-500/30 text-sm text-red-400">
|
||||||
{{ reExtractError }}
|
{{ reExtractError }}
|
||||||
@@ -138,8 +143,8 @@
|
|||||||
<span>{{ saveError }}</span>
|
<span>{{ saveError }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</VoletsAjustables>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -150,6 +155,7 @@ import PdfPreview from '../components/PdfPreview.vue'
|
|||||||
import JsonViewer from '../components/JsonViewer.vue'
|
import JsonViewer from '../components/JsonViewer.vue'
|
||||||
import TaggingStep from '../components/TaggingStep.vue'
|
import TaggingStep from '../components/TaggingStep.vue'
|
||||||
import ReExtractionDiff from '../components/ReExtractionDiff.vue'
|
import ReExtractionDiff from '../components/ReExtractionDiff.vue'
|
||||||
|
import VoletsAjustables from '../components/VoletsAjustables.vue'
|
||||||
import { computeExtractionDiff } from '../utils/diffExtraction.js'
|
import { computeExtractionDiff } from '../utils/diffExtraction.js'
|
||||||
import { cheminRetour } from '../utils/retour.js'
|
import { cheminRetour } from '../utils/retour.js'
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex-1 flex overflow-hidden">
|
<VoletsAjustables
|
||||||
|
cle-stockage="apercuPdf.largeurVolet"
|
||||||
|
:largeur-defaut="50"
|
||||||
|
classe-droite="bg-gray-950"
|
||||||
|
>
|
||||||
<!-- Left panel: Upload or PDF Preview -->
|
<!-- Left panel: Upload or PDF Preview -->
|
||||||
<div class="w-2/5 flex flex-col border-r border-gray-700">
|
<template #gauche>
|
||||||
<!-- Upload zone when no file -->
|
<!-- Upload zone when no file -->
|
||||||
<div v-if="!pdfFile" class="flex-1 flex items-center justify-center p-8 bg-gray-950">
|
<div v-if="!pdfFile" class="flex-1 flex items-center justify-center p-8 bg-gray-950">
|
||||||
<div class="max-w-md w-full">
|
<div class="max-w-md w-full">
|
||||||
@@ -36,10 +40,10 @@
|
|||||||
:file-name="pdfFile.name"
|
:file-name="pdfFile.name"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- Right panel: JSON Viewer or Tagging Step -->
|
<!-- Right panel: JSON Viewer or Tagging Step -->
|
||||||
<div class="w-3/5 flex flex-col bg-gray-950">
|
<template #droite>
|
||||||
<!-- Extract button bar -->
|
<!-- Extract button bar -->
|
||||||
<div v-if="pdfFile && !extractedData && !isExtracting && !showTagging" class="flex-shrink-0 p-4 bg-gray-900 border-b border-gray-700">
|
<div v-if="pdfFile && !extractedData && !isExtracting && !showTagging" class="flex-shrink-0 p-4 bg-gray-900 border-b border-gray-700">
|
||||||
<button
|
<button
|
||||||
@@ -111,8 +115,8 @@
|
|||||||
:is-loading="isExtracting"
|
:is-loading="isExtracting"
|
||||||
class="flex-1 overflow-hidden"
|
class="flex-1 overflow-hidden"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</VoletsAjustables>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -122,6 +126,7 @@ import { pendingFile } from '../store'
|
|||||||
import PdfPreview from '../components/PdfPreview.vue'
|
import PdfPreview from '../components/PdfPreview.vue'
|
||||||
import JsonViewer from '../components/JsonViewer.vue'
|
import JsonViewer from '../components/JsonViewer.vue'
|
||||||
import TaggingStep from '../components/TaggingStep.vue'
|
import TaggingStep from '../components/TaggingStep.vue'
|
||||||
|
import VoletsAjustables from '../components/VoletsAjustables.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
|||||||
81
frontend/tests/voletsAjustables.test.js
Normal file
81
frontend/tests/voletsAjustables.test.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// La largeur du volet PDF est un reglage que l'utilisateur pose une fois et
|
||||||
|
// retrouve ensuite : elle doit survivre au rechargement, rester dans des bornes
|
||||||
|
// utilisables, et ne jamais empecher l'ecran de s'afficher si le stockage
|
||||||
|
// du navigateur est indisponible.
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { createSSRApp } from 'vue'
|
||||||
|
import { renderToString } from 'vue/server-renderer'
|
||||||
|
|
||||||
|
import VoletsAjustables from '../src/components/VoletsAjustables.vue'
|
||||||
|
|
||||||
|
const CLE = 'apercuPdf.largeurVolet'
|
||||||
|
|
||||||
|
/** Remplace le stockage du navigateur, absent sous Node. */
|
||||||
|
function poserStockage(valeur, { leve = false } = {}) {
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: () => {
|
||||||
|
if (leve) throw new Error('stockage indisponible')
|
||||||
|
return valeur
|
||||||
|
},
|
||||||
|
setItem: () => {},
|
||||||
|
removeItem: () => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rendu(proprietes = {}) {
|
||||||
|
return renderToString(
|
||||||
|
createSSRApp(VoletsAjustables, { cleStockage: CLE, largeurDefaut: 50, ...proprietes })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete globalThis.localStorage
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('VoletsAjustables', () => {
|
||||||
|
it('applique la largeur par defaut quand rien n est memorise', async () => {
|
||||||
|
poserStockage(null)
|
||||||
|
const html = await rendu()
|
||||||
|
|
||||||
|
expect(html).toContain('width:50%')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reprend la largeur memorisee', async () => {
|
||||||
|
poserStockage('65')
|
||||||
|
const html = await rendu()
|
||||||
|
|
||||||
|
expect(html).toContain('width:65%')
|
||||||
|
// Les deux volets se partagent la totalite : la poignee ne prend pas de
|
||||||
|
// place dans le flux, sinon le second volet deborderait.
|
||||||
|
expect(html).toContain('width:35%')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignore une largeur memorisee hors bornes', async () => {
|
||||||
|
// Sans ce garde-fou, une valeur ecrite avant un changement de bornes
|
||||||
|
// resterait coincee et rendrait un volet inutilisable.
|
||||||
|
poserStockage('95')
|
||||||
|
|
||||||
|
expect(await rendu()).toContain('width:50%')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignore une valeur memorisee illisible', async () => {
|
||||||
|
poserStockage('beaucoup')
|
||||||
|
|
||||||
|
expect(await rendu()).toContain('width:50%')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("s'affiche quand même si le stockage est indisponible", async () => {
|
||||||
|
// Navigation privee ou stockage bloque : le reglage se perd, pas l'ecran.
|
||||||
|
poserStockage(null, { leve: true })
|
||||||
|
|
||||||
|
expect(await rendu()).toContain('width:50%')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expose une poignee de redimensionnement atteignable', async () => {
|
||||||
|
poserStockage(null)
|
||||||
|
const html = await rendu()
|
||||||
|
|
||||||
|
expect(html).toContain('role="separator"')
|
||||||
|
expect(html).toContain('cursor-col-resize')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user