clone

a photo toolkit for your phone that never uploads: fuji film looks for raw files, social crops, error level analysis

Social tool: post formats, crop or pad, frame, vignette, grain; share opens inside the tap with a pre-rendered JPEG

Dami committed 5bfdae4
commit
5bfdae4ed6ad18784ea7c78e5435ecceb11441db
parent
27136f2f67a0763f0f199eb1ae2224832a58d378
author
Dami <dami.etoile@gmail.com>
9 files changed, +202 -10
MREADME.md2+
M_headers3+
Mfuji.mjs16+++-
Mindex.html6++
Mmanifest.webmanifest3+-
Mscripts/package.py6+-
Asocial.html58+++++++++++++++
Asocial.mjs116++++++++++++++++++++++++++++++
Msw.js2+-
M README.md +2 -0
@@ -5,6 +5,7 @@ A mobile-first, installable photo toolkit. Source: https://github.com/FelineStat
55 Tools:66 77 - `/fuji`: develops a Fujifilm RAF in the browser with LibRaw (WebAssembly) and applies any of 16 film simulation looks via 3D LUTs, then saves a JPEG. The in-camera JPEG can be viewed and saved too. See NOTICE.md for where the LUTs come from and how close they are.8+- `/social`: crop to post formats (square, 4:5, 9:16, 16:9, 1.91:1, 3:2) or pad out, drag to position, frame in white or black, vignette and grain, then save or share a JPEG at the platform size.89 - `/perspective`: straightens converging verticals and horizontals using darktable's C algorithms (LSD line detection, RANSAC, perspective fit, homography) compiled to WebAssembly.910 1011 ## Run
@@ -45,6 +46,7 @@ The config serves only `dist/`; it has no backend, secrets, storage bindings, an
4546 4647 - `index.html` + `home.mjs`: landing grid of tools, install and about.4748 - `fuji.html` + `fuji.mjs` + `rawworker.mjs` + `raf.mjs`: the Fuji tool. `rawworker.mjs` runs LibRaw (`assets/raw.wasm`, built by `native/raw/build.sh`) and the LUTs off the main thread. `raf.mjs` parses the RAF container and the Fuji makernote. `scripts/neutral-lut.py` regenerates the neutral LUTs with darktable-cli.49+- `social.html` + `social.mjs`: the Social tool, canvas only.4850 - `perspective.html` + `app.mjs`: the perspective tool UI. `worker.mjs` runs the WebAssembly core off the main thread.4951 - `style.css`: shared styles. Dark, system font, safe-area aware; the tool page never scrolls.5052
M _headers +3 -0
M fuji.mjs +11 -5
@@ -33,6 +33,7 @@ function layout(){
3333 }3434 new ResizeObserver(layout).observe(stageEl);photo.addEventListener('load',layout);3535 let current=null,busy=false,toastTimer,sim='provia',dr=100,showCamera=false,holdCamera=false,renderId=0,cameraUrl=null,lensOn=true;36+let exportCache=null,exportTimer,exportKey='';3637 const loaded=new Set(),loading=new Map();3738 3839 function status(message,error=false){clearTimeout(toastTimer);const el=$('status');el.textContent=message;el.classList.toggle('error',error);el.hidden=!message;if(message)toastTimer=setTimeout(()=>{el.hidden=true;},error?5000:2500);}
@@ -64,6 +65,7 @@ async function render(){
6465 const r=await rpc('render',{dr:NEUTRAL_URLS[dr]?dr:100,sim});6566 if(id!==renderId)return;6667 canvas.width=r.w;canvas.height=r.h;ctx.putImageData(new ImageData(r.data,r.w,r.h),0,0);layout();68+ scheduleExport();6769 }catch(e){status(e.message,true);}6870 }6971 async function openFile(file){
@@ -100,6 +102,8 @@ const saveSheet=$('save-sheet');for(const b of saveSheet.querySelectorAll('[data
100102 $('save').onclick=()=>saveSheet.showModal();101103 for(const b of saveSheet.querySelectorAll('[data-save]'))b.onclick=()=>{saveSheet.close();save(b.dataset.save);};102104 function download(blob,name){const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);}105+// The system share sheet must open inside the tap, so the full-size JPEG is prepared ahead of time.106+function scheduleExport(){exportCache=null;clearTimeout(exportTimer);const key=[current?.name,sim,dr,lensOn].join('|');exportTimer=setTimeout(async()=>{try{const r=await developedBlob();if([current?.name,sim,dr,lensOn].join('|')===key){exportCache=r;exportKey=key;}}catch{}},900);}103107 async function developedBlob(){104108 const r=await rpc('render',{dr:NEUTRAL_URLS[dr]?dr:100,sim,full:true});105109 const c=document.createElement('canvas');c.width=r.w;c.height=r.h;c.getContext('2d').putImageData(new ImageData(r.data,r.w,r.h),0,0);
@@ -113,11 +117,13 @@ async function save(what){
113117 }catch(e){status(e.message,true);}finally{busy=false;sync();}114118 }115119 if(navigator.canShare&&navigator.share)$('share').hidden=false;116-$('share').onclick=async()=>{117- if(!current)return;busy=true;sync();118- try{const {blob}=await developedBlob();const file=new File([blob],`${current.name}-${sim}.jpg`,{type:'image/jpeg'});119- if(!navigator.canShare({files:[file]}))throw Error('Sharing files is not supported here.');await navigator.share({files:[file]});}120- catch(e){if(e.name!=='AbortError')status(e.message,true);}finally{busy=false;sync();}120+$('share').onclick=()=>{121+ if(!current)return;122+ const fname=`${current.name}-${sim}.jpg`,ready=exportCache&&exportKey===[current.name,sim,dr,lensOn].join('|');123+ if(!ready){busy=true;sync();developedBlob().then(({blob})=>{download(blob,fname);status('Saved. Share it from your photos.');}).catch(e=>status(e.message,true)).finally(()=>{busy=false;sync();});return;}124+ const file=new File([exportCache.blob],fname,{type:'image/jpeg'});125+ if(!navigator.canShare({files:[file]}))return status('Sharing files is not supported here.',true);126+ navigator.share({files:[file]}).catch(e=>{if(e.name==='AbortError')return;download(exportCache.blob,fname);status('Saved. Share it from your photos.');});121127 };122128 if('serviceWorker'in navigator){navigator.serviceWorker.register('./sw.js').catch(()=>{});let hadController=Boolean(navigator.serviceWorker.controller);navigator.serviceWorker.addEventListener('controllerchange',()=>{if(hadController&&!current)location.reload();hadController=true;});}123129 sync();
M index.html +6 -0
M manifest.webmanifest +2 -1
M scripts/package.py +3 -3
@@ -14,9 +14,9 @@ if dist.exists(): shutil.rmtree(dist)
1414 1515 # Hashed files in dependency order: leaves first, so a file's hash covers its rewritten references.1616 LUTS = sorted(p.relative_to(root).as_posix() for p in (root / 'assets' / 'luts').glob('*.png'))17-HASHED = ['assets/core.wasm', 'assets/core.mjs', 'assets/raw.wasm', 'assets/raw.mjs'] + LUTS + ['geometry.mjs', 'worker.mjs', 'app.mjs', 'raf.mjs', 'lens.mjs', 'rawworker.mjs', 'fuji.mjs', 'home.mjs', 'viewport.js', 'style.css']17+HASHED = ['assets/core.wasm', 'assets/core.mjs', 'assets/raw.wasm', 'assets/raw.mjs'] + LUTS + ['geometry.mjs', 'worker.mjs', 'app.mjs', 'raf.mjs', 'lens.mjs', 'rawworker.mjs', 'fuji.mjs', 'social.mjs', 'home.mjs', 'viewport.js', 'style.css']1818 # Fixed-name text files that reference hashed ones.19-PAGES = ['index.html', 'perspective.html', 'fuji.html']19+PAGES = ['index.html', 'perspective.html', 'fuji.html', 'social.html']2020 FIXED = ['manifest.webmanifest', 'NOTICE.md', 'README.md', '_headers']2121 DIRS = ['LICENSES']2222 ICONS = ['assets/icon.svg', 'assets/icon-192.png', 'assets/icon-512.png']
@@ -53,7 +53,7 @@ for d in DIRS: shutil.copytree(root / d, dist / d)
5353 for src in ICONS: shutil.copyfile(root / src, dist / src)5454 5555 # Service worker: precache list and a cache name derived from everything in dist.56-precache = ['./', './perspective', './fuji'] + ['./' + v for v in mapping.values()] + ['./' + f for f in ['manifest.webmanifest', 'NOTICE.md'] + ICONS] + ['./LICENSES/' + p.name for p in sorted((root / 'LICENSES').iterdir()) if p.is_file()]56+precache = ['./', './perspective', './fuji', './social'] + ['./' + v for v in mapping.values()] + ['./' + f for f in ['manifest.webmanifest', 'NOTICE.md'] + ICONS] + ['./LICENSES/' + p.name for p in sorted((root / 'LICENSES').iterdir()) if p.is_file()]5757 h = hashlib.sha256()5858 for p in sorted(dist.rglob('*')):5959 if p.is_file(): h.update(p.read_bytes())
A social.html +58 -0
@@ -0,0 +1,58 @@
1+<!doctype html>2+<html lang="en">3+<head>4+ <meta charset="utf-8">5+ <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">6+ <script src="./viewport.js"></script>7+ <meta name="theme-color" content="#1a1a1a">8+ <meta name="description" content="Crop, frame, vignette and grain for posting. Runs on your device.">9+ <meta name="apple-mobile-web-app-capable" content="yes">10+ <meta name="mobile-web-app-capable" content="yes">11+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">12+ <meta name="apple-mobile-web-app-title" content="gud.pics">13+ <link rel="manifest" href="./manifest.webmanifest">14+ <link rel="icon" href="./assets/icon.svg" type="image/svg+xml">15+ <link rel="apple-touch-icon" href="./assets/icon-192.png">16+ <link rel="stylesheet" href="./style.css">17+ <title>Social</title>18+</head>19+<body class="tool-body">20+ <div class="tool" id="tool" data-state="empty">21+ <header class="nav">22+ <a class="nav-btn" href="./" aria-label="Tools"><svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></a>23+ <h1 class="nav-title">Social</h1>24+ <button class="nav-btn" id="save" aria-label="Save" disabled><svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m-5-5l5 5 5-5M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>25+ </header>26+27+ <section class="stage" id="stage" aria-label="Preview">28+ <div class="canvas-wrap"><canvas id="canvas" aria-label="Preview"></canvas></div>29+ <button class="empty" id="open-empty" aria-label="Open photo">30+ <svg width="72" height="72" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 5h6"/><path d="M19 2v6"/><path d="M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/><circle cx="9" cy="9" r="2"/></svg>31+ <svg class="plus" width="28" height="28" viewBox="0 0 28 28" aria-hidden="true"><path d="M14 5v18M5 14h18" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"/></svg>32+ </button>33+ <div class="hud"><span class="chip" id="dims"></span></div>34+ <div class="toast" id="status" role="status" aria-live="polite" hidden></div>35+ <div class="busy" id="busy" hidden><span class="spinner"></span></div>36+ </section>37+38+ <section class="panel" aria-label="Adjustments">39+ <div class="chips" id="formats" role="radiogroup" aria-label="Format"></div>40+ <div class="segment" id="fit" role="group" aria-label="Fit">41+ <button data-fit="crop" aria-pressed="true" aria-label="Crop"><svg viewBox="0 0 28 28" aria-hidden="true"><path d="M8 3v17h17M3 8h17v17"/></svg></button>42+ <button data-fit="pad" aria-pressed="false" aria-label="Pad"><svg viewBox="0 0 28 28" aria-hidden="true"><rect x="4" y="4" width="20" height="20" rx="2"/><rect x="9" y="8" width="10" height="12" rx="1"/></svg></button>43+ </div>44+ <div class="slider" id="frame-row"><label for="frame">Frame</label><output id="frame-value" for="frame">0%</output><input id="frame" type="range" min="0" max="15" value="0" step="0.5" disabled></div>45+ <div class="slider" id="vignette-row"><label for="vignette">Vignette</label><output id="vignette-value" for="vignette">0</output><input id="vignette" type="range" min="0" max="100" value="0" disabled></div>46+ <div class="slider" id="grain-row"><label for="grain">Grain</label><output id="grain-value" for="grain">0</output><input id="grain" type="range" min="0" max="100" value="0" disabled></div>47+ <div class="toolbar">48+ <button class="tb" id="open" aria-label="Open"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.8"/><circle cx="8.5" cy="10" r="1.8" fill="currentColor"/><path d="M4 17l5-5 4 4 2.5-2.5L20 18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg></button>49+ <button class="tb" id="color" aria-label="Frame color" aria-pressed="false" disabled><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor"/></svg></button>50+ <button class="tb" id="reset" aria-label="Reset" disabled><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12a8 8 0 1 0 2.3-5.7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M4 4v5h5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></button>51+ <button class="tb" id="share" aria-label="Share" disabled hidden><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v13M8 7l4-4 4 4M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></button>52+ </div>53+ </section>54+ <input id="file" type="file" accept="image/jpeg,image/png,image/webp" hidden>55+ </div>56+ <script type="module" src="./social.mjs"></script>57+</body>58+</html>
A social.mjs +116 -0
@@ -0,0 +1,116 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later2+const $=id=>document.getElementById(id);3+// Formats: ratio null keeps the original; size is the export size for platform presets.4+const FORMATS=[5+ ['original','Original',null,null],['square','Square',1,[1080,1080]],['post','Post 4:5',4/5,[1080,1350]],6+ ['story','Story 9:16',9/16,[1080,1920]],['wide','Wide 16:9',16/9,[1920,1080]],['landscape','Post 1.91:1',1.91,[1080,566]],['classic','Classic 3:2',3/2,null]];7+const MAX_EDGE=2160,PREVIEW_EDGE=1400;8+const tool=$('tool'),canvas=$('canvas'),ctx=canvas.getContext('2d'),stage=$('stage');9+let source=null,name='photo',busy=false,toastTimer,format='original',fit='crop',dark=false,pan={x:0,y:0},dragging=null,renderTimer;10+// One large noise tile drawn at 1:1 so grain is pixel sized and never resampled.11+const NOISE=1024;const noise=(()=>{const c=document.createElement('canvas');c.width=c.height=NOISE;const x=c.getContext('2d'),d=x.createImageData(NOISE,NOISE);let s=1234567;for(let i=0;i<d.data.length;i+=4){s=(s*1664525+1013904223)>>>0;const v=(s>>>8)&255;d.data[i]=d.data[i+1]=d.data[i+2]=v;d.data[i+3]=255;}x.putImageData(d,0,0);return c;})();12+13+function status(message,error=false){clearTimeout(toastTimer);const el=$('status');el.textContent=message;el.classList.toggle('error',error);el.hidden=!message;if(message)toastTimer=setTimeout(()=>{el.hidden=true;},error?5000:2500);}14+function sync(){15+ tool.dataset.state=source?'detected':'empty';16+ const ready=Boolean(source)&&!busy;17+ for(const id of ['save','share','color','reset','frame','vignette','grain'])$(id).disabled=!ready;18+ $('open').disabled=busy;$('open-empty').disabled=busy;$('busy').hidden=!busy;19+ for(const b of $('formats').children){b.setAttribute('aria-checked',String(b.dataset.format===format));b.disabled=!ready;}20+ for(const b of $('fit').children){b.setAttribute('aria-pressed',String(b.dataset.fit===fit));b.disabled=!ready||format==='original';}21+ $('color').setAttribute('aria-pressed',String(dark));22+ $('frame-value').value=$('frame').value+'%';$('vignette-value').value=$('vignette').value;$('grain-value').value=$('grain').value;23+}24+function layout(){25+ if(!canvas.width)return;26+ const cs=getComputedStyle(stage),w=stage.clientWidth-parseFloat(cs.paddingLeft)-parseFloat(cs.paddingRight),h=stage.clientHeight-parseFloat(cs.paddingTop)-parseFloat(cs.paddingBottom);27+ const s=Math.min(w/canvas.width,h/canvas.height);canvas.style.width=Math.max(1,Math.floor(canvas.width*s))+'px';canvas.style.height=Math.max(1,Math.floor(canvas.height*s))+'px';28+}29+new ResizeObserver(layout).observe(stage);30+// Output geometry: returns {w,h, sx,sy,sw,sh (source crop), dx,dy,dw,dh (placement)} for a given long edge budget.31+function geometry(maxEdge,fixed){32+ const f=FORMATS.find(f=>f[0]===format),ratio=f[2],size=f[3],sw0=source.width,sh0=source.height;33+ let w,h;34+ if(fixed&&size){[w,h]=size;}35+ else{36+ const r=ratio||sw0/sh0;37+ if(fit==='pad'&&ratio){const contained=sw0/sh0>r?[sw0,Math.round(sw0/r)]:[Math.round(sh0*r),sh0];[w,h]=contained;}38+ else{[w,h]=sw0/sh0>r?[Math.round(sh0*r),sh0]:[sw0,Math.round(sw0/r)];}39+ const s=Math.min(1,maxEdge/Math.max(w,h));w=Math.round(w*s);h=Math.round(h*s);40+ }41+ const r=w/h;42+ if(fit==='pad'&&ratio){43+ const s=Math.min(w/sw0,h/sh0),dw=sw0*s,dh=sh0*s;44+ return {w,h,sx:0,sy:0,sw:sw0,sh:sh0,dx:(w-dw)/2,dy:(h-dh)/2,dw,dh};45+ }46+ let sw=sw0,sh=sh0;if(sw0/sh0>r)sw=sh0*r;else sh=sw0/r;47+ const sx=(sw0-sw)/2*(1+pan.x),sy=(sh0-sh)/2*(1+pan.y);48+ return {w,h,sx,sy,sw,sh,dx:0,dy:0,dw:w,dh:h};49+}50+function paint(target,g){51+ const x=target.getContext('2d'),bg=dark?'#000':'#fff';52+ target.width=g.w;target.height=g.h;53+ x.fillStyle=bg;x.fillRect(0,0,g.w,g.h);54+ const framePct=Number($('frame').value)/100,border=Math.round(Math.min(g.w,g.h)*framePct);55+ // Picture area inside the frame56+ const aw=g.w-2*border,ah=g.h-2*border;57+ x.save();x.beginPath();x.rect(border,border,aw,ah);x.clip();58+ const scale=Math.min(aw/g.w,ah/g.h);59+ x.translate(border,border);x.scale(scale,scale);60+ x.imageSmoothingQuality='high';61+ x.drawImage(source,g.sx,g.sy,g.sw,g.sh,g.dx,g.dy,g.dw,g.dh);62+ const vig=Number($('vignette').value)/100;63+ if(vig>0){const cx=g.dx+g.dw/2,cy=g.dy+g.dh/2,rad=Math.hypot(g.dw,g.dh)/2;const grad=x.createRadialGradient(cx,cy,rad*0.35,cx,cy,rad);grad.addColorStop(0,'rgba(0,0,0,0)');grad.addColorStop(1,`rgba(0,0,0,${(0.85*vig).toFixed(3)})`);x.fillStyle=grad;x.fillRect(g.dx,g.dy,g.dw,g.dh);}64+ const grain=Number($('grain').value)/100;65+ if(grain>0){x.save();x.beginPath();x.rect(g.dx,g.dy,g.dw,g.dh);x.clip();x.setTransform(1,0,0,1,0,0);x.globalCompositeOperation='overlay';x.globalAlpha=Math.min(1,grain*0.55);66+ for(let ty=0;ty<g.h;ty+=NOISE)for(let tx=0;tx<g.w;tx+=NOISE)x.drawImage(noise,tx,ty);x.restore();}67+ x.restore();68+}69+function render(){70+ if(!source)return;71+ const g=geometry(PREVIEW_EDGE,false);paint(canvas,g);layout();72+ const full=geometry(MAX_EDGE,true);$('dims').textContent=`${full.w} x ${full.h}`;$('dims').hidden=false;73+}74+function scheduleRender(){sync();clearTimeout(renderTimer);renderTimer=setTimeout(render,16);}75+async function exportBlob(){const c=document.createElement('canvas');paint(c,geometry(MAX_EDGE,true));const blob=await new Promise(res=>c.toBlob(res,'image/jpeg',0.92));if(!blob)throw Error('Could not encode the JPEG.');return {blob,w:c.width,h:c.height};}76+function download(blob,fname){const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=fname;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);}77+async function openFile(file){78+ if(busy)return;busy=true;sync();79+ try{80+ if(!/^image\/(jpeg|png|webp)$/.test(file.type))throw Error('Choose a JPEG, PNG or WebP.');81+ const bitmap=await createImageBitmap(file,{imageOrientation:'from-image'});82+ source?.close?.();source=bitmap;name=file.name.replace(/\.[^.]+$/,'');pan={x:0,y:0};83+ status('');render();84+ }catch(e){status(e.message||'Could not open this image.',true);}85+ finally{busy=false;sync();}86+}87+// Controls88+for(const [id,label] of FORMATS){const b=document.createElement('button');b.className='chip-btn';b.role='radio';b.dataset.format=id;b.textContent=label;b.disabled=true;b.onclick=()=>{format=id;pan={x:0,y:0};scheduleRender();b.scrollIntoView({inline:'center',block:'nearest',behavior:'smooth'});};$('formats').append(b);}89+for(const b of $('fit').children)b.onclick=()=>{fit=b.dataset.fit;scheduleRender();};90+for(const id of ['frame','vignette','grain'])$(id).oninput=scheduleRender;91+$('color').onclick=()=>{dark=!dark;scheduleRender();};92+$('reset').onclick=()=>{format='original';fit='crop';dark=false;pan={x:0,y:0};$('frame').value=0;$('vignette').value=0;$('grain').value=0;scheduleRender();};93+$('file').onchange=()=>{const f=$('file').files[0];if(f)openFile(f);$('file').value='';};94+$('open').onclick=()=>$('file').click();$('open-empty').onclick=()=>$('file').click();95+// Drag to position the crop.96+stage.addEventListener('pointerdown',e=>{if(!source||fit==='pad'||format==='original'||e.target.closest('button'))return;dragging={x:e.clientX,y:e.clientY,pan:{...pan}};try{stage.setPointerCapture(e.pointerId);}catch{}});97+stage.addEventListener('pointermove',e=>{if(!dragging)return;const g=geometry(PREVIEW_EDGE,false),rect=canvas.getBoundingClientRect(),k=g.w/rect.width;98+ const rangeX=(source.width-g.sw)/2*(g.w/g.sw),rangeY=(source.height-g.sh)/2*(g.h/g.sh);99+ pan.x=rangeX>0?Math.max(-1,Math.min(1,dragging.pan.x-(e.clientX-dragging.x)*k/rangeX)):0;pan.y=rangeY>0?Math.max(-1,Math.min(1,dragging.pan.y-(e.clientY-dragging.y)*k/rangeY)):0;scheduleRender();});100+for(const t of ['pointerup','pointercancel'])stage.addEventListener(t,()=>{dragging=null;});101+stage.addEventListener('dragover',e=>{e.preventDefault();e.dataTransfer.dropEffect='copy';$('open-empty').classList.add('over');});102+stage.addEventListener('dragleave',()=>$('open-empty').classList.remove('over'));103+stage.addEventListener('drop',e=>{e.preventDefault();$('open-empty').classList.remove('over');const f=e.dataTransfer.files[0];if(f)openFile(f);});104+stage.addEventListener('contextmenu',e=>e.preventDefault());105+$('save').onclick=async()=>{if(!source)return;busy=true;sync();try{const {blob,w,h}=await exportBlob();download(blob,`${name}-${format}.jpg`);status(`Saved ${w} x ${h}`);}catch(e){status(e.message,true);}finally{busy=false;sync();}};106+if(navigator.canShare&&navigator.share)$('share').hidden=false;107+$('share').onclick=async()=>{108+ if(!source)return;109+ try{110+ const {blob}=await exportBlob();const file=new File([blob],`${name}-${format}.jpg`,{type:'image/jpeg'});111+ if(!navigator.canShare({files:[file]}))throw Error('Sharing files is not supported here.');112+ await navigator.share({files:[file]});113+ }catch(e){if(e.name==='AbortError')return;if(e.name==='NotAllowedError'){const {blob}=await exportBlob();download(blob,`${name}-${format}.jpg`);status('Saved. Share it from your photos.');}else status(e.message,true);}114+};115+if('serviceWorker'in navigator){navigator.serviceWorker.register('./sw.js').catch(()=>{});let hadController=Boolean(navigator.serviceWorker.controller);navigator.serviceWorker.addEventListener('controllerchange',()=>{if(hadController&&!source)location.reload();hadController=true;});}116+sync();
M sw.js +1 -1