scratch-reveal 1.2.3 → 1.3.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,6 +38,7 @@ app.mount('#app');
38
38
  width="300"
39
39
  height="300"
40
40
  complete-percent="60"
41
+ show-status
41
42
  brush-src="/demo/assets/brush.png"
42
43
  brush-size="15"
43
44
  mask-src="/demo/assets/scratch-reveal.png"
@@ -52,6 +53,7 @@ app.mount('#app');
52
53
  <scratch-reveal
53
54
  style="width: 100%; height: 100%;"
54
55
  complete-percent="60"
56
+ show-status
55
57
  brush-src="/demo/assets/brush.png"
56
58
  brush-size="12"
57
59
  mask-src="/demo/assets/scratch-reveal.png"
@@ -86,14 +88,15 @@ el.addEventListener('error', (event) => {
86
88
 
87
89
  # Attributes
88
90
 
89
- | Attribute | Type | Default | Description |
90
- |:------------------------:|:------------------:|:--------------------------------------------:|:---------------------------------------------------------------------------------------------------------|
91
- | `width` / `height` | `number` | `300` | Container/mask size in px. If omitted, size follows layout (auto-size). |
92
- | `complete-percent` | `number` | `60` | Percent cleared to consider done. |
93
- | `brush-src` | `string` | | Brush image (**required**). |
94
- | `brush-size` | `string \| number` | `0` | Brush width: numbers mean percent of min(canvas width, height) (e.g., `12` = 12%). Use `80px` for px. `0` = natural image size. |
95
- | `mask-src` | `string` | | Top mask (scratched away) (**required**). |
96
- | `background-src` | `string` | | Background beneath the mask (**required**). |
91
+ | Attribute | Type | Default | Description |
92
+ |:------------------------:|:------------------:|:---------:|:--------------------------------------------------------------------------------------------------------------------------------|
93
+ | `width` / `height` | `number` | `300` | Container/mask size in px. If omitted, size follows layout (auto-size). |
94
+ | `complete-percent` | `number` | `60` | Percent cleared to consider done. |
95
+ | `brush-src` | `string` | | Brush image (**required**). |
96
+ | `brush-size` | `string \| number` | `0` | Brush width: numbers mean percent of min(canvas width, height) (e.g., `12` = 12%). Use `80px` for px. `0` = natural image size. |
97
+ | `mask-src` | `string` | | Top mask (scratched away) (**required**). |
98
+ | `background-src` | `string` | | Background beneath the mask (**required**). |
99
+ | `show-status` | `boolean` | — | Optional: render status message for missing attrs / errors (good for dev). |
97
100
  <br>
98
101
 
99
102
  # Styles
package/dist/index.cjs.js CHANGED
@@ -1,4 +1,4 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});class M{ctx;mouseX;mouseY;constructor(t,e,s){this.ctx=t,this.mouseX=e,this.mouseY=s}updateMousePosition(t,e){this.mouseX=t,this.mouseY=e}brush(t,e=0){if(!t)return;const s=Math.atan2(this.mouseY,this.mouseX);if(this.ctx.save(),this.ctx.translate(this.mouseX,this.mouseY),this.ctx.rotate(s),e>0){const i=e,h=e*(t.height/t.width);this.ctx.drawImage(t,-(i/2),-(h/2),i,h)}else this.ctx.drawImage(t,-(t.width/2),-(t.height/2));this.ctx.restore()}}function v(r){return new Promise((t,e)=>{const s=new Image;s.crossOrigin="anonymous",s.onload=()=>t(s),s.onerror=()=>e(new Error(`Image ${r} failed to load`)),s.src=r})}function E(r){let t=0;return((...s)=>{t||(t=requestAnimationFrame(()=>{t=0,r(...s)}))})}const b=`.sr {
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});class S{ctx;mouseX;mouseY;constructor(t,s,i){this.ctx=t,this.mouseX=s,this.mouseY=i}updateMousePosition(t,s){this.mouseX=t,this.mouseY=s}brush(t,s=0){if(!t)return;const i=Math.atan2(this.mouseY,this.mouseX);if(this.ctx.save(),this.ctx.translate(this.mouseX,this.mouseY),this.ctx.rotate(i),s>0){const e=s,h=s*(t.height/t.width);this.ctx.drawImage(t,-(e/2),-(h/2),e,h)}else this.ctx.drawImage(t,-(t.width/2),-(t.height/2));this.ctx.restore()}}function p(c){return new Promise((t,s)=>{const i=new Image;i.crossOrigin="anonymous",i.onload=()=>t(i),i.onerror=()=>s(new Error(`Image ${c} failed to load`)),i.src=c})}function m(c){let t=0;return((...i)=>{t||(t=requestAnimationFrame(()=>{t=0,c(...i)}))})}const b=`.sr {
2
2
  position: relative;
3
3
  overflow: hidden;
4
4
  width: 100%;
@@ -28,9 +28,34 @@
28
28
  -webkit-tap-highlight-color: transparent;
29
29
  }
30
30
 
31
+ .sr__status {
32
+ position: absolute;
33
+ inset: 0;
34
+ display: flex;
35
+ align-items: center;
36
+ justify-content: center;
37
+ padding: 12px;
38
+ text-align: center;
39
+ font-family:
40
+ system-ui,
41
+ -apple-system,
42
+ Segoe UI,
43
+ Roboto,
44
+ sans-serif;
45
+ font-size: 12px;
46
+ line-height: 1.4;
47
+ color: #4b5563;
48
+ background: rgba(248, 250, 252, 0.9);
49
+ }
50
+
51
+ .sr__status--error {
52
+ color: #991b1b;
53
+ background: rgba(254, 226, 226, 0.95);
54
+ }
55
+
31
56
  scratch-reveal {
32
57
  display: inline-block;
33
58
  width: 100%;
34
59
  height: 100%;
35
60
  }
