<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dais Remote</title>
<style>
*{box-sizing:border-box}[hidden]{display:none!important}
html,body{margin:0;min-height:100%;background:#101316;color:#edf2f4;font:15px/1.4 system-ui,-apple-system,Segoe UI,sans-serif}
body{display:flex;flex-direction:column}
header{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#181d22;border-bottom:1px solid #2a323a}
h1{font-size:17px;margin:0;font-weight:650}
#conn{font-size:12px;color:#8fbf9f}
main{display:grid;grid-template-columns:1fr;gap:10px;padding:10px;max-width:1080px;width:100%;margin:0 auto}
.band{background:#181d22;border:1px solid #2a323a;border-radius:8px;padding:10px}
.slides{display:grid;grid-template-columns:1fr 42%;gap:10px;align-items:start}
.slide img{display:block;width:100%;height:auto;background:#0a0c0f;border-radius:5px;border:1px solid #303840}
.slide.whiteboard img,.drawSurface.whiteboard img{opacity:0}
.slide.whiteboard .imageBack,.drawSurface.whiteboard{background:#fff;border-radius:5px}
.slide .label{display:flex;justify-content:space-between;color:#a7b0b8;font-size:12px;margin-bottom:6px}
.timer{font-variant-numeric:tabular-nums;font-size:42px;font-weight:720;line-height:1;color:#f2c14e}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.split{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.tabs{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
button{appearance:none;border:1px solid #3a4650;background:#252d35;color:#f5f7f8;border-radius:7px;padding:10px 12px;font:inherit;font-weight:650;min-height:42px}
button:active{transform:translateY(1px);background:#33404a}
button.active{background:#d8e0e8;border-color:#f5f7f8;color:#101316}
button.primary{background:#2764d8;border-color:#3e7df0}
button.warn.active{background:#4d2630;border-color:#824250}
button.good{background:#244836;border-color:#3b7556}
.view[hidden]{display:none}
.overview{display:grid;grid-template-columns:repeat(auto-fill,minmax(124px,1fr));gap:10px}
.thumb{position:relative;display:block;padding:0;border-color:#3a4650;background:#11161b;min-height:0;overflow:hidden;text-align:left}
.thumb img{display:block;width:100%;aspect-ratio:16/9;object-fit:contain;background:#080a0d}
.thumb span{display:block;padding:7px 8px;color:#c7d0d8;font-size:13px}
.thumb.current{border-color:#f2c14e;box-shadow:0 0 0 2px rgba(242,193,78,.35)}
.nav button{font-size:20px;min-height:56px}
.notes{white-space:pre-wrap;min-height:90px;color:#d8dee4}
.notes p{margin:0 0 .7em}
.notes h1,.notes h2,.notes h3{margin:.15em 0 .45em;line-height:1.15}
.notes h1{font-size:1.35em}.notes h2{font-size:1.2em}.notes h3{font-size:1.08em}
.notes ul{margin:.2em 0 .8em;padding-left:1.25em}
.notes li{margin:.18em 0}
.notes code{background:#10161d;border:1px solid #303840;border-radius:4px;padding:0 .22em}
.notesView .notes{min-height:58vh;font-size:20px;line-height:1.55}
textarea.notesEdit{display:block;width:100%;min-height:58vh;background:#11161b;border:1px solid #3e7df0;border-radius:7px;color:#edf2f4;font:20px/1.55 system-ui,-apple-system,Segoe UI,sans-serif;padding:10px;resize:vertical}
@media (max-width:720px){textarea.notesEdit{font-size:18px;min-height:62vh}}
.swatch{width:28px;min-height:28px;padding:0;border-radius:50%}
.swatch.active,.drawWidth.active{outline:2px solid #edf2f4;outline-offset:2px}
.textBoxLayer{position:absolute;inset:0;border-radius:5px;overflow:hidden;touch-action:none;pointer-events:none}
.drawSurface.textMode .textBoxLayer{pointer-events:auto;cursor:text}
.drawSurface.textMode canvas{pointer-events:none}
.remoteTextBox{position:absolute;overflow:hidden;border:1px solid rgba(100,160,255,.55);background:rgba(255,255,255,.1);color:#111;padding:0;white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.25 system-ui,-apple-system,Segoe UI,sans-serif}
.remoteTextBox.placeholder{padding:4px}
.remoteTextBox img{display:block;width:100%;height:100%;object-fit:fill;pointer-events:none}
.remoteTextBox.empty{color:#627080;font-style:italic}
.remoteTextBox.selected{border:2px solid #64a0ff;box-shadow:0 0 0 1px rgba(0,0,0,.35)}
.remoteTextBox .resizeHandle{position:absolute;right:0;bottom:0;width:14px;height:14px;background:#64a0ff;border-left:1px solid #111;border-top:1px solid #111;cursor:nwse-resize}
.textBoxDraft{position:absolute;border:1px dashed #64a0ff;background:rgba(100,160,255,.12)}
.textEditor{display:grid;grid-template-columns:1fr auto auto;gap:6px;margin-top:8px}
.textEditor textarea{min-height:42px;background:#11161b;border:1px solid #3e7df0;border-radius:7px;color:#edf2f4;font:14px/1.35 system-ui,-apple-system,Segoe UI,sans-serif;padding:7px;resize:vertical}
.meta{color:#a7b0b8;font-size:13px}
input{background:#11161b;border:1px solid #3a4650;border-radius:7px;color:#edf2f4;padding:11px;font:inherit;width:92px}
@media (max-width:720px){main{gap:8px;padding:8px}.band{padding:8px}.slides{grid-template-columns:1fr;gap:8px}.split{grid-template-columns:1fr}.tabs{gap:6px}.timer{font-size:30px}.row{gap:6px}button{min-height:38px;padding:8px 10px}.nav button{font-size:18px;min-height:48px}.notes{min-height:56px}.notesView .notes{min-height:62vh;font-size:18px}.overview{grid-template-columns:repeat(auto-fill,minmax(96px,1fr))}.notesActions{gap:6px}}
</style>
</head>
<body>
<header><h1>Dais Remote</h1><span id="conn">connecting</span></header>
<main>
<section class="band">
<div class="row" style="justify-content:space-between">
<div>
<div class="timer" id="timer">--:--</div>
<div class="meta" id="slide">Slide -- / --</div>
</div>
<div class="row">
<button id="timerToggle">Start/Pause</button>
<button id="timerReset">Reset</button>
</div>
</div>
</section>
<section class="band tabs">
<button class="primary" id="showControl">Control</button>
<button id="showNotes">Notes</button>
<button id="showOverview">Overview</button>
<button id="showAnnotate">Annotate</button>
</section>
<section class="band nav split">
<button id="prev">Prev</button>
<button class="primary" id="next">Next</button>
</section>
<div class="view" id="controlView">
<section class="slides">
<div class="slide band" id="currentSlideFrame">
<div class="label"><span>Current</span><span id="mode"></span></div>
<div class="imageBack"><img id="currentImg" alt="Current slide"></div>
</div>
<div class="slide band">
<div class="label"><span>Next</span></div>
<img id="nextImg" alt="Next slide">
</div>
</section>
<section class="band">
<div class="row">
<button id="blackout" class="warn">Blackout</button>
<button id="freeze">Freeze</button>
<button id="whiteboard">Whiteboard</button>
<button id="laser">Laser</button>
</div>
</section>
<section class="band">
<div class="row">
<input id="gotoSlide" type="number" min="1" placeholder="Slide">
<button id="gotoBtn">Go</button>
<span class="meta" id="last"></span>
</div>
</section>
</div>
<div class="view notesView" id="notesView" hidden>
<section class="band">
<div class="row" style="justify-content:space-between;margin-bottom:8px">
<div class="meta">Notes</div>
<div class="row notesActions">
<button id="notesEdit" style="min-height:32px;padding:4px 12px;font-size:13px">Edit</button>
<button id="notesSave" class="primary" style="min-height:32px;padding:4px 12px;font-size:13px" hidden>Save</button>
<button id="notesCancel" style="min-height:32px;padding:4px 12px;font-size:13px" hidden>Cancel</button>
</div>
</div>
<div class="notes" id="notes"></div>
<textarea class="notesEdit" id="notesEditArea" hidden></textarea>
</section>
</div>
<div class="view" id="overviewView" hidden>
<div class="band">
<div class="overview" id="overview"></div>
</div>
</div>
<div class="view" id="annotateView" hidden>
<section class="band">
<div class="row" id="inkStyleControls" style="margin-bottom:8px;justify-content:space-between">
<div class="row" style="gap:5px">
<button class="swatch active" data-color="255,0,0,255" style="background:#f00" title="Red"></button>
<button class="swatch" data-color="0,110,255,255" style="background:#006eff" title="Blue"></button>
<button class="swatch" data-color="0,190,60,255" style="background:#00be3c" title="Green"></button>
<button class="swatch" data-color="255,210,0,200" style="background:rgba(255,210,0,.78)" title="Yellow"></button>
<button class="swatch" data-color="255,255,255,255" style="background:#fff;border-color:#888" title="White"></button>
<button class="swatch" data-color="0,0,0,255" style="background:#000;border-color:#555" title="Black"></button>
</div>
<div class="row" style="gap:5px">
<button class="drawWidth active" data-width="2" style="font-size:12px;min-height:30px;padding:3px 10px">Thin</button>
<button class="drawWidth" data-width="4" style="font-size:12px;min-height:30px;padding:3px 10px">Med</button>
<button class="drawWidth" data-width="8" style="font-size:12px;min-height:30px;padding:3px 10px">Thick</button>
</div>
</div>
<div class="row" style="margin-bottom:8px;gap:5px">
<button id="toolPen" class="active" style="font-size:12px;min-height:30px;padding:3px 14px">Pen</button>
<button id="toolHi" style="font-size:12px;min-height:30px;padding:3px 14px">Hi</button>
<button id="toolEraser" style="font-size:12px;min-height:30px;padding:3px 14px">Eraser</button>
<button id="toolText" style="font-size:12px;min-height:30px;padding:3px 14px">Text</button>
<button id="drawClear" class="warn" style="font-size:12px;min-height:30px;padding:3px 10px;margin-left:auto">Clear</button>
</div>
<div id="drawSurface" class="drawSurface" style="position:relative">
<img id="drawImg" alt="Current slide" style="display:block;width:100%;height:auto;background:#0a0c0f;border-radius:5px;border:1px solid #303840;user-select:none;pointer-events:none">
<canvas id="drawCanvas" style="position:absolute;top:0;left:0;width:100%;height:100%;touch-action:none;cursor:crosshair;border-radius:5px"></canvas>
<div id="textBoxLayer" class="textBoxLayer"></div>
</div>
<div id="textEditor" class="textEditor" hidden>
<textarea id="textBoxContent" placeholder="Text"></textarea>
<button id="textBoxSave" class="primary" style="font-size:12px;min-height:34px;padding:4px 12px">Save</button>
<button id="textBoxDelete" class="warn" style="font-size:12px;min-height:34px;padding:4px 12px">Delete</button>
</div>
<div class="meta" id="drawStatus" style="margin-top:5px;min-height:16px"></div>
</section>
</div>
</main>
<script>
const params=new URLSearchParams(location.search);
const token=params.get('token')||localStorage.getItem('daisRemoteToken')||'';
if(token)localStorage.setItem('daisRemoteToken',token);
const tokenParam=token?'token='+encodeURIComponent(token):'';
function withToken(path){
if(!tokenParam)return path;
return path+(path.includes('?')?'&':'?')+tokenParam;
}
function withCache(path){const authed=withToken(path);return authed+(authed.includes('?')?'&':'?')+'v='+Date.now();}
const $=id=>document.getElementById(id);
let state=null,lastPage=-1,lastWhiteboard=null,nextPage=-1,pollTimer=null,overviewCount=0,activeView='control',everConnected=false,notesEditing=false;
let drawColor=[255,0,0,255],drawWidth=2,drawHiColor=[255,220,0,100],drawHiWidth=12,drawEraserRadius=0.03,drawStrokes=[],drawStroke=null,drawTool='pen',erasePath=null,drawStrokesSig='',pendingInkUntil=0;
let annotationMode='ink',textBoxes=[],selectedTextBoxId=null,textDrag=null;
function mmss(seconds){seconds=Math.max(0,seconds|0);const m=Math.floor(seconds/60),s=seconds%60;return m+':'+String(s).padStart(2,'0');}
function setConn(text,color){$('conn').textContent=text;$('conn').style.color=color;}
function connectionMessage(error){
if(error?.status===401)return 'unauthorized';
if(error?.status===403)return 'blocked';
if(everConnected)return 'reconnecting';
return 'unreachable';
}
async function api(path,opts={}){
let r;
try{r=await fetch(withToken(path),opts);}catch(e){throw Object.assign(e,{network:true});}
if(!r.ok){const e=new Error(await r.text());e.status=r.status;throw e;}
return r.headers.get('content-type')?.includes('json')?r.json():r.text();
}
async function action(name){await api('/api/v1/actions/'+name,{method:'POST'});$('last').textContent=name;}
async function timer(actionName){await api('/api/v1/commands/timer',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:actionName})});$('last').textContent='timer '+actionName;}
async function gotoSlide(slide){await api('/api/v1/commands/goto',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slide})});$('last').textContent='goto '+slide;refresh();}
function inlineMarkdown(text){
const frag=document.createDocumentFragment();
const re=/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g;
let pos=0,m;
while((m=re.exec(text))){
if(m.index>pos)frag.append(document.createTextNode(text.slice(pos,m.index)));
const token=m[0],el=token.startsWith('**')?document.createElement('strong'):token.startsWith('*')?document.createElement('em'):document.createElement('code');
el.textContent=token.startsWith('**')?token.slice(2,-2):token.slice(1,-1);
frag.append(el);pos=re.lastIndex;
}
if(pos<text.length)frag.append(document.createTextNode(text.slice(pos)));
return frag;
}
function renderMarkdown(text){
const frag=document.createDocumentFragment();
let list=null;
function closeList(){list=null;}
for(const raw of (text||'').split(/\r?\n/)){
const line=raw.trimEnd();
if(!line.trim()){closeList();continue;}
const bullet=line.match(/^\s*[-*]\s+(.+)$/);
if(bullet){
if(!list){list=document.createElement('ul');frag.append(list);}
const li=document.createElement('li');li.append(inlineMarkdown(bullet[1]));list.append(li);continue;
}
closeList();
const heading=line.match(/^(#{1,3})\s+(.+)$/);
const el=document.createElement(heading?'h'+heading[1].length:'p');
el.append(inlineMarkdown(heading?heading[2]:line));
frag.append(el);
}
if(!frag.childNodes.length){const p=document.createElement('p');p.textContent='';frag.append(p);}
return frag;
}
function showView(name){
activeView=name;
$('controlView').hidden=name!=='control';
$('notesView').hidden=name!=='notes';
$('overviewView').hidden=name!=='overview';
$('annotateView').hidden=name!=='annotate';
$('showControl').classList.toggle('primary',name==='control');
$('showNotes').classList.toggle('primary',name==='notes');
$('showOverview').classList.toggle('primary',name==='overview');
$('showAnnotate').classList.toggle('primary',name==='annotate');
if(name==='annotate'){
syncDrawControls();
if(!$('drawImg').getAttribute('src'))$('drawImg').src=withCache('/api/v1/slides/current.png');
requestAnimationFrame(initDrawCanvas);
}
}
function syncOverview(){
if(!state)return;
if(overviewCount!==state.total_logical_slides){
overviewCount=state.total_logical_slides;
$('overview').replaceChildren();
for(let i=1;i<=overviewCount;i++){
const b=document.createElement('button');b.className='thumb';b.type='button';b.dataset.slide=i;b.onclick=()=>gotoSlide(i);
const img=document.createElement('img');img.loading='lazy';img.alt='Slide '+i;img.src=withCache('/api/v1/slides/'+i+'/thumbnail.png');
const label=document.createElement('span');label.textContent='Slide '+i;
b.append(img,label);$('overview').appendChild(b);
}
}
const current=String(state.current_logical_slide+1);
document.querySelectorAll('.thumb').forEach(b=>b.classList.toggle('current',b.dataset.slide===current));
}
function renderState(nextState){
state=nextState;
everConnected=true;setConn('connected','#8fbf9f');
$('timer').textContent=mmss(state.timer.display_seconds);
$('slide').textContent='Slide '+(state.current_logical_slide+1)+' / '+state.total_logical_slides;
if(!notesEditing)$('notes').replaceChildren(renderMarkdown(state.current_notes||''));
const flags=[]; if(state.blacked_out)flags.push('blackout'); if(state.frozen)flags.push('frozen'); if(state.whiteboard_active)flags.push('whiteboard'); if(state.laser_active)flags.push('laser');
$('mode').textContent=flags.join(' ');
$('blackout').classList.toggle('active',state.blacked_out);
$('freeze').classList.toggle('primary',state.frozen);
$('whiteboard').classList.toggle('primary',state.whiteboard_active);
$('laser').classList.toggle('good',state.laser_active);
$('currentSlideFrame').classList.toggle('whiteboard',state.whiteboard_active);
$('drawSurface').classList.toggle('whiteboard',state.whiteboard_active);
if(state.current_page!==lastPage||state.whiteboard_active!==lastWhiteboard){
lastPage=state.current_page;
lastWhiteboard=state.whiteboard_active;
const slideUrl=withCache('/api/v1/slides/current.png');
$('currentImg').src=slideUrl;
$('drawImg').src=slideUrl;
drawStrokes=[];drawStroke=null;erasePath=null;drawStrokesSig='';
if(activeView==='annotate')redrawCanvas();
}
const serverTool=state.draw_tool||'pen';
drawTool=serverTool;
const stateStrokes=(state.ink_strokes||[]).map(s=>({pts:s.points,c:s.color,w:s.width}));
const stateSig=strokeSignature(stateStrokes);
if(!drawStroke&&!erasePath&&Date.now()>=pendingInkUntil&&stateSig!==drawStrokesSig){
drawStrokes=stateStrokes;
drawStrokesSig=stateSig;
if(activeView==='annotate')redrawCanvas();
}
if(!textDrag){
textBoxes=(state.text_boxes||[]).map(t=>({...t,rect:[...t.rect]}));
if(state.selected_text_box)selectedTextBoxId=state.selected_text_box;
if(selectedTextBoxId&&!textBoxes.some(t=>t.id===selectedTextBoxId))selectedTextBoxId=null;
renderTextBoxes();
}
syncDrawControls();
drawEraserRadius=state.eraser_radius||0.03;
const np=Math.min(state.current_logical_slide+2,state.total_logical_slides);
if(np!==nextPage){nextPage=np;$('nextImg').src=withCache('/api/v1/slides/next.png');}
syncOverview();
}
async function refresh(){
try{
renderState(await api('/api/v1/state'));
}catch(e){setConn(connectionMessage(e),'#ff9b9b');}
}
function sleep(ms){return new Promise(resolve=>setTimeout(resolve,ms));}
async function waitForTextBoxContent(id,content){
for(let i=0;i<12;i++){
const nextState=await api('/api/v1/state');
renderState(nextState);
if((nextState.text_boxes||[]).some(t=>t.id===id&&t.content===content))return true;
await sleep(120);
}
return false;
}
function startPolling(){if(!pollTimer)pollTimer=setInterval(refresh,900);}
function startEvents(){
if(!window.EventSource){startPolling();return;}
const events=new EventSource(withToken('/api/v1/events'));
events.onmessage=e=>renderState(JSON.parse(e.data));
events.onerror=()=>{setConn(everConnected?'reconnecting':'unreachable','#ff9b9b');startPolling();};
}
$('next').onclick=()=>action('next_slide').then(refresh);
$('prev').onclick=()=>action('previous_slide').then(refresh);
$('blackout').onclick=()=>action('toggle_blackout').then(refresh);
$('freeze').onclick=()=>action('toggle_freeze').then(refresh);
$('whiteboard').onclick=()=>action('toggle_whiteboard').then(refresh);
$('laser').onclick=()=>action('toggle_laser').then(refresh);
$('timerToggle').onclick=()=>timer('toggle').then(refresh);
$('timerReset').onclick=()=>timer('reset').then(refresh);
$('gotoBtn').onclick=()=>{const slide=+$('gotoSlide').value;if(slide>0)gotoSlide(slide);};
$('showControl').onclick=()=>showView('control');
$('showNotes').onclick=()=>showView('notes');
$('showOverview').onclick=()=>showView('overview');
$('showAnnotate').onclick=()=>showView('annotate');
function enterNotesEdit(){
notesEditing=true;
$('notesEditArea').value=state?.current_notes||'';
$('notes').hidden=true;
$('notesEditArea').hidden=false;
$('notesEdit').hidden=true;
$('notesSave').hidden=false;
$('notesCancel').hidden=false;
$('notesEditArea').focus();
}
function exitNotesEdit(){
notesEditing=false;
$('notes').hidden=false;
$('notesEditArea').hidden=true;
$('notesEdit').hidden=false;
$('notesSave').hidden=true;
$('notesCancel').hidden=true;
}
$('notesEdit').onclick=()=>enterNotesEdit();
$('notesCancel').onclick=()=>exitNotesEdit();
$('notesSave').onclick=async()=>{
const text=$('notesEditArea').value;
$('notesSave').disabled=true;$('notesSave').textContent='Saving…';
try{
await api('/api/v1/commands/notes',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({notes:text})});
exitNotesEdit();
refresh();
}catch(e){$('last').textContent='save failed';}
finally{$('notesSave').disabled=false;$('notesSave').textContent='Save';}
};
function initDrawCanvas(){
const img=$('drawImg'),canvas=$('drawCanvas');
if(!img.offsetWidth)return;
canvas.width=img.offsetWidth;canvas.height=img.offsetHeight;
redrawCanvas();
}
function redrawCanvas(){
const canvas=$('drawCanvas'),ctx=canvas.getContext('2d');
ctx.clearRect(0,0,canvas.width,canvas.height);
const all=drawStroke&&drawStroke.length>1
?[...drawStrokes,{pts:drawStroke,c:drawTool==='highlighter'?drawHiColor:drawColor,w:drawTool==='highlighter'?drawHiWidth:drawWidth}]
:drawStrokes;
for(const s of all){
if(s.pts.length<2)continue;
ctx.beginPath();
ctx.strokeStyle=`rgba(${s.c[0]},${s.c[1]},${s.c[2]},${+(s.c[3]/255).toFixed(3)})`;
ctx.lineWidth=s.w;ctx.lineCap='round';ctx.lineJoin='round';
ctx.moveTo(s.pts[0][0]*canvas.width,s.pts[0][1]*canvas.height);
for(let i=1;i<s.pts.length;i++)ctx.lineTo(s.pts[i][0]*canvas.width,s.pts[i][1]*canvas.height);
ctx.stroke();
}
if(drawTool==='eraser'&&erasePath&&erasePath.length>0){
const r=drawEraserRadius*canvas.width;
ctx.save();ctx.globalCompositeOperation='destination-out';
ctx.beginPath();
const first=erasePath[0];
ctx.arc(first[0]*canvas.width,first[1]*canvas.height,r,0,Math.PI*2);
for(let i=1;i<erasePath.length;i++){
ctx.arc(erasePath[i][0]*canvas.width,erasePath[i][1]*canvas.height,r,0,Math.PI*2);
}
ctx.fill();ctx.restore();
}
}
function sameColor(a,b){return a&&b&&a.length===b.length&&a.every((v,i)=>v===b[i]);}
function strokeSignature(strokes){
return JSON.stringify(strokes.map(s=>[s.c,s.w,s.pts]));
}
function lerpPt(a,b,t){return[a[0]+t*(b[0]-a[0]),a[1]+t*(b[1]-a[1])];}
function clipSegmentOutsideCircle(p1,p2,c,radius){
const fx=p1[0]-c[0],fy=p1[1]-c[1],ex=p2[0]-c[0],ey=p2[1]-c[1],r2=radius*radius;
const p1Inside=fx*fx+fy*fy<=r2,p2Inside=ex*ex+ey*ey<=r2;
if(p1Inside&&p2Inside)return[];
const dx=p2[0]-p1[0],dy=p2[1]-p1[1],a=dx*dx+dy*dy;
if(a<1e-12)return[[p1,p2]];
const b=2*(fx*dx+fy*dy),cc=fx*fx+fy*fy-r2,disc=b*b-4*a*cc;
if(disc<=0)return[[p1,p2]];
const root=Math.sqrt(disc),t1=(-b-root)/(2*a),t2=(-b+root)/(2*a);
const t1In=t1>=0&&t1<=1,t2In=t2>=0&&t2<=1;
if(!p1Inside&&!p2Inside){
return t1In&&t2In?[[p1,lerpPt(p1,p2,t1)],[lerpPt(p1,p2,t2),p2]]:[[p1,p2]];
}
if(!p1Inside&&p2Inside)return[[p1,t1In?lerpPt(p1,p2,t1):p1]];
return[[t2In?lerpPt(p1,p2,t2):p2,p2]];
}
function eraseStrokesNear(strokes,c,radius){
const next=[];
for(const s of strokes){
let current=[];
for(let i=1;i<s.pts.length;i++){
const a=s.pts[i-1],b=s.pts[i];
const kept=clipSegmentOutsideCircle(a,b,c,radius);
if(kept.length===1&&kept[0][0]===a&&kept[0][1]===b){
if(current.length===0)current.push(a);
current.push(b);
}else if(kept.length===1&&kept[0][0]===a){
if(current.length===0)current.push(a);
current.push(kept[0][1]);
if(current.length>=2)next.push({pts:current,c:s.c,w:s.w});
current=[];
}else if(kept.length===1){
if(current.length>=2)next.push({pts:current,c:s.c,w:s.w});
current=[kept[0][0],kept[0][1]];
}else if(kept.length===2){
if(current.length===0)current.push(a);
current.push(kept[0][1]);
if(current.length>=2)next.push({pts:current,c:s.c,w:s.w});
current=[kept[1][0],kept[1][1]];
}else{
if(current.length>=2)next.push({pts:current,c:s.c,w:s.w});
current=[];
}
}
if(current.length>=2)next.push({pts:current,c:s.c,w:s.w});
}
return next;
}
function applyLocalErase(path,radius){
drawStrokes=path.reduce((strokes,pt)=>eraseStrokesNear(strokes,pt,radius),drawStrokes);
drawStrokesSig=strokeSignature(drawStrokes);
}
function rgba(c){return`rgba(${c[0]},${c[1]},${c[2]},${+(c[3]/255).toFixed(3)})`;}
function hashString(text){
let h=2166136261;
for(let i=0;i<text.length;i++){h^=text.charCodeAt(i);h=Math.imul(h,16777619);}
return h>>>0;
}
function textBoxSvgSrc(tb,layer){
const [,,w,h]=tb.rect;
const slideW=Math.max(1,Math.round(layer.clientWidth||960));
const slideH=Math.max(1,Math.round(layer.clientHeight||540));
const boxW=Math.max(1,Math.round(slideW*w));
const boxH=Math.max(1,Math.round(slideH*h));
const sig=hashString(JSON.stringify([tb.id,tb.rect,tb.content,tb.font_size,tb.color,tb.background,slideW,slideH]));
return withToken(`/api/v1/text-boxes/${tb.id}/svg?w=${boxW}&h=${boxH}&slide_w=${slideW}&slide_h=${slideH}&v=${sig}`);
}
function selectedTextBox(){return textBoxes.find(t=>t.id===selectedTextBoxId)||null;}
function setSelectedTextBox(id,send=true){
selectedTextBoxId=id;
renderTextBoxes();
if(send&&id)api('/api/v1/commands/text-boxes/select',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})}).catch(()=>{});
}
function syncTextEditor(){
const tb=selectedTextBox();
$('textEditor').hidden=annotationMode!=='text'||!tb;
if(!tb)return;
if(document.activeElement!==$('textBoxContent'))$('textBoxContent').value=tb.content||'';
}
function renderTextBoxes(){
const layer=$('textBoxLayer');
layer.replaceChildren();
for(const tb of textBoxes){
const el=document.createElement('div');
const [x,y,w,h]=tb.rect;
el.className='remoteTextBox'+(tb.id===selectedTextBoxId?' selected':'')+(tb.content?'':' empty placeholder');
el.dataset.id=tb.id;
el.style.left=(x*100)+'%';el.style.top=(y*100)+'%';el.style.width=(w*100)+'%';el.style.height=(h*100)+'%';
el.style.color=rgba(tb.color||[0,0,0,255]);
if(tb.background)el.style.background=rgba(tb.background);
if(tb.content){
const img=document.createElement('img');
img.alt='Text box';
img.src=textBoxSvgSrc(tb,layer);
el.appendChild(img);
}else{
el.style.fontSize=Math.max(10,Math.min(24,(tb.font_size||24)*0.55))+'px';
el.textContent='Text';
}
if(tb.id===selectedTextBoxId){
const handle=document.createElement('span');
handle.className='resizeHandle';
handle.dataset.resize='1';
el.appendChild(handle);
}
layer.appendChild(el);
}
if(textDrag?.type==='place'||textDrag?.type==='placePreview'){
const [x,y,w,h]=textDrag.rect;
const draft=document.createElement('div');
draft.className='textBoxDraft';
draft.style.left=(x*100)+'%';draft.style.top=(y*100)+'%';draft.style.width=(w*100)+'%';draft.style.height=(h*100)+'%';
layer.appendChild(draft);
}
syncTextEditor();
}
function normFromLayer(e){
const r=$('textBoxLayer').getBoundingClientRect();
return[Math.max(0,Math.min(1,(e.clientX-r.left)/r.width)),Math.max(0,Math.min(1,(e.clientY-r.top)/r.height))];
}
function rectFromPoints(a,b){
return[Math.min(a[0],b[0]),Math.min(a[1],b[1]),Math.max(0.02,Math.abs(a[0]-b[0])),Math.max(0.02,Math.abs(a[1]-b[1]))];
}
function clampTextRect(tb){
tb.rect[0]=Math.max(0,Math.min(1-tb.rect[2],tb.rect[0]));
tb.rect[1]=Math.max(0,Math.min(1-tb.rect[3],tb.rect[1]));
tb.rect[2]=Math.max(0.02,Math.min(1-tb.rect[0],tb.rect[2]));
tb.rect[3]=Math.max(0.02,Math.min(1-tb.rect[1],tb.rect[3]));
}
function syncDrawControls(){
const textMode=annotationMode==='text';
$('toolPen').classList.toggle('active',!textMode&&drawTool==='pen');
$('toolHi').classList.toggle('active',!textMode&&drawTool==='highlighter');
$('toolEraser').classList.toggle('active',!textMode&&drawTool==='eraser');
$('toolText').classList.toggle('active',textMode);
$('inkStyleControls').hidden=textMode;
$('drawClear').hidden=textMode;
$('drawSurface').classList.toggle('textMode',textMode);
$('drawCanvas').style.cursor=drawTool==='eraser'?'cell':'crosshair';
document.querySelectorAll('.swatch').forEach(s=>{
const color=s.dataset.color.split(',').map(Number);
const active=drawTool==='highlighter'
?color[0]===drawHiColor[0]&&color[1]===drawHiColor[1]&&color[2]===drawHiColor[2]
:sameColor(color,drawColor);
s.classList.toggle('active',active);
});
const activeWidth=drawTool==='highlighter'?drawHiWidth/2:drawTool==='eraser'?drawEraserRadius*200:drawWidth;
document.querySelectorAll('.drawWidth').forEach(w=>w.classList.toggle('active',+w.dataset.width===activeWidth));
renderTextBoxes();
}
function canvasPt(e){
const r=$('drawCanvas').getBoundingClientRect();
return[Math.max(0,Math.min(1,(e.clientX-r.left)/r.width)),Math.max(0,Math.min(1,(e.clientY-r.top)/r.height))];
}
const dc=$('drawCanvas');
dc.addEventListener('pointerdown',e=>{
if(annotationMode==='text')return;
dc.setPointerCapture(e.pointerId);
if(drawTool==='eraser'){erasePath=[canvasPt(e)];}
else{drawStroke=[canvasPt(e)];}
e.preventDefault();
});
dc.addEventListener('pointermove',e=>{
if(annotationMode==='text')return;
if(drawTool==='eraser'){
if(!erasePath)return;
erasePath.push(canvasPt(e));
redrawCanvas();
}else{
if(!drawStroke)return;
drawStroke.push(canvasPt(e));
redrawCanvas();
}
e.preventDefault();
});
dc.addEventListener('pointerup',async e=>{
if(annotationMode==='text')return;
if(drawTool==='eraser'){
if(!erasePath||erasePath.length===0){erasePath=null;return;}
erasePath.push(canvasPt(e));
const pts=[...erasePath];
applyLocalErase(pts,drawEraserRadius);
erasePath=null;
pendingInkUntil=Date.now()+1200;
redrawCanvas();
try{
await api('/api/v1/commands/ink/erase',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({points:pts,radius:drawEraserRadius})});
$('drawStatus').textContent='';
}catch(err){$('drawStatus').textContent='erase not sent';}
}else{
if(!drawStroke)return;
drawStroke.push(canvasPt(e));
if(drawStroke.length<2){drawStroke=null;return;}
const c=drawTool==='highlighter'?[...drawHiColor]:[...drawColor];
const w=drawTool==='highlighter'?drawHiWidth:drawWidth;
const s={pts:drawStroke,c,w};
drawStrokes.push(s);drawStrokesSig=strokeSignature(drawStrokes);drawStroke=null;pendingInkUntil=Date.now()+1200;redrawCanvas();
try{
await api('/api/v1/commands/ink/stroke',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({points:s.pts,tool:drawTool,color:s.c,width:s.w})});
$('drawStatus').textContent='';
}catch(err){$('drawStatus').textContent='stroke not sent';}
}
e.preventDefault();
});
dc.addEventListener('pointercancel',()=>{drawStroke=null;erasePath=null;redrawCanvas();});
const tl=$('textBoxLayer');
tl.addEventListener('pointerdown',e=>{
if(annotationMode!=='text')return;
tl.setPointerCapture(e.pointerId);
const pt=normFromLayer(e);
const box=e.target.closest?.('.remoteTextBox');
if(box){
const id=+box.dataset.id;
const tb=textBoxes.find(t=>t.id===id);
if(!tb)return;
setSelectedTextBox(id);
textDrag={type:e.target.dataset.resize==='1'?'resize':'move',id,start:pt,orig:[...tb.rect]};
}else{
selectedTextBoxId=null;
textDrag={type:'place',start:pt,rect:[pt[0],pt[1],0.02,0.02]};
renderTextBoxes();
}
e.preventDefault();
});
tl.addEventListener('pointermove',e=>{
if(annotationMode!=='text'||!textDrag)return;
const pt=normFromLayer(e);
if(textDrag.type==='place'){
textDrag.rect=rectFromPoints(textDrag.start,pt);
}else{
const tb=textBoxes.find(t=>t.id===textDrag.id);
if(!tb)return;
const dx=pt[0]-textDrag.start[0],dy=pt[1]-textDrag.start[1];
if(textDrag.type==='move'){
tb.rect=[textDrag.orig[0]+dx,textDrag.orig[1]+dy,textDrag.orig[2],textDrag.orig[3]];
}else{
tb.rect=[textDrag.orig[0],textDrag.orig[1],textDrag.orig[2]+dx,textDrag.orig[3]+dy];
}
clampTextRect(tb);
}
renderTextBoxes();
e.preventDefault();
});
tl.addEventListener('pointerup',async e=>{
if(annotationMode!=='text'||!textDrag)return;
const drag=textDrag;
textDrag=null;
try{
if(drag.type==='place'){
const [x,y,w,h]=drag.rect;
if(w>=0.02&&h>=0.02){
await api('/api/v1/commands/text-boxes/place',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({x,y,w,h})});
await refresh();
}
}else{
const tb=textBoxes.find(t=>t.id===drag.id);
if(tb&&drag.type==='move'){
await api('/api/v1/commands/text-boxes/move',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:tb.id,x:tb.rect[0],y:tb.rect[1]})});
}else if(tb){
await api('/api/v1/commands/text-boxes/resize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:tb.id,w:tb.rect[2],h:tb.rect[3]})});
}
await refresh();
}
$('drawStatus').textContent='';
}catch(err){$('drawStatus').textContent='text box not sent';}
finally{renderTextBoxes();}
e.preventDefault();
});
tl.addEventListener('pointercancel',()=>{textDrag=null;renderTextBoxes();});
$('drawImg').addEventListener('load',initDrawCanvas);
new ResizeObserver(()=>{if(activeView==='annotate')initDrawCanvas();}).observe($('drawImg'));
document.querySelectorAll('.swatch').forEach(b=>b.onclick=()=>{
const color=b.dataset.color.split(',').map(Number);
if(drawTool==='highlighter')drawHiColor=[color[0],color[1],color[2],100];
else drawColor=color;
syncDrawControls();
});
document.querySelectorAll('.drawWidth').forEach(b=>b.onclick=()=>{
const width=+b.dataset.width;
if(drawTool==='highlighter')drawHiWidth=width*2;
else if(drawTool==='eraser')drawEraserRadius=width/200;
else drawWidth=width;
syncDrawControls();
});
function setDrawTool(tool){
annotationMode='ink';
drawTool=tool;
syncDrawControls();
api('/api/v1/commands/ink/set_tool',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tool})}).then(refresh).catch(()=>{});
}
$('toolPen').onclick=()=>{if(annotationMode!=='ink'||drawTool!=='pen')setDrawTool('pen');};
$('toolHi').onclick=()=>{if(annotationMode!=='ink'||drawTool!=='highlighter')setDrawTool('highlighter');};
$('toolEraser').onclick=()=>{if(annotationMode!=='ink'||drawTool!=='eraser')setDrawTool('eraser');};
$('toolText').onclick=()=>{annotationMode='text';drawStroke=null;erasePath=null;syncDrawControls();};
$('drawClear').onclick=async()=>{
drawStrokes=[];drawStrokesSig=strokeSignature(drawStrokes);redrawCanvas();
try{await api('/api/v1/commands/ink/clear',{method:'POST'});}
catch(e){$('drawStatus').textContent='clear failed';}
};
$('textBoxSave').onclick=async()=>{
const tb=selectedTextBox();
if(!tb)return;
const content=$('textBoxContent').value;
$('textBoxSave').disabled=true;$('textBoxSave').textContent='Saving';
try{
await api('/api/v1/commands/text-boxes/content',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:tb.id,content})});
const saved=await waitForTextBoxContent(tb.id,content);
$('drawStatus').textContent=saved?'':'save still processing';
}catch(e){
$('drawStatus').textContent='text save failed';
refresh();
}finally{
$('textBoxSave').disabled=false;$('textBoxSave').textContent='Save';
}
};
$('textBoxDelete').onclick=async()=>{
const tb=selectedTextBox();
if(!tb)return;
textBoxes=textBoxes.filter(t=>t.id!==tb.id);selectedTextBoxId=null;renderTextBoxes();
try{
await api('/api/v1/commands/text-boxes/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:tb.id})});
await refresh();
$('drawStatus').textContent='';
}catch(e){$('drawStatus').textContent='delete failed';}
};
refresh();startEvents();
</script>
</body>
</html>