discovery-media-player 0.1.142 → 0.1.143

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.
@@ -9,7 +9,7 @@ What a host application may call, what it must implement, and what will not chan
9
9
  version bump. If you are integrating the player, this page and [`API.md`](https://github.com/Juli1artha/discovery-media-player/blob/main/docs/API.md) are the two you
10
10
  need.
11
11
 
12
- ## Five rules
12
+ ## Six rules
13
13
 
14
14
  1. **One source of truth.** Fix the player in the player's repository, never in a host — not even
15
15
  in a host that once contained it. A fix written host-side is a copy, and copies drift.
@@ -23,6 +23,15 @@ need.
23
23
  the context and often the fix. Releasing stays with the maintainer because publishing a version
24
24
  decides deploy order. A local workaround is fine when you are blocked, on two conditions: report
25
25
  it the same day, and remove it when the release lands.
26
+ 6. **Describe what you do — including what you think is trivial.** Not *"report deviations"*: you
27
+ cannot know what one is, because that would mean knowing this page better than we do. ⚠️ The
28
+ over-specified sentence in [the `tts-cache` section](#four-things-that-will-bite) stood for
29
+ weeks, and it took a host mentioning its own naming **as a curiosity** for anyone to look. Its
30
+ own account of it, on 27/08: *"je ne l'ai décrite que parce que je citais `preview-fr-v2` comme
31
+ une curiosité, sans savoir que c'était un écart. Si j'avais su que votre page l'interdisait, je
32
+ me serais probablement conformé."* Complying would have orphaned **908 objects, permanently**.
33
+ A rule that asks you to spot the deviation cannot work; a rule that asks you to describe your
34
+ integration asks for nothing you do not already have. Boring descriptions are the useful ones.
26
35
 
27
36
  ## Identity card
28
37
 
@@ -99,6 +108,58 @@ different question when you want a real answer:
99
108
  GET /api/doc?contract=1&schema=1
100
109
  ```
101
110
 
111
+ ⚠️ **And if that is what you poll, the lazy verdicts are ones you will never see — which is the
112
+ whole point, and worth knowing before you read a warning aimed at somebody else.** `&schema=1`
113
+ probes every expectation *before answering*, then keeps the answers for the life of the process. So
114
+ its verdict is `complet`, or `indetermine` when the witness row itself does not answer — **never**
115
+ `non-sonde`, and `partiel` only in the window where two of them race. A plain `?contract=1` on that
116
+ same process reports `complet` afterwards too, because the probing already happened.
117
+
118
+ The consequence is a split an integrating host measured on 28/08, having read `complet` for a week
119
+ from a daily cron and put it down to luck: **the readers who need the "do not mistake this for a
120
+ regression" warning and the readers who ever meet it are disjoint.** Poll with `&schema=1` and you
121
+ cannot observe the degradation the warning describes; read `?contract=1` alone and you meet it
122
+ without having asked for a probe. Neither is a defect. But if you are checking that warning against
123
+ what you see, and you poll with `&schema=1`, **your instance is not the one it is about**.
124
+
125
+ ⚠️ **`complet` means complete *for the code you are running*, never complete for the repository —
126
+ and `connues` is there so you do not have to take that on trust.** The expectation list is compiled
127
+ into the player. A migration published after your version does not appear in it, is never probed,
128
+ and can therefore never turn `manquant` red. So a host who applies the schema **before** upgrading
129
+ the code — the safe order, the one we recommend — is exactly the host this green does not inform.
130
+
131
+ The STUDIO host measured it on both sides on 30/08: on `0.1.142` they applied migration 0024, re-read
132
+ the card, and got `attendues: 9 · sondees: 9 · complet · manquant: []` — word for word what they had
133
+ read *before* applying it. They could not watch 0024 leave a list it had never entered.
134
+
135
+ No version of this server can know a migration that postdates it. What it can do, and now does, is
136
+ say what it knows:
137
+
138
+ ```
139
+ "schema": {
140
+ "couvre": "colonnes-conditionnelles",
141
+ "attendues": 10, "sondees": 10, "verdict": "complet", "manquant": [],
142
+ "connues": ["supabase/migrations/0001-destinataire-atteste.sql", "…", "supabase/migrations/0024-rotation-en-direct.sql"]
143
+ }
144
+ ```
145
+
146
+ Read it this way, in this order:
147
+
148
+ 1. **Is the migration you care about in `connues`?** If not, this card cannot speak about it, whatever
149
+ `verdict` says. Upgrade the player, or check the database directly (below).
150
+ 2. **Only then** does `verdict` answer for it. `manquant` names its files in the same strings as
151
+ `connues`, so membership is a plain comparison and not an inference about our release history.
152
+
153
+ ⚠️ **To check a migration this player does not know about, ask the database, not the card.** The
154
+ query does not depend on which version you are running, which is the only case where you need an
155
+ answer:
156
+
157
+ ```sql
158
+ select column_name, data_type, is_nullable, column_default
159
+ from information_schema.columns
160
+ where table_name = 'doc_presentations' and column_name = 'view_rotation';
161
+ ```
162
+
102
163
  The three `presence*` fields report what the host has **observed**, not what it is configured to do:
103
164
 
104
165
  | field | meaning |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.142",
3
+ "version": "0.1.143",
4
4
  "description": "Self-hosted document viewer: per-recipient tracked links, reading analytics, live presentation. The core knows nothing about the application hosting it — everything it borrows arrives through an injected context.",
5
5
  "keywords": [
6
6
  "pdf-viewer",
@@ -6,4 +6,4 @@
6
6
  // Injecté par api/doc.js dans un <script nonce=…> de la visionneuse. Le code navigateur du
7
7
  // player vit en modules TypeScript testés sous player/src/ — c'est ici qu'il atterrit.
8
8
  "use strict";
9
- module.exports = { PLAYER_BROWSER_JS: "\"use strict\";var Player=(()=>{var C=Object.defineProperty,We=Object.defineProperties,Ve=Object.getOwnPropertyDescriptor,ze=Object.getOwnPropertyDescriptors,Fe=Object.getOwnPropertyNames,ve=Object.getOwnPropertySymbols;var we=Object.prototype.hasOwnProperty,Be=Object.prototype.propertyIsEnumerable;var Me=(e,n,t)=>n in e?C(e,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[n]=t,K=(e,n)=>{for(var t in n||(n={}))we.call(n,t)&&Me(e,t,n[t]);if(ve)for(var t of ve(n))Be.call(n,t)&&Me(e,t,n[t]);return e},xe=(e,n)=>We(e,ze(n));var M=(e,n)=>{for(var t in n)C(e,t,{get:n[t],enumerable:!0})},je=(e,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let o of Fe(n))!we.call(e,o)&&o!==t&&C(e,o,{get:()=>n[o],enumerable:!(r=Ve(n,o))||r.enumerable});return e};var Ge=e=>je(C({},\"__esModule\",{value:!0}),e);var Kn={};M(Kn,{bridge:()=>N,cadence:()=>L,chat:()=>U,live:()=>H,presentation:()=>D,presentationState:()=>V,tracking:()=>$,viewer:()=>W});var N={};M(N,{onHostMessage:()=>tn,onPlayerMessage:()=>nn,parseHostMessage:()=>Te,parsePlayerMessage:()=>Ee,sendToHost:()=>Xe,sendToPlayer:()=>Je,toWire:()=>Z});var Y=\"3dd-doc-\",Ke=/^[A-Za-z0-9_-]{1,64}$/,Ye=[\"close\",\"share\",\"embed-ready\",\"present-left\",\"present-denied\"],Ze=[\"embed-denied\"],qe=[\"present-invite\",\"present-handover\",\"present-switch\"],Qe=[\"handover-done\"];function Z(e){let n={type:Y+e.type};return\"slug\"in e&&e.slug&&(n.slug=e.slug),\"reason\"in e&&e.reason&&(n.reason=String(e.reason).slice(0,40)),n}function _e(e,n,t){if(!e||typeof e!=\"object\")return null;let r=e.type;if(typeof r!=\"string\"||!r.startsWith(Y))return null;let o=r.slice(Y.length);if(n.includes(o))return{type:o};if(Ze.includes(o)){let a=e.reason;return{type:o,reason:/^[a-z-]{1,40}$/.test(String(a||\"\"))?String(a):\"unknown\"}}if(!t.includes(o))return null;let s=e.slug;return typeof s!=\"string\"||!Ke.test(s)?null:{type:o,slug:s}}function Ee(e){return _e(e,Ye,qe)}function Te(e){return _e(e,Qe,[])}function Xe(e,n){try{let t=n||(typeof window!=\"undefined\"?window.parent:null);t&&t.postMessage(Z(e),\"*\")}catch(t){}}function Je(e,n){var t;try{(t=e==null?void 0:e.contentWindow)==null||t.postMessage(Z(n),\"*\")}catch(r){}}function en(e,n){var r;if(!e)return!0;let t=(r=e.contentWindow)!=null?r:e;return n===t}function nn(e,n,t){let r=n||(typeof window!=\"undefined\"?window:null);if(!r)return()=>{};let o=s=>{if(!en(t,s.source))return;let a=Ee(s.data);a&&e(a)};return r.addEventListener(\"message\",o),()=>r.removeEventListener(\"message\",o)}function tn(e,n){var s;let t=n||(typeof window!=\"undefined\"?window:null);if(!t)return()=>{};let r=(s=t.parent)!=null?s:null,o=a=>{if(!r||r===t||a.source!==r)return;let l=Te(a.data);l&&e(l)};return t.addEventListener(\"message\",o),()=>t.removeEventListener(\"message\",o)}var $={};M($,{createTracker:()=>pn});var L={};M(L,{PRESENTER_ACTIONS_PER_HOUR:()=>X,PRESENT_CACHE_MS:()=>an,PRESENT_QUOTA_MARGIN:()=>Ne,PRESENT_QUOTA_PER_HOUR:()=>un,PRESENT_READS_PER_HOUR:()=>Ie,PRESENT_READ_BURST:()=>Ae,PRESENT_READ_COALESCE_MS:()=>Ce,PRESENT_RESYNC_MS:()=>Re,PRESENT_SIGNAL_BUDGET_PER_HOUR:()=>sn,PRESENT_WRITE_TIMEOUT_MS:()=>J,READERS_PER_EGRESS:()=>O,RESYNC_READS_PER_HOUR:()=>Pe,SESSION_IDLE_MS:()=>Q,SESSION_INTERVAL_MS:()=>q,SESSION_QUOTA_PER_HOUR:()=>rn,SESSION_WRITES_PER_HOUR:()=>Se,VIEW_EVENTS_PER_READER_HOUR:()=>ke,VIEW_QUOTA_PER_HOUR:()=>on});var q=3e4,Se=Math.ceil(120),O=25,rn=Se*O,ke=720,on=ke*O,Q=18e4,Re=25e3,Pe=Math.ceil(36e5/Re),X=720,Ie=Pe+X,sn=X,Ae=20,J=1e4,Ce=400,an=Ce,Ne=4,un=(Ie+Ae)*O*Ne;var ln=[\"mousemove\",\"mousedown\",\"keydown\",\"wheel\",\"touchstart\",\"pointerdown\"];function cn(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(16));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}return Math.random().toString(36).slice(2)+Date.now().toString(36)}function dn(e,n){return t=>{try{let r=JSON.stringify(t),o=n.navigator;return o&&typeof o.sendBeacon==\"function\"?o.sendBeacon(e,new Blob([r],{type:\"application/json\"})):(n.fetch(e,{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:r,keepalive:!0}),!0)}catch(r){return!1}}}function pn(e={}){var be,ye;let n=e.win||(typeof window!=\"undefined\"?window:null),t=e.doc||n&&n.document,r=e.now||(()=>Date.now()),o=e.endpoint||\"/api/doc\",s=(be=e.idleMs)!=null?be:Q,a=(ye=e.sessionEveryMs)!=null?ye:3e4,l=e.slug||\"\",p=e.internal||null,g=e.sessionId||cn(),f=e.send||dn(o,n),c={},u=0,b=0,d=0,w=0,v=null,T=r(),S=!1,z=!1,I=[],De=()=>!!l||!!p,ce=i=>{if(!De())return!1;try{return f(i)!==!1}catch(m){return!1}},F=()=>!!t&&t.visibilityState===\"visible\"&&!S,de=i=>{let m=r()-i,y=Math.max(0,T-i)+s;return Math.max(0,Math.min(m,y))},B=()=>{v!=null&&u>0&&(c[u]=(c[u]||0)+de(v)/1e3),v=F()?r():null},pe=()=>{B(),v=null},j=()=>{v==null&&F()&&(v=r())},G=()=>{let i=0;for(let m in c)i+=c[m];return v!=null&&(i+=de(v)/1e3),Math.round(i)},fe=()=>{let i={};for(let m in c)i[m]=Math.round(c[m]);return i},k=()=>{T=r(),S&&(S=!1,j())},me=(i,m)=>{p||ce({slug:l,event:i,page:m||b,maxPage:d,seconds:G(),sessionId:g})},ge=\"\",A=()=>{B();let i=G(),m=`${i}|${d}|${w}`;if(m===ge)return;let y={event:\"session\",sessionId:g,numPages:w,maxPage:d,totalSeconds:i,pagesTime:fe()};p?(y.internal=!0,y.docId=p.docId,y.email=p.email,y.name=p.name,p.it&&(y.it=p.it)):y.slug=l,ce(y)&&(ge=m)},R=(i,m,y,he)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(m,y,he),I.push(()=>{i.removeEventListener(m,y,he)}))};return{sessionId:g,totalSeconds:G,maxPage:()=>d,pageTimes:fe,start(){if(z)return;z=!0,me(\"open\",1),u=1,T=r(),v=F()?r():null,R(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(pe(),A()):(k(),j())}),R(n,\"focus\",()=>{k(),j()});for(let y of ln)R(n,y,k,{passive:!0});R(e.scrollElement,\"scroll\",k,{passive:!0}),R(n,\"beforeunload\",A);let i=n.setInterval(()=>{!S&&r()-T>s&&(S=!0,pe())},5e3),m=n.setInterval(A,a);I.push(()=>{n.clearInterval(i),n.clearInterval(m)})},setPage(i){!i||i===b||(T=r(),B(),u=i,b=i,i>d&&(d=i,me(\"page\",i)))},setPageCount(i){w=i||0},noteActivity:k,flush:A,stop(){for(;I.length;){let i=I.pop();try{i==null||i()}catch(m){}}z=!1}}}var H={};M(H,{STORAGE_KEYS:()=>x,attendeeKey:()=>bn,authorToken:()=>yn,avatarHtml:()=>ee,canModerate:()=>oe,createBudget:()=>En,createFileEcritures:()=>Tn,createScheduler:()=>xn,escapeHtml:()=>h,fetchBorne:()=>_n,flattenPresence:()=>fn,formatMessageBody:()=>ne,initials:()=>Oe,isMentioned:()=>vn,isMine:()=>_,presenceId:()=>Sn,reactorId:()=>re,referenceAuteur:()=>hn,shouldNotify:()=>Mn,unreadLabel:()=>wn});var x={attendeeKey:\"3dd-present-attkey\",authorToken:\"3dd-present-authtoken\",chatMuted:\"3dd-present-mute\",presenceId:\"3dd-present-uid\"};function h(e){return String(e==null?\"\":e).replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\")}function Oe(e){let n=(e||\"?\").trim().split(/\\s+/);return(((n[0]||\"\")[0]||\"?\")+((n[1]||\"\")[0]||\"\")).toUpperCase()}function ee(e,n){return e?`<img src=\"${h(e)}\" alt=\"\">`:h(Oe(n))}function ne(e){let n=h(e);return n=n.replace(/(https?:\\/\\/[^\\s<]+)/g,t=>`<a href=\"${t}\" target=_blank rel=noreferrer class=cm-link>${t}</a>`),n=n.replace(/(^|\\s)@([\\p{L}0-9_'.-]+)/gu,(t,r,o)=>`${r}<span class=cm-mention>@${o}</span>`),n}function fn(e){let n=new Map;for(let t in e||{})for(let r of(e||{})[t]||[]){let o=String(r.uid||r.email||r.name||\"\").toLowerCase()||`_${t}_${n.size}`;(!n.get(o)||r.role===\"presenter\")&&n.set(o,r)}return[...n.values()]}function mn(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function gn(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function bn(e,n=\"anon\",t=te){try{if(!e)return`anon-${n}`;let r=e.getItem(x.attendeeKey);if(r)return r;let o=`anon-${t()}`;return e.setItem(x.attendeeKey,o),o}catch(r){return`anon-${n}`}}function te(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(24));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}try{console.warn(\"[player] crypto indisponible : jeton d'auteur affaibli\")}catch(n){}return Math.random().toString(36).slice(2)+Math.random().toString(36).slice(2)}function yn(e,n=te){let t=mn(e,x.authorToken);if(t)return t;let r=n();return gn(e,x.authorToken,r),r}async function hn(e,n){let t=n!==void 0?n:typeof globalThis!=\"undefined\"&&globalThis.crypto?globalThis.crypto.subtle:null;if(!t||!e)return\"\";try{let r=async o=>{let s=await t.digest(\"SHA-256\",new TextEncoder().encode(o));return Array.from(new Uint8Array(s),a=>a.toString(16).padStart(2,\"0\")).join(\"\")};return(await r(\"ref:\"+await r(e))).slice(0,16)}catch(r){return\"\"}}function re(e){return String(e&&e.ref||\"\")}function _(e,n){return!e||!n||!e.author_ref||!n.ref?!1:e.author_ref===n.ref}function oe(e){return!!(e&&e.role===\"presenter\")}function vn(e,n){if(!n||!n.name)return!1;let t=(n.name.split(/\\s+/)[0]||\"\").toLowerCase();return t?String(e&&e.body||\"\").toLowerCase().includes(`@${t}`)&&!_(e,n):!1}function Mn(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||_(n,t)?!1:o}function wn(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function xn(e,n={}){var b;let t=(b=n.minMs)!=null?b:300,r=n.now||(()=>Date.now()),o=n.setTimer||((d,w)=>setTimeout(d,w)),s=n.clearTimer||(d=>clearTimeout(d)),a=!1,l=!1,p=-1/0,g=null,f=!1;function c(){if(f||a){l=!0;return}let d=t-(r()-p);if(d>0){u(d);return}a=!0,l=!1,p=r();let w=!1;e(()=>{w||(w=!0,a=!1,l&&!f&&c())})}function u(d){f||g!==null||(g=o(()=>{g=null,c()},Math.max(0,d)))}return{signaler(){f||c()},maintenant(){f||(p=-1/0,c())},arreter(){f=!0,g!==null&&(s(g),g=null)}}}function _n(e,n,t=J,r={}){let o=r.fetch||(typeof fetch==\"function\"?fetch:void 0);if(!o)return Promise.reject(new Error(\"fetch indisponible\"));let s=r.setTimer||((u,b)=>setTimeout(u,b)),a=r.clearTimer||(u=>clearTimeout(u)),l=null;try{l=new AbortController}catch(u){}let p=l?xe(K({},n||{}),{signal:l.signal}):K({},n||{}),g=null,f=new Promise((u,b)=>{g=s(()=>{try{l==null||l.abort()}catch(d){}b(new Error(\"delai\"))},t)}),c=()=>{try{a(g)}catch(u){}};return Promise.race([o(e,p),f]).then(u=>(c(),u),u=>{throw c(),u})}function En(e){let n=e.now||(()=>Date.now()),t=Math.max(1,e.rafale),r=t,o=n();return{prendre(){let s=n();return r=Math.min(t,r+(s-o)*e.parHeure/36e5),o=s,r<1?!1:(r-=1,!0)},restants:()=>r}}function Tn(e={}){var g;let n=(g=e.minMs)!=null?g:0,t=e.now||(()=>Date.now()),r=e.setTimer||((f,c)=>setTimeout(f,c)),o=new Map,s=!1,a=-1/0,l=!1;function p(){if(s||o.size===0)return;let f=n-(t()-a);if(f>0){if(l)return;l=!0,r(()=>{l=!1,p()},f);return}let c=o.keys().next().value,u=o.get(c);o.delete(c),s=!0,a=t(),Promise.resolve().then(u.tache).then(b=>u.regler(b),()=>u.regler(null)).then(()=>{s=!1,p()})}return{poser(f,c){return new Promise(u=>{let b=o.get(f);o.set(f,{tache:c,regler:d=>{b&&b.regler(d),u(d)}}),p()})},enAttente:()=>o.size}}function Sn(e,n=te){try{if(e){let t=e.getItem(x.presenceId);if(t)return t;let r=`p-${n()}`;return e.setItem(x.presenceId,r),r}}catch(t){}return`p-${n()}`}var U={};M(U,{messageClassName:()=>Rn,renderActions:()=>He,renderAttachment:()=>$e,renderMessage:()=>kn,renderReactions:()=>Le});function Le(e,n){let t=e&&e.reactions||{},r=re(n),o=\"\";for(let s in t){let a=t[s]||[];if(!a.length)continue;let l=a.indexOf(r)>=0;o+=`<button class=\"re-chip${l?\" on\":\"\"}\" data-e=\"${h(s)}\">${h(s)} ${a.length}</button>`}return o}function $e(e){if(!e||!e.url)return\"\";let n=h(e.url);if(e.kind===\"image\")return`<a class=cm-att href=\"${n}\" target=_blank rel=noreferrer><img src=\"${n}\" alt=\"\" loading=lazy></a>`;let t=h(e.name||\"Document.pdf\");return`<a class=cm-att-pdf href=\"${n}\" target=_blank rel=noreferrer data-pdf=\"${n}\"><span class=cm-att-ph></span><span class=cm-pdflabel>${t}</span></a>`}function He(e,n){let t=_(e,n.me),r=`<button class=cm-react title=\"R\\xE9agir\">${n.reactIcon||\"\"}</button><button class=cm-reply title=\"R\\xE9pondre\">\\u21A9</button>`;return t&&(r+='<button class=cm-edit title=\"Modifier\">\\u270E</button>'),(t||oe(n.me))&&(r+='<button class=cm-del-btn title=\"Supprimer\">\\u{1F5D1}</button>'),r}function kn(e,n){if(e.deleted)return'<span class=a></span><span class=b><div class=\"txt cm-del\">Message supprim\\xE9</div></span>';let t=e.reply_to?`<div class=cm-q><b>${h(e.reply_name||\"\")}</b> ${h(e.reply_text||\"\")}</div>`:\"\",r=e.edited?\" <span class=cm-ed>(modifi\\xE9)</span>\":\"\",o=e.body?`<div class=txt>${ne(e.body)}${r}</div>`:\"\",s=e.is_presenter?\" <span class=tag>pr\\xE9sentateur</span>\":\"\";return`<span class=a>${ee(e.author_avatar,e.author_name)}</span><span class=b><div class=who><b>${h(e.author_name||\"Invit\\xE9\")}</b>${s}</div>`+t+o+$e(e.attachment)+`<div class=cm-re>${Le(e,n.me)}</div></span><span class=cm-act>${He(e,n)}</span>`}function Rn(e,n,t){return\"cm\"+(_(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var D={};M(D,{DEFAULT_CENTER:()=>ae,DEFAULT_ZOOM:()=>ue,MAP_TYPES:()=>P,cycleMapType:()=>Pn,initialMapContent:()=>An,mapTypeLabel:()=>In,sanitizeContent:()=>le});var P=[\"roadmap\",\"satellite\",\"hybrid\"],ae=[46.6,2.5],ue=6;function E(e){let n=Number(e);return Number.isFinite(n)?n:null}function se(e){if(!Array.isArray(e))return null;let n=E(e[0]),t=E(e[1]);return n!=null&&t!=null?[n,t]:null}var ie=(e,n,t)=>Math.max(n,Math.min(t,e));function le(e){if(!e||typeof e!=\"object\")return null;let n=e;if(n.kind===\"pdf\"||n.kind==null)return null;if(n.kind===\"streetview\"){let t=se(n.position);if(!t)return null;let r=n.pov&&typeof n.pov==\"object\"?n.pov:null,o=r?{heading:E(r.heading)||0,pitch:ie(E(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:ie(E(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:se(n.center)||[...ae],zoom:ie(Math.trunc(E(n.zoom)||ue),1,21),marker:se(n.marker),mapType:P.includes(n.mapType)?n.mapType:null,label:String(n.label||\"\").slice(0,160)||null}}function Pn(e){let n=P.indexOf(e);return P[(n+1)%P.length]}function In(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function An(){return{kind:\"map\",center:[...ae],zoom:ue,marker:null,mapType:null,label:null}}var W={};M(W,{CURRENT_PAGE_MARGIN:()=>Hn,DEFAULT_ASPECT:()=>On,MAX_ZOOM:()=>Nn,MIN_PAGE_WIDTH:()=>Ln,MIN_ZOOM:()=>Cn,PRERENDER_MARGIN:()=>$n,arrowState:()=>Wn,averageColor:()=>Fn,clampPage:()=>Ue,clampZoom:()=>Un,fitWidth:()=>zn,isImageDocument:()=>Bn,progressPercent:()=>Dn});var Cn=.5,Nn=3,On=1.35,Ln=280,$n=\"500px 0px\",Hn=\"-48% 0px -48% 0px\";function Un(e){let n=Math.round((Number(e)||0)*10)/10;return Math.max(.5,Math.min(3,n))}function Ue(e,n){return Math.max(1,Math.min(n||1,Number(e)||1))}function Dn(e,n){return n?Math.round(Ue(e,n)/n*100):0}function Wn(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var Vn=28;function zn(e){var a;let n=(a=e.zoom)!=null?a:1,t=Math.max(280,e.containerWidth||900)*n;if(!e.onePage)return t;let r=e.aspect||1.35,s=((e.containerHeight||600)-Vn-(e.overlap||0)-(e.reserve||0))/r;return s>120?Math.min(t,s):t}function Fn(e){let n=e.length/4;if(!n)return\"rgb(0,0,0)\";let t=0,r=0,o=0;for(let s=0;s<e.length;s+=4)t+=e[s],r+=e[s+1],o+=e[s+2];return`rgb(${Math.round(t/n)},${Math.round(r/n)},${Math.round(o/n)})`}function Bn(e,n){return/[.](png|jpe?g|webp|gif|avif)($|[?])/i.test(String(e||n||\"\"))}var V={};M(V,{presentationTransition:()=>jn,switchDocUrl:()=>Gn});function jn(e,n={}){if(!e)return[{kind:\"nothing\"}];if(e.active===!1)return[{kind:\"ended\"}];let t=le(e.content);if(t)return[{kind:\"show-map\",content:t}];let r=[{kind:\"leave-map\"}];return e.file_url&&e.file_url!==n.docUrl?(r.push({kind:\"switch-doc\",url:e.file_url,name:e.file_name||void 0,title:e.doc_title||void 0,updatedAt:e.updated_at||void 0}),r):(e.current_page&&r.push({kind:\"show-page\",page:e.current_page}),r)}function Gn(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return Ge(Kn);})();", SOURCE_HASH: "483d392a214f6bf8" };
9
+ module.exports = { PLAYER_BROWSER_JS: "\"use strict\";var Player=(()=>{var O=Object.defineProperty,Be=Object.defineProperties,je=Object.getOwnPropertyDescriptor,Ge=Object.getOwnPropertyDescriptors,Ke=Object.getOwnPropertyNames,Me=Object.getOwnPropertySymbols;var _e=Object.prototype.hasOwnProperty,Ye=Object.prototype.propertyIsEnumerable;var we=(e,n,t)=>n in e?O(e,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[n]=t,Z=(e,n)=>{for(var t in n||(n={}))_e.call(n,t)&&we(e,t,n[t]);if(Me)for(var t of Me(n))Ye.call(n,t)&&we(e,t,n[t]);return e},Ee=(e,n)=>Be(e,Ge(n));var M=(e,n)=>{for(var t in n)O(e,t,{get:n[t],enumerable:!0})},Ze=(e,n,t,r)=>{if(n&&typeof n==\"object\"||typeof n==\"function\")for(let o of Ke(n))!_e.call(e,o)&&o!==t&&O(e,o,{get:()=>n[o],enumerable:!(r=je(n,o))||r.enumerable});return e};var qe=e=>Ze(O({},\"__esModule\",{value:!0}),e);var Jn={};M(Jn,{bridge:()=>L,cadence:()=>H,chat:()=>W,live:()=>D,presentation:()=>V,presentationState:()=>F,tracking:()=>U,viewer:()=>z});var L={};M(L,{onHostMessage:()=>an,onPlayerMessage:()=>sn,parseHostMessage:()=>ke,parsePlayerMessage:()=>Se,sendToHost:()=>tn,sendToPlayer:()=>rn,toWire:()=>Q});var q=\"3dd-doc-\",Qe=/^[A-Za-z0-9_-]{1,64}$/,Xe=[\"close\",\"share\",\"embed-ready\",\"present-left\",\"present-denied\"],Je=[\"embed-denied\"],en=[\"present-invite\",\"present-handover\",\"present-switch\"],nn=[\"handover-done\"];function Q(e){let n={type:q+e.type};return\"slug\"in e&&e.slug&&(n.slug=e.slug),\"reason\"in e&&e.reason&&(n.reason=String(e.reason).slice(0,40)),n}function Te(e,n,t){if(!e||typeof e!=\"object\")return null;let r=e.type;if(typeof r!=\"string\"||!r.startsWith(q))return null;let o=r.slice(q.length);if(n.includes(o))return{type:o};if(Je.includes(o)){let a=e.reason;return{type:o,reason:/^[a-z-]{1,40}$/.test(String(a||\"\"))?String(a):\"unknown\"}}if(!t.includes(o))return null;let s=e.slug;return typeof s!=\"string\"||!Qe.test(s)?null:{type:o,slug:s}}function Se(e){return Te(e,Xe,en)}function ke(e){return Te(e,nn,[])}function tn(e,n){try{let t=n||(typeof window!=\"undefined\"?window.parent:null);t&&t.postMessage(Q(e),\"*\")}catch(t){}}function rn(e,n){var t;try{(t=e==null?void 0:e.contentWindow)==null||t.postMessage(Q(n),\"*\")}catch(r){}}function on(e,n){var r;if(!e)return!0;let t=(r=e.contentWindow)!=null?r:e;return n===t}function sn(e,n,t){let r=n||(typeof window!=\"undefined\"?window:null);if(!r)return()=>{};let o=s=>{if(!on(t,s.source))return;let a=Se(s.data);a&&e(a)};return r.addEventListener(\"message\",o),()=>r.removeEventListener(\"message\",o)}function an(e,n){var s;let t=n||(typeof window!=\"undefined\"?window:null);if(!t)return()=>{};let r=(s=t.parent)!=null?s:null,o=a=>{if(!r||r===t||a.source!==r)return;let u=ke(a.data);u&&e(u)};return t.addEventListener(\"message\",o),()=>t.removeEventListener(\"message\",o)}var U={};M(U,{createTracker:()=>bn});var H={};M(H,{PRESENTER_ACTIONS_PER_HOUR:()=>ee,PRESENT_CACHE_MS:()=>dn,PRESENT_QUOTA_MARGIN:()=>Le,PRESENT_QUOTA_PER_HOUR:()=>pn,PRESENT_READS_PER_HOUR:()=>Ce,PRESENT_READ_BURST:()=>Ne,PRESENT_READ_COALESCE_MS:()=>Oe,PRESENT_RESYNC_MS:()=>Ie,PRESENT_SIGNAL_BUDGET_PER_HOUR:()=>cn,PRESENT_WRITE_TIMEOUT_MS:()=>ne,READERS_PER_EGRESS:()=>$,RESYNC_READS_PER_HOUR:()=>Pe,SESSION_IDLE_MS:()=>J,SESSION_INTERVAL_MS:()=>X,SESSION_QUOTA_PER_HOUR:()=>un,SESSION_WRITES_PER_HOUR:()=>Re,VIEW_EVENTS_PER_READER_HOUR:()=>Ae,VIEW_QUOTA_PER_HOUR:()=>ln});var X=3e4,Re=Math.ceil(120),$=25,un=Re*$,Ae=720,ln=Ae*$,J=18e4,Ie=25e3,Pe=Math.ceil(36e5/Ie),ee=720,Ce=Pe+ee,cn=ee,Ne=20,ne=1e4,Oe=400,dn=Oe,Le=4,pn=(Ce+Ne)*$*Le;var mn=[\"mousemove\",\"mousedown\",\"keydown\",\"wheel\",\"touchstart\",\"pointerdown\"];function fn(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(16));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}return Math.random().toString(36).slice(2)+Date.now().toString(36)}function gn(e,n){return t=>{try{let r=JSON.stringify(t),o=n.navigator;return o&&typeof o.sendBeacon==\"function\"?o.sendBeacon(e,new Blob([r],{type:\"application/json\"})):(n.fetch(e,{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:r,keepalive:!0}),!0)}catch(r){return!1}}}function bn(e={}){var ye,xe;let n=e.win||(typeof window!=\"undefined\"?window:null),t=e.doc||n&&n.document,r=e.now||(()=>Date.now()),o=e.endpoint||\"/api/doc\",s=(ye=e.idleMs)!=null?ye:J,a=(xe=e.sessionEveryMs)!=null?xe:3e4,u=e.slug||\"\",p=e.internal||null,d=e.sessionId||fn(),m=e.send||gn(o,n),c={},l=0,b=0,f=0,w=0,v=null,S=r(),k=!1,B=!1,C=[],Fe=()=>!!u||!!p,pe=i=>{if(!Fe())return!1;try{return m(i)!==!1}catch(g){return!1}},j=()=>!!t&&t.visibilityState===\"visible\"&&!k,me=i=>{let g=r()-i,y=Math.max(0,S-i)+s;return Math.max(0,Math.min(g,y))},G=()=>{v!=null&&l>0&&(c[l]=(c[l]||0)+me(v)/1e3),v=j()?r():null},fe=()=>{G(),v=null},K=()=>{v==null&&j()&&(v=r())},Y=()=>{let i=0;for(let g in c)i+=c[g];return v!=null&&(i+=me(v)/1e3),Math.round(i)},ge=()=>{let i={};for(let g in c)i[g]=Math.round(c[g]);return i},R=()=>{S=r(),k&&(k=!1,K())},be=(i,g)=>{p||pe({slug:u,event:i,page:g||b,maxPage:f,seconds:Y(),sessionId:d})},he=\"\",N=()=>{G();let i=Y(),g=`${i}|${f}|${w}`;if(g===he)return;let y={event:\"session\",sessionId:d,numPages:w,maxPage:f,totalSeconds:i,pagesTime:ge()};p?(y.internal=!0,y.docId=p.docId,y.email=p.email,y.name=p.name,p.it&&(y.it=p.it)):y.slug=u,pe(y)&&(he=g)},A=(i,g,y,ve)=>{!i||typeof i.addEventListener!=\"function\"||(i.addEventListener(g,y,ve),C.push(()=>{i.removeEventListener(g,y,ve)}))};return{sessionId:d,totalSeconds:Y,maxPage:()=>f,pageTimes:ge,start(){if(B)return;B=!0,be(\"open\",1),l=1,S=r(),v=j()?r():null,A(t,\"visibilitychange\",()=>{t.visibilityState===\"hidden\"?(fe(),N()):(R(),K())}),A(n,\"focus\",()=>{R(),K()});for(let y of mn)A(n,y,R,{passive:!0});A(e.scrollElement,\"scroll\",R,{passive:!0}),A(n,\"beforeunload\",N);let i=n.setInterval(()=>{!k&&r()-S>s&&(k=!0,fe())},5e3),g=n.setInterval(N,a);C.push(()=>{n.clearInterval(i),n.clearInterval(g)})},setPage(i){!i||i===b||(S=r(),G(),l=i,b=i,i>f&&(f=i,be(\"page\",i)))},setPageCount(i){w=i||0},noteActivity:R,flush:N,stop(){for(;C.length;){let i=C.pop();try{i==null||i()}catch(g){}}B=!1}}}var D={};M(D,{STORAGE_KEYS:()=>_,attendeeKey:()=>vn,authorToken:()=>Mn,avatarHtml:()=>te,canModerate:()=>ie,createBudget:()=>Rn,createFileEcritures:()=>An,createScheduler:()=>Sn,escapeHtml:()=>x,fetchBorne:()=>kn,flattenPresence:()=>hn,formatMessageBody:()=>re,initials:()=>$e,isMentioned:()=>_n,isMine:()=>E,presenceId:()=>In,reactorId:()=>se,referenceAuteur:()=>wn,shouldNotify:()=>En,unreadLabel:()=>Tn});var _={attendeeKey:\"3dd-present-attkey\",authorToken:\"3dd-present-authtoken\",chatMuted:\"3dd-present-mute\",presenceId:\"3dd-present-uid\"};function x(e){return String(e==null?\"\":e).replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\")}function $e(e){let n=(e||\"?\").trim().split(/\\s+/);return(((n[0]||\"\")[0]||\"?\")+((n[1]||\"\")[0]||\"\")).toUpperCase()}function te(e,n){return e?`<img src=\"${x(e)}\" alt=\"\">`:x($e(n))}function re(e){let n=x(e);return n=n.replace(/(https?:\\/\\/[^\\s<]+)/g,t=>`<a href=\"${t}\" target=_blank rel=noreferrer class=cm-link>${t}</a>`),n=n.replace(/(^|\\s)@([\\p{L}0-9_'.-]+)/gu,(t,r,o)=>`${r}<span class=cm-mention>@${o}</span>`),n}function hn(e){let n=new Map;for(let t in e||{})for(let r of(e||{})[t]||[]){let o=String(r.uid||r.email||r.name||\"\").toLowerCase()||`_${t}_${n.size}`;(!n.get(o)||r.role===\"presenter\")&&n.set(o,r)}return[...n.values()]}function yn(e,n){try{return e?e.getItem(n):null}catch(t){return null}}function xn(e,n,t){try{e==null||e.setItem(n,t)}catch(r){}}function vn(e,n=\"anon\",t=oe){try{if(!e)return`anon-${n}`;let r=e.getItem(_.attendeeKey);if(r)return r;let o=`anon-${t()}`;return e.setItem(_.attendeeKey,o),o}catch(r){return`anon-${n}`}}function oe(){let e=typeof globalThis!=\"undefined\"?globalThis.crypto:void 0;if(e&&typeof e.randomUUID==\"function\")return e.randomUUID().replace(/-/g,\"\");if(e&&typeof e.getRandomValues==\"function\"){let n=e.getRandomValues(new Uint8Array(24));return Array.from(n,t=>t.toString(16).padStart(2,\"0\")).join(\"\")}try{console.warn(\"[player] crypto indisponible : jeton d'auteur affaibli\")}catch(n){}return Math.random().toString(36).slice(2)+Math.random().toString(36).slice(2)}function Mn(e,n=oe){let t=yn(e,_.authorToken);if(t)return t;let r=n();return xn(e,_.authorToken,r),r}async function wn(e,n){let t=n!==void 0?n:typeof globalThis!=\"undefined\"&&globalThis.crypto?globalThis.crypto.subtle:null;if(!t||!e)return\"\";try{let r=async o=>{let s=await t.digest(\"SHA-256\",new TextEncoder().encode(o));return Array.from(new Uint8Array(s),a=>a.toString(16).padStart(2,\"0\")).join(\"\")};return(await r(\"ref:\"+await r(e))).slice(0,16)}catch(r){return\"\"}}function se(e){return String(e&&e.ref||\"\")}function E(e,n){return!e||!n||!e.author_ref||!n.ref?!1:e.author_ref===n.ref}function ie(e){return!!(e&&e.role===\"presenter\")}function _n(e,n){if(!n||!n.name)return!1;let t=(n.name.split(/\\s+/)[0]||\"\").toLowerCase();return t?String(e&&e.body||\"\").toLowerCase().includes(`@${t}`)&&!E(e,n):!1}function En(e){let{msg:n,me:t,historyLoaded:r,chatHidden:o}=e;return!r||!n||n.deleted||E(n,t)?!1:o}function Tn(e){return!e||e<=0?\"\":e>9?\"9+\":String(e)}function Sn(e,n={}){var b;let t=(b=n.minMs)!=null?b:300,r=n.now||(()=>Date.now()),o=n.setTimer||((f,w)=>setTimeout(f,w)),s=n.clearTimer||(f=>clearTimeout(f)),a=!1,u=!1,p=-1/0,d=null,m=!1;function c(){if(m||a){u=!0;return}let f=t-(r()-p);if(f>0){l(f);return}a=!0,u=!1,p=r();let w=!1;e(()=>{w||(w=!0,a=!1,u&&!m&&c())})}function l(f){m||d!==null||(d=o(()=>{d=null,c()},Math.max(0,f)))}return{signaler(){m||c()},maintenant(){m||(p=-1/0,c())},arreter(){m=!0,d!==null&&(s(d),d=null)}}}function kn(e,n,t=ne,r={}){let o=r.fetch||(typeof fetch==\"function\"?fetch:void 0);if(!o)return Promise.reject(new Error(\"fetch indisponible\"));let s=r.setTimer||((l,b)=>setTimeout(l,b)),a=r.clearTimer||(l=>clearTimeout(l)),u=null;try{u=new AbortController}catch(l){}let p=u?Ee(Z({},n||{}),{signal:u.signal}):Z({},n||{}),d=null,m=new Promise((l,b)=>{d=s(()=>{try{u==null||u.abort()}catch(f){}b(new Error(\"delai\"))},t)}),c=()=>{try{a(d)}catch(l){}};return Promise.race([o(e,p),m]).then(l=>(c(),l),l=>{throw c(),l})}function Rn(e){let n=e.now||(()=>Date.now()),t=Math.max(1,e.rafale),r=t,o=n();return{prendre(){let s=n();return r=Math.min(t,r+(s-o)*e.parHeure/36e5),o=s,r<1?!1:(r-=1,!0)},restants:()=>r}}function An(e={}){var d;let n=(d=e.minMs)!=null?d:0,t=e.now||(()=>Date.now()),r=e.setTimer||((m,c)=>setTimeout(m,c)),o=new Map,s=!1,a=-1/0,u=!1;function p(){if(s||o.size===0)return;let m=n-(t()-a);if(m>0){if(u)return;u=!0,r(()=>{u=!1,p()},m);return}let c=o.keys().next().value,l=o.get(c);o.delete(c),s=!0,a=t(),Promise.resolve().then(l.tache).then(b=>l.regler(b),()=>l.regler(null)).then(()=>{s=!1,p()})}return{poser(m,c){return new Promise(l=>{let b=o.get(m);o.set(m,{tache:c,regler:f=>{b&&b.regler(f),l(f)}}),p()})},enAttente:()=>o.size}}function In(e,n=oe){try{if(e){let t=e.getItem(_.presenceId);if(t)return t;let r=`p-${n()}`;return e.setItem(_.presenceId,r),r}}catch(t){}return`p-${n()}`}var W={};M(W,{messageClassName:()=>Cn,renderActions:()=>De,renderAttachment:()=>Ue,renderMessage:()=>Pn,renderReactions:()=>He});function He(e,n){let t=e&&e.reactions||{},r=se(n),o=\"\";for(let s in t){let a=t[s]||[];if(!a.length)continue;let u=a.indexOf(r)>=0;o+=`<button class=\"re-chip${u?\" on\":\"\"}\" data-e=\"${x(s)}\">${x(s)} ${a.length}</button>`}return o}function Ue(e){if(!e||!e.url)return\"\";let n=x(e.url);if(e.kind===\"image\")return`<a class=cm-att href=\"${n}\" target=_blank rel=noreferrer><img src=\"${n}\" alt=\"\" loading=lazy></a>`;let t=x(e.name||\"Document.pdf\");return`<a class=cm-att-pdf href=\"${n}\" target=_blank rel=noreferrer data-pdf=\"${n}\"><span class=cm-att-ph></span><span class=cm-pdflabel>${t}</span></a>`}function De(e,n){let t=E(e,n.me),r=`<button class=cm-react title=\"R\\xE9agir\">${n.reactIcon||\"\"}</button><button class=cm-reply title=\"R\\xE9pondre\">\\u21A9</button>`;return t&&(r+='<button class=cm-edit title=\"Modifier\">\\u270E</button>'),(t||ie(n.me))&&(r+='<button class=cm-del-btn title=\"Supprimer\">\\u{1F5D1}</button>'),r}function Pn(e,n){if(e.deleted)return'<span class=a></span><span class=b><div class=\"txt cm-del\">Message supprim\\xE9</div></span>';let t=e.reply_to?`<div class=cm-q><b>${x(e.reply_name||\"\")}</b> ${x(e.reply_text||\"\")}</div>`:\"\",r=e.edited?\" <span class=cm-ed>(modifi\\xE9)</span>\":\"\",o=e.body?`<div class=txt>${re(e.body)}${r}</div>`:\"\",s=e.is_presenter?\" <span class=tag>pr\\xE9sentateur</span>\":\"\";return`<span class=a>${te(e.author_avatar,e.author_name)}</span><span class=b><div class=who><b>${x(e.author_name||\"Invit\\xE9\")}</b>${s}</div>`+t+o+Ue(e.attachment)+`<div class=cm-re>${He(e,n.me)}</div></span><span class=cm-act>${De(e,n)}</span>`}function Cn(e,n,t){return\"cm\"+(E(e,n)?\" me\":\"\")+(e.deleted?\" isdel\":\"\")+(t?\" mentioned\":\"\")}var V={};M(V,{DEFAULT_CENTER:()=>le,DEFAULT_ZOOM:()=>ce,MAP_TYPES:()=>I,cycleMapType:()=>Nn,initialMapContent:()=>Ln,mapTypeLabel:()=>On,sanitizeContent:()=>de});var I=[\"roadmap\",\"satellite\",\"hybrid\"],le=[46.6,2.5],ce=6;function T(e){let n=Number(e);return Number.isFinite(n)?n:null}function ae(e){if(!Array.isArray(e))return null;let n=T(e[0]),t=T(e[1]);return n!=null&&t!=null?[n,t]:null}var ue=(e,n,t)=>Math.max(n,Math.min(t,e));function de(e){if(!e||typeof e!=\"object\")return null;let n=e;if(n.kind===\"pdf\"||n.kind==null)return null;if(n.kind===\"streetview\"){let t=ae(n.position);if(!t)return null;let r=n.pov&&typeof n.pov==\"object\"?n.pov:null,o=r?{heading:T(r.heading)||0,pitch:ue(T(r.pitch)||0,-90,90)}:{heading:0,pitch:0};return{kind:\"streetview\",position:t,pov:o,zoom:ue(T(n.zoom)||1,0,5)}}return n.kind!==\"map\"?null:{kind:\"map\",center:ae(n.center)||[...le],zoom:ue(Math.trunc(T(n.zoom)||ce),1,21),marker:ae(n.marker),mapType:I.includes(n.mapType)?n.mapType:null,label:String(n.label||\"\").slice(0,160)||null}}function Nn(e){let n=I.indexOf(e);return I[(n+1)%I.length]}function On(e){return e===\"roadmap\"?\"\\u{1F6F0} Satellite\":e===\"satellite\"?\"\\u{1F5FA} Hybride\":\"\\u{1F5FA} Plan\"}function Ln(){return{kind:\"map\",center:[...le],zoom:ce,marker:null,mapType:null,label:null}}var z={};M(z,{CURRENT_PAGE_MARGIN:()=>Vn,DEFAULT_ASPECT:()=>Un,MAX_ZOOM:()=>Hn,MIN_PAGE_WIDTH:()=>Dn,MIN_ZOOM:()=>$n,PRERENDER_MARGIN:()=>Wn,ROTATIONS:()=>Zn,ancrageApresZoom:()=>qn,arrowState:()=>Bn,aspectApresRotation:()=>ze,averageColor:()=>Kn,clampPage:()=>Ve,clampZoom:()=>zn,fitWidth:()=>Gn,isImageDocument:()=>Yn,progressPercent:()=>Fn,rotationEffective:()=>P});var $n=.5,Hn=3,Un=1.35,Dn=280,Wn=\"500px 0px\",Vn=\"-48% 0px -48% 0px\";function zn(e){let n=Math.round((Number(e)||0)*10)/10;return Math.max(.5,Math.min(3,n))}function Ve(e,n){return Math.max(1,Math.min(n||1,Number(e)||1))}function Fn(e,n){return n?Math.round(Ve(e,n)/n*100):0}function Bn(e,n){return{prevDisabled:e<=1,nextDisabled:!!n&&e>=n}}var jn=28;function Gn(e){var a;let n=(a=e.zoom)!=null?a:1,t=Math.max(280,e.containerWidth||900)*n;if(!e.onePage)return t;let r=ze(e.aspect,e.rotation),s=((e.containerHeight||600)-jn-(e.overlap||0)-(e.reserve||0))/r;return s>120?Math.min(t,s):t}function Kn(e){let n=e.length/4;if(!n)return\"rgb(0,0,0)\";let t=0,r=0,o=0;for(let s=0;s<e.length;s+=4)t+=e[s],r+=e[s+1],o+=e[s+2];return`rgb(${Math.round(t/n)},${Math.round(r/n)},${Math.round(o/n)})`}function Yn(e,n){return/[.](png|jpe?g|webp|gif|avif)($|[?])/i.test(String(e||n||\"\"))}var Zn=[0,90,180,270],h=(e,n=0)=>typeof e==\"number\"&&Number.isFinite(e)?e:n;function P(e,n){return(Math.round((h(e)+h(n))/90)%4+4)%4*90}function ze(e,n){let t=h(e,0)>0?e:1.35,r=P(0,n);return r===90||r===270?1/t:t}function We(e,n,t,r,o,s,a){let u=Math.min(Math.max(0,o),Math.max(0,r)),p=u+Math.max(0,r-u)*a,d=Math.min(Math.max(0,s),Math.max(0,r)),m=r-d>0?(p-d)/(r-d):1,c=Math.max(0,(t-r)/2),l=n+e-c,b=d+Math.max(0,l-d)*m-n;return Math.min(Math.max(0,b),Math.max(0,p-t))}function qn(e){let n=h(e.zoomAvant,0),t=h(e.zoomApres,0),r={x:h(e.defilement&&e.defilement.x),y:h(e.defilement&&e.defilement.y)};if(n<=0||t<=0)return r;let o=t/n,s=e.fixe||{largeur:0,hauteur:0},a=e.fixeDebut||{largeur:0,hauteur:0};return{x:We(r.x,h(e.point&&e.point.x),h(e.cadre&&e.cadre.largeur),h(e.contenu&&e.contenu.largeur),h(s.largeur),h(a.largeur),o),y:We(r.y,h(e.point&&e.point.y),h(e.cadre&&e.cadre.hauteur),h(e.contenu&&e.contenu.hauteur),h(s.hauteur),h(a.hauteur),o)}}var F={};M(F,{presentationTransition:()=>Qn,switchDocUrl:()=>Xn});function Qn(e,n={}){if(!e)return[{kind:\"nothing\"}];if(e.active===!1)return[{kind:\"ended\"}];let t=de(e.content);if(t)return[{kind:\"show-map\",content:t}];let r=[{kind:\"leave-map\"}],o=P(0,e.view_rotation);return o!==P(0,n.rotation)&&r.push({kind:\"rotate\",rotation:o}),e.file_url&&e.file_url!==n.docUrl?(r.push({kind:\"switch-doc\",url:e.file_url,name:e.file_name||void 0,title:e.doc_title||void 0,updatedAt:e.updated_at||void 0}),r):(e.current_page&&r.push({kind:\"show-page\",page:e.current_page}),r)}function Xn(e,n){let t=encodeURIComponent(n||String(Date.now()));return`${e}${e.includes(\"?\")?\"&\":\"?\"}v=${t}`}return qe(Jn);})();", SOURCE_HASH: "720cf1b3ec849d63" };
@@ -20,7 +20,7 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
20
20
  const presenter = esc(pres.presenter_name || "");
21
21
  const logo = esc(logoUrl || "");
22
22
  const fileUrl = `/api/doc?present=${encodeURIComponent(pres.slug)}&file=1`;
23
- const cfg = jsonPourScript({ fileUrl, docUrl: pres.file_url, pdfjs: PDFJS, pdfjsWorker: PDFJS_WORKER, slug: pres.slug, fileName: pres.file_name || "", page: pres.current_page || 1, active: pres.active !== false, content: pres.content || null, supaUrl, supaKey, title: pres.doc_title || pres.file_name || "Document" });
23
+ const cfg = jsonPourScript({ fileUrl, docUrl: pres.file_url, pdfjs: PDFJS, pdfjsWorker: PDFJS_WORKER, slug: pres.slug, fileName: pres.file_name || "", page: pres.current_page || 1, rotation: pres.view_rotation || 0, active: pres.active !== false, content: pres.content || null, supaUrl, supaKey, title: pres.doc_title || pres.file_name || "Document" });
24
24
  return `<!doctype html><html lang=fr><head><meta charset=utf-8>
25
25
  <meta name=viewport content="width=device-width,initial-scale=1,maximum-scale=3">
26
26
  <meta name=robots content="noindex,nofollow">
@@ -95,7 +95,7 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
95
95
  <script nonce="${nonce}">
96
96
  (function(){
97
97
  var CFG=${cfg};
98
- var PDF=null, total=0, cur=CFG.page||1, ready=false;
98
+ var PDF=null, total=0, cur=CFG.page||1, rot=CFG.rotation||0, ready=false;
99
99
  // ⚠️ DEUX GÉNÉRATIONS, PARCE QU'IL Y A DEUX COURSES DISTINCTES (P1 audit externe).
100
100
  //
101
101
  // docGen : le présentateur change de document pendant qu'un chargement est en cours. Le
@@ -137,9 +137,13 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
137
137
  // arrivait : ce résultat n'est plus celui qu'on attend. On ne le pose pas.
138
138
  if(monDoc!==docGen || monRendu!==renderGen) return;
139
139
  var availW=Math.max(120, stage.clientWidth-40), availH=Math.max(120, stage.clientHeight-40);
140
- var v1=page.getViewport({scale:1});
140
+ // ⚠️ ON COMPOSE, ON N'ÉCRASE PAS — même piège que dans la visionneuse. getViewport prend par
141
+ // défaut la rotation du fichier ; passer une valeur absolue coucherait un document numérisé qui
142
+ // était droit. La composition vit dans src/viewer.ts, où elle est éprouvée.
143
+ var rotEff=Player.viewer.rotationEffective(page.rotate,rot);
144
+ var v1=page.getViewport({scale:1,rotation:rotEff});
141
145
  var scale=Math.min(availW/v1.width, availH/v1.height); // fit entier → une page à l'écran
142
- var dpr=window.devicePixelRatio||1, vp=page.getViewport({scale:scale});
146
+ var dpr=window.devicePixelRatio||1, vp=page.getViewport({scale:scale,rotation:rotEff});
143
147
  var cv=document.createElement('canvas'); cv.width=Math.floor(vp.width*dpr); cv.height=Math.floor(vp.height*dpr);
144
148
  cv.setAttribute('role','img'); cv.setAttribute('aria-label','Page '+n+(total>1?' sur '+total:''));
145
149
  cv.style.width=vp.width+'px'; cv.style.height=vp.height+'px';
@@ -282,10 +286,10 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
282
286
  if(!row) return;
283
287
  // La table et la diffusion portent la même vérité : sans cette garde, chaque changement de
284
288
  // page serait rendu deux fois (scintillement), et chaque carte ré-ouverte inutilement.
285
- var sig=''; try{ sig=JSON.stringify([row.active,row.current_page,row.file_url,row.content]); }catch(e){ sig=String(Math.random()); }
289
+ var sig=''; try{ sig=JSON.stringify([row.active,row.current_page,row.file_url,row.content,row.view_rotation]); }catch(e){ sig=String(Math.random()); }
286
290
  if(sig===_etatVu) return;
287
291
  _etatVu=sig;
288
- var actions=Player.presentationState.presentationTransition(row,{docUrl:CFG.docUrl});
292
+ var actions=Player.presentationState.presentationTransition(row,{docUrl:CFG.docUrl,rotation:rot});
289
293
  for(var i=0;i<actions.length;i++){ var a=actions[i];
290
294
  if(a.kind==='ended'){ ended(); return; }
291
295
  if(a.kind==='show-map'){ if(window.Map3DD){ if(a.content.kind==='streetview') Map3DD.enterSV(a.content,false); else Map3DD.enter(a.content,false); } return; }
@@ -293,6 +297,9 @@ function presentHtml(pres, nonce, logoUrl, supaUrl, supaKey) {
293
297
  // switchDoc pose LUI-MÊME docUrl/fileName/titre : les poser ici, en partie, était
294
298
  // exactement ce qui laissait fileName en arrière.
295
299
  if(a.kind==='switch-doc'){ switchDoc(row); return; }
300
+ // ⚠️ AVANT la page, et c'est l'ordre que presentationTransition garantit : tourner après avoir
301
+ // rendu ferait rendre deux fois, la première fois pour rien.
302
+ if(a.kind==='rotate'){ rot=a.rotation; renderGen++; show(cur); }
296
303
  if(a.kind==='show-page'){ show(a.page); }
297
304
  }
298
305
  }
@@ -134,7 +134,7 @@ function viewerHtml(share, nonce, logoUrl, pitch) {
134
134
  cherche une sortie. Sans bordure : c'est une sortie, pas une action de plus. Elle
135
135
  reste visible sous 520px, où le titre s'efface (.bar>b{display:none}). */
136
136
  .barx{border-color:transparent;margin-right:-4px;flex:none}
137
- .scroll{flex:1;overflow:auto;position:relative}
137
+ .scroll{flex:1;overflow:auto;position:relative;touch-action:pan-x pan-y} /* ⚠️ pan-x pan-y, PAS auto : le navigateur garde le défilement et NOUS rendons le pincement. Sans cette ligne le geste à deux doigts est happé par le zoom de page avant qu'aucun événement de pointeur ne nous parvienne. Le zoom navigateur reste disponible SUR LE RESTE de l'interface — le retirer partout serait une régression d'accessibilité pour qui grossit le chrome, pas le document. */
138
138
  #pages{display:flex;flex-direction:column;align-items:center;gap:16px;width:max-content;min-width:100%;margin:0 auto;padding:22px 14px}
139
139
  .page{position:relative;background:#fff;box-shadow:0 6px 22px #0006;border-radius:3px}
140
140
  .page canvas{display:block;border-radius:3px}
@@ -216,7 +216,7 @@ ${LEGAL_CSS}
216
216
  .barmenu button{display:block;width:100%;text-align:left;border:0;background:transparent;font:inherit;font-size:13.5px;color:#1c1c1c;padding:9px 12px;border-radius:8px;cursor:pointer}
217
217
  .barmenu button:hover{background:#f2efe9}
218
218
  @media (max-width:860px){
219
- .bar .zoom,#fs,#presentBtn,#shareBtn,#dlBtn{display:none}
219
+ .bar .zoom,#rotL,#rotR,#vignBtn,#fs,#presentBtn,#shareBtn,#dlBtn{display:none}
220
220
  .barMore{display:inline-flex}
221
221
  }
222
222
  @media (max-width:520px){ .bar>b{display:none} }
@@ -224,6 +224,22 @@ ${LEGAL_CSS}
224
224
  → .lrow/.lmain sans flex, la fenêtre scrollait à la place du conteneur et le rendu paresseux mourait. */
225
225
  .lrow{flex:1;display:flex;min-height:0;position:relative}
226
226
  .lmain{flex:1;min-width:0;display:flex;flex-direction:column;position:relative}
227
+ /* PANNEAU DE VIGNETTES. Largeur POSÉE D'UN COUP, sans transition : la reconstruction du
228
+ document mesure scrollEl.clientWidth juste après la bascule, et une largeur en cours
229
+ d'animation lui ferait calculer des pages à une taille qui n'existe qu'un dixième de
230
+ seconde. Le confort d'un glissement coûterait une mesure fausse. */
231
+ .vign{flex:0 0 auto;width:0;overflow:hidden;background:#0000004d;border-right:1px solid #ffffff14}
232
+ body.vign-on .vign{width:168px}
233
+ .vign-in{width:168px;height:100%;overflow-y:auto;overflow-x:hidden;padding:12px 0 24px;display:flex;flex-direction:column;align-items:center;gap:10px;scrollbar-width:thin}
234
+ .vg{position:relative;width:132px;flex:0 0 auto;padding:0;border:2px solid transparent;border-radius:4px;background:#ffffff10;cursor:pointer;overflow:hidden}
235
+ .vg canvas{display:block;width:100%;height:100%}
236
+ .vg:hover{border-color:#ffffff4d}
237
+ .vg.on{border-color:#fff;box-shadow:0 0 0 1px #0008}
238
+ .vg-n{position:absolute;right:3px;bottom:3px;padding:0 5px;border-radius:3px;background:#000a;color:#fff;font-size:11px;line-height:16px;pointer-events:none}
239
+ .vg:focus-visible{outline:2px solid #fff;outline-offset:1px}
240
+ /* Sous le point de rupture de la barre : TIROIR qui recouvre, pas colonne qui pousse — une
241
+ colonne de 168 px sur un écran de 380 px ne laisserait pas de document à lire. */
242
+ @media(max-width:860px){ body.vign-on .vign{position:absolute;left:0;top:0;bottom:0;z-index:7;box-shadow:0 0 24px #000a} }
227
243
  ${preview ? LIVE_CSS : ""}
228
244
  ${preview ? MAP_CSS : ""}
229
245
  ${botOn ? BOT_CSS : ""}
@@ -232,6 +248,9 @@ ${LEGAL_CSS}
232
248
  <div class=bar>${embed ? '<button class="ic barx" id=embedCloseBtn title=Fermer aria-label=Fermer><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>' : ""}<b>${title}</b>${share.is_test ? '<span class=testchip>\ud83c\udfad Répétition — session de test</span>' : ""}<span class=sp></span><span class=pg id=pg></span>
233
249
  ${preview ? LIVE_BAR : ""}
234
250
  <div class=zoom><button id=zout title="Dézoomer">−</button><span id=zlbl>100%</span><button id=zin title="Zoomer">+</button></div>
251
+ <button class=ic id=rotL title="Pivoter à gauche" aria-label="Pivoter le document à gauche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8h11a5 5 0 0 1 5 5v1M3 8l4-4M3 8l4 4"/></svg></button>
252
+ <button class=ic id=rotR title="Pivoter à droite" aria-label="Pivoter le document à droite"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8H10a5 5 0 0 0-5 5v1M21 8l-4-4M21 8l-4 4"/></svg></button>
253
+ <button class=ic id=vignBtn title="Vignettes" aria-label="Afficher les vignettes" aria-pressed=false><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="6" height="7" rx="1"/><rect x="3" y="13" width="6" height="7" rx="1"/><path d="M13 6h8M13 12h8M13 18h8"/></svg></button>
235
254
  <button class=ic id=fs title="Plein écran"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/></svg></button>
236
255
  ${preview ? '<button class=dl id=presentBtn title="Présenter en direct (lien public, page synchronisée)">Présenter</button>' : ''}
237
256
  <button class="dl primary" id=shareBtn>Partager</button>
@@ -239,6 +258,9 @@ ${LEGAL_CSS}
239
258
  <div class=barmenu id=barMenu>
240
259
  <button data-t=zin>Zoom avant</button>
241
260
  <button data-t=zout>Zoom arrière</button>
261
+ <button data-t=rotL>Pivoter à gauche</button>
262
+ <button data-t=rotR>Pivoter à droite</button>
263
+ <button data-t=vignBtn>Vignettes</button>
242
264
  <button data-t=fs>Plein écran</button>
243
265
  ${preview ? '<button data-t=presentBtn>Présenter en direct</button>' : ''}
244
266
  <button data-t=shareBtn>Partager</button>
@@ -270,6 +292,7 @@ ${LEGAL_CSS}
270
292
  </div>
271
293
  </div>
272
294
  <div class=lrow>
295
+ <div class=vign id=vign aria-hidden=true><div class=vign-in id=vignIn role=list aria-label="Vignettes des pages"></div></div>
273
296
  <div class=lmain>
274
297
  <div class=scroll id=scroll tabindex=0 role=region aria-label="Document">
275
298
  <div id=pages></div>
@@ -296,7 +319,7 @@ ${LEGAL_CSS}
296
319
  // renvoyé par le stockage). Une image = une page, sans pdf.js.
297
320
  var IS_IMG=Player.viewer.isImageDocument(CFG.fileName,CFG.fileUrl);
298
321
  var imgSrc='';
299
- var scrollEl=document.getElementById('scroll'), pagesEl=document.getElementById('pages');
322
+ var scrollEl=document.getElementById('scroll'), pagesEl=document.getElementById('pages'), vignIn=document.getElementById('vignIn');
300
323
  // Suivi de lecture (temps réel à l'écran par page, page la plus loin atteinte, session) :
301
324
  // player/src/tracking.ts — typé et testé, y compris la séparation prospect / aperçu interne.
302
325
  var T=Player.tracking.createTracker({ slug:CFG.slug||'', internal:CFG.internal||null, scrollElement:scrollEl });
@@ -309,8 +332,9 @@ ${LEGAL_CSS}
309
332
  vsplitTint:vsplitTint,
310
333
  get cur(){return cur;},get numPages(){return numPages;},get pdfDoc(){return pdfDoc;},
311
334
  get onePage(){return onePage;},
335
+ get reportEnAttente(){return tRestaurer!==null;},
312
336
  get soloOffered(){return soloOffered;},set soloOffered(v){soloOffered=!!v;}};
313
- var pdfDoc=null, zoom=1, firstAspect=1.35, rendered={}, io=null, ioCur=null;
337
+ var pdfDoc=null, zoom=1, rot=0, firstAspect=1.35, rendered={}, io=null, ioCur=null;
314
338
  // ATTENTION : LE LECTEUR GARDAIT TOUTES LES PAGES RENDUES (P1 audit externe).
315
339
  //
316
340
  // rendered[n] n etait jamais evince, les canvas restaient dans le DOM, et un build() de refit
@@ -332,7 +356,7 @@ ${LEGAL_CSS}
332
356
  var DPR_MAX=2; // au-dela, le gain visuel est nul et le cout quadratique
333
357
  var PIXELS_MAX=4e6; // plafond par canvas : ~4 M pixels = ~16 Mo de tampon
334
358
  var onePage=false, soloOffered=false; // mode « une seule page » (présentation guidée) + offre de découverte solo
335
- function setCur(p){ if(!p||p===cur)return; cur=p; try{ evincerLoin(p); }catch(e){} T.setPage(p); try{ if(window.__soloEnd)window.__soloEnd(p); }catch(e){} var pg=document.getElementById('pg'); if(pg&&numPages)pg.textContent='Page '+p+' / '+numPages; if(PRES) pushPage(); }
359
+ function setCur(p){ if(!p||p===cur)return; cur=p; try{ evincerLoin(p); }catch(e){} T.setPage(p); try{ vignMarquer(p); }catch(e){} try{ if(window.__soloEnd)window.__soloEnd(p); }catch(e){} var pg=document.getElementById('pg'); if(pg&&numPages)pg.textContent='Page '+p+' / '+numPages; if(PRES) pushPage(); }
336
360
  // Bouton « Partager » : re-partage tracé (forward) → crée un lien ENFANT et propose de l'envoyer par email.
337
361
  function wireShare(){
338
362
  var pop=document.getElementById('pop'), btn=document.getElementById('shareBtn'), err=document.getElementById('shErr');
@@ -432,12 +456,16 @@ ${LEGAL_CSS}
432
456
  // ⚠️ L'ANTI-REBOND DE 250 ms A DISPARU, ET C'EST VOULU. Il repoussait l'échéance à chaque appel —
433
457
  // un défilement continu pouvait donc ne JAMAIS écrire. La file regroupe par genre sans jamais
434
458
  // repousser : la dernière page demandée part au prochain tour, et elle part.
459
+ // ⚠️ LA ROTATION VOYAGE AVEC LA PAGE, PAR LE MEME APPEL. Elle aurait pu avoir son action a elle ;
460
+ // elle aurait alors eu son propre rang d ecriture, et deux ecritures concurrentes auraient pu
461
+ // s inverser — l audience recevant une rotation posterieure a la page qui la precede. Un seul
462
+ // message porte les deux, donc un seul ordre, et le controle de rang existant les couvre tous deux.
435
463
  function pushPage(){ if(!PRES)return;
436
464
  var f=fileEcritures(); var page=cur||1;
437
465
  if(!f) return envoyerPage(page);
438
466
  return f.poser('page',function(){ return envoyerPage(cur||page); }); }
439
467
  function envoyerPage(page){ if(!PRES)return Promise.resolve();
440
- return Player.live.fetchBorne('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-page',slug:PRES.slug,control:PRES.control,page:page,seq:prochainRang()})})
468
+ return Player.live.fetchBorne('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-page',slug:PRES.slug,control:PRES.control,page:page,rotation:rot,seq:prochainRang()})})
441
469
  .then(function(r){ if(r&&r.ok)diffuserEtat(); })
442
470
  .catch(function(){}); }
443
471
  // ⚠️ TROIS GESTES, ET L'ORDRE ÉTAIT LE PIRE DES SIX. On signalait la fin, puis on COUPAIT LE
@@ -543,6 +571,22 @@ ${LEGAL_CSS}
543
571
  if(!d||!d.ok||!d.control){ if(d&&d.status===403){ Player.bridge.sendToHost({type:'present-denied'}); } return; }
544
572
  PRES={slug:slug,control:d.control}; saveCtl(slug,d.control); _seq=0;
545
573
  showBar(slug); liveConnect(slug,d.control); startHb();
574
+ // ⚠️ A LA REPRISE, LA PRESENTATION L EMPORTE SUR L ETAT LOCAL — pour l orientation comme
575
+ // pour la page, alors qu on ne rendait que la page. Un rechargement remet la vue a son
576
+ // etat d origine (rot vaut 0), c est voulu ; mais l audience, elle, continuait d afficher
577
+ // la view_rotation de la base. Le presentateur voyait donc son document droit et son
578
+ // audience le voyait couche, sans que rien ne le dise a personne — et il n avait aucune
579
+ // raison de toucher le bouton, puisque de son cote tout allait bien.
580
+ //
581
+ // ⚠️ ON ADOPTE, ON NE POUSSE PAS. La base porte deja cette valeur : la renvoyer serait une
582
+ // ecriture pour rien, et pushPage() emporte AUSSI la page — or cur vaut 1 tant que le
583
+ // document se charge, donc pousser ici ramenerait toute l audience a la page 1. C est la
584
+ // raison pour laquelle la reprise ne pousse rien, et elle vaut encore.
585
+ //
586
+ // Au DEMARRAGE c est l inverse, et c est coherent : startPresent pousse la rotation
587
+ // courante du presentateur, parce qu il n y a encore rien a reprendre.
588
+ var rotPres=Player.viewer.rotationEffective(0,d.rotation);
589
+ if(rotPres!==rot){ rot=rotPres; if(pdfDoc||IS_IMG){ build(); if(vignOuvert) vignConstruire(); } }
546
590
  var target=Math.max(1,d.page||1);
547
591
  var tries=0; (function jump(){ var el=(document.getElementById('pages')||document).querySelector('.page[data-p="'+target+'"]'); if(el){ el.scrollIntoView({block:'start'}); } else if(tries++<40){ setTimeout(jump,150); } })();
548
592
  }).catch(function(){});
@@ -618,9 +662,167 @@ ${LEGAL_CSS}
618
662
  T.start();
619
663
  document.getElementById('zin').addEventListener('click',function(){ setZoom(zoom+0.2); });
620
664
  document.getElementById('zout').addEventListener('click',function(){ setZoom(zoom-0.2); });
665
+ document.getElementById('rotL').addEventListener('click',function(){ tournerDe(-1); });
666
+ document.getElementById('rotR').addEventListener('click',function(){ tournerDe(1); });
667
+ document.getElementById('vignBtn').addEventListener('click',basculerVignettes);
668
+ if(vignIn){
669
+ var desarmer=function(){ vignSuivi=false; };
670
+ vignIn.addEventListener('wheel',desarmer,{passive:true});
671
+ vignIn.addEventListener('pointerdown',desarmer,{passive:true});
672
+ vignIn.addEventListener('keydown',function(e){ if(e.key==='ArrowDown'||e.key==='ArrowUp') vignSuivi=false; },{passive:true});
673
+ }
674
+ // Un document d une seule page, ou une image : le panneau n aurait rien a montrer.
675
+ if(IS_IMG){ var vb=document.getElementById('vignBtn'); if(vb) vb.style.display='none'; }
676
+ brancherZoomAuGeste();
621
677
  render();
622
678
  }
623
- function setZoom(z){ zoom=Player.viewer.clampZoom(z); document.getElementById('zlbl').textContent=Math.round(zoom*100)+'%'; if(pdfDoc) build(); }
679
+ // ── ZOOM AU GESTE : apercu immediat, reconstruction differee ─────────────────────────────────
680
+ //
681
+ // ⚠️ POURQUOI LE PINCEMENT ZOOMAIT L ECRAN ENTIER : PERSONNE N ECOUTAIT. Sur trackpad, un pincement
682
+ // n est pas un evenement tactile — le navigateur l envoie comme un wheel portant ctrlKey. Aucun
683
+ // ecouteur ici ne le lisait, donc le navigateur appliquait son defaut, que la balise viewport
684
+ // autorise explicitement. Ce n etait pas un reglage manquant, c etait un silence.
685
+ //
686
+ // ⚠️ ET LE DEFAUT ETAIT PLUS LARGE QUE LE CONFORT : sous 860 px la barre replie le bloc de zoom.
687
+ // Sur mobile le pincement est donc le SEUL geste qu un lecteur tente — et il deformait toute
688
+ // l interface. Ce bloc ne rajoute pas une commodite, il repare le seul zoom jamais essaye.
689
+ //
690
+ // ⚠️ EN DEUX TEMPS, PARCE QU UN SEUL NE TIENT PAS. build() vide le conteneur, annule les rendus en
691
+ // vol et recree toutes les pages : juste pour un clic, ruineux pour un pincement qui produit des
692
+ // dizaines d evenements par seconde. Pendant le geste on ne pose qu une transformation CSS ; la
693
+ // reconstruction arrive UNE fois, quand les doigts s arretent.
694
+ //
695
+ // Note de forme : ce script vit dans un litteral de gabarit — pas d accent grave ici.
696
+ var geste=null, tGeste=null;
697
+ var DELAI_CONFIRMATION=180; // ms de silence avant de reconstruire
698
+
699
+ function majEtiquetteZoom(){ var l=document.getElementById('zlbl'); if(l)l.textContent=Math.round(zoom*100)+'%'; }
700
+
701
+ // L ancre est prise DANS LE CONTENEUR DES PAGES et une seule fois, au debut du geste : la mesurer a
702
+ // nouveau pendant l apercu la lirait a travers la transformation deja posee.
703
+ function ancreDuGeste(cx,cy){
704
+ var r=pagesEl.getBoundingClientRect();
705
+ return { x:cx-r.left, y:cy-r.top, largeur:r.width||1, cx:cx, cy:cy };
706
+ }
707
+ // ⚠️ ORIGINE EN HAUT AU CENTRE, ET UNE TRANSLATION QUI IMMOBILISE LE POINT VISE. #pages est centre
708
+ // (align-items:center, margin:0 auto) : grandir autour de son centre ferait FUIR le point que le
709
+ // lecteur regarde. La translation qui le fige vaut (ancre - origine) x (1 - facteur).
710
+ function poserApercu(f,ancre){
711
+ var tx=(ancre.x-ancre.largeur/2)*(1-f), ty=ancre.y*(1-f);
712
+ pagesEl.style.transformOrigin='50% 0';
713
+ pagesEl.style.transform='translate('+tx+'px,'+ty+'px) scale('+f+')';
714
+ pagesEl.style.willChange='transform';
715
+ }
716
+ function retirerApercu(){ pagesEl.style.transform=''; pagesEl.style.transformOrigin=''; pagesEl.style.willChange=''; }
717
+ // L apercu s arrete aux memes bornes que la reconstruction, sinon le document continue de grandir
718
+ // sous les doigts puis SAUTE en arriere au relachement.
719
+ function facteurBorne(g){ return Player.viewer.clampZoom(g.depart*g.facteur)/g.depart; }
720
+
721
+ // ⚠️ LA PART FIXE DU CONTENU N EST PAS UN DETAIL. #pages porte 22 px de marge en haut et en bas et
722
+ // 16 px entre chaque page : cent pages font plus de 1500 px qui ne grandissent PAS avec le zoom. Les
723
+ // compter comme proportionnels projette le lecteur trop bas, d autant plus qu il est loin dans le
724
+ // document.
725
+ function partFixeDuContenu(){ return { largeur:28, hauteur:44+16*Math.max(0,numPages-1) }; }
726
+ // ⚠️ ET LA TETE DE CE FIXE SE COMPORTE AUTREMENT : les 22 px de marge HAUTE arrivent avant la
727
+ // premiere page et ne bougent jamais, alors que les espaces entre pages sont proportionnels au
728
+ // nombre de pages au-dessus du point vise. Les confondre coutait 14,5 px de fuite, MESURES dans
729
+ // Chromium sur un document de 40 pages : le point vise glissait d autant plus qu on lisait loin.
730
+ function teteFixeDuContenu(){ return { largeur:14, hauteur:22 }; }
731
+
732
+ // Le point de passage unique : boutons, molette, trackpad et doigts finissent tous ici.
733
+ function confirmerZoom(z,cx,cy){
734
+ clearTimeout(tGeste); tGeste=null; geste=null;
735
+ retirerApercu();
736
+ var avant=zoom, apres=Player.viewer.clampZoom(z);
737
+ if(apres===avant){ majEtiquetteZoom(); return; }
738
+ var sr=scrollEl.getBoundingClientRect();
739
+ var cible=Player.viewer.ancrageApresZoom({
740
+ defilement:{x:scrollEl.scrollLeft,y:scrollEl.scrollTop},
741
+ point:{x:cx-sr.left,y:cy-sr.top},
742
+ cadre:{largeur:scrollEl.clientWidth,hauteur:scrollEl.clientHeight},
743
+ contenu:{largeur:scrollEl.scrollWidth,hauteur:scrollEl.scrollHeight},
744
+ fixe:partFixeDuContenu(), fixeDebut:teteFixeDuContenu(),
745
+ zoomAvant:avant, zoomApres:apres });
746
+ zoom=apres; majEtiquetteZoom();
747
+ // ⚠️ if(pdfDoc) SEUL LAISSAIT LES IMAGES SANS ZOOM. Le commentaire de renderImage annonce que
748
+ // « tout le chrome — loader, zoom, plein ecran… — fonctionne tel quel » : c etait faux pour le
749
+ // zoom, pdfDoc restant nul sur une image. Le defaut passait inapercu tant que le zoom tenait a
750
+ // deux boutons ; intercepter le pincement sans le corriger aurait AVALE le geste sur ces
751
+ // documents, donc fait pire qu avant.
752
+ if(!pdfDoc && !IS_IMG) return;
753
+ build();
754
+ scrollEl.scrollLeft=cible.x; scrollEl.scrollTop=cible.y;
755
+ }
756
+
757
+ // Les boutons visent le centre du cadre. Ils heritent donc de l ancrage, et la derive qu ils avaient
758
+ // deja — les pages changent de largeur, le defilement reste en pixels — disparait.
759
+ function setZoom(z){
760
+ var sr=scrollEl.getBoundingClientRect();
761
+ confirmerZoom(z, sr.left+scrollEl.clientWidth/2, sr.top+scrollEl.clientHeight/2);
762
+ }
763
+
764
+ function brancherZoomAuGeste(){
765
+ // A. TRACKPAD ET CTRL+MOLETTE. NON PASSIF, et c est la ligne qui compte : wheel est passif par
766
+ // defaut sur les navigateurs recents, et un ecouteur passif voit son preventDefault IGNORE —
767
+ // l ecran zoomerait quand meme, en silence.
768
+ scrollEl.addEventListener('wheel',function(e){
769
+ if(!e.ctrlKey)return;
770
+ e.preventDefault();
771
+ if(!geste) geste={ancre:ancreDuGeste(e.clientX,e.clientY),depart:zoom,facteur:1};
772
+ // Exponentielle plutot que lineaire : le geste devient symetrique — pincer puis ecarter d autant
773
+ // revient exactement au zoom de depart, ce qu une addition ne donne pas.
774
+ geste.facteur*=Math.exp(-e.deltaY/180);
775
+ geste.ancre.cx=e.clientX; geste.ancre.cy=e.clientY;
776
+ poserApercu(facteurBorne(geste),geste.ancre);
777
+ // La molette n a pas de fin declaree : le silence en tient lieu.
778
+ clearTimeout(tGeste);
779
+ tGeste=setTimeout(function(){ if(geste) confirmerZoom(geste.depart*geste.facteur,geste.ancre.cx,geste.ancre.cy); },DELAI_CONFIRMATION);
780
+ },{passive:false});
781
+
782
+ // B. SAFARI. Ses evenements de geste ne sont pas standard mais sont les seuls disponibles la-bas ;
783
+ // sans le preventDefault du gesturestart, Safari zoome la page avant nous.
784
+ if('ongesturestart' in window){
785
+ scrollEl.addEventListener('gesturestart',function(e){ e.preventDefault(); geste={ancre:ancreDuGeste(e.clientX,e.clientY),depart:zoom,facteur:1}; },{passive:false});
786
+ scrollEl.addEventListener('gesturechange',function(e){ e.preventDefault(); if(!geste)return; geste.facteur=e.scale||1; poserApercu(facteurBorne(geste),geste.ancre); },{passive:false});
787
+ scrollEl.addEventListener('gestureend',function(e){ e.preventDefault(); if(!geste)return; confirmerZoom(geste.depart*geste.facteur,geste.ancre.cx,geste.ancre.cy); },{passive:false});
788
+ }
789
+
790
+ // C. TACTILE A DEUX DOIGTS. On ne prend la main qu a DEUX pointeurs : un seul doigt reste le
791
+ // defilement du navigateur, qui le fait mieux que nous et sans a-coups.
792
+ var doigts=Object.create(null), nDoigts=0, ecart0=0;
793
+ function ecartEtMilieu(){
794
+ var a=null,b=null;
795
+ for(var k in doigts){ if(!a)a=doigts[k]; else { b=doigts[k]; break; } }
796
+ if(!a||!b)return null;
797
+ var dx=a.x-b.x, dy=a.y-b.y;
798
+ return { d:Math.sqrt(dx*dx+dy*dy), cx:(a.x+b.x)/2, cy:(a.y+b.y)/2 };
799
+ }
800
+ scrollEl.addEventListener('pointerdown',function(e){
801
+ if(e.pointerType!=='touch')return;
802
+ if(!doigts[e.pointerId]) nDoigts++;
803
+ doigts[e.pointerId]={x:e.clientX,y:e.clientY};
804
+ if(nDoigts===2){ var m=ecartEtMilieu(); if(m&&m.d>0){ ecart0=m.d; geste={ancre:ancreDuGeste(m.cx,m.cy),depart:zoom,facteur:1}; } }
805
+ },{passive:true});
806
+ scrollEl.addEventListener('pointermove',function(e){
807
+ if(e.pointerType!=='touch'||!doigts[e.pointerId])return;
808
+ doigts[e.pointerId]={x:e.clientX,y:e.clientY};
809
+ if(nDoigts!==2||!geste||!ecart0)return;
810
+ var m=ecartEtMilieu(); if(!m)return;
811
+ e.preventDefault();
812
+ geste.facteur=m.d/ecart0; geste.ancre.cx=m.cx; geste.ancre.cy=m.cy;
813
+ poserApercu(facteurBorne(geste),geste.ancre);
814
+ },{passive:false});
815
+ // ⚠️ ON CONFIRME DES QU IL NE RESTE QU UN DOIGT, pas quand ils sont tous partis : un lecteur qui
816
+ // leve un doigt et garde l autre pose continue a lire, et l apercu resterait fige sur une
817
+ // transformation que rien ne viendrait plus confirmer.
818
+ function doigtLeve(e){
819
+ if(e.pointerType!=='touch'||!doigts[e.pointerId])return;
820
+ delete doigts[e.pointerId]; nDoigts=Math.max(0,nDoigts-1);
821
+ if(nDoigts<2&&geste){ var g=geste; ecart0=0; confirmerZoom(g.depart*g.facteur,g.ancre.cx,g.ancre.cy); }
822
+ }
823
+ scrollEl.addEventListener('pointerup',doigtLeve,{passive:true});
824
+ scrollEl.addEventListener('pointercancel',doigtLeve,{passive:true});
825
+ }
624
826
  // ⚠️ UN REFUS ARRÊTE LE LECTEUR, et les deux replis plus doux ont été MESURÉS avant d'être
625
827
  // écartés — pas jugés sur leur mine :
626
828
  //
@@ -682,7 +884,13 @@ ${LEGAL_CSS}
682
884
  function onePageReserve(){ var band=capReserve(); return document.body.classList.contains('botplayer')?(document.body.classList.contains('vsplit')?Math.round(window.innerHeight*0.38)+70:(isLand()?12:260)):(band?band:(document.body.classList.contains('deskaudio')?4:0)); }
683
885
  // La géométrie (bornes, respiration, seuil d'abandon) vit dans player/src/viewer.ts ; ici on ne
684
886
  // fournit que les MESURES, seules choses que le DOM connaisse.
685
- function targetWidth(){ return Player.viewer.fitWidth({containerWidth:baseWidth(),containerHeight:scrollEl.clientHeight,zoom:zoom,onePage:onePage,aspect:firstAspect,overlap:botOverlap(),reserve:onePageReserve()}); }
887
+ // ⚠️ LA PROPORTION EFFECTIVE GOUVERNE LE SUIVI DE LECTURE, PAS SEULEMENT L AFFICHAGE. Elle fixe la
888
+ // hauteur des gabarits poses AVANT rendu ; ces gabarits fixent la longueur du document ; cette
889
+ // longueur decide de la page que l observateur d intersection appelle « courante » ; et c est cette
890
+ // page-la que le suivi enregistre. Une proportion non tournee a 90 degres fausse donc les
891
+ // statistiques d un partage, en silence et sans rien casser a l ecran.
892
+ function aspectEffectif(){ return Player.viewer.aspectApresRotation(firstAspect,rot); }
893
+ function targetWidth(){ return Player.viewer.fitWidth({containerWidth:baseWidth(),containerHeight:scrollEl.clientHeight,zoom:zoom,onePage:onePage,aspect:firstAspect,rotation:rot,overlap:botOverlap(),reserve:onePageReserve()}); }
686
894
  // IMAGE — la visionneuse ne savait lire QUE du PDF (pdf.js). Une axonométrie en .jpg
687
895
  // partait donc dans getDocument() et échouait sur « structure invalide ».
688
896
  // Elle devient une page unique : tout le chrome — loader, zoom, plein écran, Partager,
@@ -706,12 +914,18 @@ ${LEGAL_CSS}
706
914
  task.onProgress=function(p){ if(p&&p.total){ var pct=Math.max(8,Math.min(99,Math.round(p.loaded/p.total*100))); var bar=document.getElementById('lbar'); if(bar)bar.classList.remove('idle'); var f=document.getElementById('lbarFill'); if(f)f.style.width=pct+'%'; var l=document.getElementById('lpct'); if(l)l.textContent=pct+' %'; } };
707
915
  task.promise.then(function(pdf){
708
916
  pdfDoc=pdf; numPages=pdf.numPages; window.__n=pdf.numPages; T.setPageCount(pdf.numPages);
917
+ // Un document d une seule page : le panneau de vignettes n aurait rien a montrer.
918
+ if(numPages<2){ var vb1=document.getElementById('vignBtn'); if(vb1) vb1.style.display='none'; }
709
919
  document.getElementById('pg').textContent='Page 1 / '+pdf.numPages;
710
920
  pdf.getPage(1).then(function(p){ var vp=p.getViewport({scale:1}); firstAspect=vp.height/vp.width; build(); try{if(window.PlayerBot)window.PlayerBot.init(VIEWER);}catch(e){} })
711
921
  .catch(function(){ build(); try{if(window.PlayerBot)window.PlayerBot.init(VIEWER);}catch(e){} });
712
922
  }).catch(function(){ loadError("Impossible d'afficher ce document."); });
713
923
  }
714
924
  function build(){
925
+ // Un apercu de zoom n a plus de sens des qu on reconstruit : le laisser poserait une
926
+ // transformation orpheline sur des pages neuves. Cas reel : un redimensionnement de
927
+ // fenetre pendant un pincement.
928
+ retirerApercu();
715
929
  // Une nouvelle construction invalide tout ce qui etait en vol : sans cela, les rendus lances
716
930
  // par la precedente peignent dans des elements DETACHES (pagesEl a ete vide) et le travail est
717
931
  // fait deux fois. C est le meme mecanisme de generation que la page audience.
@@ -727,7 +941,7 @@ ${LEGAL_CSS}
727
941
  ioCur=new IntersectionObserver(function(es){es.forEach(function(e){ if(e.isIntersecting){ setCur(+e.target.dataset.p); } });},{root:scrollEl,rootMargin:Player.viewer.CURRENT_PAGE_MARGIN,threshold:0});
728
942
  pagesEl.innerHTML='';
729
943
  var w=Math.round(targetWidth());
730
- for(var i=1;i<=numPages;i++){ var d=document.createElement('div'); d.className='page ph'; d.dataset.p=i; d.style.width=w+'px'; d.style.height=Math.round(w*firstAspect)+'px'; d.textContent='Page '+i; pagesEl.appendChild(d); io.observe(d); ioCur.observe(d); }
944
+ for(var i=1;i<=numPages;i++){ var d=document.createElement('div'); d.className='page ph'; d.dataset.p=i; d.style.width=w+'px'; d.style.height=Math.round(w*aspectEffectif())+'px'; d.textContent='Page '+i; pagesEl.appendChild(d); io.observe(d); ioCur.observe(d); }
731
945
  var _band=capReserve(); var _pb=document.body.classList.contains('botplayer')?(document.body.classList.contains('vsplit')?Math.round(window.innerHeight*0.38)+50:(isLand()?0:240)):(botOverlap()+(_band?_band+12:(document.body.classList.contains('deskaudio')?16:0))); pagesEl.style.paddingBottom = onePage ? (_pb+'px') : ''; // centre la page dans l'espace VISIBLE (au-dessus de la sheet mobile / du bandeau desktop / sous le header en audio seul)
732
946
  if(onePage) showPage(cur||1);
733
947
  }
@@ -737,11 +951,184 @@ ${LEGAL_CSS}
737
951
  var els=pagesEl.querySelectorAll('.page'); for(var i=0;i<els.length;i++){ els[i].classList.toggle('cur',(+els[i].dataset.p)===p); } var el=pagesEl.querySelector('.page[data-p="'+p+'"]'); if(el){ renderPage(p,el); var nx=pagesEl.querySelector('.page[data-p="'+(p+1)+'"]'); if(nx)renderPage(p+1,nx); } setCur(p); syncArrows();
738
952
  var pf=document.getElementById('pglineF'); if(pf&&numPages)pf.style.width=Player.viewer.progressPercent(p,numPages)+'%'; } // ligne de progression (mode présentation)
739
953
  function enterOnePage(){ if(onePage)return; onePage=true; document.body.classList.add('onepage'); document.body.classList.add('botlock'); if(pdfDoc){ var c=cur||1; build(); showPage(c); } syncArrows(); }
740
- function exitOnePage(){ if(!onePage)return; onePage=false; soloOffered=false; document.body.classList.remove('onepage'); document.body.classList.remove('botlock'); var c=cur||1; if(pdfDoc){ build(); setTimeout(function(){ scrollToPage(c); },30); } syncArrows(); }
954
+ function exitOnePage(){ if(!onePage)return; onePage=false; soloOffered=false; document.body.classList.remove('onepage'); document.body.classList.remove('botlock'); var c=cur||1; if(pdfDoc){ build(); restaurerPage(c); } syncArrows(); }
741
955
  // Re-fit du document à la largeur réelle (resize fenêtre, ouverture/fermeture du chat ancré, plein écran)
742
956
  // en conservant la page courante. Débouncé pour rester fluide.
743
- function scrollToPage(p){ var el=pagesEl.querySelector('.page[data-p="'+p+'"]'); if(el) el.scrollIntoView({block:'start'}); }
744
- window.__refit=function(){ if(!pdfDoc)return; var c=cur||1; build(); setTimeout(function(){ scrollToPage(c); },30); };
957
+ // ⚠️ UN REPORT DE PAGE N EST VALABLE QUE TANT QUE PERSONNE N A NAVIGUE ENTRE-TEMPS.
958
+ //
959
+ // Une reconstruction ne peut pas restaurer la page tout de suite : les gabarits viennent d etre
960
+ // poses et la geometrie n est pas encore stable. On differe donc de 30 ms. Mais ce report SURVIT a
961
+ // une navigation faite dans l intervalle, et il la DEFAIT — le lecteur qui ouvre le panneau de
962
+ // vignettes puis clique aussitot une vignette est ramene la ou il etait.
963
+ //
964
+ // Trouve par la forge, pas ici : en local les vignettes ne se rendaient pas, le banc attendait donc
965
+ // bien au-dela des 30 ms et la course ne se produisait jamais. Reproduit ensuite en ouvrant le
966
+ // panneau et en cliquant dans le meme instant.
967
+ var tRestaurer=null;
968
+ function restaurerPage(c){
969
+ clearTimeout(tRestaurer);
970
+ tRestaurer=setTimeout(function(){ tRestaurer=null; scrollToPage(c); },30);
971
+ }
972
+ function scrollToPage(p){
973
+ // Toute navigation explicite PERIME le report en attente : c est ce qui ferme la course.
974
+ clearTimeout(tRestaurer); tRestaurer=null;
975
+ var el=pagesEl.querySelector('.page[data-p="'+p+'"]'); if(el) el.scrollIntoView({block:'start'});
976
+ }
977
+ // ⚠️ ET « NAVIGUER » NE SE RESUME PAS A CLIQUER. Le report ne se perimait qu au passage par
978
+ // scrollToPage — donc au clic sur une vignette, une fleche, le sommaire. Le lecteur qui ouvre le
979
+ // panneau puis fait DEFILER LE DOCUMENT LUI-MEME dans les 30 ms ne passe par rien de tout cela :
980
+ // son geste etait defait et il revenait a sa page de depart. Meme course que ci-dessus, autre
981
+ // porte. La forge l a trouvee en rendant les vignettes plus vite qu ici — le banc du clic, lui,
982
+ // passait, parce qu un clic de vignette PASSE par scrollToPage.
983
+ //
984
+ // ⚠️ ON N ECOUTE PAS 'scroll'. La reconstruction en emet elle-meme : annuler le report sur sa
985
+ // propre consequence le rendrait inutile, et le lecteur perdrait sa page a chaque refit — la
986
+ // regression exacte que ce report existe pour empecher. On ecoute donc l ENTREE DU LECTEUR,
987
+ // qu aucune mise en page ne fabrique.
988
+ //
989
+ // L ordre joue en notre faveur : ces evenements arrivent AVANT le clic qui arme un report (le
990
+ // bouton du panneau et les vignettes sont HORS de scrollEl), jamais apres. Annuler ici ne peut
991
+ // donc pas desarmer un report qui n existe pas encore.
992
+ //
993
+ // Ce que ca NE couvre PAS, et c est assume : une touche pressee alors que le document n a pas le
994
+ // focus. Elle ne fait pas defiler le document non plus, donc il n y a rien a perimer.
995
+ ['wheel','touchmove','pointerdown','keydown'].forEach(function(nom){
996
+ scrollEl.addEventListener(nom,function(){ clearTimeout(tRestaurer); tRestaurer=null; },{passive:true});
997
+ });
998
+ // ⚠️ PAS window.__refit ICI : il commence par « if(!pdfDoc) return », donc il ne ferait RIEN sur un
999
+ // document image — le meme garde qui laissait les images sans zoom avant le lot 1.
1000
+ // ── PANNEAU DE VIGNETTES ─────────────────────────────────────────────────────────────────────
1001
+ //
1002
+ // ⚠️ LE MOTEUR EXISTAIT DEJA, DANS LE CHAT — et une seule de ses parties ne se transpose pas.
1003
+ // gabarit-live.js fabrique des vignettes de pieces jointes avec un cache borne, une file de
1004
+ // concurrence, un chargement paresseux et des URL objet plutot que des data-URL. Tout cela sert
1005
+ // ici. Ce qui NE se transpose pas est le getDocument suivi d un destroy : ce panneau montre les
1006
+ // pages du document DEJA OUVERT. Le reprendre tel quel telechargerait le fichier une seconde fois
1007
+ // et ferait tourner deux workers sur le meme PDF.
1008
+ //
1009
+ // ⚠️ ET LE CACHE EST BORNE PARCE QU UN DOCUMENT NE L EST PAS. Cinq cents pages font cinq cents
1010
+ // boutons — c est peu — mais cinq cents canvas seraient le defaut que la fenetre glissante ferme
1011
+ // deja pour les pages. On garde les derniers, on rend les autres a l etat de gabarit, et
1012
+ // l observateur les refabrique s ils reviennent a l ecran.
1013
+ var VIGN_LARGEUR=132, VIGN_MAX=48, VIGN_PARALLELE=2;
1014
+ var vignOuvert=false, vignIO=null, vignFile=[], vignActifs=0, vignOrdre=[], vignFaites=Object.create(null), vignEchecs=Object.create(null);
1015
+ // ⚠️ LE SUIVI AUTOMATIQUE SE DESARME DES QUE LE LECTEUR TOUCHE LE PANNEAU. Sans ca, parcourir les
1016
+ // vignettes ramene sans cesse le panneau sur la page en cours et le lecteur se bat contre son
1017
+ // outil. On ecoute les GESTES, pas l evenement scroll : un defilement que NOUS provoquons ne doit
1018
+ // pas compter comme une intention du lecteur.
1019
+ var vignSuivi=true;
1020
+
1021
+ function vignVider(bt){
1022
+ bt.innerHTML='';
1023
+ bt.style.height=Math.round(VIGN_LARGEUR*aspectEffectif())+'px';
1024
+ var s=document.createElement('span'); s.className='vg-n'; s.textContent=bt.dataset.p; bt.appendChild(s);
1025
+ }
1026
+ function vignEvincer(){
1027
+ while(vignOrdre.length>VIGN_MAX){
1028
+ var vieux=vignOrdre.shift();
1029
+ delete vignFaites[vieux];
1030
+ var bt=vignIn.querySelector('.vg[data-p="'+vieux+'"]');
1031
+ if(bt){ vignVider(bt); if(vignIO) vignIO.observe(bt); }
1032
+ }
1033
+ }
1034
+ function vignSuivante(){
1035
+ if(vignActifs>=VIGN_PARALLELE||!vignFile.length)return;
1036
+ var t=vignFile.shift(); vignActifs++;
1037
+ Promise.resolve().then(t).catch(function(){}).then(function(){ vignActifs--; vignSuivante(); });
1038
+ }
1039
+ function vignRendre(bt){
1040
+ var n=+bt.dataset.p;
1041
+ if(!pdfDoc||vignFaites[n])return;
1042
+ vignFaites[n]=1; vignOrdre.push(n); vignEvincer();
1043
+ // ⚠️ UN ECHEC DOIT POUVOIR ETRE RETENTE, ET C EST LE DEFAUT QUE LE CHEMIN PRINCIPAL DOCUMENTE
1044
+ // DEJA : « on ne marque pas RENDUE avant de l avoir fait ». Ici la marque est posee AVANT, pour
1045
+ // dedoublonner les demandes concurrentes ; il faut donc la RETIRER si le rendu echoue, sinon la
1046
+ // vignette reste vide pour toujours et plus rien ne la redemande. Trouve en mesurant : le
1047
+ // navigateur d essai rejetait chaque rendu, et la file avalait le rejet sans un mot.
1048
+ //
1049
+ // ⚠️ ET LA REPRISE EST BORNEE, PARCE QU OBSERVER UN ELEMENT DEJA VISIBLE RAPPELLE LE CALLBACK
1050
+ // TOUT DE SUITE. Re-observer sans compter fabriquerait une BOUCLE INFINIE des que les rendus
1051
+ // echouent en serie — un moteur trop ancien, une memoire pleine — et brulerait le processeur du
1052
+ // lecteur en silence. Mesure sur un navigateur ou chaque rendu echoue : sans borne, la file ne
1053
+ // se vidait jamais. Deux tentatives, puis on laisse la vignette vide plutot que de tourner.
1054
+ var oublier=function(){
1055
+ delete vignFaites[n];
1056
+ var j=vignOrdre.indexOf(n); if(j>=0) vignOrdre.splice(j,1);
1057
+ vignEchecs[n]=(vignEchecs[n]||0)+1;
1058
+ if(vignEchecs[n]<2 && vignIO && bt.isConnected) vignIO.observe(bt);
1059
+ };
1060
+ vignFile.push(function(){
1061
+ return pdfDoc.getPage(n).then(function(page){
1062
+ if(!vignFaites[n])return; // evincee pendant l attente
1063
+ var rotEff=Player.viewer.rotationEffective(page.rotate,rot);
1064
+ var v0=page.getViewport({scale:1,rotation:rotEff});
1065
+ var v=page.getViewport({scale:VIGN_LARGEUR/v0.width,rotation:rotEff});
1066
+ var c=document.createElement('canvas');
1067
+ c.width=Math.ceil(v.width); c.height=Math.ceil(v.height);
1068
+ return page.render({canvasContext:c.getContext('2d'),viewport:v}).promise.then(function(){
1069
+ if(!vignFaites[n])return;
1070
+ bt.style.height=''; bt.innerHTML=''; bt.appendChild(c);
1071
+ var s=document.createElement('span'); s.className='vg-n'; s.textContent=n; bt.appendChild(s);
1072
+ }, oublier);
1073
+ }, oublier);
1074
+ });
1075
+ vignSuivante();
1076
+ }
1077
+ function vignMarquer(p){
1078
+ if(!vignOuvert||!vignIn)return;
1079
+ var els=vignIn.querySelectorAll('.vg');
1080
+ for(var i=0;i<els.length;i++) els[i].classList.toggle('on',(+els[i].dataset.p)===p);
1081
+ if(!vignSuivi)return;
1082
+ var el=vignIn.querySelector('.vg[data-p="'+p+'"]');
1083
+ // On deplace le defilement DU PANNEAU, jamais scrollIntoView : celui-ci remonte la chaine des
1084
+ // ancetres scrollables et emporterait le document avec lui.
1085
+ if(el) vignIn.scrollTop=Math.max(0,el.offsetTop-(vignIn.clientHeight-el.offsetHeight)/2);
1086
+ }
1087
+ function vignConstruire(){
1088
+ if(!vignIn)return;
1089
+ if(vignIO){ vignIO.disconnect(); vignIO=null; }
1090
+ vignIn.innerHTML=''; vignFaites=Object.create(null); vignEchecs=Object.create(null); vignOrdre=[]; vignFile=[];
1091
+ if(!pdfDoc||numPages<2)return;
1092
+ vignIO=new IntersectionObserver(function(es){
1093
+ es.forEach(function(e){ if(e.isIntersecting){ vignIO.unobserve(e.target); vignRendre(e.target); } });
1094
+ },{root:vignIn,rootMargin:'220px 0px'});
1095
+ for(var i=1;i<=numPages;i++){
1096
+ var bt=document.createElement('button');
1097
+ bt.type='button'; bt.className='vg'; bt.dataset.p=i; bt.setAttribute('role','listitem');
1098
+ bt.setAttribute('aria-label','Aller à la page '+i);
1099
+ vignVider(bt);
1100
+ bt.addEventListener('click',function(){
1101
+ var n=+this.dataset.p; vignSuivi=true;
1102
+ if(onePage) showPage(n); else scrollToPage(n);
1103
+ vignMarquer(n);
1104
+ });
1105
+ vignIn.appendChild(bt); vignIO.observe(bt);
1106
+ }
1107
+ vignMarquer(cur||1);
1108
+ }
1109
+ function basculerVignettes(){
1110
+ vignOuvert=!vignOuvert;
1111
+ document.body.classList.toggle('vign-on',vignOuvert);
1112
+ var b=document.getElementById('vignBtn'); if(b) b.setAttribute('aria-pressed',vignOuvert?'true':'false');
1113
+ var v=document.getElementById('vign'); if(v) v.setAttribute('aria-hidden',vignOuvert?'false':'true');
1114
+ if(vignOuvert){ vignSuivi=true; vignConstruire(); }
1115
+ else if(vignIO){ vignIO.disconnect(); vignIO=null; vignIn.innerHTML=''; vignFaites=Object.create(null); vignEchecs=Object.create(null); vignOrdre=[]; vignFile=[]; }
1116
+ // Le cadre a change de largeur : le document se recalcule, en gardant sa page.
1117
+ var c=cur||1;
1118
+ if(pdfDoc||IS_IMG){ build(); if(onePage) showPage(c); else restaurerPage(c); }
1119
+ }
1120
+
1121
+ function tournerDe(quarts){
1122
+ rot=Player.viewer.rotationEffective(rot,quarts*90);
1123
+ // En presentation, l audience suit : le meme message que la page, donc le meme ordre.
1124
+ try{ if(PRES) pushPage(); }catch(e){}
1125
+ var c=cur||1;
1126
+ if(!pdfDoc && !IS_IMG) return;
1127
+ build();
1128
+ if(vignOuvert) vignConstruire(); // la proportion des vignettes a change
1129
+ if(onePage) showPage(c); else restaurerPage(c);
1130
+ }
1131
+ window.__refit=function(){ if(!pdfDoc)return; var c=cur||1; build(); restaurerPage(c); };
745
1132
  var _rzT; window.addEventListener('resize',function(){ clearTimeout(_rzT); _rzT=setTimeout(function(){ window.__refit(); },160); });
746
1133
  // ÉCRAN PARTAGÉ mobile : le fond au-dessus/en-dessous du document prolonge les couleurs de la page
747
1134
  // (échantillon des bords haut/bas du canvas) — du header jusqu'à la vidéo, dynamique à chaque page.
@@ -766,7 +1153,7 @@ ${LEGAL_CSS}
766
1153
  if(taches[n]){ try{ taches[n].cancel(); }catch(e){} delete taches[n]; }
767
1154
  var w=Math.round(targetWidth());
768
1155
  el.innerHTML=''; el.className='page ph'; el.textContent='Page '+n;
769
- el.style.width=w+'px'; el.style.height=Math.round(w*firstAspect)+'px';
1156
+ el.style.width=w+'px'; el.style.height=Math.round(w*aspectEffectif())+'px';
770
1157
  delete rendered[n]; delete enCours[n];
771
1158
  }
772
1159
  // ATTENTION : ON BORNE PAR UN BUDGET DE PIXELS, PAS SEULEMENT PAR UN NOMBRE DE PAGES.
@@ -805,8 +1192,21 @@ ${LEGAL_CSS}
805
1192
  if(IS_IMG){
806
1193
  var w=Math.round(targetWidth());
807
1194
  var im=document.createElement('img');
808
- im.src=imgSrc; im.alt=''; im.style.width=w+'px'; im.style.display='block';
809
- el.style.height=''; el.classList.remove('ph'); el.textContent=''; el.style.width=w+'px';
1195
+ im.src=imgSrc; im.alt=''; im.style.display='block';
1196
+ el.classList.remove('ph'); el.textContent=''; el.style.width=w+'px';
1197
+ // ⚠️ UN DOCUMENT IMAGE N A PAS DE VIEWPORT : sa rotation ne peut etre qu une transformation CSS.
1198
+ // Et un element transforme occupe TOUJOURS sa boite d origine — sans echanger les dimensions du
1199
+ // cadre et sortir l image du flux, la mise en page se disloque : la page suivante viendrait se
1200
+ // poser par-dessus.
1201
+ if(rot===90||rot===270){
1202
+ im.style.width=Math.round(w/(firstAspect||1))+'px';
1203
+ im.style.position='absolute'; im.style.left='50%'; im.style.top='50%';
1204
+ im.style.transform='translate(-50%,-50%) rotate('+rot+'deg)';
1205
+ el.style.height=Math.round(w*aspectEffectif())+'px';
1206
+ } else {
1207
+ im.style.width=w+'px'; el.style.height='';
1208
+ if(rot===180) im.style.transform='rotate(180deg)';
1209
+ }
810
1210
  el.appendChild(im);
811
1211
  im.onload=function(){ if(gen!==renderGen)return; delete enCours[n]; rendered[n]=1; hideLoader(); };
812
1212
  im.onerror=function(){ delete enCours[n]; }; // reessayable
@@ -821,8 +1221,14 @@ ${LEGAL_CSS}
821
1221
  // ×2 est nul, le cout est quadratique. On plafonne le DPR, puis on plafonne le NOMBRE DE
822
1222
  // PIXELS du canvas : deux bornes, parce que le zoom peut faire grandir la page independamment
823
1223
  // de la densite de l ecran.
824
- var scale=Math.min(5,targetWidth()/page.getViewport({scale:1}).width);
825
- var v=page.getViewport({scale:scale});
1224
+ // ⚠️ ON COMPOSE, ON N ECRASE PAS. La documentation de getViewport dit : « si omise, la rotation
1225
+ // vaut celle de la page ». Passer une valeur ABSOLUE ecraserait donc le /Rotate du fichier — que
1226
+ // portent tres couramment les documents numerises en paysage. Un « remettre a zero » qui passerait
1227
+ // 0 ne redresserait pas un document de travers : il COUCHERAIT un document qui etait droit. La
1228
+ // composition vit dans src/viewer.ts, ou elle est eprouvee.
1229
+ var rotEff=Player.viewer.rotationEffective(page.rotate,rot);
1230
+ var scale=Math.min(5,targetWidth()/page.getViewport({scale:1,rotation:rotEff}).width);
1231
+ var v=page.getViewport({scale:scale,rotation:rotEff});
826
1232
  // ATTENTION : LE PLANCHER A 1 CREVAIT LE BUDGET, ET LE BUDGET ETAIT ANNONCE.
827
1233
  //
828
1234
  // L ancienne formule reduisait le DPR sans jamais le laisser descendre SOUS 1. Or au zoom
@@ -7,6 +7,13 @@ const crypto = require("crypto");
7
7
  // Base de données via le contexte injecté (cf. _player-context.js) — aucune adhérence au studio.
8
8
  // ⚠️ Le contexte est REÇU, pas construit. Ce module ne doit pas savoir d'où il vient : c'est ce
9
9
  // qui lui permettra de partir dans le dépôt du player sans emporter le studio avec lui.
10
+ // Les quatre orientations qu'une vue peut prendre. ÉNONCÉE UNE FOIS : cette porte est franchie à
11
+ // l'écriture (ce que le navigateur du présentateur envoie) ET à la lecture (ce qu'une base peut
12
+ // porter si un hôte l'a écrite à la main). Deux copies d'une liste blanche, c'est une divergence en
13
+ // attente — et c'est exactement ce qui vient d'arriver entre le serveur et le navigateur sur le
14
+ // TYPE accepté.
15
+ const ROTATIONS = [0, 90, 180, 270];
16
+
10
17
  let PLAYER = null;
11
18
  function init(ctx) { PLAYER = ctx; _bumpSansDurcissementJusqua = 0; _avertRpcPresence = false; _etatDurcissement = "inconnu"; }
12
19
 
@@ -115,7 +122,34 @@ async function reclaimPresentation(slug, email) {
115
122
  { control_hash: sha(control), active: true, ...(rangDispo ? { write_seq: 0 } : {}), last_seen: new Date().toISOString(), updated_at: new Date().toISOString() },
116
123
  );
117
124
  if (!ecrit) return { ok: false, status: 409 };
118
- return { ok: true, slug, control, page: row.current_page || 1, fileUrl: row.file_url, fileName: row.file_name, docTitle: row.doc_title, docId: row.doc_id };
125
+ // ⚠️ LA REPRISE RENDAIT LA PAGE ET PAS L'ORIENTATION, ET C'EST UNE DIVERGENCE MUETTE. Le
126
+ // présentateur qui recharge repart avec `rot = 0` — c'est voulu, un rechargement remet la vue à
127
+ // son état d'origine — pendant que l'audience continue d'afficher la `view_rotation` de la base.
128
+ // Il voyait donc son document droit et l'audience le voyait couché, sans que rien ne le dise à
129
+ // personne : il n'a aucune raison de toucher le bouton, de son côté tout va bien.
130
+ //
131
+ // La règle est celle qui valait déjà pour la page, étendue à ce qui manquait : À LA REPRISE, LA
132
+ // PRÉSENTATION L'EMPORTE SUR L'ÉTAT LOCAL. Au DÉMARRAGE c'est l'inverse, et c'est cohérent —
133
+ // `startPresent` pousse la rotation courante du présentateur, parce qu'il n'y a encore rien à
134
+ // reprendre.
135
+ //
136
+ // ⚠️ AUCUN PIÈGE DE MIGRATION ICI, et il vaut la peine de dire pourquoi plutôt que de le
137
+ // supposer : on LIT, `getPresentation` lit `select=*`, donc chez un hôte non migré la colonne est
138
+ // simplement absente — `undefined`, puis 0. Le rejet du PATCH entier sur colonne inconnue ne
139
+ // concerne que l'écriture, et il n'y en a pas.
140
+ //
141
+ // Normalisée comme à l'écriture, par la MÊME liste blanche : une base qu'un hôte a écrite à la
142
+ // main peut porter n'importe quoi, et un viewport oblique casse la couche de texte de toute
143
+ // l'audience.
144
+ // ⚠️ LA VALEUR EST EXTRAITE SUR SA PROPRE LIGNE, ET CE N'EST PAS DU STYLE. Écrit en ternaire —
145
+ // `… ? row.view_rotation : 0` — ce code est indiscernable d'une clé d'objet `view_rotation:` pour
146
+ // la garde `colonneMigreeConditionnelle`, qui refuse toute colonne migrée écrite sans condition.
147
+ // Elle avait raison les deux fois précédentes ; ici elle se trompe, parce qu'on LIT. Mais la
148
+ // rendre assez fine pour distinguer un ternaire d'une clé lui ferait rater de vrais PATCH, et une
149
+ // garde qui sur-approxime est le bon compromis quand le coût de s'y plier est une ligne.
150
+ const brute = row.view_rotation;
151
+ const rot = typeof brute === "number" && ROTATIONS.includes(brute) ? brute : 0;
152
+ return { ok: true, slug, control, page: row.current_page || 1, rotation: rot, fileUrl: row.file_url, fileName: row.file_name, docTitle: row.doc_title, docId: row.doc_id };
119
153
  }
120
154
 
121
155
  // Heartbeat présentateur (control requis) : rafraîchit last_seen → distingue une présentation vivante d'une orpheline.
@@ -296,7 +330,7 @@ async function rangAccepte(row, seq) {
296
330
  return { controle: true, perime: false, rang };
297
331
  }
298
332
 
299
- async function setPage(slug, control, page, seq) {
333
+ async function setPage(slug, control, page, seq, rotation) {
300
334
  const row = await getPresentation(slug);
301
335
  if (!row) return { ok: false, status: 404 };
302
336
  if (!tokenMatches(control, row.control_hash)) return { ok: false, status: 403 };
@@ -305,12 +339,26 @@ async function setPage(slug, control, page, seq) {
305
339
  // navigateur n'affiche rien — la page qu'il voulait écrire est déjà dépassée par une plus récente.
306
340
  if (rang.perime) return { ok: true, perime: true };
307
341
  const p = Math.max(1, Math.trunc(Number(page) || 1));
342
+ // ⚠️ UNE LISTE BLANCHE, PAS UNE NORMALISATION. Le navigateur du présentateur ramène déjà sa
343
+ // rotation au quart de tour ; tout ce qui arrive ici hors des quatre valeurs attendues est donc
344
+ // malformé ou hostile, et se ramène à zéro plutôt qu'à « la valeur la plus proche ». Deviner ce
345
+ // qu'un client cassé voulait dire lui donnerait raison.
346
+ //
347
+ // ⚠️ ET LE TYPE COMPTE, PAS SEULEMENT LA VALEUR. `Number("90")` vaut 90 : accepter la chaîne
348
+ // rendrait cette porte PLUS PERMISSIVE que celle du navigateur, où `rotationEffective` exige un
349
+ // nombre. Deux validateurs du même geste qui ne disent pas la même chose, c'est une des deux qui
350
+ // ment — et on ne saurait pas laquelle. Relevé par le banc, pas par la relecture.
351
+ const rot = typeof rotation === "number" && ROTATIONS.includes(rotation) ? rotation : 0;
352
+ // ⚠️ CONDITIONNEL, ET C'EST LA COLONNE QUI L'IMPOSE. PostgREST rejette le PATCH ENTIER si elle
353
+ // manque : la nommer chez un hôte non migré ne perdrait pas la rotation, elle perdrait AUSSI le
354
+ // changement de page. La rotation ne se propage simplement pas là-bas, et rien d'autre ne bouge.
355
+ const rotDispo = await require("./schema").attendue("rotationDirecte");
308
356
  // Le jeton DANS la condition : si la présentation a été terminée entre-temps, il a été annulé
309
357
  // et cette écriture ne trouve plus sa ligne. Le rang aussi, pour que deux écritures concurrentes
310
358
  // ne puissent pas s'inverser — la comparaison et l'écriture deviennent le même geste.
311
359
  const ecrit = await ecrireSiEncoreVrai(
312
360
  `doc_presentations?slug=eq.${enc(slug)}&control_hash=eq.${sha(control)}${filtreRang(rang)}`,
313
- { current_page: p, active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString(), ...(rang.controle ? { write_seq: rang.rang } : {}) },
361
+ { current_page: p, active: true, last_seen: new Date().toISOString(), updated_at: new Date().toISOString(), ...(rang.controle ? { write_seq: rang.rang } : {}), ...(rotDispo ? { view_rotation: rot } : {}) },
314
362
  );
315
363
  // Refus SILENCIEUX, comme un rang périmé : le navigateur n'a rien à afficher, la page qu'il
316
364
  // voulait écrire n'a simplement plus de présentation où aller.
@@ -39,7 +39,7 @@ async function traiter(req, res, body, _slug) {
39
39
  return jp(200, { ok: true, slug: out.slug, control: out.control });
40
40
  }
41
41
  const r = body.action === "present-page"
42
- ? await setPage(String(body.slug || ""), String(body.control || ""), body.page, body.seq)
42
+ ? await setPage(String(body.slug || ""), String(body.control || ""), body.page, body.seq, body.rotation)
43
43
  : body.action === "present-touch"
44
44
  ? await touchPresentation(String(body.slug || ""), String(body.control || ""))
45
45
  : await endPresentation(String(body.slug || ""), String(body.control || ""));
package/server/schema.js CHANGED
@@ -39,6 +39,11 @@ const ATTENDUES = {
39
39
  migration: "supabase/migrations/0002-ordre-des-ecritures.sql",
40
40
  fonction: "refuser une écriture de pilotage doublée en vol",
41
41
  },
42
+ rotationDirecte: {
43
+ table: "doc_presentations", colonne: "view_rotation",
44
+ migration: "supabase/migrations/0024-rotation-en-direct.sql",
45
+ fonction: "faire suivre à l'audience l'orientation que le présentateur donne au document",
46
+ },
42
47
  envoiUnique: {
43
48
  table: "doc_presentation_messages", colonne: "client_key",
44
49
  migration: "supabase/migrations/0005-envoi-unique.sql",
@@ -185,6 +190,24 @@ function etatDuSchema() {
185
190
  const attendues = Object.keys(ATTENDUES).length;
186
191
  return {
187
192
  attendues,
193
+ // ⚠️ CE QUE CE CODE SAIT ATTENDRE — ET SANS CETTE LISTE, « complet » EST UN VERT QUI NE PEUT PAS
194
+ // ROUGIR. La session STUDIO l'a mesuré des deux côtés le 30/08 : elle applique la 0024, relit la
195
+ // carte, et lit `attendues: 9 · sondees: 9 · complet · manquant: []` — mot pour mot ce qu'elle
196
+ // lisait AVANT la migration. Elle ne pouvait pas voir la 0024 quitter une liste où elle n'était
197
+ // jamais entrée : leur player est en 0.1.142, et `view_rotation` n'entre dans ATTENDUES qu'avec
198
+ // la version qui apporte la fonction.
199
+ //
200
+ // `complet` a donc toujours voulu dire « complet POUR LE CODE QUE JE SUIS », jamais « complet
201
+ // pour le dépôt » — et rien, ni ici ni dans HOST-CONTRACT.md, ne disait la différence. Un hôte
202
+ // qui applique le schéma AVANT le code (l'ordre sûr, celui que nous recommandons) est
203
+ // exactement celui que ce vert ne renseigne pas.
204
+ //
205
+ // Aucune version de ce serveur ne peut connaître une migration postérieure à elle. Ce qu'elle
206
+ // peut faire, et que la carte ne faisait pas, c'est DIRE CE QU'ELLE CONNAÎT : « 0024 n'est pas
207
+ // dans cette liste » devient une lecture directe, au lieu d'une déduction qui demande de
208
+ // connaître l'historique du dépôt. C'est la seule forme de réponse qui reste vraie quand le
209
+ // lecteur est plus récent que nous.
210
+ connues: [...new Set(Object.values(ATTENDUES).map((a) => a.migration))].sort(),
188
211
  sondees: reponses.size,
189
212
  // ⚠️ UN MOT, PAS UN TABLEAU VIDE À INTERPRÉTER. `manquant: []` a quatre sens selon ce qu'on
190
213
  // sait par ailleurs — rien demandé, tout vérifié, vérifié en partie, base muette — et forcer
package/supabase/init.sql CHANGED
@@ -174,6 +174,10 @@ create table if not exists public.doc_presentations (
174
174
  doc_title text,
175
175
  presenter_name text,
176
176
  current_page integer not null default 1,
177
+ -- Orientation imposée par le PRÉSENTATEUR (0/90/180/270), distincte du /Rotate que porte le
178
+ -- fichier : le player les COMPOSE. Voir 0024 — elle est normalisée à la RÉCEPTION, cette valeur
179
+ -- pouvant arriver du navigateur du présentateur par la voie broadcast.
180
+ view_rotation integer not null default 0,
177
181
  active boolean not null default true,
178
182
  created_at timestamptz not null default now(),
179
183
  updated_at timestamptz not null default now(),
@@ -359,7 +363,8 @@ create index if not exists doc_tts_objects_created_idx
359
363
  on public.doc_tts_objects (created_at);
360
364
  -- RLS activée SANS politique : sous RLS, l'absence de politique refuse tout — seul le rôle de
361
365
  -- service passe. Même posture que `player_rate_limits`, et pour la même raison : ni un visiteur ni
362
- -- l'équipe n'ont de raison de lire cette table.
366
+ -- l'équipe n'ont de raison de lire cette table. ⚠️ « Seul le rôle de service » est vrai en effet,
367
+ -- pas en droit : voir le bloc « Accès » plus bas, et l'en-tête de la migration 0021.
363
368
  alter table public.doc_tts_objects enable row level security;
364
369
  comment on table public.doc_tts_objects is
365
370
  'Trace des objets écrits dans le bucket public tts-cache : une empreinte, une date, jamais le '
@@ -588,6 +593,88 @@ $$;
588
593
  -- RLS activé SANS politique permissive : seul `service_role` (donc la route du player) passe.
589
594
  -- ⚠️ N'ajoutez pas de politique de lecture publique « pour que le direct fonctionne ». C'est
590
595
  -- précisément l'état dont l'hôte historique a dû sortir : le suivi passe par broadcast.
596
+ --
597
+ -- ⚠️ ET « SEUL `service_role` PASSE » EST VRAI EN EFFET, PAS EN DROIT. Un hôte l'a relevé le 27/08 :
598
+ -- sur une installation de type Supabase, `anon` possède le droit SELECT sur ces tables, hérité des
599
+ -- privilèges par défaut du schéma `public`. Ce n'est donc pas l'absence de `grant` qui les ferme,
600
+ -- c'est UNIQUEMENT la RLS — et il n'y a rien en dessous. Une politique permissive ajoutée « pour
601
+ -- débloquer un cas » ne retire pas une protection sur deux : elle retire la seule. Un hôte qui veut
602
+ -- la seconde couche l'écrit chez lui (`revoke select … from anon, authenticated`) ; ce dépôt ne la
603
+ -- pose pas à sa place, ces rôles étant ceux de son installation et non de Postgres.
604
+ --
605
+ -- ⚠️ ET CE CONSEIL ÉTAIT INCOMPLET — SA PRÉCONDITION MANQUAIT, ET SANS ELLE IL CASSE. Un `revoke`
606
+ -- global n'est sûr QUE là où aucune politique n'accorde. Là où une politique permissive nomme
607
+ -- `anon`, la RLS dit oui et le droit dit non : la surface publique tombe, sans erreur de
608
+ -- configuration nulle part. Sur les tables de CE fichier la condition est remplie — zéro politique,
609
+ -- rien d'ouvert à refermer — mais un hôte qui généralise le geste à SA base applicative peut y
610
+ -- perdre une carte publique. Un hôte l'a mesuré le 28/08 : il a posé le `revoke` sur les dix tables
611
+ -- du player, et ne l'a PAS posé sur ses 221 tables applicatives, dont trois portent une politique
612
+ -- nommant `anon`.
613
+ --
614
+ -- ⚠️ ET LA VÉRIFICATION QUI TRANCHE N'EST PAS CELLE QU'ON CROIT — TROIS ÉCRITURES, TROIS PORTÉES.
615
+ -- La première proposée ici filtrait `roles::text like '%anon%'`. Or le geste conseillé retire le
616
+ -- droit à `anon` ET à `authenticated` : une base dont toutes les politiques nomment
617
+ -- `authenticated` obtient donc ZÉRO LIGNE — un feu vert — alors qu'elle est au maximum exposée.
618
+ -- Relevé par un hôte le 28/08, chez qui 28 politiques sur 28 nomment `authenticated` et aucune
619
+ -- `anon`. Une sonde dont le filtre est plus étroit que le geste qu'elle vérifie rend un zéro qui
620
+ -- veut dire « je n'ai pas regardé », pas « il n'y a rien ».
621
+ --
622
+ -- ⚠️ ET LA DEUXIÈME ÉCRITURE AVAIT LE MÊME DÉFAUT D'UN CRAN PLUS BAS : elle choisissait UN rôle par
623
+ -- politique (`case when … then 'anon' else 'authenticated' end`). Sur une politique qui nomme les
624
+ -- deux et où seul `authenticated` a perdu le droit, elle regarde `anon`, le trouve intact, et ne
625
+ -- dit rien. Mesuré contre un vrai Postgres : elle voit 1 des 2 tables cassées.
626
+ --
627
+ -- La forme qui tient déplie CHAQUE rôle nommé, et mesure l'ÉTAT RÉSULTANT plutôt que de demander si
628
+ -- le `revoke` a été posé — c'est la seule chose qui compte pour l'hôte :
629
+ --
630
+ -- ⚠️ LES DEUX MARQUES CI-DESSOUS NE SONT PAS DÉCORATIVES : LA CI EXTRAIT CE QUI EST ENTRE ELLES ET
631
+ -- L'EXÉCUTE. Ce bloc n'est donc pas la transcription d'une requête éprouvée ailleurs — c'est
632
+ -- l'artefact éprouvé lui-même. Le job `schema` monte trois profils de politique contre un vrai
633
+ -- Postgres, en casse un dans chacun, et exige que CETTE requête nomme la table morte à chaque fois.
634
+ -- Si vous l'éditez, c'est votre version qui est mesurée à la course suivante ; si vous la cassez, la
635
+ -- CI le dit avant l'hôte. Aucune des trois écritures de cette requête n'a été trouvée fautive par
636
+ -- son auteur — d'où le banc plutôt qu'une relecture de plus.
637
+ -- ⚠️ ET IL Y AVAIT UN QUATRIÈME PROFIL, QUI EST LA FORME PAR DÉFAUT — TROUVÉ PAR UN HÔTE LE 29/08
638
+ -- ALORS QUE LE BANC CI-DESSOUS TOURNAIT DÉJÀ SUR TROIS. Une politique écrite SANS clause `TO`
639
+ -- s'applique à `PUBLIC`, donc à tous les rôles, `anon` compris — et `pg_policies` l'affiche
640
+ -- `{public}`, jamais `{anon}`. Un filtre `in ('anon','authenticated')` la saute intégralement. Ce
641
+ -- n'est pas un cas exotique : c'est ce que produit l'interface Supabase pour un bucket public, et
642
+ -- l'hôte en a trouvé cinq chez lui, dont les trois qui servent ses buckets publics. Un hôte qui pose
643
+ -- le `revoke` puis lance la requête obtient alors zéro ligne pendant que ses buckets sont morts.
644
+ --
645
+ -- ⚠️ ET LE `case` NE CHOISIT PAS UN RÔLE, IL EN DÉPLIE DEUX. Tester `anon` comme représentant de
646
+ -- `public` raterait le cas où seul `authenticated` est privé — l'hôte l'a écrit lui-même en
647
+ -- proposant sa piste, et c'est mesuré : sur une politique `{public}` dont seul `authenticated` a
648
+ -- perdu le droit, la version « un représentant » se tait, celle qui déplie rend la ligne.
649
+ --
650
+ -- ⚠️ ET `to_regrole` N'EST PAS UNE COQUETTERIE. Sur une installation qui n'a NI `anon` NI
651
+ -- `authenticated` — tout ce qui n'est pas Supabase — `has_table_privilege('anon', …)` ne rend pas
652
+ -- faux : il LÈVE « role "anon" does not exist », et la requête entière échoue. Filtrer sur
653
+ -- `pg_roles` ne suffit pas, mesuré : le planificateur évalue la fonction dans le même filtre de
654
+ -- jointure et la sous-requête ne la court-circuite pas. `to_regrole` rend NULL sur un rôle absent,
655
+ -- le prédicat devient NULL, la ligne sort du résultat — quel que soit l'ordre d'évaluation.
656
+ --
657
+ -- [banc:requete-diagnostic]
658
+ -- select p.schemaname, p.tablename, p.policyname, e.role
659
+ -- from pg_policies p
660
+ -- cross join lateral unnest(p.roles) as r(role)
661
+ -- cross join lateral unnest(
662
+ -- case when r.role::text = 'public' then array['anon', 'authenticated']
663
+ -- else array[r.role::text] end
664
+ -- ) as e(role)
665
+ -- join pg_class c on c.relname = p.tablename
666
+ -- join pg_namespace n on n.oid = c.relnamespace and n.nspname = p.schemaname
667
+ -- where e.role in ('anon', 'authenticated')
668
+ -- and not has_table_privilege(to_regrole(e.role), c.oid, 'SELECT');
669
+ -- [/banc:requete-diagnostic]
670
+ --
671
+ -- Rien en retour : aucune politique n'est privée du droit qu'elle suppose. Une ligne : cette
672
+ -- politique est MORTE — la RLS dit oui, le droit dit non — et la ligne nomme la table et le rôle.
673
+ --
674
+ -- ⚠️ ET CETTE POSTURE EST DÉSORMAIS MESURÉE, PAS SEULEMENT ÉCRITE. Elle l'était ici dix fois et
675
+ -- confrontée zéro fois — aucun banc, aucune étape ne demandait à la base si elle en avait tenu
676
+ -- compte. Le job `schema` relève maintenant ce que ces fichiers DÉCLARENT et le confronte à ce que
677
+ -- le moteur a RETENU, politiques comprises.
591
678
  alter table public.commercial_doc_shares enable row level security;
592
679
  alter table public.commercial_doc_views enable row level security;
593
680
  alter table public.commercial_doc_sessions enable row level security;
@@ -689,6 +776,17 @@ alter table public.commercial_doc_shares
689
776
  add column if not exists revoked_at timestamptz;
690
777
  update public.commercial_doc_shares set revoked_at = now() where revoked = true and revoked_at is null;
691
778
 
779
+ -- 0024, et le rattrapage a été appris ICI, par le job `schema` : la colonne était déclarée dans le
780
+ -- corps de `doc_presentations` ci-dessus — ce qui suffit pour une base VIERGE — et le scénario
781
+ -- « init v0.1.64 → rejeu de l'init actuel » a refusé la forme obtenue. `create table if not
782
+ -- exists` ne fait RIEN sur une table déjà là, donc un hôte installé avant aujourd'hui n'aurait
783
+ -- jamais vu `view_rotation` en rejouant ce fichier, et sa sonde de schéma aurait dégradé la
784
+ -- rotation en silence : exactement le mode de panne que tout le paragraphe ci-dessus décrit.
785
+ -- Le type, la nullabilité et le défaut sont ceux de la 0024, au caractère près — c'est ce que le
786
+ -- job compare.
787
+ alter table public.doc_presentations
788
+ add column if not exists view_rotation integer not null default 0;
789
+
692
790
  -- ⚠️ ET LE RATTRAPAGE VAUT AUSSI POUR LES CONTRAINTES, PAS SEULEMENT POUR LES COLONNES (0020).
693
791
  -- Elles sont déclarées dans le corps des tables ci-dessus, ce qui règle la base VIERGE — et ne
694
792
  -- touche pas une base déjà installée, où `create table if not exists` ne fait rien. Le scénario
@@ -41,6 +41,33 @@ create index if not exists doc_tts_objects_created_idx
41
41
  -- politique n'est pas « ouverte par défaut » : sous RLS, l'absence de politique REFUSE tout. C'est
42
42
  -- la même posture que `player_rate_limits`, et elle est délibérée : cette table n'a aucune raison
43
43
  -- d'être lue par un visiteur, ni par l'équipe.
44
+ --
45
+ -- ⚠️ ET « PERSONNE » EST VRAI EN EFFET, PAS EN DROIT — LA NUANCE EST TOUT CE QUI VOUS PROTÈGE.
46
+ -- Un hôte l'a relevé le 27/08 en appliquant cette migration : sur une installation de type
47
+ -- Supabase, `anon` possède le droit SELECT sur cette table, hérité des privilèges par défaut du
48
+ -- schéma `public`. Ce n'est donc PAS l'absence de `grant` qui ferme la table — c'est UNIQUEMENT la
49
+ -- RLS, et il n'y a rien en dessous. Le jour où quelqu'un ajoute une politique permissive « pour
50
+ -- débloquer un cas », il ne retire pas une protection sur deux : il retire la seule. Si vous
51
+ -- voulez la seconde couche, elle s'écrit chez vous (`revoke select on public.doc_tts_objects from
52
+ -- anon, authenticated;`) — ce dépôt ne la pose pas à votre place, parce que ces rôles sont ceux de
53
+ -- votre installation, pas de Postgres.
54
+ --
55
+ -- ⚠️ ET LA PRÉCONDITION DE CE CONSEIL, QUI MANQUAIT : un `revoke` n'est sûr QUE là où aucune
56
+ -- politique n'accorde. Sur cette table la condition est remplie (zéro politique). Sur VOTRE base
57
+ -- applicative, une politique permissive qui nomme `anon` OU `authenticated` ferait dire oui à la RLS
58
+ -- et non au droit : la surface publique tombe sans qu'aucune configuration paraisse fautive.
59
+ --
60
+ -- ⚠️ ET LA REQUÊTE DE VÉRIFICATION DOIT COUVRIR CHAQUE RÔLE NOMMÉ — Y COMPRIS CEUX QU'UNE POLITIQUE
61
+ -- NE NOMME PAS. Sans quoi elle rend un zéro rassurant sur une base exposée : TROIS écritures s'y
62
+ -- sont cassées, chacune trouvée par quelqu'un d'autre que son auteur, toutes mesurées contre un vrai
63
+ -- Postgres. La dernière butait sur le cas le plus courant de tous — une politique écrite sans clause
64
+ -- `TO` porte `{public}`, jamais `{anon}`, et c'est la forme que produit l'interface Supabase pour un
65
+ -- bucket public. La forme qui tient est dans le bloc « Accès » d'`init.sql` ; elle déplie `public`
66
+ -- en ses rôles concrets et mesure l'ÉTAT RÉSULTANT (`has_table_privilege`) au lieu de demander si le
67
+ -- `revoke` a été posé. La forge l'EXÉCUTE à chaque course contre quatre profils de politique.
68
+ --
69
+ -- Ce que la forge vérifie désormais à chaque course, contre une vraie base : que la RLS déclarée
70
+ -- ici est bien RETENUE par le moteur, et qu'AUCUNE politique n'ouvre cette table.
44
71
  alter table public.doc_tts_objects enable row level security;
45
72
 
46
73
  comment on table public.doc_tts_objects is
@@ -0,0 +1,34 @@
1
+ -- LA ROTATION DU PRÉSENTATEUR NE SUIVAIT PAS SON AUDIENCE — ELLE N'AVAIT NULLE PART OÙ VOYAGER.
2
+ --
3
+ -- ⚠️ CE QUI MANQUAIT. Depuis la 0.1.143, un présentateur peut redresser un document à 90° dans sa
4
+ -- visionneuse. Sa rotation restait LOCALE : l'audience continuait de voir le document couché
5
+ -- pendant qu'il commentait un document droit. Le pilotage en direct transporte la page courante, le
6
+ -- document et la vue carte — pas l'orientation.
7
+ --
8
+ -- ⚠️ POURQUOI `view_rotation` ET NON `rotation`. Le nom dit ROTATION DE LA VUE, par opposition au
9
+ -- `/Rotate` que porte le fichier PDF lui-même : ce sont deux choses différentes, et le player les
10
+ -- COMPOSE au lieu de les confondre. Le nom court aurait aussi collisionné avec l'option `rotation`
11
+ -- de pdf.js, présente partout dans le code de la visionneuse — la garde qui vérifie qu'une colonne
12
+ -- migrée n'est jamais écrite sans condition aurait alors crié en permanence sur des lignes qui ne
13
+ -- touchent pas la base. Une alerte qui sonne quand tout va bien apprend à cliquer à côté.
14
+ --
15
+ -- ⚠️ POURQUOI UNE COLONNE ET PAS LA COLONNE `content`. `content` porte un type VALIDÉ (carte ou
16
+ -- Street View) et il est ré-assaini à la réception. Y glisser une orientation reviendrait à faire
17
+ -- porter deux sens au même champ, et à affaiblir le validateur qui protège toute l'audience d'une
18
+ -- vue imposée par un participant. Une orientation n'est pas un contenu.
19
+ --
20
+ -- ⚠️ Sans lui : RIEN NE CASSE, ET LA ROTATION NE SE PROPAGE SIMPLEMENT PAS. Le player interroge la
21
+ -- présence de cette colonne avant d'écrire (`GET /api/doc?schema=1` la nomme). Chez un hôte non
22
+ -- migré, le présentateur tourne son document pour lui seul, exactement comme avant — mais PostgREST
23
+ -- rejetterait le PATCH ENTIER si on la nommait sans qu'elle existe, ce qui ferait perdre AUSSI le
24
+ -- changement de page. C'est pourquoi le champ est conditionnel côté serveur et non systématique.
25
+ --
26
+ -- ⚠️ IDEMPOTENTE : `if not exists`, rejouable sans effet.
27
+
28
+ alter table public.doc_presentations
29
+ add column if not exists view_rotation integer not null default 0;
30
+
31
+ comment on column public.doc_presentations.view_rotation is
32
+ 'Orientation du document imposée par le présentateur, en degrés (0, 90, 180, 270). Normalisée au '
33
+ 'quart de tour à la RÉCEPTION : sur la voie broadcast, cette valeur vient du navigateur du '
34
+ 'présentateur, et un viewport oblique casserait la couche de texte de toute l''audience.';