36
- `,R=b;function z(r){return"adoptedStyleSheets"in r}function P(r){if(typeof CSSStyleSheet>"u")return null;try{const t=new CSSStyleSheet;return t.replaceSync(r),t}catch{return null}}const _=P(b),u={width:300,height:300,brushSrc:"",imageMaskSrc:"",imageBackgroundSrc:"",brushSize:0,percentToFinish:60};class A{config;ctx;container;_canvas;brush;maskImage;backgroundImage;brushImage;backgroundEl;brushSize=0;percent=0;done=!1;completing=!1;destroyed=!1;lastProgressEmitMs=0;removeListeners;get canvas(){return this._canvas}constructor(t,e={}){if(this.config={...u,...e},this.container=typeof t=="string"?document.querySelector(t):t,!this.container)throw new Error("ScratchReveal: container not found");this._canvas=this.createCanvas(this.config.width,this.config.height),this.ctx=this._canvas.getContext("2d",{willReadFrequently:!0}),this.brush=new M(this.ctx,0,0),this.brushSize=this.config.brushSize,this.container.appendChild(this._canvas)}async init(){if(!this.config.brushSrc)throw new Error('ScratchReveal: "brushSrc" is required');if(!this.config.imageMaskSrc)throw new Error('ScratchReveal: "imageMaskSrc" is required');if(!this.config.imageBackgroundSrc)throw new Error('ScratchReveal: "imageBackgroundSrc" is required');const[t,e,s]=await Promise.all([v(this.config.brushSrc),v(this.config.imageMaskSrc),v(this.config.imageBackgroundSrc)]);return this.destroyed?this:(this.brushImage=t,this.maskImage=e,this.backgroundImage=s,this.drawMask(),this.setBackground(),this.bindEvents(),this)}destroy(){this.destroyed=!0,this.removeListeners?.()}getPercent(){return this.percent}createCanvas(t,e){const s=document.createElement("canvas");return s.className="sr__canvas",s.width=t,s.height=e,s.style.width="100%",s.style.height="100%",s}resize(t,e){this.destroyed||this.done||t<=0||e<=0||this._canvas.width===t&&this._canvas.height===e||(this._canvas.width=t,this._canvas.height=e,this.percent=0,this.ctx.globalCompositeOperation="source-over",this.drawMask())}setBrushSize(t){this.destroyed||!Number.isFinite(t)||t<0||(this.brushSize=t)}bindEvents(){const e=E((n,a)=>{this.percent=this.updatePercent();const o=performance.now();(!this.lastProgressEmitMs||o-this.lastProgressEmitMs>=120)&&(this.lastProgressEmitMs=o,this.config.onProgress?.(this.percent)),this.finish(n,a)}),s=E(n=>{(n.buttons&1)!==0&&(this.updatePosition(n),this.scratch(),e(n,s))}),i=n=>{try{this._canvas.releasePointerCapture(n.pointerId)}catch{}this._canvas.removeEventListener("pointermove",s),this.percent=this.updatePercent(),this.config.onProgress?.(this.percent),this.finish(n,s)},h=n=>{n.preventDefault(),this.updatePosition(n),this.scratch(),this._canvas.setPointerCapture(n.pointerId),this._canvas.addEventListener("pointermove",s),this.percent=this.updatePercent(),this.config.onProgress?.(this.percent),this.finish(n,s)};this._canvas.addEventListener("pointerdown",h),this._canvas.addEventListener("pointerup",i),this._canvas.addEventListener("pointercancel",i),this.removeListeners=()=>{this._canvas.removeEventListener("pointerdown",h),this._canvas.removeEventListener("pointerup",i),this._canvas.removeEventListener("pointercancel",i),this._canvas.removeEventListener("pointermove",s)}}updatePosition(t){const e=this._canvas.getBoundingClientRect(),s=e.width?this._canvas.width/e.width:1,i=e.height?this._canvas.height/e.height:1,h=(t.clientX-e.left)*s,n=(t.clientY-e.top)*i;this.brush.updateMousePosition(h,n)}drawMask(){this.maskImage&&(this.ctx.globalCompositeOperation="source-over",this.ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this.ctx.drawImage(this.maskImage,0,0,this._canvas.width,this._canvas.height))}setBackground(){if(this.destroyed||!this.backgroundImage||!this.container.contains(this._canvas))return;const t=this.backgroundEl??document.createElement("img");t.src=this.backgroundImage.src,t.className="sr__bg",t.isConnected||this.container.insertBefore(t,this._canvas),this.backgroundEl=t}scratch(){if(!this.brushImage)return;const t=this.brushSize>0?this.brushSize:0;this.ctx.globalCompositeOperation="destination-out",this.ctx.save(),this.brush.brush(this.brushImage,t),this.ctx.restore()}updatePercent(){const e=this.ctx.getImageData(0,0,this._canvas.width,this._canvas.height).data;let s=0;for(let i=3;i<e.length;i+=4)e[i]===0&&s++;return s/(this._canvas.width*this._canvas.height)*100}finish(t,e){if(!this.done&&this.percent>this.config.percentToFinish&&(this.done=!0,this._canvas.style.pointerEvents="none",this.config.onComplete?.(),this.playCompleteEffect(),t&&e)){try{this._canvas.releasePointerCapture(t.pointerId)}catch{}this._canvas.removeEventListener("pointermove",e)}}playCompleteEffect(){if(this.destroyed||this.completing)return;this.completing=!0;const t=350,e="ease-out";this._canvas.style.transition=`opacity ${t}ms ${e}`,this._canvas.style.opacity||(this._canvas.style.opacity="1"),requestAnimationFrame(()=>{this._canvas.style.opacity="0",window.setTimeout(()=>{this.destroyed||this.clear()},t)})}clear(){this.ctx.clearRect(0,0,this._canvas.width,this._canvas.height)}}function y(r="scratch-reveal"){if(typeof window>"u"||!("customElements"in window)||customElements.get(r))return;function t(s,i,h,n){if(!s)return n;const a=s.trim();if(!a)return n;const o=Number.parseFloat(a.endsWith("%")?a.slice(0,-1):a.endsWith("px")?a.slice(0,-2):a);if(!Number.isFinite(o))return n;if(a.endsWith("px"))return Math.max(0,o);const d=Math.min(i,h);return Math.max(0,d*o/100)}class e extends HTMLElement{instance;container;styleEl=null;rebuildScheduled=!1;resizeObserver;lastErrorMessage=null;static get observedAttributes(){return["width","height","complete-percent","brush-src","brush-size","mask-src","background-src"]}constructor(){super();const i=this.attachShadow({mode:"open"});z(i)&&_?i.adoptedStyleSheets=[_]:(this.styleEl=document.createElement("style"),this.styleEl.textContent=b,i.append(this.styleEl)),this.container=document.createElement("div"),this.container.className="sr",i.append(this.container)}connectedCallback(){this.scheduleRebuild()}disconnectedCallback(){this.instance?.destroy(),this.instance=void 0,this.resizeObserver?.disconnect(),this.resizeObserver=void 0}attributeChangedCallback(i,h,n){h!==n&&this.scheduleRebuild()}scheduleRebuild(){this.rebuildScheduled||(this.rebuildScheduled=!0,queueMicrotask(()=>{this.rebuildScheduled=!1,this.isConnected&&this.rebuild()}))}rebuild(){this.container.replaceChildren(),this.instance?.destroy();const i=this.hasAttribute("width"),h=this.hasAttribute("height"),n=this.getBoundingClientRect(),a=Math.round(n.width),o=Math.round(n.height),d=i?Number(this.getAttribute("width")):a||u.width,m=h?Number(this.getAttribute("height")):o||u.height,k=Number(this.getAttribute("complete-percent")??u.percentToFinish),p=(this.getAttribute("brush-src")??"").trim(),x=t(this.getAttribute("brush-size"),d,m,u.brushSize),f=(this.getAttribute("mask-src")??"").trim(),S=(this.getAttribute("background-src")??"").trim(),l=[];if(p||l.push("brush-src"),f||l.push("mask-src"),S||l.push("background-src"),l.length){const c=`ScratchReveal: missing required attribute(s): ${l.join(", ")}`;this.renderError(c);return}i?this.container.style.width=`${d}px`:this.container.style.width="100%",h?this.container.style.height=`${m}px`:this.container.style.height="100%",this.instance=new A(this.container,{width:d,height:m,percentToFinish:k,brushSrc:p,brushSize:x,imageMaskSrc:f,imageBackgroundSrc:S,onProgress:c=>{this.dispatchEvent(new CustomEvent("progress",{detail:{percent:c},bubbles:!0,composed:!0}))},onComplete:()=>{this.dispatchEvent(new CustomEvent("complete",{detail:{percent:100},bubbles:!0,composed:!0}))}}),this.instance.init().catch(c=>{const g=c instanceof Error?c.message:"ScratchReveal: init failed";this.renderError(g)}),(!i||!h)&&"ResizeObserver"in window?(this.resizeObserver?.disconnect(),this.resizeObserver=new ResizeObserver(()=>{if(this.hasAttribute("width")&&this.hasAttribute("height")){this.resizeObserver?.disconnect(),this.resizeObserver=void 0;return}const c=this.getBoundingClientRect(),g=Math.round(c.width),w=Math.round(c.height);this.instance?.resize(g,w);const C=t(this.getAttribute("brush-size"),g,w,u.brushSize);this.instance?.setBrushSize(C)}),this.resizeObserver.observe(this)):(this.resizeObserver?.disconnect(),this.resizeObserver=void 0)}renderError(i){this.instance?.destroy(),this.instance=void 0,this.lastErrorMessage!==i&&(this.lastErrorMessage=i,this.dispatchEvent(new CustomEvent("error",{detail:{message:i},bubbles:!0,composed:!0})))}}customElements.define(r,e)}function I(r){y(),r.config.globalProperties.$scratchReveal=!0}exports.installScratchReveal=I;exports.registerScratchRevealElement=y;exports.scratchRevealCssText=R;
61
+ `,w=b;function _(c){return"adoptedStyleSheets"in c}function E(c){if(typeof CSSStyleSheet>"u")return null;try{const t=new CSSStyleSheet;return t.replaceSync(c),t}catch{return null}}const v=E(b),d={width:300,height:300,brushSrc:"",imageMaskSrc:"",imageBackgroundSrc:"",brushSize:0,percentToFinish:60};class y{config;ctx;container;_canvas;dpr=1;brush;maskImage;backgroundImage;brushImage;backgroundEl;brushSize=0;percent=0;done=!1;completing=!1;destroyed=!1;lastProgressEmitMs=0;removeListeners;get canvas(){return this._canvas}constructor(t,s={}){if(this.config={...d,...s},this.container=typeof t=="string"?document.querySelector(t):t,!this.container)throw new Error("ScratchReveal: container not found");this._canvas=this.createCanvas(this.config.width,this.config.height),this.ctx=this._canvas.getContext("2d",{willReadFrequently:!0}),this.setCanvasScale(this.config.width,this.config.height),this.brush=new S(this.ctx,0,0),this.brushSize=this.config.brushSize,this.container.appendChild(this._canvas)}async init(){if(!this.config.brushSrc)throw new Error('ScratchReveal: "brushSrc" is required');if(!this.config.imageMaskSrc)throw new Error('ScratchReveal: "imageMaskSrc" is required');if(!this.config.imageBackgroundSrc)throw new Error('ScratchReveal: "imageBackgroundSrc" is required');const[t,s,i]=await Promise.all([p(this.config.brushSrc),p(this.config.imageMaskSrc),p(this.config.imageBackgroundSrc)]);return this.destroyed?this:(this.brushImage=t,this.maskImage=s,this.backgroundImage=i,this.drawMask(),this.setBackground(),this.bindEvents(),this)}destroy(){this.destroyed=!0,this.removeListeners?.()}getPercent(){return this.percent}createCanvas(t,s){const i=document.createElement("canvas");return i.className="sr__canvas",i.style.width="100%",i.style.height="100%",i}setCanvasScale(t,s){const i=Math.max(1,Math.min(window.devicePixelRatio||1,3));this.dpr=i,this._canvas.width=Math.round(t*this.dpr),this._canvas.height=Math.round(s*this.dpr),this._canvas.style.width=`${t}px`,this._canvas.style.height=`${s}px`,this.ctx.setTransform(this.dpr,0,0,this.dpr,0,0)}resize(t,s){this.destroyed||this.done||t<=0||s<=0||this._canvas.width===t*this.dpr&&this._canvas.height===s*this.dpr||(this.setCanvasScale(t,s),this.percent=0,this.ctx.globalCompositeOperation="source-over",this.drawMask())}setBrushSize(t){this.destroyed||!Number.isFinite(t)||t<0||(this.brushSize=t)}bindEvents(){const s=m((n,r)=>{this.percent=this.updatePercent();const a=performance.now();(!this.lastProgressEmitMs||a-this.lastProgressEmitMs>=120)&&(this.lastProgressEmitMs=a,this.config.onProgress?.(this.percent)),this.finish(n,r)}),i=m(n=>{(n.buttons&1)!==0&&(this.updatePosition(n),this.scratch(),s(n,i))}),e=n=>{try{this._canvas.releasePointerCapture(n.pointerId)}catch{}this._canvas.removeEventListener("pointermove",i),this.percent=this.updatePercent(),this.config.onProgress?.(this.percent),this.finish(n,i)},h=n=>{n.preventDefault(),this.updatePosition(n),this.scratch(),this._canvas.setPointerCapture(n.pointerId),this._canvas.addEventListener("pointermove",i),this.percent=this.updatePercent(),this.config.onProgress?.(this.percent),this.finish(n,i)};this._canvas.addEventListener("pointerdown",h),this._canvas.addEventListener("pointerup",e),this._canvas.addEventListener("pointercancel",e),this.removeListeners=()=>{this._canvas.removeEventListener("pointerdown",h),this._canvas.removeEventListener("pointerup",e),this._canvas.removeEventListener("pointercancel",e),this._canvas.removeEventListener("pointermove",i)}}updatePosition(t){const s=this._canvas.getBoundingClientRect(),i=s.width?this._canvas.width/s.width:1,e=s.height?this._canvas.height/s.height:1,h=(t.clientX-s.left)*i/this.dpr,n=(t.clientY-s.top)*e/this.dpr;this.brush.updateMousePosition(h,n)}drawMask(){this.maskImage&&(this.ctx.globalCompositeOperation="source-over",this.ctx.clearRect(0,0,this._canvas.width/this.dpr,this._canvas.height/this.dpr),this.ctx.drawImage(this.maskImage,0,0,this._canvas.width/this.dpr,this._canvas.height/this.dpr))}setBackground(){if(this.destroyed||!this.backgroundImage||!this.container.contains(this._canvas))return;const t=this.backgroundEl??document.createElement("img");t.src=this.backgroundImage.src,t.className="sr__bg",t.isConnected||this.container.insertBefore(t,this._canvas),this.backgroundEl=t}scratch(){if(!this.brushImage)return;const t=this.brushSize>0?this.brushSize:0;this.ctx.globalCompositeOperation="destination-out",this.ctx.save(),this.brush.brush(this.brushImage,t),this.ctx.restore()}updatePercent(){const s=this.ctx.getImageData(0,0,this._canvas.width,this._canvas.height).data;let i=0;for(let e=3;e<s.length;e+=4)s[e]===0&&i++;return i/(this._canvas.width*this._canvas.height)*100}finish(t,s){if(!this.done&&this.percent>this.config.percentToFinish&&(this.done=!0,this._canvas.style.pointerEvents="none",this.config.onComplete?.(),this.playCompleteEffect(),t&&s)){try{this._canvas.releasePointerCapture(t.pointerId)}catch{}this._canvas.removeEventListener("pointermove",s)}}playCompleteEffect(){if(this.destroyed||this.completing)return;this.completing=!0;const t=350,s="ease-out";this._canvas.style.transition=`opacity ${t}ms ${s}`,this._canvas.style.opacity||(this._canvas.style.opacity="1"),requestAnimationFrame(()=>{this._canvas.style.opacity="0",window.setTimeout(()=>{this.destroyed||this.clear()},t)})}clear(){this.ctx.clearRect(0,0,this._canvas.width/this.dpr,this._canvas.height/this.dpr)}}function f(c="scratch-reveal"){if(typeof window>"u"||!("customElements"in window)||customElements.get(c))return;function t(i,e,h,n){if(!i)return n;const r=i.trim();if(!r)return n;const a=Number.parseFloat(r.endsWith("%")?r.slice(0,-1):r.endsWith("px")?r.slice(0,-2):r);if(!Number.isFinite(a))return n;if(r.endsWith("px"))return Math.max(0,a);const o=Math.min(e,h);return Math.max(0,o*a/100)}class s extends HTMLElement{instance;container;styleEl=null;rebuildScheduled=!1;resizeObserver;lastErrorMessage=null;static get observedAttributes(){return["width","height","complete-percent","brush-src","brush-size","mask-src","background-src","show-status"]}constructor(){super();const e=this.attachShadow({mode:"open"});_(e)&&v?e.adoptedStyleSheets=[v]:(this.styleEl=document.createElement("style"),this.styleEl.textContent=b,e.append(this.styleEl)),this.container=document.createElement("div"),this.container.className="sr",e.append(this.container)}connectedCallback(){this.scheduleRebuild()}disconnectedCallback(){this.instance?.destroy(),this.instance=void 0,this.resizeObserver?.disconnect(),this.resizeObserver=void 0}attributeChangedCallback(e,h,n){if(h!==n){if(e==="brush-size"&&this.instance){const{width:r,height:a}=this.resolveSize(),o=t(this.getAttribute("brush-size"),r,a,d.brushSize);this.instance.setBrushSize(o);return}if((e==="width"||e==="height")&&this.instance){const r=this.resolveSize();this.syncContainerSize(r),this.instance.resize(r.width,r.height);const a=t(this.getAttribute("brush-size"),r.width,r.height,d.brushSize);this.instance.setBrushSize(a),this.updateResizeObserver(r);return}this.scheduleRebuild()}}scheduleRebuild(){this.rebuildScheduled||(this.rebuildScheduled=!0,queueMicrotask(()=>{this.rebuildScheduled=!1,this.isConnected&&this.rebuild()}))}rebuild(){const e=this.resolveSize(),h=this.resolvePercentToFinish(),{brushSrc:n,imageMaskSrc:r,imageBackgroundSrc:a}=this.resolveSources(),o=t(this.getAttribute("brush-size"),e.width,e.height,d.brushSize),u=[];if(n||u.push("brush-src"),r||u.push("mask-src"),a||u.push("background-src"),this.syncContainerSize(e),u.length){this.renderWaiting(u),this.updateResizeObserver(e);return}this.container.replaceChildren(),this.instance?.destroy(),this.instance=void 0,this.lastErrorMessage=null,this.instance=new y(this.container,{width:e.width,height:e.height,percentToFinish:h,brushSrc:n,brushSize:o,imageMaskSrc:r,imageBackgroundSrc:a,onProgress:l=>{this.dispatchEvent(new CustomEvent("progress",{detail:{percent:l},bubbles:!0,composed:!0}))},onComplete:()=>{this.dispatchEvent(new CustomEvent("complete",{detail:{percent:100},bubbles:!0,composed:!0}))}}),this.instance.init().catch(l=>{const g=l instanceof Error?l.message:"ScratchReveal: init failed";this.renderError(g)}),this.updateResizeObserver(e)}renderError(e){this.instance?.destroy(),this.instance=void 0,this.renderStatus(e,"error"),this.lastErrorMessage!==e&&(this.lastErrorMessage=e,this.dispatchEvent(new CustomEvent("error",{detail:{message:e},bubbles:!0,composed:!0})))}renderWaiting(e){const h=`ScratchReveal: waiting for required attribute(s): ${e.join(", ")}`;this.renderStatus(h,"waiting")}renderStatus(e,h){if(this.container.replaceChildren(),this.instance?.destroy(),this.instance=void 0,!this.hasAttribute("show-status"))return;const n=document.createElement("div");n.className=`sr__status sr__status--${h}`,n.textContent=e,n.setAttribute("role",h==="error"?"alert":"status"),this.container.appendChild(n)}resolveSize(){const e=this.hasAttribute("width"),h=this.hasAttribute("height"),n=this.getBoundingClientRect(),r=Math.round(n.width),a=Math.round(n.height),o=Number(this.getAttribute("width")),u=Number(this.getAttribute("height")),l=e&&Number.isFinite(o)?o:r||d.width,g=h&&Number.isFinite(u)?u:a||d.height;return{hasWidthAttr:e,hasHeightAttr:h,width:l,height:g}}resolvePercentToFinish(){const e=Number(this.getAttribute("complete-percent"));return Number.isFinite(e)?e:d.percentToFinish}resolveSources(){return{brushSrc:(this.getAttribute("brush-src")??"").trim(),imageMaskSrc:(this.getAttribute("mask-src")??"").trim(),imageBackgroundSrc:(this.getAttribute("background-src")??"").trim()}}syncContainerSize(e){e.hasWidthAttr?this.container.style.width=`${e.width}px`:this.container.style.width="100%",e.hasHeightAttr?this.container.style.height=`${e.height}px`:this.container.style.height="100%"}updateResizeObserver(e){(!e.hasWidthAttr||!e.hasHeightAttr)&&"ResizeObserver"in window?(this.resizeObserver?.disconnect(),this.resizeObserver=new ResizeObserver(()=>{if(this.hasAttribute("width")&&this.hasAttribute("height")){this.resizeObserver?.disconnect(),this.resizeObserver=void 0;return}const n=this.getBoundingClientRect(),r=Math.round(n.width),a=Math.round(n.height);this.instance?.resize(r,a);const o=t(this.getAttribute("brush-size"),r,a,d.brushSize);this.instance?.setBrushSize(o)}),this.resizeObserver.observe(this)):(this.resizeObserver?.disconnect(),this.resizeObserver=void 0)}}customElements.define(c,s)}function z(c){f(),c.config.globalProperties.$scratchReveal=!0}exports.installScratchReveal=z;exports.registerScratchRevealElement=f;exports.scratchRevealCssText=w;
package/dist/index.es.js CHANGED
@@ -1,40 +1,40 @@
1
- class C {
1
+ class f {
2
2
  ctx;
3
3
  mouseX;
4
4
  mouseY;
5
- constructor(t, e, s) {
6
- this.ctx = t, this.mouseX = e, this.mouseY = s;
5
+ constructor(t, s, i) {
6
+ this.ctx = t, this.mouseX = s, this.mouseY = i;
7
7
  }
8
- updateMousePosition(t, e) {
9
- this.mouseX = t, this.mouseY = e;
8
+ updateMousePosition(t, s) {
9
+ this.mouseX = t, this.mouseY = s;
10
10
  }
11
- brush(t, e = 0) {
11
+ brush(t, s = 0) {
12
12
  if (!t)
13
13
  return;
14
- const s = Math.atan2(this.mouseY, this.mouseX);
15
- if (this.ctx.save(), this.ctx.translate(this.mouseX, this.mouseY), this.ctx.rotate(s), e > 0) {
16
- const i = e, h = e * (t.height / t.width);
17
- this.ctx.drawImage(t, -(i / 2), -(h / 2), i, h);
14
+ const i = Math.atan2(this.mouseY, this.mouseX);
15
+ if (this.ctx.save(), this.ctx.translate(this.mouseX, this.mouseY), this.ctx.rotate(i), s > 0) {
16
+ const e = s, h = s * (t.height / t.width);
17
+ this.ctx.drawImage(t, -(e / 2), -(h / 2), e, h);
18
18
  } else
19
19
  this.ctx.drawImage(t, -(t.width / 2), -(t.height / 2));
20
20
  this.ctx.restore();
21
21
  }
22
22
  }
23
- function b(r) {
24
- return new Promise((t, e) => {
25
- const s = new Image();
26
- s.crossOrigin = "anonymous", s.onload = () => t(s), s.onerror = () => e(new Error(`Image ${r} failed to load`)), s.src = r;
23
+ function p(o) {
24
+ return new Promise((t, s) => {
25
+ const i = new Image();
26
+ i.crossOrigin = "anonymous", i.onload = () => t(i), i.onerror = () => s(new Error(`Image ${o} failed to load`)), i.src = o;
27
27
  });
28
28
  }
29
- function E(r) {
29
+ function m(o) {
30
30
  let t = 0;
31
- return ((...s) => {
31
+ return ((...i) => {
32
32
  t || (t = requestAnimationFrame(() => {
33
- t = 0, r(...s);
33
+ t = 0, o(...i);
34
34
  }));
35
35
  });
36
36
  }
37
- const v = `.sr {
37
+ const b = `.sr {
38
38
  position: relative;
39
39
  overflow: hidden;
40
40
  width: 100%;
@@ -64,25 +64,50 @@ const v = `.sr {
64
64
  -webkit-tap-highlight-color: transparent;
65
65
  }
66
66
 
67
+ .sr__status {
68
+ position: absolute;
69
+ inset: 0;
70
+ display: flex;
71
+ align-items: center;
72
+ justify-content: center;
73
+ padding: 12px;
74
+ text-align: center;
75
+ font-family:
76
+ system-ui,
77
+ -apple-system,
78
+ Segoe UI,
79
+ Roboto,
80
+ sans-serif;
81
+ font-size: 12px;
82
+ line-height: 1.4;
83
+ color: #4b5563;
84
+ background: rgba(248, 250, 252, 0.9);
85
+ }
86
+
87
+ .sr__status--error {
88
+ color: #991b1b;
89
+ background: rgba(254, 226, 226, 0.95);
90
+ }
91
+
67
92
  scratch-reveal {
68
93
  display: inline-block;
69
94
  width: 100%;
70
95
  height: 100%;
71
96
  }
72
- `, I = v;
73
- function M(r) {
74
- return "adoptedStyleSheets" in r;
97
+ `, y = b;
98
+ function S(o) {
99
+ return "adoptedStyleSheets" in o;
75
100
  }
76
- function z(r) {
101
+ function w(o) {
77
102
  if (typeof CSSStyleSheet > "u") return null;
78
103
  try {
79
104
  const t = new CSSStyleSheet();
80
- return t.replaceSync(r), t;
105
+ return t.replaceSync(o), t;
81
106
  } catch {
82
107
  return null;
83
108
  }
84
109
  }
85
- const _ = z(v), u = {
110
+ const v = w(b), d = {
86
111
  width: 300,
87
112
  height: 300,
88
113
  brushSrc: "",
@@ -91,11 +116,12 @@ const _ = z(v), u = {
91
116
  brushSize: 0,
92
117
  percentToFinish: 60
93
118
  };
94
- class R {
119
+ class _ {
95
120
  config;
96
121
  ctx;
97
122
  container;
98
123
  _canvas;
124
+ dpr = 1;
99
125
  brush;
100
126
  maskImage;
101
127
  backgroundImage;
@@ -111,12 +137,12 @@ class R {
111
137
  get canvas() {
112
138
  return this._canvas;
113
139
  }
114
- constructor(t, e = {}) {
115
- if (this.config = { ...u, ...e }, this.container = typeof t == "string" ? document.querySelector(t) : t, !this.container)
140
+ constructor(t, s = {}) {
141
+ if (this.config = { ...d, ...s }, this.container = typeof t == "string" ? document.querySelector(t) : t, !this.container)
116
142
  throw new Error("ScratchReveal: container not found");
117
143
  this._canvas = this.createCanvas(this.config.width, this.config.height), this.ctx = this._canvas.getContext("2d", {
118
144
  willReadFrequently: !0
119
- }), this.brush = new C(this.ctx, 0, 0), this.brushSize = this.config.brushSize, this.container.appendChild(this._canvas);
145
+ }), this.setCanvasScale(this.config.width, this.config.height), this.brush = new f(this.ctx, 0, 0), this.brushSize = this.config.brushSize, this.container.appendChild(this._canvas);
120
146
  }
121
147
  async init() {
122
148
  if (!this.config.brushSrc)
@@ -125,12 +151,12 @@ class R {
125
151
  throw new Error('ScratchReveal: "imageMaskSrc" is required');
126
152
  if (!this.config.imageBackgroundSrc)
127
153
  throw new Error('ScratchReveal: "imageBackgroundSrc" is required');
128
- const [t, e, s] = await Promise.all([
129
- b(this.config.brushSrc),
130
- b(this.config.imageMaskSrc),
131
- b(this.config.imageBackgroundSrc)
154
+ const [t, s, i] = await Promise.all([
155
+ p(this.config.brushSrc),
156
+ p(this.config.imageMaskSrc),
157
+ p(this.config.imageBackgroundSrc)
132
158
  ]);
133
- return this.destroyed ? this : (this.brushImage = t, this.maskImage = e, this.backgroundImage = s, this.drawMask(), this.setBackground(), this.bindEvents(), this);
159
+ return this.destroyed ? this : (this.brushImage = t, this.maskImage = s, this.backgroundImage = i, this.drawMask(), this.setBackground(), this.bindEvents(), this);
134
160
  }
135
161
  destroy() {
136
162
  this.destroyed = !0, this.removeListeners?.();
@@ -138,44 +164,54 @@ class R {
138
164
  getPercent() {
139
165
  return this.percent;
140
166
  }
141
- createCanvas(t, e) {
142
- const s = document.createElement("canvas");
143
- return s.className = "sr__canvas", s.width = t, s.height = e, s.style.width = "100%", s.style.height = "100%", s;
167
+ createCanvas(t, s) {
168
+ const i = document.createElement("canvas");
169
+ return i.className = "sr__canvas", i.style.width = "100%", i.style.height = "100%", i;
170
+ }
171
+ setCanvasScale(t, s) {
172
+ const i = Math.max(1, Math.min(window.devicePixelRatio || 1, 3));
173
+ this.dpr = i, this._canvas.width = Math.round(t * this.dpr), this._canvas.height = Math.round(s * this.dpr), this._canvas.style.width = `${t}px`, this._canvas.style.height = `${s}px`, this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
144
174
  }
145
- resize(t, e) {
146
- this.destroyed || this.done || t <= 0 || e <= 0 || this._canvas.width === t && this._canvas.height === e || (this._canvas.width = t, this._canvas.height = e, this.percent = 0, this.ctx.globalCompositeOperation = "source-over", this.drawMask());
175
+ resize(t, s) {
176
+ this.destroyed || this.done || t <= 0 || s <= 0 || this._canvas.width === t * this.dpr && this._canvas.height === s * this.dpr || (this.setCanvasScale(t, s), this.percent = 0, this.ctx.globalCompositeOperation = "source-over", this.drawMask());
147
177
  }
148
178
  setBrushSize(t) {
149
179
  this.destroyed || !Number.isFinite(t) || t < 0 || (this.brushSize = t);
150
180
  }
151
181
  bindEvents() {
152
- const e = E(
153
- (n, a) => {
182
+ const s = m(
183
+ (n, r) => {
154
184
  this.percent = this.updatePercent();
155
- const o = performance.now();
156
- (!this.lastProgressEmitMs || o - this.lastProgressEmitMs >= 120) && (this.lastProgressEmitMs = o, this.config.onProgress?.(this.percent)), this.finish(n, a);
185
+ const a = performance.now();
186
+ (!this.lastProgressEmitMs || a - this.lastProgressEmitMs >= 120) && (this.lastProgressEmitMs = a, this.config.onProgress?.(this.percent)), this.finish(n, r);
157
187
  }
158
- ), s = E((n) => {
159
- (n.buttons & 1) !== 0 && (this.updatePosition(n), this.scratch(), e(n, s));
160
- }), i = (n) => {
188
+ ), i = m((n) => {
189
+ (n.buttons & 1) !== 0 && (this.updatePosition(n), this.scratch(), s(n, i));
190
+ }), e = (n) => {
161
191
  try {
162
192
  this._canvas.releasePointerCapture(n.pointerId);
163
193
  } catch {
164
194
  }
165
- this._canvas.removeEventListener("pointermove", s), this.percent = this.updatePercent(), this.config.onProgress?.(this.percent), this.finish(n, s);
195
+ this._canvas.removeEventListener("pointermove", i), this.percent = this.updatePercent(), this.config.onProgress?.(this.percent), this.finish(n, i);
166
196
  }, h = (n) => {
167
- n.preventDefault(), this.updatePosition(n), this.scratch(), this._canvas.setPointerCapture(n.pointerId), this._canvas.addEventListener("pointermove", s), this.percent = this.updatePercent(), this.config.onProgress?.(this.percent), this.finish(n, s);
197
+ n.preventDefault(), this.updatePosition(n), this.scratch(), this._canvas.setPointerCapture(n.pointerId), this._canvas.addEventListener("pointermove", i), this.percent = this.updatePercent(), this.config.onProgress?.(this.percent), this.finish(n, i);
168
198
  };
169
- this._canvas.addEventListener("pointerdown", h), this._canvas.addEventListener("pointerup", i), this._canvas.addEventListener("pointercancel", i), this.removeListeners = () => {
170
- this._canvas.removeEventListener("pointerdown", h), this._canvas.removeEventListener("pointerup", i), this._canvas.removeEventListener("pointercancel", i), this._canvas.removeEventListener("pointermove", s);
199
+ this._canvas.addEventListener("pointerdown", h), this._canvas.addEventListener("pointerup", e), this._canvas.addEventListener("pointercancel", e), this.removeListeners = () => {
200
+ this._canvas.removeEventListener("pointerdown", h), this._canvas.removeEventListener("pointerup", e), this._canvas.removeEventListener("pointercancel", e), this._canvas.removeEventListener("pointermove", i);
171
201
  };
172
202
  }
173
203
  updatePosition(t) {
174
- const e = this._canvas.getBoundingClientRect(), s = e.width ? this._canvas.width / e.width : 1, i = e.height ? this._canvas.height / e.height : 1, h = (t.clientX - e.left) * s, n = (t.clientY - e.top) * i;
204
+ const s = this._canvas.getBoundingClientRect(), i = s.width ? this._canvas.width / s.width : 1, e = s.height ? this._canvas.height / s.height : 1, h = (t.clientX - s.left) * i / this.dpr, n = (t.clientY - s.top) * e / this.dpr;
175
205
  this.brush.updateMousePosition(h, n);
176
206
  }
177
207
  drawMask() {
178
- this.maskImage && (this.ctx.globalCompositeOperation = "source-over", this.ctx.clearRect(0, 0, this._canvas.width, this._canvas.height), this.ctx.drawImage(this.maskImage, 0, 0, this._canvas.width, this._canvas.height));
208
+ this.maskImage && (this.ctx.globalCompositeOperation = "source-over", this.ctx.clearRect(0, 0, this._canvas.width / this.dpr, this._canvas.height / this.dpr), this.ctx.drawImage(
209
+ this.maskImage,
210
+ 0,
211
+ 0,
212
+ this._canvas.width / this.dpr,
213
+ this._canvas.height / this.dpr
214
+ ));
179
215
  }
180
216
  setBackground() {
181
217
  if (this.destroyed || !this.backgroundImage || !this.container.contains(this._canvas)) return;
@@ -188,51 +224,51 @@ class R {
188
224
  this.ctx.globalCompositeOperation = "destination-out", this.ctx.save(), this.brush.brush(this.brushImage, t), this.ctx.restore();
189
225
  }
190
226
  updatePercent() {
191
- const e = this.ctx.getImageData(0, 0, this._canvas.width, this._canvas.height).data;
192
- let s = 0;
193
- for (let i = 3; i < e.length; i += 4)
194
- e[i] === 0 && s++;
195
- return s / (this._canvas.width * this._canvas.height) * 100;
227
+ const s = this.ctx.getImageData(0, 0, this._canvas.width, this._canvas.height).data;
228
+ let i = 0;
229
+ for (let e = 3; e < s.length; e += 4)
230
+ s[e] === 0 && i++;
231
+ return i / (this._canvas.width * this._canvas.height) * 100;
196
232
  }
197
- finish(t, e) {
198
- if (!this.done && this.percent > this.config.percentToFinish && (this.done = !0, this._canvas.style.pointerEvents = "none", this.config.onComplete?.(), this.playCompleteEffect(), t && e)) {
233
+ finish(t, s) {
234
+ if (!this.done && this.percent > this.config.percentToFinish && (this.done = !0, this._canvas.style.pointerEvents = "none", this.config.onComplete?.(), this.playCompleteEffect(), t && s)) {
199
235
  try {
200
236
  this._canvas.releasePointerCapture(t.pointerId);
201
237
  } catch {
202
238
  }
203
- this._canvas.removeEventListener("pointermove", e);
239
+ this._canvas.removeEventListener("pointermove", s);
204
240
  }
205
241
  }
206
242
  playCompleteEffect() {
207
243
  if (this.destroyed || this.completing) return;
208
244
  this.completing = !0;
209
- const t = 350, e = "ease-out";
210
- this._canvas.style.transition = `opacity ${t}ms ${e}`, this._canvas.style.opacity || (this._canvas.style.opacity = "1"), requestAnimationFrame(() => {
245
+ const t = 350, s = "ease-out";
246
+ this._canvas.style.transition = `opacity ${t}ms ${s}`, this._canvas.style.opacity || (this._canvas.style.opacity = "1"), requestAnimationFrame(() => {
211
247
  this._canvas.style.opacity = "0", window.setTimeout(() => {
212
248
  this.destroyed || this.clear();
213
249
  }, t);
214
250
  });
215
251
  }
216
252
  clear() {
217
- this.ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
253
+ this.ctx.clearRect(0, 0, this._canvas.width / this.dpr, this._canvas.height / this.dpr);
218
254
  }
219
255
  }
220
- function P(r = "scratch-reveal") {
221
- if (typeof window > "u" || !("customElements" in window) || customElements.get(r)) return;
222
- function t(s, i, h, n) {
223
- if (!s) return n;
224
- const a = s.trim();
225
- if (!a) return n;
226
- const o = Number.parseFloat(
227
- a.endsWith("%") ? a.slice(0, -1) : a.endsWith("px") ? a.slice(0, -2) : a
256
+ function E(o = "scratch-reveal") {
257
+ if (typeof window > "u" || !("customElements" in window) || customElements.get(o)) return;
258
+ function t(i, e, h, n) {
259
+ if (!i) return n;
260
+ const r = i.trim();
261
+ if (!r) return n;
262
+ const a = Number.parseFloat(
263
+ r.endsWith("%") ? r.slice(0, -1) : r.endsWith("px") ? r.slice(0, -2) : r
228
264
  );
229
- if (!Number.isFinite(o)) return n;
230
- if (a.endsWith("px"))
231
- return Math.max(0, o);
232
- const d = Math.min(i, h);
233
- return Math.max(0, d * o / 100);
265
+ if (!Number.isFinite(a)) return n;
266
+ if (r.endsWith("px"))
267
+ return Math.max(0, a);
268
+ const c = Math.min(e, h);
269
+ return Math.max(0, c * a / 100);
234
270
  }
235
- class e extends HTMLElement {
271
+ class s extends HTMLElement {
236
272
  instance;
237
273
  container;
238
274
  styleEl = null;
@@ -247,13 +283,14 @@ function P(r = "scratch-reveal") {
247
283
  "brush-src",
248
284
  "brush-size",
249
285
  "mask-src",
250
- "background-src"
286
+ "background-src",
287
+ "show-status"
251
288
  ];
252
289
  }
253
290
  constructor() {
254
291
  super();
255
- const i = this.attachShadow({ mode: "open" });
256
- M(i) && _ ? i.adoptedStyleSheets = [_] : (this.styleEl = document.createElement("style"), this.styleEl.textContent = v, i.append(this.styleEl)), this.container = document.createElement("div"), this.container.className = "sr", i.append(this.container);
292
+ const e = this.attachShadow({ mode: "open" });
293
+ S(e) && v ? e.adoptedStyleSheets = [v] : (this.styleEl = document.createElement("style"), this.styleEl.textContent = b, e.append(this.styleEl)), this.container = document.createElement("div"), this.container.className = "sr", e.append(this.container);
257
294
  }
258
295
  connectedCallback() {
259
296
  this.scheduleRebuild();
@@ -261,8 +298,32 @@ function P(r = "scratch-reveal") {
261
298
  disconnectedCallback() {
262
299
  this.instance?.destroy(), this.instance = void 0, this.resizeObserver?.disconnect(), this.resizeObserver = void 0;
263
300
  }
264
- attributeChangedCallback(i, h, n) {
265
- h !== n && this.scheduleRebuild();
301
+ attributeChangedCallback(e, h, n) {
302
+ if (h !== n) {
303
+ if (e === "brush-size" && this.instance) {
304
+ const { width: r, height: a } = this.resolveSize(), c = t(
305
+ this.getAttribute("brush-size"),
306
+ r,
307
+ a,
308
+ d.brushSize
309
+ );
310
+ this.instance.setBrushSize(c);
311
+ return;
312
+ }
313
+ if ((e === "width" || e === "height") && this.instance) {
314
+ const r = this.resolveSize();
315
+ this.syncContainerSize(r), this.instance.resize(r.width, r.height);
316
+ const a = t(
317
+ this.getAttribute("brush-size"),
318
+ r.width,
319
+ r.height,
320
+ d.brushSize
321
+ );
322
+ this.instance.setBrushSize(a), this.updateResizeObserver(r);
323
+ return;
324
+ }
325
+ this.scheduleRebuild();
326
+ }
266
327
  }
267
328
  scheduleRebuild() {
268
329
  this.rebuildScheduled || (this.rebuildScheduled = !0, queueMicrotask(() => {
@@ -270,32 +331,28 @@ function P(r = "scratch-reveal") {
270
331
  }));
271
332
  }
272
333
  rebuild() {
273
- this.container.replaceChildren(), this.instance?.destroy();
274
- const i = this.hasAttribute("width"), h = this.hasAttribute("height"), n = this.getBoundingClientRect(), a = Math.round(n.width), o = Math.round(n.height), d = i ? Number(this.getAttribute("width")) : a || u.width, m = h ? Number(this.getAttribute("height")) : o || u.height, y = Number(
275
- this.getAttribute("complete-percent") ?? u.percentToFinish
276
- ), p = (this.getAttribute("brush-src") ?? "").trim(), k = t(
334
+ const e = this.resolveSize(), h = this.resolvePercentToFinish(), { brushSrc: n, imageMaskSrc: r, imageBackgroundSrc: a } = this.resolveSources(), c = t(
277
335
  this.getAttribute("brush-size"),
278
- d,
279
- m,
280
- u.brushSize
281
- ), f = (this.getAttribute("mask-src") ?? "").trim(), w = (this.getAttribute("background-src") ?? "").trim(), l = [];
282
- if (p || l.push("brush-src"), f || l.push("mask-src"), w || l.push("background-src"), l.length) {
283
- const c = `ScratchReveal: missing required attribute(s): ${l.join(", ")}`;
284
- this.renderError(c);
336
+ e.width,
337
+ e.height,
338
+ d.brushSize
339
+ ), u = [];
340
+ if (n || u.push("brush-src"), r || u.push("mask-src"), a || u.push("background-src"), this.syncContainerSize(e), u.length) {
341
+ this.renderWaiting(u), this.updateResizeObserver(e);
285
342
  return;
286
343
  }
287
- i ? this.container.style.width = `${d}px` : this.container.style.width = "100%", h ? this.container.style.height = `${m}px` : this.container.style.height = "100%", this.instance = new R(this.container, {
288
- width: d,
289
- height: m,
290
- percentToFinish: y,
291
- brushSrc: p,
292
- brushSize: k,
293
- imageMaskSrc: f,
294
- imageBackgroundSrc: w,
295
- onProgress: (c) => {
344
+ this.container.replaceChildren(), this.instance?.destroy(), this.instance = void 0, this.lastErrorMessage = null, this.instance = new _(this.container, {
345
+ width: e.width,
346
+ height: e.height,
347
+ percentToFinish: h,
348
+ brushSrc: n,
349
+ brushSize: c,
350
+ imageMaskSrc: r,
351
+ imageBackgroundSrc: a,
352
+ onProgress: (l) => {
296
353
  this.dispatchEvent(
297
354
  new CustomEvent("progress", {
298
- detail: { percent: c },
355
+ detail: { percent: l },
299
356
  bubbles: !0,
300
357
  composed: !0
301
358
  })
@@ -310,42 +367,72 @@ function P(r = "scratch-reveal") {
310
367
  })
311
368
  );
312
369
  }
313
- }), this.instance.init().catch((c) => {
314
- const g = c instanceof Error ? c.message : "ScratchReveal: init failed";
370
+ }), this.instance.init().catch((l) => {
371
+ const g = l instanceof Error ? l.message : "ScratchReveal: init failed";
315
372
  this.renderError(g);
316
- }), (!i || !h) && "ResizeObserver" in window ? (this.resizeObserver?.disconnect(), this.resizeObserver = new ResizeObserver(() => {
373
+ }), this.updateResizeObserver(e);
374
+ }
375
+ renderError(e) {
376
+ this.instance?.destroy(), this.instance = void 0, this.renderStatus(e, "error"), this.lastErrorMessage !== e && (this.lastErrorMessage = e, this.dispatchEvent(
377
+ new CustomEvent("error", {
378
+ detail: { message: e },
379
+ bubbles: !0,
380
+ composed: !0
381
+ })
382
+ ));
383
+ }
384
+ renderWaiting(e) {
385
+ const h = `ScratchReveal: waiting for required attribute(s): ${e.join(", ")}`;
386
+ this.renderStatus(h, "waiting");
387
+ }
388
+ renderStatus(e, h) {
389
+ if (this.container.replaceChildren(), this.instance?.destroy(), this.instance = void 0, !this.hasAttribute("show-status")) return;
390
+ const n = document.createElement("div");
391
+ n.className = `sr__status sr__status--${h}`, n.textContent = e, n.setAttribute("role", h === "error" ? "alert" : "status"), this.container.appendChild(n);
392
+ }
393
+ resolveSize() {
394
+ const e = this.hasAttribute("width"), h = this.hasAttribute("height"), n = this.getBoundingClientRect(), r = Math.round(n.width), a = Math.round(n.height), c = Number(this.getAttribute("width")), u = Number(this.getAttribute("height")), l = e && Number.isFinite(c) ? c : r || d.width, g = h && Number.isFinite(u) ? u : a || d.height;
395
+ return { hasWidthAttr: e, hasHeightAttr: h, width: l, height: g };
396
+ }
397
+ resolvePercentToFinish() {
398
+ const e = Number(this.getAttribute("complete-percent"));
399
+ return Number.isFinite(e) ? e : d.percentToFinish;
400
+ }
401
+ resolveSources() {
402
+ return {
403
+ brushSrc: (this.getAttribute("brush-src") ?? "").trim(),
404
+ imageMaskSrc: (this.getAttribute("mask-src") ?? "").trim(),
405
+ imageBackgroundSrc: (this.getAttribute("background-src") ?? "").trim()
406
+ };
407
+ }
408
+ syncContainerSize(e) {
409
+ e.hasWidthAttr ? this.container.style.width = `${e.width}px` : this.container.style.width = "100%", e.hasHeightAttr ? this.container.style.height = `${e.height}px` : this.container.style.height = "100%";
410
+ }
411
+ updateResizeObserver(e) {
412
+ (!e.hasWidthAttr || !e.hasHeightAttr) && "ResizeObserver" in window ? (this.resizeObserver?.disconnect(), this.resizeObserver = new ResizeObserver(() => {
317
413
  if (this.hasAttribute("width") && this.hasAttribute("height")) {
318
414
  this.resizeObserver?.disconnect(), this.resizeObserver = void 0;
319
415
  return;
320
416
  }
321
- const c = this.getBoundingClientRect(), g = Math.round(c.width), S = Math.round(c.height);
322
- this.instance?.resize(g, S);
323
- const x = t(
417
+ const n = this.getBoundingClientRect(), r = Math.round(n.width), a = Math.round(n.height);
418
+ this.instance?.resize(r, a);
419
+ const c = t(
324
420
  this.getAttribute("brush-size"),
325
- g,
326
- S,
327
- u.brushSize
421
+ r,
422
+ a,
423
+ d.brushSize
328
424
  );
329
- this.instance?.setBrushSize(x);
425
+ this.instance?.setBrushSize(c);
330
426
  }), this.resizeObserver.observe(this)) : (this.resizeObserver?.disconnect(), this.resizeObserver = void 0);
331
427
  }
332
- renderError(i) {
333
- this.instance?.destroy(), this.instance = void 0, this.lastErrorMessage !== i && (this.lastErrorMessage = i, this.dispatchEvent(
334
- new CustomEvent("error", {
335
- detail: { message: i },
336
- bubbles: !0,
337
- composed: !0
338
- })
339
- ));
340
- }
341
428
  }
342
- customElements.define(r, e);
429
+ customElements.define(o, s);
343
430
  }
344
- function T(r) {
345
- P(), r.config.globalProperties.$scratchReveal = !0;
431
+ function z(o) {
432
+ E(), o.config.globalProperties.$scratchReveal = !0;
346
433
  }
347
434
  export {
348
- T as installScratchReveal,
349
- P as registerScratchRevealElement,
350
- I as scratchRevealCssText
435
+ z as installScratchReveal,
436
+ E as registerScratchRevealElement,
437
+ y as scratchRevealCssText
351
438
  };
package/dist/index.umd.js CHANGED
@@ -1,4 +1,4 @@
1
- (function(o,l){typeof exports=="object"&&typeof module<"u"?l(exports):typeof define=="function"&&define.amd?define(["exports"],l):(o=typeof globalThis<"u"?globalThis:o||self,l(o.ScratchReveal={}))})(this,(function(o){"use strict";class l{ctx;mouseX;mouseY;constructor(t,e,s){this.ctx=t,this.mouseX=e,this.mouseY=s}updateMousePosition(t,e){this.mouseX=t,this.mouseY=e}brush(t,e=0){if(!t)return;const s=Math.atan2(this.mouseY,this.mouseX);if(this.ctx.save(),this.ctx.translate(this.mouseX,this.mouseY),this.ctx.rotate(s),e>0){const i=e,h=e*(t.height/t.width);this.ctx.drawImage(t,-(i/2),-(h/2),i,h)}else this.ctx.drawImage(t,-(t.width/2),-(t.height/2));this.ctx.restore()}}function b(r){return new Promise((t,e)=>{const s=new Image;s.crossOrigin="anonymous",s.onload=()=>t(s),s.onerror=()=>e(new Error(`Image ${r} failed to load`)),s.src=r})}function S(r){let t=0;return((...s)=>{t||(t=requestAnimationFrame(()=>{t=0,r(...s)}))})}const p=`.sr {
1
+ (function(u,p){typeof exports=="object"&&typeof module<"u"?p(exports):typeof define=="function"&&define.amd?define(["exports"],p):(u=typeof globalThis<"u"?globalThis:u||self,p(u.ScratchReveal={}))})(this,(function(u){"use strict";class p{ctx;mouseX;mouseY;constructor(t,s,i){this.ctx=t,this.mouseX=s,this.mouseY=i}updateMousePosition(t,s){this.mouseX=t,this.mouseY=s}brush(t,s=0){if(!t)return;const i=Math.atan2(this.mouseY,this.mouseX);if(this.ctx.save(),this.ctx.translate(this.mouseX,this.mouseY),this.ctx.rotate(i),s>0){const e=s,h=s*(t.height/t.width);this.ctx.drawImage(t,-(e/2),-(h/2),e,h)}else this.ctx.drawImage(t,-(t.width/2),-(t.height/2));this.ctx.restore()}}function b(o){return new Promise((t,s)=>{const i=new Image;i.crossOrigin="anonymous",i.onload=()=>t(i),i.onerror=()=>s(new Error(`Image ${o} failed to load`)),i.src=o})}function f(o){let t=0;return((...i)=>{t||(t=requestAnimationFrame(()=>{t=0,o(...i)}))})}const m=`.sr {
2
2
  position: relative;
3
3
  overflow: hidden;
4
4
  width: 100%;
@@ -28,9 +28,34 @@
28
28
  -webkit-tap-highlight-color: transparent;
29
29
  }
30
30
 
31
+ .sr__status {
32
+ position: absolute;
33
+ inset: 0;
34
+ display: flex;
35
+ align-items: center;
36
+ justify-content: center;
37
+ padding: 12px;
38
+ text-align: center;
39
+ font-family:
40
+ system-ui,
41
+ -apple-system,
42
+ Segoe UI,
43
+ Roboto,
44
+ sans-serif;
45
+ font-size: 12px;
46
+ line-height: 1.4;
47
+ color: #4b5563;
48
+ background: rgba(248, 250, 252, 0.9);
49
+ }
50
+
51
+ .sr__status--error {
52
+ color: #991b1b;
53
+ background: rgba(254, 226, 226, 0.95);
54
+ }
55
+
31
56
  scratch-reveal {
32
57
  display: inline-block;
33
58
  width: 100%;
34
59
  height: 100%;
35
60
  }
36
- `,C=p;function R(r){return"adoptedStyleSheets"in r}function M(r){if(typeof CSSStyleSheet>"u")return null;try{const t=new CSSStyleSheet;return t.replaceSync(r),t}catch{return null}}const w=M(p),d={width:300,height:300,brushSrc:"",imageMaskSrc:"",imageBackgroundSrc:"",brushSize:0,percentToFinish:60};class z{config;ctx;container;_canvas;brush;maskImage;backgroundImage;brushImage;backgroundEl;brushSize=0;percent=0;done=!1;completing=!1;destroyed=!1;lastProgressEmitMs=0;removeListeners;get canvas(){return this._canvas}constructor(t,e={}){if(this.config={...d,...e},this.container=typeof t=="string"?document.querySelector(t):t,!this.container)throw new Error("ScratchReveal: container not found");this._canvas=this.createCanvas(this.config.width,this.config.height),this.ctx=this._canvas.getContext("2d",{willReadFrequently:!0}),this.brush=new l(this.ctx,0,0),this.brushSize=this.config.brushSize,this.container.appendChild(this._canvas)}async init(){if(!this.config.brushSrc)throw new Error('ScratchReveal: "brushSrc" is required');if(!this.config.imageMaskSrc)throw new Error('ScratchReveal: "imageMaskSrc" is required');if(!this.config.imageBackgroundSrc)throw new Error('ScratchReveal: "imageBackgroundSrc" is required');const[t,e,s]=await Promise.all([b(this.config.brushSrc),b(this.config.imageMaskSrc),b(this.config.imageBackgroundSrc)]);return this.destroyed?this:(this.brushImage=t,this.maskImage=e,this.backgroundImage=s,this.drawMask(),this.setBackground(),this.bindEvents(),this)}destroy(){this.destroyed=!0,this.removeListeners?.()}getPercent(){return this.percent}createCanvas(t,e){const s=document.createElement("canvas");return s.className="sr__canvas",s.width=t,s.height=e,s.style.width="100%",s.style.height="100%",s}resize(t,e){this.destroyed||this.done||t<=0||e<=0||this._canvas.width===t&&this._canvas.height===e||(this._canvas.width=t,this._canvas.height=e,this.percent=0,this.ctx.globalCompositeOperation="source-over",this.drawMask())}setBrushSize(t){this.destroyed||!Number.isFinite(t)||t<0||(this.brushSize=t)}bindEvents(){const e=S((n,a)=>{this.percent=this.updatePercent();const u=performance.now();(!this.lastProgressEmitMs||u-this.lastProgressEmitMs>=120)&&(this.lastProgressEmitMs=u,this.config.onProgress?.(this.percent)),this.finish(n,a)}),s=S(n=>{(n.buttons&1)!==0&&(this.updatePosition(n),this.scratch(),e(n,s))}),i=n=>{try{this._canvas.releasePointerCapture(n.pointerId)}catch{}this._canvas.removeEventListener("pointermove",s),this.percent=this.updatePercent(),this.config.onProgress?.(this.percent),this.finish(n,s)},h=n=>{n.preventDefault(),this.updatePosition(n),this.scratch(),this._canvas.setPointerCapture(n.pointerId),this._canvas.addEventListener("pointermove",s),this.percent=this.updatePercent(),this.config.onProgress?.(this.percent),this.finish(n,s)};this._canvas.addEventListener("pointerdown",h),this._canvas.addEventListener("pointerup",i),this._canvas.addEventListener("pointercancel",i),this.removeListeners=()=>{this._canvas.removeEventListener("pointerdown",h),this._canvas.removeEventListener("pointerup",i),this._canvas.removeEventListener("pointercancel",i),this._canvas.removeEventListener("pointermove",s)}}updatePosition(t){const e=this._canvas.getBoundingClientRect(),s=e.width?this._canvas.width/e.width:1,i=e.height?this._canvas.height/e.height:1,h=(t.clientX-e.left)*s,n=(t.clientY-e.top)*i;this.brush.updateMousePosition(h,n)}drawMask(){this.maskImage&&(this.ctx.globalCompositeOperation="source-over",this.ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this.ctx.drawImage(this.maskImage,0,0,this._canvas.width,this._canvas.height))}setBackground(){if(this.destroyed||!this.backgroundImage||!this.container.contains(this._canvas))return;const t=this.backgroundEl??document.createElement("img");t.src=this.backgroundImage.src,t.className="sr__bg",t.isConnected||this.container.insertBefore(t,this._canvas),this.backgroundEl=t}scratch(){if(!this.brushImage)return;const t=this.brushSize>0?this.brushSize:0;this.ctx.globalCompositeOperation="destination-out",this.ctx.save(),this.brush.brush(this.brushImage,t),this.ctx.restore()}updatePercent(){const e=this.ctx.getImageData(0,0,this._canvas.width,this._canvas.height).data;let s=0;for(let i=3;i<e.length;i+=4)e[i]===0&&s++;return s/(this._canvas.width*this._canvas.height)*100}finish(t,e){if(!this.done&&this.percent>this.config.percentToFinish&&(this.done=!0,this._canvas.style.pointerEvents="none",this.config.onComplete?.(),this.playCompleteEffect(),t&&e)){try{this._canvas.releasePointerCapture(t.pointerId)}catch{}this._canvas.removeEventListener("pointermove",e)}}playCompleteEffect(){if(this.destroyed||this.completing)return;this.completing=!0;const t=350,e="ease-out";this._canvas.style.transition=`opacity ${t}ms ${e}`,this._canvas.style.opacity||(this._canvas.style.opacity="1"),requestAnimationFrame(()=>{this._canvas.style.opacity="0",window.setTimeout(()=>{this.destroyed||this.clear()},t)})}clear(){this.ctx.clearRect(0,0,this._canvas.width,this._canvas.height)}}function E(r="scratch-reveal"){if(typeof window>"u"||!("customElements"in window)||customElements.get(r))return;function t(s,i,h,n){if(!s)return n;const a=s.trim();if(!a)return n;const u=Number.parseFloat(a.endsWith("%")?a.slice(0,-1):a.endsWith("px")?a.slice(0,-2):a);if(!Number.isFinite(u))return n;if(a.endsWith("px"))return Math.max(0,u);const g=Math.min(i,h);return Math.max(0,g*u/100)}class e extends HTMLElement{instance;container;styleEl=null;rebuildScheduled=!1;resizeObserver;lastErrorMessage=null;static get observedAttributes(){return["width","height","complete-percent","brush-src","brush-size","mask-src","background-src"]}constructor(){super();const i=this.attachShadow({mode:"open"});R(i)&&w?i.adoptedStyleSheets=[w]:(this.styleEl=document.createElement("style"),this.styleEl.textContent=p,i.append(this.styleEl)),this.container=document.createElement("div"),this.container.className="sr",i.append(this.container)}connectedCallback(){this.scheduleRebuild()}disconnectedCallback(){this.instance?.destroy(),this.instance=void 0,this.resizeObserver?.disconnect(),this.resizeObserver=void 0}attributeChangedCallback(i,h,n){h!==n&&this.scheduleRebuild()}scheduleRebuild(){this.rebuildScheduled||(this.rebuildScheduled=!0,queueMicrotask(()=>{this.rebuildScheduled=!1,this.isConnected&&this.rebuild()}))}rebuild(){this.container.replaceChildren(),this.instance?.destroy();const i=this.hasAttribute("width"),h=this.hasAttribute("height"),n=this.getBoundingClientRect(),a=Math.round(n.width),u=Math.round(n.height),g=i?Number(this.getAttribute("width")):a||d.width,f=h?Number(this.getAttribute("height")):u||d.height,A=Number(this.getAttribute("complete-percent")??d.percentToFinish),_=(this.getAttribute("brush-src")??"").trim(),I=t(this.getAttribute("brush-size"),g,f,d.brushSize),y=(this.getAttribute("mask-src")??"").trim(),k=(this.getAttribute("background-src")??"").trim(),m=[];if(_||m.push("brush-src"),y||m.push("mask-src"),k||m.push("background-src"),m.length){const c=`ScratchReveal: missing required attribute(s): ${m.join(", ")}`;this.renderError(c);return}i?this.container.style.width=`${g}px`:this.container.style.width="100%",h?this.container.style.height=`${f}px`:this.container.style.height="100%",this.instance=new z(this.container,{width:g,height:f,percentToFinish:A,brushSrc:_,brushSize:I,imageMaskSrc:y,imageBackgroundSrc:k,onProgress:c=>{this.dispatchEvent(new CustomEvent("progress",{detail:{percent:c},bubbles:!0,composed:!0}))},onComplete:()=>{this.dispatchEvent(new CustomEvent("complete",{detail:{percent:100},bubbles:!0,composed:!0}))}}),this.instance.init().catch(c=>{const v=c instanceof Error?c.message:"ScratchReveal: init failed";this.renderError(v)}),(!i||!h)&&"ResizeObserver"in window?(this.resizeObserver?.disconnect(),this.resizeObserver=new ResizeObserver(()=>{if(this.hasAttribute("width")&&this.hasAttribute("height")){this.resizeObserver?.disconnect(),this.resizeObserver=void 0;return}const c=this.getBoundingClientRect(),v=Math.round(c.width),x=Math.round(c.height);this.instance?.resize(v,x);const T=t(this.getAttribute("brush-size"),v,x,d.brushSize);this.instance?.setBrushSize(T)}),this.resizeObserver.observe(this)):(this.resizeObserver?.disconnect(),this.resizeObserver=void 0)}renderError(i){this.instance?.destroy(),this.instance=void 0,this.lastErrorMessage!==i&&(this.lastErrorMessage=i,this.dispatchEvent(new CustomEvent("error",{detail:{message:i},bubbles:!0,composed:!0})))}}customElements.define(r,e)}function P(r){E(),r.config.globalProperties.$scratchReveal=!0}o.installScratchReveal=P,o.registerScratchRevealElement=E,o.scratchRevealCssText=C,Object.defineProperty(o,Symbol.toStringTag,{value:"Module"})}));
61
+ `,_=m;function y(o){return"adoptedStyleSheets"in o}function E(o){if(typeof CSSStyleSheet>"u")return null;try{const t=new CSSStyleSheet;return t.replaceSync(o),t}catch{return null}}const S=E(m),d={width:300,height:300,brushSrc:"",imageMaskSrc:"",imageBackgroundSrc:"",brushSize:0,percentToFinish:60};class z{config;ctx;container;_canvas;dpr=1;brush;maskImage;backgroundImage;brushImage;backgroundEl;brushSize=0;percent=0;done=!1;completing=!1;destroyed=!1;lastProgressEmitMs=0;removeListeners;get canvas(){return this._canvas}constructor(t,s={}){if(this.config={...d,...s},this.container=typeof t=="string"?document.querySelector(t):t,!this.container)throw new Error("ScratchReveal: container not found");this._canvas=this.createCanvas(this.config.width,this.config.height),this.ctx=this._canvas.getContext("2d",{willReadFrequently:!0}),this.setCanvasScale(this.config.width,this.config.height),this.brush=new p(this.ctx,0,0),this.brushSize=this.config.brushSize,this.container.appendChild(this._canvas)}async init(){if(!this.config.brushSrc)throw new Error('ScratchReveal: "brushSrc" is required');if(!this.config.imageMaskSrc)throw new Error('ScratchReveal: "imageMaskSrc" is required');if(!this.config.imageBackgroundSrc)throw new Error('ScratchReveal: "imageBackgroundSrc" is required');const[t,s,i]=await Promise.all([b(this.config.brushSrc),b(this.config.imageMaskSrc),b(this.config.imageBackgroundSrc)]);return this.destroyed?this:(this.brushImage=t,this.maskImage=s,this.backgroundImage=i,this.drawMask(),this.setBackground(),this.bindEvents(),this)}destroy(){this.destroyed=!0,this.removeListeners?.()}getPercent(){return this.percent}createCanvas(t,s){const i=document.createElement("canvas");return i.className="sr__canvas",i.style.width="100%",i.style.height="100%",i}setCanvasScale(t,s){const i=Math.max(1,Math.min(window.devicePixelRatio||1,3));this.dpr=i,this._canvas.width=Math.round(t*this.dpr),this._canvas.height=Math.round(s*this.dpr),this._canvas.style.width=`${t}px`,this._canvas.style.height=`${s}px`,this.ctx.setTransform(this.dpr,0,0,this.dpr,0,0)}resize(t,s){this.destroyed||this.done||t<=0||s<=0||this._canvas.width===t*this.dpr&&this._canvas.height===s*this.dpr||(this.setCanvasScale(t,s),this.percent=0,this.ctx.globalCompositeOperation="source-over",this.drawMask())}setBrushSize(t){this.destroyed||!Number.isFinite(t)||t<0||(this.brushSize=t)}bindEvents(){const s=f((n,r)=>{this.percent=this.updatePercent();const a=performance.now();(!this.lastProgressEmitMs||a-this.lastProgressEmitMs>=120)&&(this.lastProgressEmitMs=a,this.config.onProgress?.(this.percent)),this.finish(n,r)}),i=f(n=>{(n.buttons&1)!==0&&(this.updatePosition(n),this.scratch(),s(n,i))}),e=n=>{try{this._canvas.releasePointerCapture(n.pointerId)}catch{}this._canvas.removeEventListener("pointermove",i),this.percent=this.updatePercent(),this.config.onProgress?.(this.percent),this.finish(n,i)},h=n=>{n.preventDefault(),this.updatePosition(n),this.scratch(),this._canvas.setPointerCapture(n.pointerId),this._canvas.addEventListener("pointermove",i),this.percent=this.updatePercent(),this.config.onProgress?.(this.percent),this.finish(n,i)};this._canvas.addEventListener("pointerdown",h),this._canvas.addEventListener("pointerup",e),this._canvas.addEventListener("pointercancel",e),this.removeListeners=()=>{this._canvas.removeEventListener("pointerdown",h),this._canvas.removeEventListener("pointerup",e),this._canvas.removeEventListener("pointercancel",e),this._canvas.removeEventListener("pointermove",i)}}updatePosition(t){const s=this._canvas.getBoundingClientRect(),i=s.width?this._canvas.width/s.width:1,e=s.height?this._canvas.height/s.height:1,h=(t.clientX-s.left)*i/this.dpr,n=(t.clientY-s.top)*e/this.dpr;this.brush.updateMousePosition(h,n)}drawMask(){this.maskImage&&(this.ctx.globalCompositeOperation="source-over",this.ctx.clearRect(0,0,this._canvas.width/this.dpr,this._canvas.height/this.dpr),this.ctx.drawImage(this.maskImage,0,0,this._canvas.width/this.dpr,this._canvas.height/this.dpr))}setBackground(){if(this.destroyed||!this.backgroundImage||!this.container.contains(this._canvas))return;const t=this.backgroundEl??document.createElement("img");t.src=this.backgroundImage.src,t.className="sr__bg",t.isConnected||this.container.insertBefore(t,this._canvas),this.backgroundEl=t}scratch(){if(!this.brushImage)return;const t=this.brushSize>0?this.brushSize:0;this.ctx.globalCompositeOperation="destination-out",this.ctx.save(),this.brush.brush(this.brushImage,t),this.ctx.restore()}updatePercent(){const s=this.ctx.getImageData(0,0,this._canvas.width,this._canvas.height).data;let i=0;for(let e=3;e<s.length;e+=4)s[e]===0&&i++;return i/(this._canvas.width*this._canvas.height)*100}finish(t,s){if(!this.done&&this.percent>this.config.percentToFinish&&(this.done=!0,this._canvas.style.pointerEvents="none",this.config.onComplete?.(),this.playCompleteEffect(),t&&s)){try{this._canvas.releasePointerCapture(t.pointerId)}catch{}this._canvas.removeEventListener("pointermove",s)}}playCompleteEffect(){if(this.destroyed||this.completing)return;this.completing=!0;const t=350,s="ease-out";this._canvas.style.transition=`opacity ${t}ms ${s}`,this._canvas.style.opacity||(this._canvas.style.opacity="1"),requestAnimationFrame(()=>{this._canvas.style.opacity="0",window.setTimeout(()=>{this.destroyed||this.clear()},t)})}clear(){this.ctx.clearRect(0,0,this._canvas.width/this.dpr,this._canvas.height/this.dpr)}}function w(o="scratch-reveal"){if(typeof window>"u"||!("customElements"in window)||customElements.get(o))return;function t(i,e,h,n){if(!i)return n;const r=i.trim();if(!r)return n;const a=Number.parseFloat(r.endsWith("%")?r.slice(0,-1):r.endsWith("px")?r.slice(0,-2):r);if(!Number.isFinite(a))return n;if(r.endsWith("px"))return Math.max(0,a);const c=Math.min(e,h);return Math.max(0,c*a/100)}class s extends HTMLElement{instance;container;styleEl=null;rebuildScheduled=!1;resizeObserver;lastErrorMessage=null;static get observedAttributes(){return["width","height","complete-percent","brush-src","brush-size","mask-src","background-src","show-status"]}constructor(){super();const e=this.attachShadow({mode:"open"});y(e)&&S?e.adoptedStyleSheets=[S]:(this.styleEl=document.createElement("style"),this.styleEl.textContent=m,e.append(this.styleEl)),this.container=document.createElement("div"),this.container.className="sr",e.append(this.container)}connectedCallback(){this.scheduleRebuild()}disconnectedCallback(){this.instance?.destroy(),this.instance=void 0,this.resizeObserver?.disconnect(),this.resizeObserver=void 0}attributeChangedCallback(e,h,n){if(h!==n){if(e==="brush-size"&&this.instance){const{width:r,height:a}=this.resolveSize(),c=t(this.getAttribute("brush-size"),r,a,d.brushSize);this.instance.setBrushSize(c);return}if((e==="width"||e==="height")&&this.instance){const r=this.resolveSize();this.syncContainerSize(r),this.instance.resize(r.width,r.height);const a=t(this.getAttribute("brush-size"),r.width,r.height,d.brushSize);this.instance.setBrushSize(a),this.updateResizeObserver(r);return}this.scheduleRebuild()}}scheduleRebuild(){this.rebuildScheduled||(this.rebuildScheduled=!0,queueMicrotask(()=>{this.rebuildScheduled=!1,this.isConnected&&this.rebuild()}))}rebuild(){const e=this.resolveSize(),h=this.resolvePercentToFinish(),{brushSrc:n,imageMaskSrc:r,imageBackgroundSrc:a}=this.resolveSources(),c=t(this.getAttribute("brush-size"),e.width,e.height,d.brushSize),l=[];if(n||l.push("brush-src"),r||l.push("mask-src"),a||l.push("background-src"),this.syncContainerSize(e),l.length){this.renderWaiting(l),this.updateResizeObserver(e);return}this.container.replaceChildren(),this.instance?.destroy(),this.instance=void 0,this.lastErrorMessage=null,this.instance=new z(this.container,{width:e.width,height:e.height,percentToFinish:h,brushSrc:n,brushSize:c,imageMaskSrc:r,imageBackgroundSrc:a,onProgress:g=>{this.dispatchEvent(new CustomEvent("progress",{detail:{percent:g},bubbles:!0,composed:!0}))},onComplete:()=>{this.dispatchEvent(new CustomEvent("complete",{detail:{percent:100},bubbles:!0,composed:!0}))}}),this.instance.init().catch(g=>{const v=g instanceof Error?g.message:"ScratchReveal: init failed";this.renderError(v)}),this.updateResizeObserver(e)}renderError(e){this.instance?.destroy(),this.instance=void 0,this.renderStatus(e,"error"),this.lastErrorMessage!==e&&(this.lastErrorMessage=e,this.dispatchEvent(new CustomEvent("error",{detail:{message:e},bubbles:!0,composed:!0})))}renderWaiting(e){const h=`ScratchReveal: waiting for required attribute(s): ${e.join(", ")}`;this.renderStatus(h,"waiting")}renderStatus(e,h){if(this.container.replaceChildren(),this.instance?.destroy(),this.instance=void 0,!this.hasAttribute("show-status"))return;const n=document.createElement("div");n.className=`sr__status sr__status--${h}`,n.textContent=e,n.setAttribute("role",h==="error"?"alert":"status"),this.container.appendChild(n)}resolveSize(){const e=this.hasAttribute("width"),h=this.hasAttribute("height"),n=this.getBoundingClientRect(),r=Math.round(n.width),a=Math.round(n.height),c=Number(this.getAttribute("width")),l=Number(this.getAttribute("height")),g=e&&Number.isFinite(c)?c:r||d.width,v=h&&Number.isFinite(l)?l:a||d.height;return{hasWidthAttr:e,hasHeightAttr:h,width:g,height:v}}resolvePercentToFinish(){const e=Number(this.getAttribute("complete-percent"));return Number.isFinite(e)?e:d.percentToFinish}resolveSources(){return{brushSrc:(this.getAttribute("brush-src")??"").trim(),imageMaskSrc:(this.getAttribute("mask-src")??"").trim(),imageBackgroundSrc:(this.getAttribute("background-src")??"").trim()}}syncContainerSize(e){e.hasWidthAttr?this.container.style.width=`${e.width}px`:this.container.style.width="100%",e.hasHeightAttr?this.container.style.height=`${e.height}px`:this.container.style.height="100%"}updateResizeObserver(e){(!e.hasWidthAttr||!e.hasHeightAttr)&&"ResizeObserver"in window?(this.resizeObserver?.disconnect(),this.resizeObserver=new ResizeObserver(()=>{if(this.hasAttribute("width")&&this.hasAttribute("height")){this.resizeObserver?.disconnect(),this.resizeObserver=void 0;return}const n=this.getBoundingClientRect(),r=Math.round(n.width),a=Math.round(n.height);this.instance?.resize(r,a);const c=t(this.getAttribute("brush-size"),r,a,d.brushSize);this.instance?.setBrushSize(c)}),this.resizeObserver.observe(this)):(this.resizeObserver?.disconnect(),this.resizeObserver=void 0)}}customElements.define(o,s)}function k(o){w(),o.config.globalProperties.$scratchReveal=!0}u.installScratchReveal=k,u.registerScratchRevealElement=w,u.scratchRevealCssText=_,Object.defineProperty(u,Symbol.toStringTag,{value:"Module"})}));
@@ -1 +1 @@
1
- .sr{width:100%;height:100%;position:relative;overflow:hidden}.sr__bg{object-fit:cover;-webkit-user-select:none;user-select:none;-webkit-user-drag:none;pointer-events:none;width:100%;height:100%;display:block;position:relative}.sr__canvas{touch-action:none;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;width:100%;height:100%;position:absolute;inset:0}scratch-reveal{width:100%;height:100%;display:inline-block}
1
+ .sr{width:100%;height:100%;position:relative;overflow:hidden}.sr__bg{object-fit:cover;-webkit-user-select:none;user-select:none;-webkit-user-drag:none;pointer-events:none;width:100%;height:100%;display:block;position:relative}.sr__canvas{touch-action:none;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;width:100%;height:100%;position:absolute;inset:0}.sr__status{text-align:center;color:#4b5563;background:#f8fafce6;justify-content:center;align-items:center;padding:12px;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:12px;line-height:1.4;display:flex;position:absolute;inset:0}.sr__status--error{color:#991b1b;background:#fee2e2f2}scratch-reveal{width:100%;height:100%;display:inline-block}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scratch-reveal",
3
- "version": "1.2.3",
3
+ "version": "1.3.0-dev.1",
4
4
  "description": "Scratch & reveal Web Component: mask + background with brush-only reveal, Vue-friendly, shadow-styled.",
5
5
  "author": "ux-ui.pro",
6
6
  "license": "MIT",
@@ -17,7 +17,6 @@
17
17
  "clean": "rimraf dist",
18
18
  "build": "vite build",
19
19
  "dev": "vite",
20
- "gen:brush:spray": "node scripts/gen-spray-brush.mjs",
21
20
  "verify": "yarn lint && yarn typecheck",
22
21
  "lint": "biome check src",
23
22
  "lint:fix": "biome check --write src",