diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 00000000..e69de29b diff --git a/404.html b/404.html new file mode 100644 index 00000000..5fbe7a36 --- /dev/null +++ b/404.html @@ -0,0 +1,463 @@ + + + + + + + + + + + + + + + + + + + + + + Telephoto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+ + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + +
+
+ +

404 - Not found

+ +
+
+ + + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/assets/alignment_bottom_small.mp4 b/assets/alignment_bottom_small.mp4 new file mode 100644 index 00000000..cf75f5f4 Binary files /dev/null and b/assets/alignment_bottom_small.mp4 differ diff --git a/assets/alignment_top_small.mp4 b/assets/alignment_top_small.mp4 new file mode 100644 index 00000000..40c73010 Binary files /dev/null and b/assets/alignment_top_small.mp4 differ diff --git a/assets/demo_small.mp4 b/assets/demo_small.mp4 new file mode 100644 index 00000000..1973932d Binary files /dev/null and b/assets/demo_small.mp4 differ diff --git a/assets/edge_detection_after.mp4 b/assets/edge_detection_after.mp4 new file mode 100644 index 00000000..5f974dc0 Binary files /dev/null and b/assets/edge_detection_after.mp4 differ diff --git a/assets/edge_detection_before.mp4 b/assets/edge_detection_before.mp4 new file mode 100644 index 00000000..0fd4b649 Binary files /dev/null and b/assets/edge_detection_before.mp4 differ diff --git a/assets/images/favicon.png b/assets/images/favicon.png new file mode 100644 index 00000000..1cf13b9f Binary files /dev/null and b/assets/images/favicon.png differ diff --git a/assets/javascripts/bundle.51d95adb.min.js b/assets/javascripts/bundle.51d95adb.min.js new file mode 100644 index 00000000..b20ec683 --- /dev/null +++ b/assets/javascripts/bundle.51d95adb.min.js @@ -0,0 +1,29 @@ +"use strict";(()=>{var Hi=Object.create;var xr=Object.defineProperty;var Pi=Object.getOwnPropertyDescriptor;var $i=Object.getOwnPropertyNames,kt=Object.getOwnPropertySymbols,Ii=Object.getPrototypeOf,Er=Object.prototype.hasOwnProperty,an=Object.prototype.propertyIsEnumerable;var on=(e,t,r)=>t in e?xr(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,P=(e,t)=>{for(var r in t||(t={}))Er.call(t,r)&&on(e,r,t[r]);if(kt)for(var r of kt(t))an.call(t,r)&&on(e,r,t[r]);return e};var sn=(e,t)=>{var r={};for(var n in e)Er.call(e,n)&&t.indexOf(n)<0&&(r[n]=e[n]);if(e!=null&&kt)for(var n of kt(e))t.indexOf(n)<0&&an.call(e,n)&&(r[n]=e[n]);return r};var Ht=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var Fi=(e,t,r,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let o of $i(t))!Er.call(e,o)&&o!==r&&xr(e,o,{get:()=>t[o],enumerable:!(n=Pi(t,o))||n.enumerable});return e};var yt=(e,t,r)=>(r=e!=null?Hi(Ii(e)):{},Fi(t||!e||!e.__esModule?xr(r,"default",{value:e,enumerable:!0}):r,e));var fn=Ht((wr,cn)=>{(function(e,t){typeof wr=="object"&&typeof cn!="undefined"?t():typeof define=="function"&&define.amd?define(t):t()})(wr,function(){"use strict";function e(r){var n=!0,o=!1,i=null,a={text:!0,search:!0,url:!0,tel:!0,email:!0,password:!0,number:!0,date:!0,month:!0,week:!0,time:!0,datetime:!0,"datetime-local":!0};function s(T){return!!(T&&T!==document&&T.nodeName!=="HTML"&&T.nodeName!=="BODY"&&"classList"in T&&"contains"in T.classList)}function f(T){var Ke=T.type,We=T.tagName;return!!(We==="INPUT"&&a[Ke]&&!T.readOnly||We==="TEXTAREA"&&!T.readOnly||T.isContentEditable)}function c(T){T.classList.contains("focus-visible")||(T.classList.add("focus-visible"),T.setAttribute("data-focus-visible-added",""))}function u(T){T.hasAttribute("data-focus-visible-added")&&(T.classList.remove("focus-visible"),T.removeAttribute("data-focus-visible-added"))}function p(T){T.metaKey||T.altKey||T.ctrlKey||(s(r.activeElement)&&c(r.activeElement),n=!0)}function m(T){n=!1}function d(T){s(T.target)&&(n||f(T.target))&&c(T.target)}function h(T){s(T.target)&&(T.target.classList.contains("focus-visible")||T.target.hasAttribute("data-focus-visible-added"))&&(o=!0,window.clearTimeout(i),i=window.setTimeout(function(){o=!1},100),u(T.target))}function v(T){document.visibilityState==="hidden"&&(o&&(n=!0),B())}function B(){document.addEventListener("mousemove",z),document.addEventListener("mousedown",z),document.addEventListener("mouseup",z),document.addEventListener("pointermove",z),document.addEventListener("pointerdown",z),document.addEventListener("pointerup",z),document.addEventListener("touchmove",z),document.addEventListener("touchstart",z),document.addEventListener("touchend",z)}function re(){document.removeEventListener("mousemove",z),document.removeEventListener("mousedown",z),document.removeEventListener("mouseup",z),document.removeEventListener("pointermove",z),document.removeEventListener("pointerdown",z),document.removeEventListener("pointerup",z),document.removeEventListener("touchmove",z),document.removeEventListener("touchstart",z),document.removeEventListener("touchend",z)}function z(T){T.target.nodeName&&T.target.nodeName.toLowerCase()==="html"||(n=!1,re())}document.addEventListener("keydown",p,!0),document.addEventListener("mousedown",m,!0),document.addEventListener("pointerdown",m,!0),document.addEventListener("touchstart",m,!0),document.addEventListener("visibilitychange",v,!0),B(),r.addEventListener("focus",d,!0),r.addEventListener("blur",h,!0),r.nodeType===Node.DOCUMENT_FRAGMENT_NODE&&r.host?r.host.setAttribute("data-js-focus-visible",""):r.nodeType===Node.DOCUMENT_NODE&&(document.documentElement.classList.add("js-focus-visible"),document.documentElement.setAttribute("data-js-focus-visible",""))}if(typeof window!="undefined"&&typeof document!="undefined"){window.applyFocusVisiblePolyfill=e;var t;try{t=new CustomEvent("focus-visible-polyfill-ready")}catch(r){t=document.createEvent("CustomEvent"),t.initCustomEvent("focus-visible-polyfill-ready",!1,!1,{})}window.dispatchEvent(t)}typeof document!="undefined"&&e(document)})});var un=Ht(Sr=>{(function(e){var t=function(){try{return!!Symbol.iterator}catch(c){return!1}},r=t(),n=function(c){var u={next:function(){var p=c.shift();return{done:p===void 0,value:p}}};return r&&(u[Symbol.iterator]=function(){return u}),u},o=function(c){return encodeURIComponent(c).replace(/%20/g,"+")},i=function(c){return decodeURIComponent(String(c).replace(/\+/g," "))},a=function(){var c=function(p){Object.defineProperty(this,"_entries",{writable:!0,value:{}});var m=typeof p;if(m!=="undefined")if(m==="string")p!==""&&this._fromString(p);else if(p instanceof c){var d=this;p.forEach(function(re,z){d.append(z,re)})}else if(p!==null&&m==="object")if(Object.prototype.toString.call(p)==="[object Array]")for(var h=0;hd[0]?1:0}),c._entries&&(c._entries={});for(var p=0;p1?i(d[1]):"")}})})(typeof global!="undefined"?global:typeof window!="undefined"?window:typeof self!="undefined"?self:Sr);(function(e){var t=function(){try{var o=new e.URL("b","http://a");return o.pathname="c d",o.href==="http://a/c%20d"&&o.searchParams}catch(i){return!1}},r=function(){var o=e.URL,i=function(f,c){typeof f!="string"&&(f=String(f)),c&&typeof c!="string"&&(c=String(c));var u=document,p;if(c&&(e.location===void 0||c!==e.location.href)){c=c.toLowerCase(),u=document.implementation.createHTMLDocument(""),p=u.createElement("base"),p.href=c,u.head.appendChild(p);try{if(p.href.indexOf(c)!==0)throw new Error(p.href)}catch(T){throw new Error("URL unable to set base "+c+" due to "+T)}}var m=u.createElement("a");m.href=f,p&&(u.body.appendChild(m),m.href=m.href);var d=u.createElement("input");if(d.type="url",d.value=f,m.protocol===":"||!/:/.test(m.href)||!d.checkValidity()&&!c)throw new TypeError("Invalid URL");Object.defineProperty(this,"_anchorElement",{value:m});var h=new e.URLSearchParams(this.search),v=!0,B=!0,re=this;["append","delete","set"].forEach(function(T){var Ke=h[T];h[T]=function(){Ke.apply(h,arguments),v&&(B=!1,re.search=h.toString(),B=!0)}}),Object.defineProperty(this,"searchParams",{value:h,enumerable:!0});var z=void 0;Object.defineProperty(this,"_updateSearchParams",{enumerable:!1,configurable:!1,writable:!1,value:function(){this.search!==z&&(z=this.search,B&&(v=!1,this.searchParams._fromString(this.search),v=!0))}})},a=i.prototype,s=function(f){Object.defineProperty(a,f,{get:function(){return this._anchorElement[f]},set:function(c){this._anchorElement[f]=c},enumerable:!0})};["hash","host","hostname","port","protocol"].forEach(function(f){s(f)}),Object.defineProperty(a,"search",{get:function(){return this._anchorElement.search},set:function(f){this._anchorElement.search=f,this._updateSearchParams()},enumerable:!0}),Object.defineProperties(a,{toString:{get:function(){var f=this;return function(){return f.href}}},href:{get:function(){return this._anchorElement.href.replace(/\?$/,"")},set:function(f){this._anchorElement.href=f,this._updateSearchParams()},enumerable:!0},pathname:{get:function(){return this._anchorElement.pathname.replace(/(^\/?)/,"/")},set:function(f){this._anchorElement.pathname=f},enumerable:!0},origin:{get:function(){var f={"http:":80,"https:":443,"ftp:":21}[this._anchorElement.protocol],c=this._anchorElement.port!=f&&this._anchorElement.port!=="";return this._anchorElement.protocol+"//"+this._anchorElement.hostname+(c?":"+this._anchorElement.port:"")},enumerable:!0},password:{get:function(){return""},set:function(f){},enumerable:!0},username:{get:function(){return""},set:function(f){},enumerable:!0}}),i.createObjectURL=function(f){return o.createObjectURL.apply(o,arguments)},i.revokeObjectURL=function(f){return o.revokeObjectURL.apply(o,arguments)},e.URL=i};if(t()||r(),e.location!==void 0&&!("origin"in e.location)){var n=function(){return e.location.protocol+"//"+e.location.hostname+(e.location.port?":"+e.location.port:"")};try{Object.defineProperty(e.location,"origin",{get:n,enumerable:!0})}catch(o){setInterval(function(){e.location.origin=n()},100)}}})(typeof global!="undefined"?global:typeof window!="undefined"?window:typeof self!="undefined"?self:Sr)});var Qr=Ht((Lt,Kr)=>{/*! + * clipboard.js v2.0.11 + * https://clipboardjs.com/ + * + * Licensed MIT © Zeno Rocha + */(function(t,r){typeof Lt=="object"&&typeof Kr=="object"?Kr.exports=r():typeof define=="function"&&define.amd?define([],r):typeof Lt=="object"?Lt.ClipboardJS=r():t.ClipboardJS=r()})(Lt,function(){return function(){var e={686:function(n,o,i){"use strict";i.d(o,{default:function(){return ki}});var a=i(279),s=i.n(a),f=i(370),c=i.n(f),u=i(817),p=i.n(u);function m(j){try{return document.execCommand(j)}catch(O){return!1}}var d=function(O){var w=p()(O);return m("cut"),w},h=d;function v(j){var O=document.documentElement.getAttribute("dir")==="rtl",w=document.createElement("textarea");w.style.fontSize="12pt",w.style.border="0",w.style.padding="0",w.style.margin="0",w.style.position="absolute",w.style[O?"right":"left"]="-9999px";var k=window.pageYOffset||document.documentElement.scrollTop;return w.style.top="".concat(k,"px"),w.setAttribute("readonly",""),w.value=j,w}var B=function(O,w){var k=v(O);w.container.appendChild(k);var F=p()(k);return m("copy"),k.remove(),F},re=function(O){var w=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body},k="";return typeof O=="string"?k=B(O,w):O instanceof HTMLInputElement&&!["text","search","url","tel","password"].includes(O==null?void 0:O.type)?k=B(O.value,w):(k=p()(O),m("copy")),k},z=re;function T(j){return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?T=function(w){return typeof w}:T=function(w){return w&&typeof Symbol=="function"&&w.constructor===Symbol&&w!==Symbol.prototype?"symbol":typeof w},T(j)}var Ke=function(){var O=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},w=O.action,k=w===void 0?"copy":w,F=O.container,q=O.target,Le=O.text;if(k!=="copy"&&k!=="cut")throw new Error('Invalid "action" value, use either "copy" or "cut"');if(q!==void 0)if(q&&T(q)==="object"&&q.nodeType===1){if(k==="copy"&&q.hasAttribute("disabled"))throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');if(k==="cut"&&(q.hasAttribute("readonly")||q.hasAttribute("disabled")))throw new Error(`Invalid "target" attribute. You can't cut text from elements with "readonly" or "disabled" attributes`)}else throw new Error('Invalid "target" value, use a valid Element');if(Le)return z(Le,{container:F});if(q)return k==="cut"?h(q):z(q,{container:F})},We=Ke;function Ie(j){return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?Ie=function(w){return typeof w}:Ie=function(w){return w&&typeof Symbol=="function"&&w.constructor===Symbol&&w!==Symbol.prototype?"symbol":typeof w},Ie(j)}function Ti(j,O){if(!(j instanceof O))throw new TypeError("Cannot call a class as a function")}function nn(j,O){for(var w=0;w0&&arguments[0]!==void 0?arguments[0]:{};this.action=typeof F.action=="function"?F.action:this.defaultAction,this.target=typeof F.target=="function"?F.target:this.defaultTarget,this.text=typeof F.text=="function"?F.text:this.defaultText,this.container=Ie(F.container)==="object"?F.container:document.body}},{key:"listenClick",value:function(F){var q=this;this.listener=c()(F,"click",function(Le){return q.onClick(Le)})}},{key:"onClick",value:function(F){var q=F.delegateTarget||F.currentTarget,Le=this.action(q)||"copy",Rt=We({action:Le,container:this.container,target:this.target(q),text:this.text(q)});this.emit(Rt?"success":"error",{action:Le,text:Rt,trigger:q,clearSelection:function(){q&&q.focus(),window.getSelection().removeAllRanges()}})}},{key:"defaultAction",value:function(F){return yr("action",F)}},{key:"defaultTarget",value:function(F){var q=yr("target",F);if(q)return document.querySelector(q)}},{key:"defaultText",value:function(F){return yr("text",F)}},{key:"destroy",value:function(){this.listener.destroy()}}],[{key:"copy",value:function(F){var q=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body};return z(F,q)}},{key:"cut",value:function(F){return h(F)}},{key:"isSupported",value:function(){var F=arguments.length>0&&arguments[0]!==void 0?arguments[0]:["copy","cut"],q=typeof F=="string"?[F]:F,Le=!!document.queryCommandSupported;return q.forEach(function(Rt){Le=Le&&!!document.queryCommandSupported(Rt)}),Le}}]),w}(s()),ki=Ri},828:function(n){var o=9;if(typeof Element!="undefined"&&!Element.prototype.matches){var i=Element.prototype;i.matches=i.matchesSelector||i.mozMatchesSelector||i.msMatchesSelector||i.oMatchesSelector||i.webkitMatchesSelector}function a(s,f){for(;s&&s.nodeType!==o;){if(typeof s.matches=="function"&&s.matches(f))return s;s=s.parentNode}}n.exports=a},438:function(n,o,i){var a=i(828);function s(u,p,m,d,h){var v=c.apply(this,arguments);return u.addEventListener(m,v,h),{destroy:function(){u.removeEventListener(m,v,h)}}}function f(u,p,m,d,h){return typeof u.addEventListener=="function"?s.apply(null,arguments):typeof m=="function"?s.bind(null,document).apply(null,arguments):(typeof u=="string"&&(u=document.querySelectorAll(u)),Array.prototype.map.call(u,function(v){return s(v,p,m,d,h)}))}function c(u,p,m,d){return function(h){h.delegateTarget=a(h.target,p),h.delegateTarget&&d.call(u,h)}}n.exports=f},879:function(n,o){o.node=function(i){return i!==void 0&&i instanceof HTMLElement&&i.nodeType===1},o.nodeList=function(i){var a=Object.prototype.toString.call(i);return i!==void 0&&(a==="[object NodeList]"||a==="[object HTMLCollection]")&&"length"in i&&(i.length===0||o.node(i[0]))},o.string=function(i){return typeof i=="string"||i instanceof String},o.fn=function(i){var a=Object.prototype.toString.call(i);return a==="[object Function]"}},370:function(n,o,i){var a=i(879),s=i(438);function f(m,d,h){if(!m&&!d&&!h)throw new Error("Missing required arguments");if(!a.string(d))throw new TypeError("Second argument must be a String");if(!a.fn(h))throw new TypeError("Third argument must be a Function");if(a.node(m))return c(m,d,h);if(a.nodeList(m))return u(m,d,h);if(a.string(m))return p(m,d,h);throw new TypeError("First argument must be a String, HTMLElement, HTMLCollection, or NodeList")}function c(m,d,h){return m.addEventListener(d,h),{destroy:function(){m.removeEventListener(d,h)}}}function u(m,d,h){return Array.prototype.forEach.call(m,function(v){v.addEventListener(d,h)}),{destroy:function(){Array.prototype.forEach.call(m,function(v){v.removeEventListener(d,h)})}}}function p(m,d,h){return s(document.body,m,d,h)}n.exports=f},817:function(n){function o(i){var a;if(i.nodeName==="SELECT")i.focus(),a=i.value;else if(i.nodeName==="INPUT"||i.nodeName==="TEXTAREA"){var s=i.hasAttribute("readonly");s||i.setAttribute("readonly",""),i.select(),i.setSelectionRange(0,i.value.length),s||i.removeAttribute("readonly"),a=i.value}else{i.hasAttribute("contenteditable")&&i.focus();var f=window.getSelection(),c=document.createRange();c.selectNodeContents(i),f.removeAllRanges(),f.addRange(c),a=f.toString()}return a}n.exports=o},279:function(n){function o(){}o.prototype={on:function(i,a,s){var f=this.e||(this.e={});return(f[i]||(f[i]=[])).push({fn:a,ctx:s}),this},once:function(i,a,s){var f=this;function c(){f.off(i,c),a.apply(s,arguments)}return c._=a,this.on(i,c,s)},emit:function(i){var a=[].slice.call(arguments,1),s=((this.e||(this.e={}))[i]||[]).slice(),f=0,c=s.length;for(f;f{"use strict";/*! + * escape-html + * Copyright(c) 2012-2013 TJ Holowaychuk + * Copyright(c) 2015 Andreas Lubbe + * Copyright(c) 2015 Tiancheng "Timothy" Gu + * MIT Licensed + */var is=/["'&<>]/;Jo.exports=as;function as(e){var t=""+e,r=is.exec(t);if(!r)return t;var n,o="",i=0,a=0;for(i=r.index;i0&&i[i.length-1])&&(c[0]===6||c[0]===2)){r=0;continue}if(c[0]===3&&(!i||c[1]>i[0]&&c[1]=e.length&&(e=void 0),{value:e&&e[n++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}function W(e,t){var r=typeof Symbol=="function"&&e[Symbol.iterator];if(!r)return e;var n=r.call(e),o,i=[],a;try{for(;(t===void 0||t-- >0)&&!(o=n.next()).done;)i.push(o.value)}catch(s){a={error:s}}finally{try{o&&!o.done&&(r=n.return)&&r.call(n)}finally{if(a)throw a.error}}return i}function D(e,t,r){if(r||arguments.length===2)for(var n=0,o=t.length,i;n1||s(m,d)})})}function s(m,d){try{f(n[m](d))}catch(h){p(i[0][3],h)}}function f(m){m.value instanceof Xe?Promise.resolve(m.value.v).then(c,u):p(i[0][2],m)}function c(m){s("next",m)}function u(m){s("throw",m)}function p(m,d){m(d),i.shift(),i.length&&s(i[0][0],i[0][1])}}function mn(e){if(!Symbol.asyncIterator)throw new TypeError("Symbol.asyncIterator is not defined.");var t=e[Symbol.asyncIterator],r;return t?t.call(e):(e=typeof xe=="function"?xe(e):e[Symbol.iterator](),r={},n("next"),n("throw"),n("return"),r[Symbol.asyncIterator]=function(){return this},r);function n(i){r[i]=e[i]&&function(a){return new Promise(function(s,f){a=e[i](a),o(s,f,a.done,a.value)})}}function o(i,a,s,f){Promise.resolve(f).then(function(c){i({value:c,done:s})},a)}}function A(e){return typeof e=="function"}function at(e){var t=function(n){Error.call(n),n.stack=new Error().stack},r=e(t);return r.prototype=Object.create(Error.prototype),r.prototype.constructor=r,r}var $t=at(function(e){return function(r){e(this),this.message=r?r.length+` errors occurred during unsubscription: +`+r.map(function(n,o){return o+1+") "+n.toString()}).join(` + `):"",this.name="UnsubscriptionError",this.errors=r}});function De(e,t){if(e){var r=e.indexOf(t);0<=r&&e.splice(r,1)}}var Fe=function(){function e(t){this.initialTeardown=t,this.closed=!1,this._parentage=null,this._finalizers=null}return e.prototype.unsubscribe=function(){var t,r,n,o,i;if(!this.closed){this.closed=!0;var a=this._parentage;if(a)if(this._parentage=null,Array.isArray(a))try{for(var s=xe(a),f=s.next();!f.done;f=s.next()){var c=f.value;c.remove(this)}}catch(v){t={error:v}}finally{try{f&&!f.done&&(r=s.return)&&r.call(s)}finally{if(t)throw t.error}}else a.remove(this);var u=this.initialTeardown;if(A(u))try{u()}catch(v){i=v instanceof $t?v.errors:[v]}var p=this._finalizers;if(p){this._finalizers=null;try{for(var m=xe(p),d=m.next();!d.done;d=m.next()){var h=d.value;try{dn(h)}catch(v){i=i!=null?i:[],v instanceof $t?i=D(D([],W(i)),W(v.errors)):i.push(v)}}}catch(v){n={error:v}}finally{try{d&&!d.done&&(o=m.return)&&o.call(m)}finally{if(n)throw n.error}}}if(i)throw new $t(i)}},e.prototype.add=function(t){var r;if(t&&t!==this)if(this.closed)dn(t);else{if(t instanceof e){if(t.closed||t._hasParent(this))return;t._addParent(this)}(this._finalizers=(r=this._finalizers)!==null&&r!==void 0?r:[]).push(t)}},e.prototype._hasParent=function(t){var r=this._parentage;return r===t||Array.isArray(r)&&r.includes(t)},e.prototype._addParent=function(t){var r=this._parentage;this._parentage=Array.isArray(r)?(r.push(t),r):r?[r,t]:t},e.prototype._removeParent=function(t){var r=this._parentage;r===t?this._parentage=null:Array.isArray(r)&&De(r,t)},e.prototype.remove=function(t){var r=this._finalizers;r&&De(r,t),t instanceof e&&t._removeParent(this)},e.EMPTY=function(){var t=new e;return t.closed=!0,t}(),e}();var Or=Fe.EMPTY;function It(e){return e instanceof Fe||e&&"closed"in e&&A(e.remove)&&A(e.add)&&A(e.unsubscribe)}function dn(e){A(e)?e():e.unsubscribe()}var Ae={onUnhandledError:null,onStoppedNotification:null,Promise:void 0,useDeprecatedSynchronousErrorHandling:!1,useDeprecatedNextContext:!1};var st={setTimeout:function(e,t){for(var r=[],n=2;n0},enumerable:!1,configurable:!0}),t.prototype._trySubscribe=function(r){return this._throwIfClosed(),e.prototype._trySubscribe.call(this,r)},t.prototype._subscribe=function(r){return this._throwIfClosed(),this._checkFinalizedStatuses(r),this._innerSubscribe(r)},t.prototype._innerSubscribe=function(r){var n=this,o=this,i=o.hasError,a=o.isStopped,s=o.observers;return i||a?Or:(this.currentObservers=null,s.push(r),new Fe(function(){n.currentObservers=null,De(s,r)}))},t.prototype._checkFinalizedStatuses=function(r){var n=this,o=n.hasError,i=n.thrownError,a=n.isStopped;o?r.error(i):a&&r.complete()},t.prototype.asObservable=function(){var r=new U;return r.source=this,r},t.create=function(r,n){return new wn(r,n)},t}(U);var wn=function(e){ne(t,e);function t(r,n){var o=e.call(this)||this;return o.destination=r,o.source=n,o}return t.prototype.next=function(r){var n,o;(o=(n=this.destination)===null||n===void 0?void 0:n.next)===null||o===void 0||o.call(n,r)},t.prototype.error=function(r){var n,o;(o=(n=this.destination)===null||n===void 0?void 0:n.error)===null||o===void 0||o.call(n,r)},t.prototype.complete=function(){var r,n;(n=(r=this.destination)===null||r===void 0?void 0:r.complete)===null||n===void 0||n.call(r)},t.prototype._subscribe=function(r){var n,o;return(o=(n=this.source)===null||n===void 0?void 0:n.subscribe(r))!==null&&o!==void 0?o:Or},t}(E);var Et={now:function(){return(Et.delegate||Date).now()},delegate:void 0};var wt=function(e){ne(t,e);function t(r,n,o){r===void 0&&(r=1/0),n===void 0&&(n=1/0),o===void 0&&(o=Et);var i=e.call(this)||this;return i._bufferSize=r,i._windowTime=n,i._timestampProvider=o,i._buffer=[],i._infiniteTimeWindow=!0,i._infiniteTimeWindow=n===1/0,i._bufferSize=Math.max(1,r),i._windowTime=Math.max(1,n),i}return t.prototype.next=function(r){var n=this,o=n.isStopped,i=n._buffer,a=n._infiniteTimeWindow,s=n._timestampProvider,f=n._windowTime;o||(i.push(r),!a&&i.push(s.now()+f)),this._trimBuffer(),e.prototype.next.call(this,r)},t.prototype._subscribe=function(r){this._throwIfClosed(),this._trimBuffer();for(var n=this._innerSubscribe(r),o=this,i=o._infiniteTimeWindow,a=o._buffer,s=a.slice(),f=0;f0?e.prototype.requestAsyncId.call(this,r,n,o):(r.actions.push(this),r._scheduled||(r._scheduled=ut.requestAnimationFrame(function(){return r.flush(void 0)})))},t.prototype.recycleAsyncId=function(r,n,o){var i;if(o===void 0&&(o=0),o!=null?o>0:this.delay>0)return e.prototype.recycleAsyncId.call(this,r,n,o);var a=r.actions;n!=null&&((i=a[a.length-1])===null||i===void 0?void 0:i.id)!==n&&(ut.cancelAnimationFrame(n),r._scheduled=void 0)},t}(Ut);var On=function(e){ne(t,e);function t(){return e!==null&&e.apply(this,arguments)||this}return t.prototype.flush=function(r){this._active=!0;var n=this._scheduled;this._scheduled=void 0;var o=this.actions,i;r=r||o.shift();do if(i=r.execute(r.state,r.delay))break;while((r=o[0])&&r.id===n&&o.shift());if(this._active=!1,i){for(;(r=o[0])&&r.id===n&&o.shift();)r.unsubscribe();throw i}},t}(Wt);var we=new On(Tn);var R=new U(function(e){return e.complete()});function Dt(e){return e&&A(e.schedule)}function kr(e){return e[e.length-1]}function Qe(e){return A(kr(e))?e.pop():void 0}function Se(e){return Dt(kr(e))?e.pop():void 0}function Vt(e,t){return typeof kr(e)=="number"?e.pop():t}var pt=function(e){return e&&typeof e.length=="number"&&typeof e!="function"};function zt(e){return A(e==null?void 0:e.then)}function Nt(e){return A(e[ft])}function qt(e){return Symbol.asyncIterator&&A(e==null?void 0:e[Symbol.asyncIterator])}function Kt(e){return new TypeError("You provided "+(e!==null&&typeof e=="object"?"an invalid object":"'"+e+"'")+" where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.")}function Ki(){return typeof Symbol!="function"||!Symbol.iterator?"@@iterator":Symbol.iterator}var Qt=Ki();function Yt(e){return A(e==null?void 0:e[Qt])}function Gt(e){return ln(this,arguments,function(){var r,n,o,i;return Pt(this,function(a){switch(a.label){case 0:r=e.getReader(),a.label=1;case 1:a.trys.push([1,,9,10]),a.label=2;case 2:return[4,Xe(r.read())];case 3:return n=a.sent(),o=n.value,i=n.done,i?[4,Xe(void 0)]:[3,5];case 4:return[2,a.sent()];case 5:return[4,Xe(o)];case 6:return[4,a.sent()];case 7:return a.sent(),[3,2];case 8:return[3,10];case 9:return r.releaseLock(),[7];case 10:return[2]}})})}function Bt(e){return A(e==null?void 0:e.getReader)}function $(e){if(e instanceof U)return e;if(e!=null){if(Nt(e))return Qi(e);if(pt(e))return Yi(e);if(zt(e))return Gi(e);if(qt(e))return _n(e);if(Yt(e))return Bi(e);if(Bt(e))return Ji(e)}throw Kt(e)}function Qi(e){return new U(function(t){var r=e[ft]();if(A(r.subscribe))return r.subscribe(t);throw new TypeError("Provided object does not correctly implement Symbol.observable")})}function Yi(e){return new U(function(t){for(var r=0;r=2;return function(n){return n.pipe(e?_(function(o,i){return e(o,i,n)}):me,Oe(1),r?He(t):zn(function(){return new Xt}))}}function Nn(){for(var e=[],t=0;t=2,!0))}function fe(e){e===void 0&&(e={});var t=e.connector,r=t===void 0?function(){return new E}:t,n=e.resetOnError,o=n===void 0?!0:n,i=e.resetOnComplete,a=i===void 0?!0:i,s=e.resetOnRefCountZero,f=s===void 0?!0:s;return function(c){var u,p,m,d=0,h=!1,v=!1,B=function(){p==null||p.unsubscribe(),p=void 0},re=function(){B(),u=m=void 0,h=v=!1},z=function(){var T=u;re(),T==null||T.unsubscribe()};return g(function(T,Ke){d++,!v&&!h&&B();var We=m=m!=null?m:r();Ke.add(function(){d--,d===0&&!v&&!h&&(p=jr(z,f))}),We.subscribe(Ke),!u&&d>0&&(u=new et({next:function(Ie){return We.next(Ie)},error:function(Ie){v=!0,B(),p=jr(re,o,Ie),We.error(Ie)},complete:function(){h=!0,B(),p=jr(re,a),We.complete()}}),$(T).subscribe(u))})(c)}}function jr(e,t){for(var r=[],n=2;ne.next(document)),e}function K(e,t=document){return Array.from(t.querySelectorAll(e))}function V(e,t=document){let r=se(e,t);if(typeof r=="undefined")throw new ReferenceError(`Missing element: expected "${e}" to be present`);return r}function se(e,t=document){return t.querySelector(e)||void 0}function _e(){return document.activeElement instanceof HTMLElement&&document.activeElement||void 0}function tr(e){return L(b(document.body,"focusin"),b(document.body,"focusout")).pipe(ke(1),l(()=>{let t=_e();return typeof t!="undefined"?e.contains(t):!1}),N(e===_e()),Y())}function Be(e){return{x:e.offsetLeft,y:e.offsetTop}}function Yn(e){return L(b(window,"load"),b(window,"resize")).pipe(Ce(0,we),l(()=>Be(e)),N(Be(e)))}function rr(e){return{x:e.scrollLeft,y:e.scrollTop}}function dt(e){return L(b(e,"scroll"),b(window,"resize")).pipe(Ce(0,we),l(()=>rr(e)),N(rr(e)))}var Bn=function(){if(typeof Map!="undefined")return Map;function e(t,r){var n=-1;return t.some(function(o,i){return o[0]===r?(n=i,!0):!1}),n}return function(){function t(){this.__entries__=[]}return Object.defineProperty(t.prototype,"size",{get:function(){return this.__entries__.length},enumerable:!0,configurable:!0}),t.prototype.get=function(r){var n=e(this.__entries__,r),o=this.__entries__[n];return o&&o[1]},t.prototype.set=function(r,n){var o=e(this.__entries__,r);~o?this.__entries__[o][1]=n:this.__entries__.push([r,n])},t.prototype.delete=function(r){var n=this.__entries__,o=e(n,r);~o&&n.splice(o,1)},t.prototype.has=function(r){return!!~e(this.__entries__,r)},t.prototype.clear=function(){this.__entries__.splice(0)},t.prototype.forEach=function(r,n){n===void 0&&(n=null);for(var o=0,i=this.__entries__;o0},e.prototype.connect_=function(){!zr||this.connected_||(document.addEventListener("transitionend",this.onTransitionEnd_),window.addEventListener("resize",this.refresh),xa?(this.mutationsObserver_=new MutationObserver(this.refresh),this.mutationsObserver_.observe(document,{attributes:!0,childList:!0,characterData:!0,subtree:!0})):(document.addEventListener("DOMSubtreeModified",this.refresh),this.mutationEventsAdded_=!0),this.connected_=!0)},e.prototype.disconnect_=function(){!zr||!this.connected_||(document.removeEventListener("transitionend",this.onTransitionEnd_),window.removeEventListener("resize",this.refresh),this.mutationsObserver_&&this.mutationsObserver_.disconnect(),this.mutationEventsAdded_&&document.removeEventListener("DOMSubtreeModified",this.refresh),this.mutationsObserver_=null,this.mutationEventsAdded_=!1,this.connected_=!1)},e.prototype.onTransitionEnd_=function(t){var r=t.propertyName,n=r===void 0?"":r,o=ya.some(function(i){return!!~n.indexOf(i)});o&&this.refresh()},e.getInstance=function(){return this.instance_||(this.instance_=new e),this.instance_},e.instance_=null,e}(),Jn=function(e,t){for(var r=0,n=Object.keys(t);r0},e}(),Zn=typeof WeakMap!="undefined"?new WeakMap:new Bn,eo=function(){function e(t){if(!(this instanceof e))throw new TypeError("Cannot call a class as a function.");if(!arguments.length)throw new TypeError("1 argument required, but only 0 present.");var r=Ea.getInstance(),n=new Ra(t,r,this);Zn.set(this,n)}return e}();["observe","unobserve","disconnect"].forEach(function(e){eo.prototype[e]=function(){var t;return(t=Zn.get(this))[e].apply(t,arguments)}});var ka=function(){return typeof nr.ResizeObserver!="undefined"?nr.ResizeObserver:eo}(),to=ka;var ro=new E,Ha=I(()=>H(new to(e=>{for(let t of e)ro.next(t)}))).pipe(x(e=>L(Te,H(e)).pipe(C(()=>e.disconnect()))),J(1));function de(e){return{width:e.offsetWidth,height:e.offsetHeight}}function ge(e){return Ha.pipe(S(t=>t.observe(e)),x(t=>ro.pipe(_(({target:r})=>r===e),C(()=>t.unobserve(e)),l(()=>de(e)))),N(de(e)))}function bt(e){return{width:e.scrollWidth,height:e.scrollHeight}}function ar(e){let t=e.parentElement;for(;t&&(e.scrollWidth<=t.scrollWidth&&e.scrollHeight<=t.scrollHeight);)t=(e=t).parentElement;return t?e:void 0}var no=new E,Pa=I(()=>H(new IntersectionObserver(e=>{for(let t of e)no.next(t)},{threshold:0}))).pipe(x(e=>L(Te,H(e)).pipe(C(()=>e.disconnect()))),J(1));function sr(e){return Pa.pipe(S(t=>t.observe(e)),x(t=>no.pipe(_(({target:r})=>r===e),C(()=>t.unobserve(e)),l(({isIntersecting:r})=>r))))}function oo(e,t=16){return dt(e).pipe(l(({y:r})=>{let n=de(e),o=bt(e);return r>=o.height-n.height-t}),Y())}var cr={drawer:V("[data-md-toggle=drawer]"),search:V("[data-md-toggle=search]")};function io(e){return cr[e].checked}function qe(e,t){cr[e].checked!==t&&cr[e].click()}function je(e){let t=cr[e];return b(t,"change").pipe(l(()=>t.checked),N(t.checked))}function $a(e,t){switch(e.constructor){case HTMLInputElement:return e.type==="radio"?/^Arrow/.test(t):!0;case HTMLSelectElement:case HTMLTextAreaElement:return!0;default:return e.isContentEditable}}function Ia(){return L(b(window,"compositionstart").pipe(l(()=>!0)),b(window,"compositionend").pipe(l(()=>!1))).pipe(N(!1))}function ao(){let e=b(window,"keydown").pipe(_(t=>!(t.metaKey||t.ctrlKey)),l(t=>({mode:io("search")?"search":"global",type:t.key,claim(){t.preventDefault(),t.stopPropagation()}})),_(({mode:t,type:r})=>{if(t==="global"){let n=_e();if(typeof n!="undefined")return!$a(n,r)}return!0}),fe());return Ia().pipe(x(t=>t?R:e))}function Me(){return new URL(location.href)}function ot(e){location.href=e.href}function so(){return new E}function co(e,t){if(typeof t=="string"||typeof t=="number")e.innerHTML+=t.toString();else if(t instanceof Node)e.appendChild(t);else if(Array.isArray(t))for(let r of t)co(e,r)}function M(e,t,...r){let n=document.createElement(e);if(t)for(let o of Object.keys(t))typeof t[o]!="undefined"&&(typeof t[o]!="boolean"?n.setAttribute(o,t[o]):n.setAttribute(o,""));for(let o of r)co(n,o);return n}function fr(e){if(e>999){let t=+((e-950)%1e3>99);return`${((e+1e-6)/1e3).toFixed(t)}k`}else return e.toString()}function fo(){return location.hash.substring(1)}function uo(e){let t=M("a",{href:e});t.addEventListener("click",r=>r.stopPropagation()),t.click()}function Fa(){return b(window,"hashchange").pipe(l(fo),N(fo()),_(e=>e.length>0),J(1))}function po(){return Fa().pipe(l(e=>se(`[id="${e}"]`)),_(e=>typeof e!="undefined"))}function Nr(e){let t=matchMedia(e);return Zt(r=>t.addListener(()=>r(t.matches))).pipe(N(t.matches))}function lo(){let e=matchMedia("print");return L(b(window,"beforeprint").pipe(l(()=>!0)),b(window,"afterprint").pipe(l(()=>!1))).pipe(N(e.matches))}function qr(e,t){return e.pipe(x(r=>r?t():R))}function ur(e,t={credentials:"same-origin"}){return ve(fetch(`${e}`,t)).pipe(ce(()=>R),x(r=>r.status!==200?Tt(()=>new Error(r.statusText)):H(r)))}function Ue(e,t){return ur(e,t).pipe(x(r=>r.json()),J(1))}function mo(e,t){let r=new DOMParser;return ur(e,t).pipe(x(n=>n.text()),l(n=>r.parseFromString(n,"text/xml")),J(1))}function pr(e){let t=M("script",{src:e});return I(()=>(document.head.appendChild(t),L(b(t,"load"),b(t,"error").pipe(x(()=>Tt(()=>new ReferenceError(`Invalid script: ${e}`))))).pipe(l(()=>{}),C(()=>document.head.removeChild(t)),Oe(1))))}function ho(){return{x:Math.max(0,scrollX),y:Math.max(0,scrollY)}}function bo(){return L(b(window,"scroll",{passive:!0}),b(window,"resize",{passive:!0})).pipe(l(ho),N(ho()))}function vo(){return{width:innerWidth,height:innerHeight}}function go(){return b(window,"resize",{passive:!0}).pipe(l(vo),N(vo()))}function yo(){return Q([bo(),go()]).pipe(l(([e,t])=>({offset:e,size:t})),J(1))}function lr(e,{viewport$:t,header$:r}){let n=t.pipe(X("size")),o=Q([n,r]).pipe(l(()=>Be(e)));return Q([r,t,o]).pipe(l(([{height:i},{offset:a,size:s},{x:f,y:c}])=>({offset:{x:a.x-f,y:a.y-c+i},size:s})))}(()=>{function e(n,o){parent.postMessage(n,o||"*")}function t(...n){return n.reduce((o,i)=>o.then(()=>new Promise(a=>{let s=document.createElement("script");s.src=i,s.onload=a,document.body.appendChild(s)})),Promise.resolve())}var r=class{constructor(n){this.url=n,this.onerror=null,this.onmessage=null,this.onmessageerror=null,this.m=a=>{a.source===this.w&&(a.stopImmediatePropagation(),this.dispatchEvent(new MessageEvent("message",{data:a.data})),this.onmessage&&this.onmessage(a))},this.e=(a,s,f,c,u)=>{if(s===this.url.toString()){let p=new ErrorEvent("error",{message:a,filename:s,lineno:f,colno:c,error:u});this.dispatchEvent(p),this.onerror&&this.onerror(p)}};let o=new EventTarget;this.addEventListener=o.addEventListener.bind(o),this.removeEventListener=o.removeEventListener.bind(o),this.dispatchEvent=o.dispatchEvent.bind(o);let i=document.createElement("iframe");i.width=i.height=i.frameBorder="0",document.body.appendChild(this.iframe=i),this.w.document.open(),this.w.document.write(` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + +
+
+ + + + + + + + +

Overview

+ + + +

+

Designing a cohesive media experience for Android can be a lot of work. Telephoto aims to make that easier by offering some building blocks for Compose UI.

+

Zoomable Image

+

Drop-in replacement for Image() composables featuring support for pan & zoom gestures and automatic sub‑sampling of large images that'd otherwise not fit into memory.

+

Modifier.zoomable()

+

ZoomableImage's gesture detector, packaged as a standalone Modifier that can be used with non-image composables.

+ + + + + + +
+
+ + + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/overrides/main.html b/overrides/main.html new file mode 100644 index 00000000..86c357b1 --- /dev/null +++ b/overrides/main.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block extrahead %} + + {% if page and page.meta and page.meta.title %} + + {% elif page and page.title and not page.is_homepage %} + + {% else %} + + {% endif %} + + + + + +{% endblock %} diff --git a/releasing/index.html b/releasing/index.html new file mode 100644 index 00000000..e7f5d672 --- /dev/null +++ b/releasing/index.html @@ -0,0 +1,508 @@ + + + + + + + + + + + + + + + + + + + + + + + + Release checklist - Telephoto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + +
+
+ + + + + + + + +

Release checklist

+
    +
  • [ ] Check that tests have passed on CI.
  • +
  • [ ] Run manual test cases that are difficult to automate:
      +
    • [ ] Fling animations can be interrupted by pressing anywhere.
    • +
    • [ ] Double-click-to-zoom animations can be interrupted only by starting another swipe gesture.
    • +
    • [ ] Images that use a placeholder play a cross-fade animation when the image is loaded.
    • +
    +
  • +
  • [ ] Change version name from SNAPSHOT to an actual name.
  • +
  • [ ] Update project website with: + - [ ] new version’s maven address + - [ ] new APIs
  • +
  • [ ] Commit Prepare to release vX.X.X. Do not push yet.
  • +
  • [ ] Upload archives to maven. + g clean publish --no-parallel --no-daemon
  • +
  • [ ] Wait for artifacts to be available. + dependency-watch await me.saket.telephoto:zoomable:{version}
  • +
  • [ ] Ensure that the release is available on maven by using it in :sample
  • +
  • [ ] Check that the library sources were correctly available.
  • +
  • [ ] Run the sample app and perform sanity tests.
  • +
  • [ ] Push commit.
  • +
  • [ ] Generate a sample APK.
  • +
  • [ ] Draft a changelog.
  • +
  • [ ] Make a release on Github.
  • +
  • [ ] Push a new commit Prepare next development version by bumping version and changing library version to SNAPSHOT.
  • +
+ + + + + + +
+
+ + + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/search/search_index.json b/search/search_index.json new file mode 100644 index 00000000..8cae4d2d --- /dev/null +++ b/search/search_index.json @@ -0,0 +1 @@ +{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"]},"docs":[{"location":"","title":"Overview","text":"

Designing a cohesive media experience for Android can be a lot of work. Telephoto aims to make that easier by offering some building blocks for Compose UI.

"},{"location":"#zoomable-image","title":"Zoomable Image","text":"

Drop-in replacement for Image() composables featuring support for pan & zoom gestures and automatic sub\u2011sampling of large images that'd otherwise not fit into memory.

"},{"location":"#modifierzoomable","title":"Modifier.zoomable()","text":"

ZoomableImage's gesture detector, packaged as a standalone Modifier that can be used with non-image composables.

"},{"location":"releasing/","title":"Release checklist","text":""},{"location":"releasing/#release-checklist","title":"Release checklist","text":"
  • [ ] Check that tests have passed on CI.
  • [ ] Run manual test cases that are difficult to automate:
    • [ ] Fling animations can be interrupted by pressing anywhere.
    • [ ] Double-click-to-zoom animations can be interrupted only by starting another swipe gesture.
    • [ ] Images that use a placeholder play a cross-fade animation when the image is loaded.
  • [ ] Change version name from SNAPSHOT to an actual name.
  • [ ] Update project website with: - [ ] new version\u2019s maven address - [ ] new APIs
  • [ ] Commit Prepare to release vX.X.X. Do not push yet.
  • [ ] Upload archives to maven. g clean publish --no-parallel --no-daemon
  • [ ] Wait for artifacts to be available. dependency-watch await me.saket.telephoto:zoomable:{version}
  • [ ] Ensure that the release is available on maven by using it in :sample
  • [ ] Check that the library sources were correctly available.
  • [ ] Run the sample app and perform sanity tests.
  • [ ] Push commit.
  • [ ] Generate a sample APK.
  • [ ] Draft a changelog.
  • [ ] Make a release on Github.
  • [ ] Push a new commit Prepare next development version by bumping version and changing library version to SNAPSHOT.
"},{"location":"zoomable/","title":"Overview","text":""},{"location":"zoomable/#modifierzoomable","title":"Modifier.zoomable()","text":"

A Modifier for handling pan & zoom gestures, designed to be shared across all your media composables so that your users can use the same familiar gestures throughout your app.

Features

  • Gestures:
    • Pinch-to-zoom and flings
    • Double click to zoom
    • Single finger zoom (double-tap and hold)
  • Haptic feedback when reaching zoom limits
  • Compatibility with nested scrolling
  • Click listeners
  • Keyboard and mouse shortcuts
  • State preservation across config changes (including screen rotations)
"},{"location":"zoomable/#installation","title":"Installation","text":"
implementation(\"me.saket.telephoto:zoomable:0.14.0\")\n
Box(\nModifier\n.size(200.dp)\n.zoomable(rememberZoomableState())\n.background(\nbrush = Brush.linearGradient(listOf(Color.Cyan, Color.Blue)),\nshape = RoundedCornerShape(4.dp)\n)\n)\n

While Modifier.zoomable() was primarily written with images & videos in mind, it can be used for anything such as text, canvas drawings, etc.

"},{"location":"zoomable/#edge-detection","title":"Edge detection","text":"Without edge detection With edge detection

For preventing your content from over-zooming or over-panning, Modifier.zoomable() will use your content's layout size by default. This is good enough for composables that fill every pixel of their drawing space.

For richer content such as an Image() whose visual size may not always match its layout size, Modifier.zoomable() will need your assistance.

val painter = resourcePainter(R.drawable.example)\nval zoomableState = rememberZoomableState().apply {\nsetContentLocation(\nZoomableContentLocation.scaledInsideAndCenterAligned(painter.intrinsicSize)\n)\n}\nImage(\nmodifier = Modifier\n.fillMaxSize()\n.background(Color.Orange)\n.zoomable(zoomableState),\npainter = painter,\ncontentDescription = \u2026,\ncontentScale = ContentScale.Inside,\nalignment = Alignment.Center,\n)\n
"},{"location":"zoomable/#click-listeners","title":"Click listeners","text":"

For detecting double clicks, Modifier.zoomable() consumes all tap gestures making it incompatible with Modifier.clickable() and Modifier.combinedClickable(). As an alternative, its onClick and onLongClick parameters can be used.

Modifier.zoomable(\nstate = rememberZoomableState(),\nonClick = { \u2026 },\nonLongClick = { \u2026 },\n)\n

The default behavior of toggling between minimum and maximum zoom levels on double-clicks can be overridden by using the onDoubleClick parameter:

Modifier.zoomable(\nonDoubleClick = { state, centroid -> \u2026 },\n)\n
"},{"location":"zoomable/#applying-gesture-transformations","title":"Applying gesture transformations","text":"

When pan & zoom gestures are received, Modifier.zoomable() automatically applies their resulting scale and translation onto your content using Modifier.graphicsLayer().

This can be disabled if your content prefers applying the transformations in a bespoke manner.

val state = rememberZoomableState(\nautoApplyTransformations = false\n)\nText(\nmodifier = Modifier\n.fillMaxSize()\n.zoomable(state),\ntext = \"Nicolas Cage\",\nstyle = state.contentTransformation.let {\nval weightMultiplier = if (it.isUnspecified) 1f else it.scale.scaleX\nTextStyle(\nfontSize = 36.sp,\nfontWeight = FontWeight(400 * weightMultiplier),\n)\n}\n)\n
"},{"location":"zoomable/#keyboard-shortcuts","title":"Keyboard shortcuts","text":"

ZoomableImage() can observe keyboard and mouse shortcuts for panning and zooming when it is focused, either by the user or using a FocusRequester:

val focusRequester = remember { FocusRequester() }\nLaunchedEffect(Unit) {\n// Automatically request focus when the image is displayed. This assumes there \n// is only one zoomable image present in the hierarchy. If you're displaying \n// multiple images in a pager, apply this only for the active page.  \nfocusRequester.requestFocus()\n}\nBox(\nModifier\n.focusRequester(focusRequester)\n.zoomable(),\n)\n

By default, the following shortcuts are recognized. These can be customized (or disabled) by passing a custom HardwareShortcutsSpec to rememberZoomableState().

Android Desktop Zoom in Control + = Meta + = Zoom out Control + - Meta + - Pan Arrow keys Arrow keys Extra pan Alt + arrow keys Option + arrow keys"},{"location":"zoomable/recipes/","title":"Recipes","text":""},{"location":"zoomable/recipes/#observing-pan-zoom","title":"Observing pan & zoom","text":"
val state = rememberZoomableState()\nBox(\nModifier.zoomable(state)\n)\nLaunchedEffect(state.contentTransformation) {\nprintln(\"Pan = ${state.contentTransformation.offset}\")\nprintln(\"Zoom = ${state.contentTransformation.scale}\")\nprintln(\"Zoom fraction = ${state.zoomFraction}\")\n}\n// Example use case: Hide system bars when image is zoomed in.\nval systemUi = rememberSystemUiController()\nval isZoomedOut = (zoomState.zoomFraction ?: 0f) < 0.1f\nLaunchedEffect(isZoomedOut) {\nsystemUi.isSystemBarsVisible = isZoomedOut\n}\n
"},{"location":"zoomable/recipes/#controlling-pan-zoom","title":"Controlling pan & zoom","text":"
val state = rememberZoomableState()\nBox(\nModifier.zoomable(state)\n)\nButton(onClick = { state.zoomBy(zoomFactor = 1.2f) }) {\nText(\"+\")\n}\nButton(onClick = { state.zoomBy(zoomFactor = 1 / 1.2f) }) {\nText(\"-\")\n}\nButton(onClick = { state.panBy(offset = 50.dp) }) {\nText(\">\")\n}\nButton(onClick = { state.panBy(offset = -50.dp) }) {\nText(\"<\")\n}\n
"},{"location":"zoomable/recipes/#resetting-zoom","title":"Resetting zoom","text":"

Modifier.zoomable() will automatically retain its pan & zoom across state restorations. You may want to prevent this in lazy layouts such as a Pager(), where each page is restored every time it becomes visible.

val pagerState = rememberPagerState()\nHorizontalPager(\nstate = pagerState,\npageCount = 3,\n) { pageNum ->\nval zoomableState = rememberZoomableState()\nZoomableContent(\nstate = zoomableState\n)\nif (pagerState.settledPage != pageNum) {\n// Page is now off-screen. Prevent restoration of \n// current zoom when this page becomes visible again.\nLaunchedEffect(Unit) {\nzoomableState.resetZoom(animationSpec = SnapSpec())\n}\n}\n}\n

Warning

A bug in Pager() previously caused settledPage to reset to 0 upon state restoration. This issue has been resolved in androidx.compose.foundation:foundation:1.5.0-alpha02.

"},{"location":"zoomableimage/","title":"Overview","text":""},{"location":"zoomableimage/#zoomable-image","title":"Zoomable Image","text":"

A drop-in replacement for async Image() composables featuring support for pan & zoom gestures and automatic sub-sampling of large images. This ensures that images maintain their intricate details even when fully zoomed in, without causing any OutOfMemory exceptions.

Features

  • Automatic sub-sampling of bitmaps
  • Gestures:
    • Pinch-to-zoom and flings
    • Double click to zoom
    • Single finger zoom (double-tap and hold)
  • Haptic feedback when reaching zoom limits
  • Compatibility with nested scrolling
  • Click listeners
  • Keyboard and mouse shortcuts
  • State preservation across config changes (including screen rotations)
"},{"location":"zoomableimage/#installation","title":"Installation","text":"CoilGlide
// For Coil 2.x\nimplementation(\"me.saket.telephoto:zoomable-image-coil:0.14.0\")\n// For Coil 3.x\nimplementation(\"me.saket.telephoto:zoomable-image-coil3:0.14.0\")\n
implementation(\"me.saket.telephoto:zoomable-image-glide:0.14.0\")\n
CoilGlide
- AsyncImage(\n+ ZoomableAsyncImage(\n   model = \"https://example.com/image.jpg\",\n    contentDescription = \u2026\n  )\n
- GlideImage(\n+ ZoomableGlideImage(\n   model = \"https://example.com/image.jpg\",\n    contentDescription = \u2026\n  )\n
"},{"location":"zoomableimage/#image-requests","title":"Image requests","text":"

For complex scenarios, ZoomableImage can also take full image requests:

CoilGlide
ZoomableAsyncImage(\nmodel = ImageRequest.Builder(LocalContext.current)\n.data(\"https://example.com/image.jpg\")\n.listener(\nonSuccess = { \u2026 },\nonError = { \u2026 },\n)\n.crossfade(1_000)\n.memoryCachePolicy(CachePolicy.DISABLED)\n.build(),\nimageLoader = LocalContext.current.imageLoader, // Optional.\ncontentDescription = \u2026\n)\n
ZoomableGlideImage(\nmodel = \"https://example.com/image.jpg\",\ncontentDescription = \u2026\n) {\nit.addListener(object : RequestListener<Drawable> {\noverride fun onResourceReady(\u2026): Boolean = TODO()\noverride fun onLoadFailed(\u2026): Boolean = TODO()\n})\n.transition(withCrossFade(1_000))\n.skipMemoryCache(true)\n.disallowHardwareConfig()\n.timeout(30_000),\n}\n
"},{"location":"zoomableimage/#placeholders","title":"Placeholders","text":"

If your images are available in multiple resolutions, telephoto highly recommends using their lower resolutions as placeholders while their full quality equivalents are loaded in the background.

When combined with a cross-fade transition, ZoomableImage will smoothly swap out placeholders when their full quality versions are ready to be displayed.

CoilGlide

ZoomableAsyncImage(\nmodifier = Modifier.fillMaxSize(),\nmodel = ImageRequest.Builder(LocalContext.current)\n.data(\"https://example.com/image.jpg\")\n.placeholderMemoryCacheKey(\u2026)\n.crossfade(1_000)\n.build(),\ncontentDescription = \u2026\n)\n
More details about placeholderMemoryCacheKey() can be found on Coil's website.

ZoomableGlideImage(\nmodifier = Modifier.fillMaxSize(),\nmodel = \"https://example.com/image.jpg\",\ncontentDescription = \u2026\n) {\nit.thumbnail(\u2026)   // or placeholder()\n.transition(withCrossFade(1_000)),\n}\n
More details about thumbnail() can be found on Glide's website.

Warning

Placeholders are visually incompatible with Modifier.wrapContentSize().

"},{"location":"zoomableimage/#content-alignment","title":"Content alignment","text":"Alignment.TopCenter Alignment.BottomCenter

When images are zoomed, they're scaled with respect to their alignment until they're large enough to fill all available space. After that, they're scaled uniformly. The default alignment is Alignment.Center.

CoilGlide
ZoomableAsyncImage(\nmodifier = Modifier.fillMaxSize(),\nmodel = \"https://example.com/image.jpg\",\nalignment = Alignment.TopCenter\n)\n
ZoomableGlideImage(\nmodifier = Modifier.fillMaxSize(),\nmodel = \"https://example.com/image.jpg\",\nalignment = Alignment.TopCenter\n)\n
"},{"location":"zoomableimage/#content-scale","title":"Content scale","text":"ContentScale.Inside ContentScale.Crop

Images are scaled using ContentScale.Fit by default, but can be customized. A visual guide of all possible values can be found here.

Unlike Image(), ZoomableImage can pan images even when they're cropped. This can be useful for applications like wallpaper apps that may want to use ContentScale.Crop to ensure that images always fill the screen.

CoilGlide
ZoomableAsyncImage(\nmodifier = Modifier.fillMaxSize(),\nmodel = \"https://example.com/image.jpg\",\ncontentScale = ContentScale.Crop\n)\n
ZoomableGlideImage(\nmodifier = Modifier.fillMaxSize(),\nmodel = \"https://example.com/image.jpg\",\ncontentScale = ContentScale.Crop\n)\n

Warning

Placeholders are visually incompatible with ContentScale.Inside.

"},{"location":"zoomableimage/#click-listeners","title":"Click listeners","text":"

For detecting double clicks, ZoomableImage consumes all tap gestures making it incompatible with Modifier.clickable() and Modifier.combinedClickable(). As an alternative, its onClick and onLongClick parameters can be used.

CoilGlide
ZoomableAsyncImage(\nmodifier = Modifier.clickable { error(\"This will not work\") },\nmodel = \"https://example.com/image.jpg\",\nonClick = { \u2026 },\nonLongClick = { \u2026 },\n)\n
ZoomableGlideImage(\nmodifier = Modifier.clickable { error(\"This will not work\") },\nmodel = \"https://example.com/image.jpg\",\nonClick = { \u2026 },\nonLongClick = { \u2026 },\n)\n

The default behavior of toggling between minimum and maximum zoom levels on double-clicks can be overridden by using the onDoubleClick parameter:

CoilGlide
ZoomableAsyncImage(\nmodel = \"https://example.com/image.jpg\",\nonDoubleClick = { state, centroid -> \u2026 },\n)\n
ZoomableGlideImage(\nmodel = \"https://example.com/image.jpg\",\nonDoubleClick = { state, centroid -> \u2026 },\n)\n
"},{"location":"zoomableimage/#keyboard-shortcuts","title":"Keyboard shortcuts","text":"

ZoomableImage() can observe keyboard and mouse shortcuts for panning and zooming when it is focused, either by the user or using a FocusRequester:

val focusRequester = remember { FocusRequester() }\nLaunchedEffect(Unit) {\n// Automatically request focus when the image is displayed. This assumes there \n// is only one zoomable image present in the hierarchy. If you're displaying \n// multiple images in a pager, apply this only for the active page.  \nfocusRequester.requestFocus()\n}\n
CoilGlide
ZoomableAsyncImage(\nmodifier = Modifier.focusRequester(focusRequester),\nmodel = \"https://example.com/image.jpg\",\n)\n
ZoomableGlideImage(\nmodifier = Modifier.focusRequester(focusRequester),\nmodel = \"https://example.com/image.jpg\",\n)\n

By default, the following shortcuts are recognized. These can be customized (or disabled) by passing a custom HardwareShortcutsSpec to rememberZoomableState().

Android Zoom in Control + = Zoom out Control + - Pan Arrow keys Extra pan Alt + arrow keys"},{"location":"zoomableimage/#sharing-hoisted-state","title":"Sharing hoisted state","text":"

For handling zoom gestures, Zoomablemage uses Modifier.zoomable() underneath. If your app displays different kinds of media, it is recommended to hoist the ZoomableState outside so that it can be shared with all zoomable composables:

CoilGlide
val zoomableState = rememberZoomableState()\nwhen (media) {\nis Image -> {\nZoomableAsyncImage(\nmodel = media.imageUrl,\nstate = rememberZoomableImageState(zoomableState),\n)\n}\nis Video -> {\nZoomableVideoPlayer(\nmodel = media.videoUrl,\nstate = rememberZoomableExoState(zoomableState),\n)\n}\n}\n
val zoomableState = rememberZoomableState()\nwhen (media) {\nis Image -> {\nZoomableGlideImage(\nmodel = media.imageUrl,\nstate = rememberZoomableImageState(zoomableState),\n)\n}\nis Video -> {\nZoomableVideoPlayer(\nmodel = media.videoUrl,\nstate = rememberZoomableExoState(zoomableState),\n)\n}\n}\n
"},{"location":"zoomableimage/custom-image-loaders/","title":"Custom image loaders","text":""},{"location":"zoomableimage/custom-image-loaders/#custom-image-loaders","title":"Custom image loaders","text":"

In its essence, ZoomableImage is simply an abstraction over an image loading library. If your preferred library isn't supported by telephoto out of the box, you can create your own by implementing ZoomableImageSource.

@Composable\nfun ZoomablePicassoImage(\nmodel: Any?,\ncontentDescription: String?,\n) {\nZoomableImage(\nimage = ZoomableImageSource.picasso(model),\ncontentDescription = contentDescription,\n)\n}\n@Composable\nprivate fun ZoomableImageSource.Companion.picasso(\nmodel: Any?,\npicasso: Picasso = Picasso\n.Builder(LocalContext.current)\n.build(),\n): ZoomableImageSource {\nreturn remember(model, picasso) {\nTODO(\"See ZoomableImageSource.coil() or glide() for an example.\")\n}\n}\n

ZoomableImageSource.picasso() will be responsible for loading images and determining whether they can be displayed as-is or should be presented in a sub-sampled image viewer to prevent OOM errors. Here are two examples:

  • CoilImageSource
  • GlideImageSource
"},{"location":"zoomableimage/recipes/","title":"Recipes","text":""},{"location":"zoomableimage/recipes/#recipes","title":"Recipes","text":""},{"location":"zoomableimage/recipes/#modifierzoomable","title":"Modifier.zoomable()","text":"
  • Observing pan & zoom
  • Controlling pan & zoom
  • Resetting zoom
"},{"location":"zoomableimage/recipes/#setting-zoom-limits","title":"Setting zoom limits","text":"CoilGlide
val zoomableState = rememberZoomableState(\nzoomSpec = ZoomSpec(maxZoomFactor = 4f)\n)\nZoomableAsyncImage(\nstate = rememberZoomableImageState(zoomableState),\nmodel = \"https://example.com/image.jpg\",\ncontentDescription = \u2026,\n)\n
val zoomableState = rememberZoomableState(\nzoomSpec = ZoomSpec(maxZoomFactor = 4f)\n)\nZoomableGlideImage(\nstate = rememberZoomableImageState(zoomableState),\nmodel = \"https://example.com/image.jpg\",\ncontentDescription = \u2026,\n)\n
"},{"location":"zoomableimage/recipes/#observing-image-loads","title":"Observing image loads","text":"
val imageState = rememberZoomableImageState()\n// Whether the full quality image is loaded. This will be false for placeholders\n// or thumbnails, in which case isPlaceholderDisplayed can be used instead.\nval showLoadingIndicator = imageState.isImageDisplayed\nAnimatedVisibility(visible = showLoadingIndicator) {\nCircularProgressIndicator()    }\n
"},{"location":"zoomableimage/recipes/#grabbing-downloaded-images","title":"Grabbing downloaded images","text":"

Low resolution drawables can be accessed by using request listeners. These images are down-sampled by your image loading library to fit in memory and are suitable for simple use-cases such as color extraction.

CoilGlide
ZoomableAsyncImage(\nmodel = ImageRequest.Builder(LocalContext.current)\n.data(\"https://example.com/image.jpg\")\n.listener(onSuccess = { _, result ->\n// TODO: do something with result.drawable.\n})\n.build(),\ncontentDescription = \u2026\n)\n
ZoomableGlideImage(\nmodel = \"https://example.com/image.jpg\",\ncontentDescription = \u2026\n) {\nit.addListener(object : RequestListener<Drawable> {\noverride fun onResourceReady(resource: Drawable, \u2026): Boolean {\n// TODO: do something with resource.\n}\n})\n}\n

Full resolutions must be obtained as files because ZoomableImage streams them directly from disk. The easiest way to do this is to load them again from cache.

CoilGlide

val state = rememberZoomableImageState()\nZoomableAsyncImage(\nmodel = imageUrl,\nstate = state,\ncontentDescription = \u2026,\n)\nif (state.isImageDisplayed) {\nButton(onClick = { downloadImage(context, imageUrl) }) {\nText(\"Download image\")\n}\n}\n
suspend fun downloadImage(context: Context, imageUrl: HttpUrl) {\nval result = context.imageLoader.execute(\nImageRequest.Builder(context)\n.data(imageUrl)\n.build()\n)\nif (result is SuccessResult) {\nval cacheKey = result.diskCacheKey ?: error(\"image wasn't saved to disk\")\nval diskCache = context.imageLoader.diskCache!!\ndiskCache.openSnapshot(cacheKey)!!.use { // TODO: copy to Downloads directory.           \n}\n}\n}\n

val state = rememberZoomableImageState()\nZoomableGlideImage(\nmodel = imageUrl,\nstate = state,\ncontentDescription = \u2026,\n)\nif (state.isImageDisplayed) {\nButton(onClick = { downloadImage(context, imageUrl) }) {\nText(\"Download image\")\n}\n}\n
fun downloadImage(context: Context, imageUrl: Uri) {\nGlide.with(context)\n.download(imageUrl)\n.into(object : CustomTarget<File>() {\noverride fun onResourceReady(resource: File, \u2026) {\n// TODO: copy file to Downloads directory.\n}\noverride fun onLoadCleared(placeholder: Drawable?) = Unit\n)\n}\n
"},{"location":"zoomableimage/sub-sampling/","title":"Sub-sampling","text":""},{"location":"zoomableimage/sub-sampling/#sub-sampling","title":"Sub-sampling","text":"

For displaying large images that may not fit into memory, ZoomableImage automatically divides them into tiles so that they can be loaded lazily.

If ZoomableImage can't be used or if sub-sampling of images is always desired, you could potentially use SubSamplingImage() directly.

implementation(\"me.saket.telephoto:sub-sampling-image:0.14.0\")\n
val zoomableState = rememberZoomableState()\nval imageState = rememberSubSamplingImageState(\nzoomableState = zoomableState,\nimageSource = SubSamplingImageSource.asset(\"fox.jpg\")\n)\nSubSamplingImage(\nmodifier = Modifier\n.fillMaxSize()\n.zoomable(zoomableState),\nstate = imageState,\ncontentDescription = \u2026,\n)\n

SubSamplingImage() is an adaptation of the excellent subsampling-scale-image-view by Dave Morrissey.

"}]} \ No newline at end of file diff --git a/sitemap.xml b/sitemap.xml new file mode 100644 index 00000000..920a588b --- /dev/null +++ b/sitemap.xml @@ -0,0 +1,43 @@ + + + + https://saket.github.io/telephoto/ + 2025-01-03 + daily + + + https://saket.github.io/telephoto/releasing/ + 2025-01-03 + daily + + + https://saket.github.io/telephoto/zoomable/ + 2025-01-03 + daily + + + https://saket.github.io/telephoto/zoomable/recipes/ + 2025-01-03 + daily + + + https://saket.github.io/telephoto/zoomableimage/ + 2025-01-03 + daily + + + https://saket.github.io/telephoto/zoomableimage/custom-image-loaders/ + 2025-01-03 + daily + + + https://saket.github.io/telephoto/zoomableimage/recipes/ + 2025-01-03 + daily + + + https://saket.github.io/telephoto/zoomableimage/sub-sampling/ + 2025-01-03 + daily + + \ No newline at end of file diff --git a/sitemap.xml.gz b/sitemap.xml.gz new file mode 100644 index 00000000..2aca50d1 Binary files /dev/null and b/sitemap.xml.gz differ diff --git a/stylesheets/extra.css b/stylesheets/extra.css new file mode 100644 index 00000000..985c3378 --- /dev/null +++ b/stylesheets/extra.css @@ -0,0 +1,105 @@ +@import url('https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@125,600&display=swap'); + +.md-typeset h1, .md-typeset h2, .md-typeset h3, .md-typeset h4 { + font-family: "Archivo", "Helvetica Neue", helvetica, sans-serif; + line-height: normal; + color: var(--md-default-fg-color); +} + +.md-header__title { + font-family: "Archivo", "Helvetica Neue", helvetica, sans-serif; +} +.md-header { + box-shadow: none !important; + padding: 0.7rem 0rem; + background-color: var(--md-header-bg-color); +} + +@media screen and (max-width: 76.1875em) { + /* Side drawer's header. */ + .md-nav--primary .md-nav__title[for=__drawer] { + background-color: var(--md-header-bg-color); + } + /* Repository link shown in side drawer. */ + .md-nav__source { + background-color: var(--md-header-bg-color); + } +} + +.md-search__form { + border-radius: 0.24rem; + background-color: var(--md-header-search-bg-color); +} +.md-search__input { + color: var(--md-default-fg-color); +} + +/* Active links on the left sidebar aren't very obvious in light mode. Increase their font-weight. */ +.md-nav__item .md-nav__link--active { + font-weight: 600; +} + +/* Code blocks, both inline and multi-line. */ +.md-typeset code { + background: var(--md-inline-code-bg-color); + border: 0.1em solid var(--md-code-border-color); + border-radius: 0.25rem; +} +/* Code blocks. */ +.md-typeset pre>code { + background: var(--md-code-bg-color); + border-radius: 0.5rem; +} + +.md-typeset video { + background: var(--md-video-bg-color); + outline: 0.1em solid var(--md-code-border-color); + border-radius: 0.3rem; +} + +table { + font-size: 1em !important; +} + +/* + * Reduce the main column's width because it looks too + * wide with the right sidebar (table of contents) hidden. + */ +.md-grid { + max-width: 50rem; +} +@media screen and (min-width: 76.25em) { + .md-content__inner { + margin-right: 2rem; + } +} + + +[data-md-color-scheme="default"] { + --md-header-bg-color: #DAE6CB; + --md-accent-fg-color: #386A20; + --md-primary-fg-color: var(--md-accent-fg-color); + --md-primary-fg-color--dark: var(--md-accent-fg-color); + --md-primary-bg-color: var(--md-default-fg-color); + --md-primary-bg-color--light: #666; + --md-default-bg-color: #F0F3E8; + --md-inline-code-bg-color: #FEFEFE; + --md-code-bg-color: var(--md-inline-code-bg-color); + --md-video-bg-color: var(--md-code-bg-color); + --md-code-border-color: var(--md-header-bg-color); + --md-header-search-bg-color: var(--md-default-bg-color); + --md-code-hl-color: #e5ff76a3; +} + +[data-md-color-scheme="slate"] { + --md-header-bg-color: #22232F; + --md-accent-fg-color: #B6F397; + --md-primary-fg-color: var(--md-accent-fg-color); + --md-primary-fg-color--dark: var(--md-accent-fg-color); + --md-primary-bg-color: var(--md-default-fg-color); + --md-default-bg-color: #2E303E; + --md-video-bg-color: #393C4E; + --md-inline-code-bg-color: var(--md-code-bg-color); + --md-code-border-color: transparent; + --md-header-search-bg-color: var(--md-default-bg-color); +} diff --git a/zoomable/index.html b/zoomable/index.html new file mode 100644 index 00000000..bd075276 --- /dev/null +++ b/zoomable/index.html @@ -0,0 +1,705 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Overview - Telephoto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + +
+
+ + + + + + + + +

Modifier.zoomable()

+

A Modifier for handling pan & zoom gestures, designed to be shared across all your media composables so that your users can use the same familiar gestures throughout your app.

+

Features

+
    +
  • Gestures:
      +
    • Pinch-to-zoom and flings
    • +
    • Double click to zoom
    • +
    • Single finger zoom (double-tap and hold)
    • +
    +
  • +
  • Haptic feedback when reaching zoom limits
  • +
  • Compatibility with nested scrolling
  • +
  • Click listeners
  • +
  • Keyboard and mouse shortcuts
  • +
  • State preservation across config changes (including screen rotations)
  • +
+

Installation

+
implementation("me.saket.telephoto:zoomable:0.14.0")
+
+
Box(
+  Modifier
+    .size(200.dp)
+    .zoomable(rememberZoomableState())
+    .background(
+      brush = Brush.linearGradient(listOf(Color.Cyan, Color.Blue)),
+      shape = RoundedCornerShape(4.dp)
+    )
+)
+
+

While Modifier.zoomable() was primarily written with images & videos in mind, it can be used for anything such as text, canvas drawings, etc.

+

Edge detection

+ + + + + + + + + + + + + +
Without edge detectionWith edge detection
+

For preventing your content from over-zooming or over-panning, Modifier.zoomable() will use your content's layout size by default. This is good enough for composables that fill every pixel of their drawing space.

+

For richer content such as an Image() whose visual size may not always match its layout size, Modifier.zoomable() will need your assistance.

+
val painter = resourcePainter(R.drawable.example)
+val zoomableState = rememberZoomableState().apply {
+  setContentLocation(
+    ZoomableContentLocation.scaledInsideAndCenterAligned(painter.intrinsicSize)
+  )
+}
+
+Image(
+  modifier = Modifier
+    .fillMaxSize()
+    .background(Color.Orange)
+    .zoomable(zoomableState),
+  painter = painter,
+  contentDescription = ,
+  contentScale = ContentScale.Inside,
+  alignment = Alignment.Center,
+)
+
+

Click listeners

+

For detecting double clicks, Modifier.zoomable() consumes all tap gestures making it incompatible with Modifier.clickable() and Modifier.combinedClickable(). As an alternative, its onClick and onLongClick parameters can be used.

+
Modifier.zoomable(
+  state = rememberZoomableState(),
+  onClick = {  },
+  onLongClick = {  },
+)
+
+

The default behavior of toggling between minimum and maximum zoom levels on double-clicks can be overridden by using the onDoubleClick parameter:

+
Modifier.zoomable(
+  onDoubleClick = { state, centroid ->  },
+)
+
+

Applying gesture transformations

+

When pan & zoom gestures are received, Modifier.zoomable() automatically applies their resulting scale and translation onto your content using Modifier.graphicsLayer().

+

This can be disabled if your content prefers applying the transformations in a bespoke manner.

+
val state = rememberZoomableState(
+  autoApplyTransformations = false
+)
+
+Text(
+  modifier = Modifier
+    .fillMaxSize()
+    .zoomable(state),
+  text = "Nicolas Cage",
+  style = state.contentTransformation.let {
+    val weightMultiplier = if (it.isUnspecified) 1f else it.scale.scaleX
+    TextStyle(
+      fontSize = 36.sp,
+      fontWeight = FontWeight(400 * weightMultiplier),
+    )
+  }
+)
+
+

Keyboard shortcuts

+

ZoomableImage() can observe keyboard and mouse shortcuts for panning and zooming when it is focused, either by the +user or using a FocusRequester:

+
val focusRequester = remember { FocusRequester() }
+LaunchedEffect(Unit) {
+  // Automatically request focus when the image is displayed. This assumes there 
+  // is only one zoomable image present in the hierarchy. If you're displaying 
+  // multiple images in a pager, apply this only for the active page.  
+  focusRequester.requestFocus()
+}
+
+Box(
+  Modifier
+    .focusRequester(focusRequester)
+    .zoomable(),
+)
+
+

By default, the following shortcuts are recognized. These can be customized (or disabled) by passing a +custom HardwareShortcutsSpec to rememberZoomableState().

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AndroidDesktop
Zoom inControl + =Meta + =
Zoom outControl + -Meta + -
PanArrow keysArrow keys
Extra panAlt + arrow keysOption + arrow keys
+ + + + + + +
+
+ + + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/zoomable/recipes/index.html b/zoomable/recipes/index.html new file mode 100644 index 00000000..37b43ea6 --- /dev/null +++ b/zoomable/recipes/index.html @@ -0,0 +1,601 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + Recipes - Telephoto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + +
+
+ + + + + + + + +

Recipes

+ +

Observing pan & zoom

+
val state = rememberZoomableState()
+Box(
+  Modifier.zoomable(state)
+)
+
+LaunchedEffect(state.contentTransformation) {
+  println("Pan = ${state.contentTransformation.offset}")
+  println("Zoom = ${state.contentTransformation.scale}")
+  println("Zoom fraction = ${state.zoomFraction}")
+}
+
+// Example use case: Hide system bars when image is zoomed in.
+val systemUi = rememberSystemUiController()
+val isZoomedOut = (zoomState.zoomFraction ?: 0f) < 0.1f
+LaunchedEffect(isZoomedOut) {
+  systemUi.isSystemBarsVisible = isZoomedOut
+}
+
+

Controlling pan & zoom

+
val state = rememberZoomableState()
+Box(
+  Modifier.zoomable(state)
+)
+
+Button(onClick = { state.zoomBy(zoomFactor = 1.2f) }) {
+  Text("+")
+}
+Button(onClick = { state.zoomBy(zoomFactor = 1 / 1.2f) }) {
+  Text("-")
+}
+Button(onClick = { state.panBy(offset = 50.dp) }) {
+  Text(">")
+}
+Button(onClick = { state.panBy(offset = -50.dp) }) {
+  Text("<")
+}
+
+

Resetting zoom

+

Modifier.zoomable() will automatically retain its pan & zoom across state restorations. You may want to prevent this in lazy layouts such as a Pager(), where each page is restored every time it becomes visible.

+
val pagerState = rememberPagerState()
+HorizontalPager(
+  state = pagerState,
+  pageCount = 3,
+) { pageNum ->
+  val zoomableState = rememberZoomableState()
+  ZoomableContent(
+    state = zoomableState
+  )
+
+  if (pagerState.settledPage != pageNum) {
+    // Page is now off-screen. Prevent restoration of 
+    // current zoom when this page becomes visible again.
+    LaunchedEffect(Unit) {
+      zoomableState.resetZoom(animationSpec = SnapSpec())
+    }
+  }
+}
+
+
+

Warning

+

A bug in Pager() previously caused settledPage to reset to 0 upon state restoration. This issue has been resolved in androidx.compose.foundation:foundation:1.5.0-alpha02.

+
+ + + + + + +
+
+ + + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/zoomableimage/custom-image-loaders/index.html b/zoomableimage/custom-image-loaders/index.html new file mode 100644 index 00000000..67efed76 --- /dev/null +++ b/zoomableimage/custom-image-loaders/index.html @@ -0,0 +1,527 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Custom image loaders - Telephoto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + +
+
+ + + + + + + + +

Custom image loaders

+

In its essence, ZoomableImage is simply an abstraction over an image loading library. If your preferred library isn't supported by telephoto out of the box, you can create your own by implementing ZoomableImageSource.

+
@Composable
+fun ZoomablePicassoImage(
+  model: Any?,
+  contentDescription: String?,
+) {
+  ZoomableImage(
+    image = ZoomableImageSource.picasso(model),
+    contentDescription = contentDescription,
+  )
+}
+
+@Composable
+private fun ZoomableImageSource.Companion.picasso(
+  model: Any?,
+  picasso: Picasso = Picasso
+    .Builder(LocalContext.current)
+    .build(),
+): ZoomableImageSource {
+  return remember(model, picasso) {
+    TODO("See ZoomableImageSource.coil() or glide() for an example.")
+  }
+}
+
+

ZoomableImageSource.picasso() will be responsible for loading images and determining whether they can be displayed as-is or should be presented in a sub-sampled image viewer to prevent OOM errors. Here are two examples:

+
+ + + + + + +
+
+ + + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/zoomableimage/index.html b/zoomableimage/index.html new file mode 100644 index 00000000..9b6183cf --- /dev/null +++ b/zoomableimage/index.html @@ -0,0 +1,927 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Overview - Telephoto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + +
+
+ + + + + + + + +

Zoomable Image

+

+

A drop-in replacement for async Image() composables featuring support for pan & zoom gestures and automatic sub-sampling of large images. This ensures that images maintain their intricate details even when fully zoomed in, without causing any OutOfMemory exceptions.

+

Features

+
    +
  • Automatic sub-sampling of bitmaps
  • +
  • Gestures:
      +
    • Pinch-to-zoom and flings
    • +
    • Double click to zoom
    • +
    • Single finger zoom (double-tap and hold)
    • +
    +
  • +
  • Haptic feedback when reaching zoom limits
  • +
  • Compatibility with nested scrolling
  • +
  • Click listeners
  • +
  • Keyboard and mouse shortcuts
  • +
  • State preservation across config changes (including screen rotations)
  • +
+

Installation

+
+
+
+
// For Coil 2.x
+implementation("me.saket.telephoto:zoomable-image-coil:0.14.0")
+
+// For Coil 3.x
+implementation("me.saket.telephoto:zoomable-image-coil3:0.14.0")
+
+
+
+
implementation("me.saket.telephoto:zoomable-image-glide:0.14.0")
+
+
+
+
+ +
+
+
+
- AsyncImage(
++ ZoomableAsyncImage(
+    model = "https://example.com/image.jpg",
+    contentDescription = …
+  )
+
+
+
+
- GlideImage(
++ ZoomableGlideImage(
+    model = "https://example.com/image.jpg",
+    contentDescription = …
+  )
+
+
+
+
+

Image requests

+

For complex scenarios, ZoomableImage can also take full image requests:

+
+
+
+
ZoomableAsyncImage(
+  model = ImageRequest.Builder(LocalContext.current)
+    .data("https://example.com/image.jpg")
+    .listener(
+      onSuccess = {  },
+      onError = {  },
+    )
+    .crossfade(1_000)
+    .memoryCachePolicy(CachePolicy.DISABLED)
+    .build(),
+  imageLoader = LocalContext.current.imageLoader, // Optional.
+  contentDescription = 
+)
+
+
+
+
ZoomableGlideImage(
+  model = "https://example.com/image.jpg",
+  contentDescription = 
+) {
+  it.addListener(object : RequestListener<Drawable> {
+      override fun onResourceReady(): Boolean = TODO()
+      override fun onLoadFailed(): Boolean = TODO()
+    })
+    .transition(withCrossFade(1_000))
+    .skipMemoryCache(true)
+    .disallowHardwareConfig()
+    .timeout(30_000),
+}
+
+
+
+
+

Placeholders

+

+

If your images are available in multiple resolutions, telephoto highly recommends using their lower resolutions as placeholders while their full quality equivalents are loaded in the background.

+

When combined with a cross-fade transition, ZoomableImage will smoothly swap out placeholders when their full quality versions are ready to be displayed.

+
+
+
+

ZoomableAsyncImage(
+  modifier = Modifier.fillMaxSize(),
+  model = ImageRequest.Builder(LocalContext.current)
+    .data("https://example.com/image.jpg")
+    .placeholderMemoryCacheKey()
+    .crossfade(1_000)
+    .build(),
+  contentDescription = 
+)
+
+More details about placeholderMemoryCacheKey() can be found on Coil's website. +
+
+

ZoomableGlideImage(
+  modifier = Modifier.fillMaxSize(),
+  model = "https://example.com/image.jpg",
+  contentDescription = 
+) {
+  it.thumbnail()   // or placeholder()
+    .transition(withCrossFade(1_000)),
+}
+
+More details about thumbnail() can be found on Glide's website. +
+
+
+
+

Warning

+

Placeholders are visually incompatible with Modifier.wrapContentSize().

+
+

Content alignment

+ + + + + + + + + + + + + +
Alignment.TopCenterAlignment.BottomCenter
+

When images are zoomed, they're scaled with respect to their alignment until they're large enough to fill all available space. After that, they're scaled uniformly. The default alignment is Alignment.Center.

+
+
+
+
ZoomableAsyncImage(
+  modifier = Modifier.fillMaxSize(),
+  model = "https://example.com/image.jpg",
+  alignment = Alignment.TopCenter
+)
+
+
+
+
ZoomableGlideImage(
+  modifier = Modifier.fillMaxSize(),
+  model = "https://example.com/image.jpg",
+  alignment = Alignment.TopCenter
+)
+
+
+
+
+

Content scale

+ + + + + + + + + + + + + +
ContentScale.InsideContentScale.Crop
+

Images are scaled using ContentScale.Fit by default, but can be customized. A visual guide of all possible values can be found here.

+

Unlike Image(), ZoomableImage can pan images even when they're cropped. This can be useful for applications like wallpaper apps that may want to use ContentScale.Crop to ensure that images always fill the screen.

+
+
+
+
ZoomableAsyncImage(
+  modifier = Modifier.fillMaxSize(),
+  model = "https://example.com/image.jpg",
+  contentScale = ContentScale.Crop
+)
+
+
+
+
ZoomableGlideImage(
+  modifier = Modifier.fillMaxSize(),
+  model = "https://example.com/image.jpg",
+  contentScale = ContentScale.Crop
+)
+
+
+
+
+
+

Warning

+

Placeholders are visually incompatible with ContentScale.Inside.

+
+

Click listeners

+

For detecting double clicks, ZoomableImage consumes all tap gestures making it incompatible with Modifier.clickable() and Modifier.combinedClickable(). As an alternative, its onClick and onLongClick parameters can be used.

+
+
+
+
ZoomableAsyncImage(
+  modifier = Modifier.clickable { error("This will not work") },
+  model = "https://example.com/image.jpg",
+  onClick = {  },
+  onLongClick = {  },
+)
+
+
+
+
ZoomableGlideImage(
+  modifier = Modifier.clickable { error("This will not work") },
+  model = "https://example.com/image.jpg",
+  onClick = {  },
+  onLongClick = {  },
+)
+
+
+
+
+

The default behavior of toggling between minimum and maximum zoom levels on double-clicks can be overridden by using the onDoubleClick parameter:

+
+
+
+
ZoomableAsyncImage(
+  model = "https://example.com/image.jpg",
+  onDoubleClick = { state, centroid ->  },
+)
+
+
+
+
ZoomableGlideImage(
+  model = "https://example.com/image.jpg",
+  onDoubleClick = { state, centroid ->  },
+)
+
+
+
+
+

Keyboard shortcuts

+

ZoomableImage() can observe keyboard and mouse shortcuts for panning and zooming when it is focused, either by the user or using a FocusRequester:

+
val focusRequester = remember { FocusRequester() }
+LaunchedEffect(Unit) {
+  // Automatically request focus when the image is displayed. This assumes there 
+  // is only one zoomable image present in the hierarchy. If you're displaying 
+  // multiple images in a pager, apply this only for the active page.  
+  focusRequester.requestFocus()
+}
+
+
+
+
+
ZoomableAsyncImage(
+  modifier = Modifier.focusRequester(focusRequester),
+  model = "https://example.com/image.jpg",
+)
+
+
+
+
ZoomableGlideImage(
+  modifier = Modifier.focusRequester(focusRequester),
+  model = "https://example.com/image.jpg",
+)
+
+
+
+
+

By default, the following shortcuts are recognized. These can be customized (or disabled) by passing a custom HardwareShortcutsSpec to rememberZoomableState().

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Android
Zoom inControl + =
Zoom outControl + -
PanArrow keys
Extra panAlt + arrow keys
+

Sharing hoisted state

+

For handling zoom gestures, Zoomablemage uses Modifier.zoomable() underneath. If your app displays different kinds of media, it is recommended to hoist the ZoomableState outside so that it can be shared with all zoomable composables:

+
+
+
+
val zoomableState = rememberZoomableState()
+
+when (media) {
+ is Image -> {
+    ZoomableAsyncImage(
+     model = media.imageUrl,
+     state = rememberZoomableImageState(zoomableState),
+    )
+  }
+  is Video -> {
+    ZoomableVideoPlayer(
+      model = media.videoUrl,
+      state = rememberZoomableExoState(zoomableState),
+    )
+  }
+}
+
+
+
+
val zoomableState = rememberZoomableState()
+
+when (media) {
+ is Image -> {
+    ZoomableGlideImage(
+     model = media.imageUrl,
+     state = rememberZoomableImageState(zoomableState),
+    )
+  }
+  is Video -> {
+    ZoomableVideoPlayer(
+      model = media.videoUrl,
+      state = rememberZoomableExoState(zoomableState),
+    )
+  }
+}
+
+
+
+
+ + + + + + +
+
+ + + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/zoomableimage/recipes/index.html b/zoomableimage/recipes/index.html new file mode 100644 index 00000000..b9dbeb84 --- /dev/null +++ b/zoomableimage/recipes/index.html @@ -0,0 +1,689 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Recipes - Telephoto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + +
+
+ + + + + + + + +

Recipes

+

Modifier.zoomable()

+ +

Setting zoom limits

+
+
+
+
val zoomableState = rememberZoomableState(
+  zoomSpec = ZoomSpec(maxZoomFactor = 4f)
+)
+
+ZoomableAsyncImage(
+  state = rememberZoomableImageState(zoomableState),
+  model = "https://example.com/image.jpg",
+  contentDescription = ,
+)
+
+
+
+
val zoomableState = rememberZoomableState(
+  zoomSpec = ZoomSpec(maxZoomFactor = 4f)
+)
+
+ZoomableGlideImage(
+  state = rememberZoomableImageState(zoomableState),
+  model = "https://example.com/image.jpg",
+  contentDescription = ,
+)
+
+
+
+
+

Observing image loads

+
val imageState = rememberZoomableImageState()
+
+// Whether the full quality image is loaded. This will be false for placeholders
+// or thumbnails, in which case isPlaceholderDisplayed can be used instead.
+val showLoadingIndicator = imageState.isImageDisplayed
+
+AnimatedVisibility(visible = showLoadingIndicator) {
+  CircularProgressIndicator()    
+}
+
+

Grabbing downloaded images

+

Low resolution drawables can be accessed by using request listeners. These images are down-sampled by your image loading library to fit in memory and are suitable for simple use-cases such as color extraction.

+
+
+
+
ZoomableAsyncImage(
+  model = ImageRequest.Builder(LocalContext.current)
+    .data("https://example.com/image.jpg")
+    .listener(onSuccess = { _, result ->
+      // TODO: do something with result.drawable.
+    })
+    .build(),
+  contentDescription = 
+)
+
+
+
+
ZoomableGlideImage(
+  model = "https://example.com/image.jpg",
+  contentDescription = 
+) {
+  it.addListener(object : RequestListener<Drawable> {
+    override fun onResourceReady(resource: Drawable, ): Boolean {
+      // TODO: do something with resource.
+    }
+  })
+}
+
+
+
+
+

Full resolutions must be obtained as files because ZoomableImage streams them directly from disk. The easiest way to do this is to load them again from cache.

+
+
+
+

val state = rememberZoomableImageState()
+ZoomableAsyncImage(
+  model = imageUrl,
+  state = state,
+  contentDescription = ,
+)
+
+if (state.isImageDisplayed) {
+  Button(onClick = { downloadImage(context, imageUrl) }) {
+    Text("Download image")
+  }
+}
+
+
suspend fun downloadImage(context: Context, imageUrl: HttpUrl) {
+  val result = context.imageLoader.execute(
+    ImageRequest.Builder(context)
+      .data(imageUrl)
+      .build()
+  )
+  if (result is SuccessResult) {
+    val cacheKey = result.diskCacheKey ?: error("image wasn't saved to disk")
+    val diskCache = context.imageLoader.diskCache!!
+    diskCache.openSnapshot(cacheKey)!!.use { 
+      // TODO: copy to Downloads directory.           
+    }
+  }
+}
+
+
+
+

val state = rememberZoomableImageState()
+ZoomableGlideImage(
+  model = imageUrl,
+  state = state,
+  contentDescription = ,
+)
+
+if (state.isImageDisplayed) {
+  Button(onClick = { downloadImage(context, imageUrl) }) {
+    Text("Download image")
+  }
+}
+
+
fun downloadImage(context: Context, imageUrl: Uri) {
+  Glide.with(context)
+    .download(imageUrl)
+    .into(object : CustomTarget<File>() {
+      override fun onResourceReady(resource: File, ) {
+        // TODO: copy file to Downloads directory.
+      }
+
+      override fun onLoadCleared(placeholder: Drawable?) = Unit
+    )
+}
+
+
+
+
+ + + + + + +
+
+ + + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/zoomableimage/sub-sampling/index.html b/zoomableimage/sub-sampling/index.html new file mode 100644 index 00000000..073e965d --- /dev/null +++ b/zoomableimage/sub-sampling/index.html @@ -0,0 +1,518 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sub-sampling - Telephoto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + +
+
+ + + + + + + + +

Sub-sampling

+

+

For displaying large images that may not fit into memory, ZoomableImage automatically divides them into tiles so that they can be loaded lazily.

+

If ZoomableImage can't be used or if sub-sampling of images is always desired, you could potentially use SubSamplingImage() directly.

+
implementation("me.saket.telephoto:sub-sampling-image:0.14.0")
+
+
val zoomableState = rememberZoomableState()
+val imageState = rememberSubSamplingImageState(
+  zoomableState = zoomableState,
+  imageSource = SubSamplingImageSource.asset("fox.jpg")
+)
+
+SubSamplingImage(
+  modifier = Modifier
+    .fillMaxSize()
+    .zoomable(zoomableState),
+  state = imageState,
+  contentDescription = ,
+)
+
+

SubSamplingImage() is an adaptation of the excellent subsampling-scale-image-view by Dave Morrissey.

+ + + + + + +
+
+ + + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file