scratch-reveal 1.0.0 → 1.1.0

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
@@ -37,9 +37,9 @@ app.mount('#app');
37
37
  <scratch-reveal
38
38
  width="300"
39
39
  height="300"
40
- percent-to-finish="60"
40
+ complete-percent="60"
41
41
  brush-src="/demo/assets/brush.png"
42
- brush-size="15%"
42
+ brush-size="15"
43
43
  mask-src="/demo/assets/scratch-reveal.png"
44
44
  background-src="/demo/assets/scratch-reveal-background.svg"
45
45
  ></scratch-reveal>
@@ -51,9 +51,9 @@ app.mount('#app');
51
51
  <div style="width: 420px; height: 240px;">
52
52
  <scratch-reveal
53
53
  style="width: 100%; height: 100%;"
54
- percent-to-finish="60"
54
+ complete-percent="60"
55
55
  brush-src="/demo/assets/brush.png"
56
- brush-size="12%"
56
+ brush-size="12"
57
57
  mask-src="/demo/assets/scratch-reveal.png"
58
58
  background-src="/demo/assets/scratch-reveal-background.svg"
59
59
  ></scratch-reveal>
@@ -72,9 +72,13 @@ el.addEventListener('progress', (event) => {
72
72
  el.addEventListener('complete', () => {
73
73
  console.log('done!');
74
74
  });
75
+ el.addEventListener('error', (event) => {
76
+ console.error(event.detail.message);
77
+ });
75
78
  ```
76
79
  — `progress` (detail: `{ percent: number }`)
77
- — `complete` (detail: `{ percent: 100 }`)
80
+ — `complete` (detail: `{ percent: 100 }`)
81
+ — `error` (detail: `{ message: string }`)
78
82
 
79
83
  <br>
80
84
 
@@ -83,12 +87,11 @@ el.addEventListener('complete', () => {
83
87
  | Attribute | Type | Default | Description |
84
88
  |:------------------------:|:------------------:|:--------------------------------------------:|:---------------------------------------------------------------------------------------------------------|
85
89
  | `width` / `height` | `number` | `300` | Container/mask size in px. If omitted, size follows layout (auto-size). |
86
- | `percent-to-finish` | `number` | `60` | Percent cleared to consider done. |
87
- | `brush-src` | `string` | `/demo/assets/brush.png` | Brush image. |
88
- | `brush-size` | `string \| number` | `0` | Brush width: `80`/`80px` (px) or `12%` (percent of min(canvas width, height)). `0` = natural image size. |
89
- | `mask-src` | `string` | `/demo/assets/scratch-reveal.png` | Top mask (scratched away). |
90
- | `background-src` | `string` | `/demo/assets/scratch-reveal-background.svg` | Background beneath the mask. |
91
- | `enabled-percent-update` | `boolean` | `true` | Compute cleared percent (used for `progress` and threshold checks). |
90
+ | `complete-percent` | `number` | `60` | Percent cleared to consider done. |
91
+ | `brush-src` | `string` || Brush image (**required**). |
92
+ | `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. |
93
+ | `mask-src` | `string` || Top mask (scratched away) (**required**). |
94
+ | `background-src` | `string` || Background beneath the mask (**required**). |
92
95
  <br>
93
96
 
94
97
  ➠ **Styles**
package/dist/index.cjs.js CHANGED
@@ -1,4 +1,4 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});class z{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){const i=new Error("Brush.brush: img is required");console.log(i.message);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,n=e*(t.height/t.width);this.ctx.drawImage(t,-(i/2),-(n/2),i,n)}else this.ctx.drawImage(t,-(t.width/2),-(t.height/2));this.ctx.restore()}}function b(h){return new Promise((t,e)=>{const s=new Image;s.crossOrigin="anonymous",s.onload=()=>t(s),s.onerror=()=>e(new Error(`Image ${h} failed to load`)),s.src=h})}function P(h){let t=0;return((...s)=>{t||(t=requestAnimationFrame(()=>{t=0,h(...s)}))})}const m=`.sr {
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});class z{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,n=e*(t.height/t.width);this.ctx.drawImage(t,-(i/2),-(n/2),i,n)}else this.ctx.drawImage(t,-(t.width/2),-(t.height/2));this.ctx.restore()}}function v(h){return new Promise((t,e)=>{const s=new Image;s.crossOrigin="anonymous",s.onload=()=>t(s),s.onerror=()=>e(new Error(`Image ${h} failed to load`)),s.src=h})}function E(h){let t=0;return((...s)=>{t||(t=requestAnimationFrame(()=>{t=0,h(...s)}))})}const b=`.sr {
2
2
  position: relative;
3
3
  overflow: hidden;
4
4
  width: 100%;
@@ -25,4 +25,4 @@ scratch-reveal {
25
25
  width: 100%;
26
26
  height: 100%;
27
27
  }
28
- `,A=m;function R(h){return"adoptedStyleSheets"in h}function I(h){if(typeof CSSStyleSheet>"u")return null;try{const t=new CSSStyleSheet;return t.replaceSync(h),t}catch{return null}}const f=I(m),a={width:300,height:300,brushSrc:"/demo/assets/brush.png",imageMaskSrc:"/demo/assets/scratch-reveal.png",imageBackgroundSrc:"/demo/assets/scratch-reveal-background.svg",brushSize:0,percentToFinish:60,enabledPercentUpdate:!0};class M{config;ctx;container;_canvas;brush;maskImage;backgroundImage;brushImage;backgroundEl;brushSize=0;percent=0;done=!1;destroyed=!1;removeListeners;get canvas(){return this._canvas}constructor(t,e={}){if(this.config={...a,...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 z(this.ctx,0,0),this.brushSize=this.config.brushSize,this.container.appendChild(this._canvas)}async init(){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 t=P(i=>{this.updatePosition(i),this.scratch(),this.config.enabledPercentUpdate&&(this.percent=this.updatePercent(),this.config.onProgress?.(this.percent)),this.finish(i,t)}),e=i=>{i.preventDefault(),this.updatePosition(i),this.scratch(),this._canvas.setPointerCapture(i.pointerId),this._canvas.addEventListener("pointermove",t),this.config.enabledPercentUpdate&&(this.percent=this.updatePercent(),this.config.onProgress?.(this.percent)),this.finish(i,t)},s=i=>{this.finish(i,t)};this._canvas.addEventListener("pointerdown",e),this._canvas.addEventListener("pointerup",s),this._canvas.addEventListener("pointerleave",s),this.removeListeners=()=>{this._canvas.removeEventListener("pointerdown",e),this._canvas.removeEventListener("pointerup",s),this._canvas.removeEventListener("pointerleave",s),this._canvas.removeEventListener("pointermove",t)}}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,n=(t.clientX-e.left)*s,r=(t.clientY-e.top)*i;this.brush.updateMousePosition(n,r)}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(){this.brushImage&&(this.ctx.globalCompositeOperation="destination-out",this.ctx.save(),this.brush.brush(this.brushImage,this.brushSize),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.clear(),this._canvas.style.pointerEvents="none",this.config.onComplete?.(),t&&e)){try{this._canvas.releasePointerCapture(t.pointerId)}catch{}this._canvas.removeEventListener("pointermove",e)}}clear(){this.ctx.clearRect(0,0,this._canvas.width,this._canvas.height)}}function S(h="scratch-reveal"){if(typeof window>"u"||!("customElements"in window)||customElements.get(h))return;function t(i,n){if(i===null)return n;const r=i.trim().toLowerCase();return r===""?!0:r==="false"||r==="0"||r==="no"||r==="off"?!1:r==="true"||r==="1"||r==="yes"||r==="on"?!0:n}function e(i,n,r,c){if(!i)return c;const o=i.trim();if(!o)return c;if(o.endsWith("%")){const u=Number.parseFloat(o.slice(0,-1));if(!Number.isFinite(u))return c;const d=Math.min(n,r);return Math.max(0,d*u/100)}const l=o.endsWith("px")?Number.parseFloat(o.slice(0,-2)):Number.parseFloat(o);return Number.isFinite(l)?Math.max(0,l):c}class s extends HTMLElement{instance;container;styleEl=null;rebuildScheduled=!1;resizeObserver;static get observedAttributes(){return["width","height","percent-to-finish","brush-src","brush-size","mask-src","background-src","enabled-percent-update"]}constructor(){super();const n=this.attachShadow({mode:"open"});R(n)&&f?n.adoptedStyleSheets=[f]:(this.styleEl=document.createElement("style"),this.styleEl.textContent=m,n.append(this.styleEl)),this.container=document.createElement("div"),this.container.className="sr",n.append(this.container)}connectedCallback(){this.scheduleRebuild()}disconnectedCallback(){this.instance?.destroy(),this.instance=void 0,this.resizeObserver?.disconnect(),this.resizeObserver=void 0}attributeChangedCallback(n,r,c){r!==c&&this.scheduleRebuild()}scheduleRebuild(){this.rebuildScheduled||(this.rebuildScheduled=!0,queueMicrotask(()=>{this.rebuildScheduled=!1,this.isConnected&&this.rebuild()}))}rebuild(){this.container.replaceChildren(),this.instance?.destroy();const n=this.hasAttribute("width"),r=this.hasAttribute("height"),c=this.getBoundingClientRect(),o=Math.round(c.width),l=Math.round(c.height),u=n?Number(this.getAttribute("width")):o||a.width,d=r?Number(this.getAttribute("height")):l||a.height,w=Number(this.getAttribute("percent-to-finish")??a.percentToFinish),_=this.getAttribute("brush-src")??a.brushSrc,E=t(this.getAttribute("enabled-percent-update"),a.enabledPercentUpdate),y=e(this.getAttribute("brush-size"),u,d,a.brushSize),k=this.getAttribute("mask-src")??a.imageMaskSrc,x=this.getAttribute("background-src")??a.imageBackgroundSrc;n?this.container.style.width=`${u}px`:this.container.style.width="100%",r?this.container.style.height=`${d}px`:this.container.style.height="100%",this.instance=new M(this.container,{width:u,height:d,percentToFinish:w,brushSrc:_,brushSize:y,imageMaskSrc:k,imageBackgroundSrc:x,enabledPercentUpdate:E,onProgress:g=>{this.dispatchEvent(new CustomEvent("progress",{detail:{percent:g}}))},onComplete:()=>{this.dispatchEvent(new CustomEvent("complete",{detail:{percent:100}}))}}),this.instance.init(),(!n||!r)&&"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 g=this.getBoundingClientRect(),v=Math.round(g.width),p=Math.round(g.height);this.instance?.resize(v,p);const C=e(this.getAttribute("brush-size"),v,p,a.brushSize);this.instance?.setBrushSize(C)}),this.resizeObserver.observe(this)):(this.resizeObserver?.disconnect(),this.resizeObserver=void 0)}}customElements.define(h,s)}function L(h){S(),h.config.globalProperties.$scratchReveal=!0}exports.installScratchReveal=L;exports.registerScratchRevealElement=S;exports.scratchRevealCssText=A;
28
+ `,C=b;function R(h){return"adoptedStyleSheets"in h}function P(h){if(typeof CSSStyleSheet>"u")return null;try{const t=new CSSStyleSheet;return t.replaceSync(h),t}catch{return null}}const _=P(b),u={width:300,height:300,brushSrc:"",imageMaskSrc:"",imageBackgroundSrc:"",brushSize:0,percentToFinish:60};class I{config;ctx;container;_canvas;brush;maskImage;backgroundImage;brushImage;backgroundEl;brushSize=0;percent=0;done=!1;completing=!1;destroyed=!1;lastProgressEmitMs=0;lastScratchX;lastScratchY;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 z(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((r,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(r,a)}),s=E(r=>{this.updatePosition(r),this.scratch(),e(r,s)}),i=r=>{r.preventDefault(),this.updatePosition(r),this.scratch(),this._canvas.setPointerCapture(r.pointerId),this._canvas.addEventListener("pointermove",s),this.resetScratchTrail(),this.percent=this.updatePercent(),this.config.onProgress?.(this.percent),this.finish(r,s)},n=r=>{this.resetScratchTrail(),this.percent=this.updatePercent(),this.config.onProgress?.(this.percent),this.finish(r,s)};this._canvas.addEventListener("pointerdown",i),this._canvas.addEventListener("pointerup",n),this._canvas.addEventListener("pointerleave",n),this.removeListeners=()=>{this._canvas.removeEventListener("pointerdown",i),this._canvas.removeEventListener("pointerup",n),this._canvas.removeEventListener("pointerleave",n),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,n=(t.clientX-e.left)*s,r=(t.clientY-e.top)*i;this.brush.updateMousePosition(n,r)}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:Math.max(1,Math.min(this.brushImage.width,this.brushImage.height)),e=Math.max(2,t*.4);if(this.lastScratchX!==void 0&&this.lastScratchY!==void 0){const i=this.brush.mouseX-this.lastScratchX,n=this.brush.mouseY-this.lastScratchY;if(i*i+n*n<e*e)return}const s=this.brushSize>0?this.brushSize:0;this.ctx.globalCompositeOperation="destination-out",this.ctx.save(),this.brush.brush(this.brushImage,s),this.ctx.restore(),this.lastScratchX=this.brush.mouseX,this.lastScratchY=this.brush.mouseY}resetScratchTrail(){this.lastScratchX=void 0,this.lastScratchY=void 0}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(h="scratch-reveal"){if(typeof window>"u"||!("customElements"in window)||customElements.get(h))return;function t(s,i,n,r){if(!s)return r;const a=s.trim();if(!a)return r;const o=Number.parseFloat(a.endsWith("%")?a.slice(0,-1):a.endsWith("px")?a.slice(0,-2):a);if(!Number.isFinite(o))return r;if(a.endsWith("px"))return Math.max(0,o);const d=Math.min(i,n);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"});R(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,n,r){n!==r&&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"),n=this.hasAttribute("height"),r=this.getBoundingClientRect(),a=Math.round(r.width),o=Math.round(r.height),d=i?Number(this.getAttribute("width")):a||u.width,m=n?Number(this.getAttribute("height")):o||u.height,k=Number(this.getAttribute("complete-percent")??u.percentToFinish),f=(this.getAttribute("brush-src")??"").trim(),x=t(this.getAttribute("brush-size"),d,m,u.brushSize),p=(this.getAttribute("mask-src")??"").trim(),S=(this.getAttribute("background-src")??"").trim(),l=[];if(f||l.push("brush-src"),p||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%",n?this.container.style.height=`${m}px`:this.container.style.height="100%",this.instance=new I(this.container,{width:d,height:m,percentToFinish:k,brushSrc:f,brushSize:x,imageMaskSrc:p,imageBackgroundSrc:S,onProgress:c=>{this.dispatchEvent(new CustomEvent("progress",{detail:{percent:c}}))},onComplete:()=>{this.dispatchEvent(new CustomEvent("complete",{detail:{percent:100}}))}}),this.instance.init().catch(c=>{const g=c instanceof Error?c.message:"ScratchReveal: init failed";this.renderError(g)}),(!i||!n)&&"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 M=t(this.getAttribute("brush-size"),g,w,u.brushSize);this.instance?.setBrushSize(M)}),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}})))}}customElements.define(h,e)}function A(h){y(),h.config.globalProperties.$scratchReveal=!0}exports.installScratchReveal=A;exports.registerScratchRevealElement=y;exports.scratchRevealCssText=C;
package/dist/index.es.js CHANGED
@@ -1,4 +1,4 @@
1
- class C {
1
+ class M {
2
2
  ctx;
3
3
  mouseX;
4
4
  mouseY;
@@ -9,11 +9,8 @@ class C {
9
9
  this.mouseX = t, this.mouseY = e;
10
10
  }
11
11
  brush(t, e = 0) {
12
- if (!t) {
13
- const i = new Error("Brush.brush: img is required");
14
- console.log(i.message);
12
+ if (!t)
15
13
  return;
16
- }
17
14
  const s = Math.atan2(this.mouseY, this.mouseX);
18
15
  if (this.ctx.save(), this.ctx.translate(this.mouseX, this.mouseY), this.ctx.rotate(s), e > 0) {
19
16
  const i = e, n = e * (t.height / t.width);
@@ -23,13 +20,13 @@ class C {
23
20
  this.ctx.restore();
24
21
  }
25
22
  }
26
- function b(h) {
23
+ function v(h) {
27
24
  return new Promise((t, e) => {
28
25
  const s = new Image();
29
26
  s.crossOrigin = "anonymous", s.onload = () => t(s), s.onerror = () => e(new Error(`Image ${h} failed to load`)), s.src = h;
30
27
  });
31
28
  }
32
- function z(h) {
29
+ function E(h) {
33
30
  let t = 0;
34
31
  return ((...s) => {
35
32
  t || (t = requestAnimationFrame(() => {
@@ -37,7 +34,7 @@ function z(h) {
37
34
  }));
38
35
  });
39
36
  }
40
- const m = `.sr {
37
+ const b = `.sr {
41
38
  position: relative;
42
39
  overflow: hidden;
43
40
  width: 100%;
@@ -64,11 +61,11 @@ scratch-reveal {
64
61
  width: 100%;
65
62
  height: 100%;
66
63
  }
67
- `, L = m;
68
- function A(h) {
64
+ `, A = b;
65
+ function z(h) {
69
66
  return "adoptedStyleSheets" in h;
70
67
  }
71
- function P(h) {
68
+ function C(h) {
72
69
  if (typeof CSSStyleSheet > "u") return null;
73
70
  try {
74
71
  const t = new CSSStyleSheet();
@@ -77,17 +74,16 @@ function P(h) {
77
74
  return null;
78
75
  }
79
76
  }
80
- const f = P(m), a = {
77
+ const _ = C(b), u = {
81
78
  width: 300,
82
79
  height: 300,
83
- brushSrc: "/demo/assets/brush.png",
84
- imageMaskSrc: "/demo/assets/scratch-reveal.png",
85
- imageBackgroundSrc: "/demo/assets/scratch-reveal-background.svg",
80
+ brushSrc: "",
81
+ imageMaskSrc: "",
82
+ imageBackgroundSrc: "",
86
83
  brushSize: 0,
87
- percentToFinish: 60,
88
- enabledPercentUpdate: !0
84
+ percentToFinish: 60
89
85
  };
90
- class I {
86
+ class R {
91
87
  config;
92
88
  ctx;
93
89
  container;
@@ -100,23 +96,33 @@ class I {
100
96
  brushSize = 0;
101
97
  percent = 0;
102
98
  done = !1;
99
+ completing = !1;
103
100
  destroyed = !1;
101
+ lastProgressEmitMs = 0;
102
+ lastScratchX;
103
+ lastScratchY;
104
104
  removeListeners;
105
105
  get canvas() {
106
106
  return this._canvas;
107
107
  }
108
108
  constructor(t, e = {}) {
109
- if (this.config = { ...a, ...e }, this.container = typeof t == "string" ? document.querySelector(t) : t, !this.container)
109
+ if (this.config = { ...u, ...e }, this.container = typeof t == "string" ? document.querySelector(t) : t, !this.container)
110
110
  throw new Error("ScratchReveal: container not found");
111
111
  this._canvas = this.createCanvas(this.config.width, this.config.height), this.ctx = this._canvas.getContext("2d", {
112
112
  willReadFrequently: !0
113
- }), this.brush = new C(this.ctx, 0, 0), this.brushSize = this.config.brushSize, this.container.appendChild(this._canvas);
113
+ }), this.brush = new M(this.ctx, 0, 0), this.brushSize = this.config.brushSize, this.container.appendChild(this._canvas);
114
114
  }
115
115
  async init() {
116
+ if (!this.config.brushSrc)
117
+ throw new Error('ScratchReveal: "brushSrc" is required');
118
+ if (!this.config.imageMaskSrc)
119
+ throw new Error('ScratchReveal: "imageMaskSrc" is required');
120
+ if (!this.config.imageBackgroundSrc)
121
+ throw new Error('ScratchReveal: "imageBackgroundSrc" is required');
116
122
  const [t, e, s] = await Promise.all([
117
- b(this.config.brushSrc),
118
- b(this.config.imageMaskSrc),
119
- b(this.config.imageBackgroundSrc)
123
+ v(this.config.brushSrc),
124
+ v(this.config.imageMaskSrc),
125
+ v(this.config.imageBackgroundSrc)
120
126
  ]);
121
127
  return this.destroyed ? this : (this.brushImage = t, this.maskImage = e, this.backgroundImage = s, this.drawMask(), this.setBackground(), this.bindEvents(), this);
122
128
  }
@@ -137,15 +143,21 @@ class I {
137
143
  this.destroyed || !Number.isFinite(t) || t < 0 || (this.brushSize = t);
138
144
  }
139
145
  bindEvents() {
140
- const t = z((i) => {
141
- this.updatePosition(i), this.scratch(), this.config.enabledPercentUpdate && (this.percent = this.updatePercent(), this.config.onProgress?.(this.percent)), this.finish(i, t);
142
- }), e = (i) => {
143
- i.preventDefault(), this.updatePosition(i), this.scratch(), this._canvas.setPointerCapture(i.pointerId), this._canvas.addEventListener("pointermove", t), this.config.enabledPercentUpdate && (this.percent = this.updatePercent(), this.config.onProgress?.(this.percent)), this.finish(i, t);
144
- }, s = (i) => {
145
- this.finish(i, t);
146
+ const e = E(
147
+ (r, a) => {
148
+ this.percent = this.updatePercent();
149
+ const o = performance.now();
150
+ (!this.lastProgressEmitMs || o - this.lastProgressEmitMs >= 120) && (this.lastProgressEmitMs = o, this.config.onProgress?.(this.percent)), this.finish(r, a);
151
+ }
152
+ ), s = E((r) => {
153
+ this.updatePosition(r), this.scratch(), e(r, s);
154
+ }), i = (r) => {
155
+ r.preventDefault(), this.updatePosition(r), this.scratch(), this._canvas.setPointerCapture(r.pointerId), this._canvas.addEventListener("pointermove", s), this.resetScratchTrail(), this.percent = this.updatePercent(), this.config.onProgress?.(this.percent), this.finish(r, s);
156
+ }, n = (r) => {
157
+ this.resetScratchTrail(), this.percent = this.updatePercent(), this.config.onProgress?.(this.percent), this.finish(r, s);
146
158
  };
147
- this._canvas.addEventListener("pointerdown", e), this._canvas.addEventListener("pointerup", s), this._canvas.addEventListener("pointerleave", s), this.removeListeners = () => {
148
- this._canvas.removeEventListener("pointerdown", e), this._canvas.removeEventListener("pointerup", s), this._canvas.removeEventListener("pointerleave", s), this._canvas.removeEventListener("pointermove", t);
159
+ this._canvas.addEventListener("pointerdown", i), this._canvas.addEventListener("pointerup", n), this._canvas.addEventListener("pointerleave", n), this.removeListeners = () => {
160
+ this._canvas.removeEventListener("pointerdown", i), this._canvas.removeEventListener("pointerup", n), this._canvas.removeEventListener("pointerleave", n), this._canvas.removeEventListener("pointermove", s);
149
161
  };
150
162
  }
151
163
  updatePosition(t) {
@@ -161,7 +173,18 @@ class I {
161
173
  t.src = this.backgroundImage.src, t.className = "sr__bg", t.isConnected || this.container.insertBefore(t, this._canvas), this.backgroundEl = t;
162
174
  }
163
175
  scratch() {
164
- this.brushImage && (this.ctx.globalCompositeOperation = "destination-out", this.ctx.save(), this.brush.brush(this.brushImage, this.brushSize), this.ctx.restore());
176
+ if (!this.brushImage) return;
177
+ const t = this.brushSize > 0 ? this.brushSize : Math.max(1, Math.min(this.brushImage.width, this.brushImage.height)), e = Math.max(2, t * 0.4);
178
+ if (this.lastScratchX !== void 0 && this.lastScratchY !== void 0) {
179
+ const i = this.brush.mouseX - this.lastScratchX, n = this.brush.mouseY - this.lastScratchY;
180
+ if (i * i + n * n < e * e)
181
+ return;
182
+ }
183
+ const s = this.brushSize > 0 ? this.brushSize : 0;
184
+ this.ctx.globalCompositeOperation = "destination-out", this.ctx.save(), this.brush.brush(this.brushImage, s), this.ctx.restore(), this.lastScratchX = this.brush.mouseX, this.lastScratchY = this.brush.mouseY;
185
+ }
186
+ resetScratchTrail() {
187
+ this.lastScratchX = void 0, this.lastScratchY = void 0;
165
188
  }
166
189
  updatePercent() {
167
190
  const e = this.ctx.getImageData(0, 0, this._canvas.width, this._canvas.height).data;
@@ -171,7 +194,7 @@ class I {
171
194
  return s / (this._canvas.width * this._canvas.height) * 100;
172
195
  }
173
196
  finish(t, e) {
174
- if (!this.done && this.percent > this.config.percentToFinish && (this.done = !0, this.clear(), this._canvas.style.pointerEvents = "none", this.config.onComplete?.(), t && e)) {
197
+ if (!this.done && this.percent > this.config.percentToFinish && (this.done = !0, this._canvas.style.pointerEvents = "none", this.config.onComplete?.(), this.playCompleteEffect(), t && e)) {
175
198
  try {
176
199
  this._canvas.releasePointerCapture(t.pointerId);
177
200
  } catch {
@@ -179,52 +202,57 @@ class I {
179
202
  this._canvas.removeEventListener("pointermove", e);
180
203
  }
181
204
  }
205
+ playCompleteEffect() {
206
+ if (this.destroyed || this.completing) return;
207
+ this.completing = !0;
208
+ const t = 350, e = "ease-out";
209
+ this._canvas.style.transition = `opacity ${t}ms ${e}`, this._canvas.style.opacity || (this._canvas.style.opacity = "1"), requestAnimationFrame(() => {
210
+ this._canvas.style.opacity = "0", window.setTimeout(() => {
211
+ this.destroyed || this.clear();
212
+ }, t);
213
+ });
214
+ }
182
215
  clear() {
183
216
  this.ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
184
217
  }
185
218
  }
186
- function M(h = "scratch-reveal") {
219
+ function I(h = "scratch-reveal") {
187
220
  if (typeof window > "u" || !("customElements" in window) || customElements.get(h)) return;
188
- function t(i, n) {
189
- if (i === null) return n;
190
- const r = i.trim().toLowerCase();
191
- return r === "" ? !0 : r === "false" || r === "0" || r === "no" || r === "off" ? !1 : r === "true" || r === "1" || r === "yes" || r === "on" ? !0 : n;
192
- }
193
- function e(i, n, r, c) {
194
- if (!i) return c;
195
- const o = i.trim();
196
- if (!o) return c;
197
- if (o.endsWith("%")) {
198
- const u = Number.parseFloat(o.slice(0, -1));
199
- if (!Number.isFinite(u)) return c;
200
- const d = Math.min(n, r);
201
- return Math.max(0, d * u / 100);
202
- }
203
- const l = o.endsWith("px") ? Number.parseFloat(o.slice(0, -2)) : Number.parseFloat(o);
204
- return Number.isFinite(l) ? Math.max(0, l) : c;
221
+ function t(s, i, n, r) {
222
+ if (!s) return r;
223
+ const a = s.trim();
224
+ if (!a) return r;
225
+ const o = Number.parseFloat(
226
+ a.endsWith("%") ? a.slice(0, -1) : a.endsWith("px") ? a.slice(0, -2) : a
227
+ );
228
+ if (!Number.isFinite(o)) return r;
229
+ if (a.endsWith("px"))
230
+ return Math.max(0, o);
231
+ const d = Math.min(i, n);
232
+ return Math.max(0, d * o / 100);
205
233
  }
206
- class s extends HTMLElement {
234
+ class e extends HTMLElement {
207
235
  instance;
208
236
  container;
209
237
  styleEl = null;
210
238
  rebuildScheduled = !1;
211
239
  resizeObserver;
240
+ lastErrorMessage = null;
212
241
  static get observedAttributes() {
213
242
  return [
214
243
  "width",
215
244
  "height",
216
- "percent-to-finish",
245
+ "complete-percent",
217
246
  "brush-src",
218
247
  "brush-size",
219
248
  "mask-src",
220
- "background-src",
221
- "enabled-percent-update"
249
+ "background-src"
222
250
  ];
223
251
  }
224
252
  constructor() {
225
253
  super();
226
- const n = this.attachShadow({ mode: "open" });
227
- A(n) && f ? n.adoptedStyleSheets = [f] : (this.styleEl = document.createElement("style"), this.styleEl.textContent = m, n.append(this.styleEl)), this.container = document.createElement("div"), this.container.className = "sr", n.append(this.container);
254
+ const i = this.attachShadow({ mode: "open" });
255
+ 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);
228
256
  }
229
257
  connectedCallback() {
230
258
  this.scheduleRebuild();
@@ -232,8 +260,8 @@ function M(h = "scratch-reveal") {
232
260
  disconnectedCallback() {
233
261
  this.instance?.destroy(), this.instance = void 0, this.resizeObserver?.disconnect(), this.resizeObserver = void 0;
234
262
  }
235
- attributeChangedCallback(n, r, c) {
236
- r !== c && this.scheduleRebuild();
263
+ attributeChangedCallback(i, n, r) {
264
+ n !== r && this.scheduleRebuild();
237
265
  }
238
266
  scheduleRebuild() {
239
267
  this.rebuildScheduled || (this.rebuildScheduled = !0, queueMicrotask(() => {
@@ -242,56 +270,63 @@ function M(h = "scratch-reveal") {
242
270
  }
243
271
  rebuild() {
244
272
  this.container.replaceChildren(), this.instance?.destroy();
245
- const n = this.hasAttribute("width"), r = this.hasAttribute("height"), c = this.getBoundingClientRect(), o = Math.round(c.width), l = Math.round(c.height), u = n ? Number(this.getAttribute("width")) : o || a.width, d = r ? Number(this.getAttribute("height")) : l || a.height, w = Number(
246
- this.getAttribute("percent-to-finish") ?? a.percentToFinish
247
- ), S = this.getAttribute("brush-src") ?? a.brushSrc, _ = t(
248
- this.getAttribute("enabled-percent-update"),
249
- a.enabledPercentUpdate
250
- ), E = e(
273
+ const i = this.hasAttribute("width"), n = this.hasAttribute("height"), r = this.getBoundingClientRect(), a = Math.round(r.width), o = Math.round(r.height), d = i ? Number(this.getAttribute("width")) : a || u.width, m = n ? Number(this.getAttribute("height")) : o || u.height, y = Number(
274
+ this.getAttribute("complete-percent") ?? u.percentToFinish
275
+ ), p = (this.getAttribute("brush-src") ?? "").trim(), k = t(
251
276
  this.getAttribute("brush-size"),
252
- u,
253
277
  d,
254
- a.brushSize
255
- ), y = this.getAttribute("mask-src") ?? a.imageMaskSrc, k = this.getAttribute("background-src") ?? a.imageBackgroundSrc;
256
- n ? this.container.style.width = `${u}px` : this.container.style.width = "100%", r ? this.container.style.height = `${d}px` : this.container.style.height = "100%", this.instance = new I(this.container, {
257
- width: u,
258
- height: d,
259
- percentToFinish: w,
260
- brushSrc: S,
261
- brushSize: E,
262
- imageMaskSrc: y,
263
- imageBackgroundSrc: k,
264
- enabledPercentUpdate: _,
265
- onProgress: (g) => {
266
- this.dispatchEvent(new CustomEvent("progress", { detail: { percent: g } }));
278
+ m,
279
+ u.brushSize
280
+ ), f = (this.getAttribute("mask-src") ?? "").trim(), S = (this.getAttribute("background-src") ?? "").trim(), l = [];
281
+ if (p || l.push("brush-src"), f || l.push("mask-src"), S || l.push("background-src"), l.length) {
282
+ const c = `ScratchReveal: missing required attribute(s): ${l.join(", ")}`;
283
+ this.renderError(c);
284
+ return;
285
+ }
286
+ i ? this.container.style.width = `${d}px` : this.container.style.width = "100%", n ? this.container.style.height = `${m}px` : this.container.style.height = "100%", this.instance = new R(this.container, {
287
+ width: d,
288
+ height: m,
289
+ percentToFinish: y,
290
+ brushSrc: p,
291
+ brushSize: k,
292
+ imageMaskSrc: f,
293
+ imageBackgroundSrc: S,
294
+ onProgress: (c) => {
295
+ this.dispatchEvent(new CustomEvent("progress", { detail: { percent: c } }));
267
296
  },
268
297
  onComplete: () => {
269
298
  this.dispatchEvent(new CustomEvent("complete", { detail: { percent: 100 } }));
270
299
  }
271
- }), this.instance.init(), (!n || !r) && "ResizeObserver" in window ? (this.resizeObserver?.disconnect(), this.resizeObserver = new ResizeObserver(() => {
300
+ }), this.instance.init().catch((c) => {
301
+ const g = c instanceof Error ? c.message : "ScratchReveal: init failed";
302
+ this.renderError(g);
303
+ }), (!i || !n) && "ResizeObserver" in window ? (this.resizeObserver?.disconnect(), this.resizeObserver = new ResizeObserver(() => {
272
304
  if (this.hasAttribute("width") && this.hasAttribute("height")) {
273
305
  this.resizeObserver?.disconnect(), this.resizeObserver = void 0;
274
306
  return;
275
307
  }
276
- const g = this.getBoundingClientRect(), v = Math.round(g.width), p = Math.round(g.height);
277
- this.instance?.resize(v, p);
278
- const x = e(
308
+ const c = this.getBoundingClientRect(), g = Math.round(c.width), w = Math.round(c.height);
309
+ this.instance?.resize(g, w);
310
+ const x = t(
279
311
  this.getAttribute("brush-size"),
280
- v,
281
- p,
282
- a.brushSize
312
+ g,
313
+ w,
314
+ u.brushSize
283
315
  );
284
316
  this.instance?.setBrushSize(x);
285
317
  }), this.resizeObserver.observe(this)) : (this.resizeObserver?.disconnect(), this.resizeObserver = void 0);
286
318
  }
319
+ renderError(i) {
320
+ this.instance?.destroy(), this.instance = void 0, this.lastErrorMessage !== i && (this.lastErrorMessage = i, this.dispatchEvent(new CustomEvent("error", { detail: { message: i } })));
321
+ }
287
322
  }
288
- customElements.define(h, s);
323
+ customElements.define(h, e);
289
324
  }
290
- function B(h) {
291
- M(), h.config.globalProperties.$scratchReveal = !0;
325
+ function T(h) {
326
+ I(), h.config.globalProperties.$scratchReveal = !0;
292
327
  }
293
328
  export {
294
- B as installScratchReveal,
295
- M as registerScratchRevealElement,
296
- L as scratchRevealCssText
329
+ T as installScratchReveal,
330
+ I as registerScratchRevealElement,
331
+ A as scratchRevealCssText
297
332
  };
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){const i=new Error("Brush.brush: img is required");console.log(i.message);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,n=e*(t.height/t.width);this.ctx.drawImage(t,-(i/2),-(n/2),i,n)}else this.ctx.drawImage(t,-(t.width/2),-(t.height/2));this.ctx.restore()}}function v(h){return new Promise((t,e)=>{const s=new Image;s.crossOrigin="anonymous",s.onload=()=>t(s),s.onerror=()=>e(new Error(`Image ${h} failed to load`)),s.src=h})}function E(h){let t=0;return((...s)=>{t||(t=requestAnimationFrame(()=>{t=0,h(...s)}))})}const p=`.sr {
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,n=e*(t.height/t.width);this.ctx.drawImage(t,-(i/2),-(n/2),i,n)}else this.ctx.drawImage(t,-(t.width/2),-(t.height/2));this.ctx.restore()}}function f(h){return new Promise((t,e)=>{const s=new Image;s.crossOrigin="anonymous",s.onload=()=>t(s),s.onerror=()=>e(new Error(`Image ${h} failed to load`)),s.src=h})}function S(h){let t=0;return((...s)=>{t||(t=requestAnimationFrame(()=>{t=0,h(...s)}))})}const b=`.sr {
2
2
  position: relative;
3
3
  overflow: hidden;
4
4
  width: 100%;
@@ -25,4 +25,4 @@ scratch-reveal {
25
25
  width: 100%;
26
26
  height: 100%;
27
27
  }
28
- `,y=p;function k(h){return"adoptedStyleSheets"in h}function x(h){if(typeof CSSStyleSheet>"u")return null;try{const t=new CSSStyleSheet;return t.replaceSync(h),t}catch{return null}}const f=x(p),a={width:300,height:300,brushSrc:"/demo/assets/brush.png",imageMaskSrc:"/demo/assets/scratch-reveal.png",imageBackgroundSrc:"/demo/assets/scratch-reveal-background.svg",brushSize:0,percentToFinish:60,enabledPercentUpdate:!0};class C{config;ctx;container;_canvas;brush;maskImage;backgroundImage;brushImage;backgroundEl;brushSize=0;percent=0;done=!1;destroyed=!1;removeListeners;get canvas(){return this._canvas}constructor(t,e={}){if(this.config={...a,...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(){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 t=E(i=>{this.updatePosition(i),this.scratch(),this.config.enabledPercentUpdate&&(this.percent=this.updatePercent(),this.config.onProgress?.(this.percent)),this.finish(i,t)}),e=i=>{i.preventDefault(),this.updatePosition(i),this.scratch(),this._canvas.setPointerCapture(i.pointerId),this._canvas.addEventListener("pointermove",t),this.config.enabledPercentUpdate&&(this.percent=this.updatePercent(),this.config.onProgress?.(this.percent)),this.finish(i,t)},s=i=>{this.finish(i,t)};this._canvas.addEventListener("pointerdown",e),this._canvas.addEventListener("pointerup",s),this._canvas.addEventListener("pointerleave",s),this.removeListeners=()=>{this._canvas.removeEventListener("pointerdown",e),this._canvas.removeEventListener("pointerup",s),this._canvas.removeEventListener("pointerleave",s),this._canvas.removeEventListener("pointermove",t)}}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,n=(t.clientX-e.left)*s,r=(t.clientY-e.top)*i;this.brush.updateMousePosition(n,r)}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(){this.brushImage&&(this.ctx.globalCompositeOperation="destination-out",this.ctx.save(),this.brush.brush(this.brushImage,this.brushSize),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.clear(),this._canvas.style.pointerEvents="none",this.config.onComplete?.(),t&&e)){try{this._canvas.releasePointerCapture(t.pointerId)}catch{}this._canvas.removeEventListener("pointermove",e)}}clear(){this.ctx.clearRect(0,0,this._canvas.width,this._canvas.height)}}function S(h="scratch-reveal"){if(typeof window>"u"||!("customElements"in window)||customElements.get(h))return;function t(i,n){if(i===null)return n;const r=i.trim().toLowerCase();return r===""?!0:r==="false"||r==="0"||r==="no"||r==="off"?!1:r==="true"||r==="1"||r==="yes"||r==="on"?!0:n}function e(i,n,r,c){if(!i)return c;const u=i.trim();if(!u)return c;if(u.endsWith("%")){const d=Number.parseFloat(u.slice(0,-1));if(!Number.isFinite(d))return c;const g=Math.min(n,r);return Math.max(0,g*d/100)}const m=u.endsWith("px")?Number.parseFloat(u.slice(0,-2)):Number.parseFloat(u);return Number.isFinite(m)?Math.max(0,m):c}class s extends HTMLElement{instance;container;styleEl=null;rebuildScheduled=!1;resizeObserver;static get observedAttributes(){return["width","height","percent-to-finish","brush-src","brush-size","mask-src","background-src","enabled-percent-update"]}constructor(){super();const n=this.attachShadow({mode:"open"});k(n)&&f?n.adoptedStyleSheets=[f]:(this.styleEl=document.createElement("style"),this.styleEl.textContent=p,n.append(this.styleEl)),this.container=document.createElement("div"),this.container.className="sr",n.append(this.container)}connectedCallback(){this.scheduleRebuild()}disconnectedCallback(){this.instance?.destroy(),this.instance=void 0,this.resizeObserver?.disconnect(),this.resizeObserver=void 0}attributeChangedCallback(n,r,c){r!==c&&this.scheduleRebuild()}scheduleRebuild(){this.rebuildScheduled||(this.rebuildScheduled=!0,queueMicrotask(()=>{this.rebuildScheduled=!1,this.isConnected&&this.rebuild()}))}rebuild(){this.container.replaceChildren(),this.instance?.destroy();const n=this.hasAttribute("width"),r=this.hasAttribute("height"),c=this.getBoundingClientRect(),u=Math.round(c.width),m=Math.round(c.height),d=n?Number(this.getAttribute("width")):u||a.width,g=r?Number(this.getAttribute("height")):m||a.height,P=Number(this.getAttribute("percent-to-finish")??a.percentToFinish),A=this.getAttribute("brush-src")??a.brushSrc,R=t(this.getAttribute("enabled-percent-update"),a.enabledPercentUpdate),I=e(this.getAttribute("brush-size"),d,g,a.brushSize),M=this.getAttribute("mask-src")??a.imageMaskSrc,T=this.getAttribute("background-src")??a.imageBackgroundSrc;n?this.container.style.width=`${d}px`:this.container.style.width="100%",r?this.container.style.height=`${g}px`:this.container.style.height="100%",this.instance=new C(this.container,{width:d,height:g,percentToFinish:P,brushSrc:A,brushSize:I,imageMaskSrc:M,imageBackgroundSrc:T,enabledPercentUpdate:R,onProgress:b=>{this.dispatchEvent(new CustomEvent("progress",{detail:{percent:b}}))},onComplete:()=>{this.dispatchEvent(new CustomEvent("complete",{detail:{percent:100}}))}}),this.instance.init(),(!n||!r)&&"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 b=this.getBoundingClientRect(),w=Math.round(b.width),_=Math.round(b.height);this.instance?.resize(w,_);const L=e(this.getAttribute("brush-size"),w,_,a.brushSize);this.instance?.setBrushSize(L)}),this.resizeObserver.observe(this)):(this.resizeObserver?.disconnect(),this.resizeObserver=void 0)}}customElements.define(h,s)}function z(h){S(),h.config.globalProperties.$scratchReveal=!0}o.installScratchReveal=z,o.registerScratchRevealElement=S,o.scratchRevealCssText=y,Object.defineProperty(o,Symbol.toStringTag,{value:"Module"})}));
28
+ `,M=b;function R(h){return"adoptedStyleSheets"in h}function z(h){if(typeof CSSStyleSheet>"u")return null;try{const t=new CSSStyleSheet;return t.replaceSync(h),t}catch{return null}}const w=z(b),d={width:300,height:300,brushSrc:"",imageMaskSrc:"",imageBackgroundSrc:"",brushSize:0,percentToFinish:60};class C{config;ctx;container;_canvas;brush;maskImage;backgroundImage;brushImage;backgroundEl;brushSize=0;percent=0;done=!1;completing=!1;destroyed=!1;lastProgressEmitMs=0;lastScratchX;lastScratchY;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([f(this.config.brushSrc),f(this.config.imageMaskSrc),f(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((r,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(r,a)}),s=S(r=>{this.updatePosition(r),this.scratch(),e(r,s)}),i=r=>{r.preventDefault(),this.updatePosition(r),this.scratch(),this._canvas.setPointerCapture(r.pointerId),this._canvas.addEventListener("pointermove",s),this.resetScratchTrail(),this.percent=this.updatePercent(),this.config.onProgress?.(this.percent),this.finish(r,s)},n=r=>{this.resetScratchTrail(),this.percent=this.updatePercent(),this.config.onProgress?.(this.percent),this.finish(r,s)};this._canvas.addEventListener("pointerdown",i),this._canvas.addEventListener("pointerup",n),this._canvas.addEventListener("pointerleave",n),this.removeListeners=()=>{this._canvas.removeEventListener("pointerdown",i),this._canvas.removeEventListener("pointerup",n),this._canvas.removeEventListener("pointerleave",n),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,n=(t.clientX-e.left)*s,r=(t.clientY-e.top)*i;this.brush.updateMousePosition(n,r)}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:Math.max(1,Math.min(this.brushImage.width,this.brushImage.height)),e=Math.max(2,t*.4);if(this.lastScratchX!==void 0&&this.lastScratchY!==void 0){const i=this.brush.mouseX-this.lastScratchX,n=this.brush.mouseY-this.lastScratchY;if(i*i+n*n<e*e)return}const s=this.brushSize>0?this.brushSize:0;this.ctx.globalCompositeOperation="destination-out",this.ctx.save(),this.brush.brush(this.brushImage,s),this.ctx.restore(),this.lastScratchX=this.brush.mouseX,this.lastScratchY=this.brush.mouseY}resetScratchTrail(){this.lastScratchX=void 0,this.lastScratchY=void 0}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(h="scratch-reveal"){if(typeof window>"u"||!("customElements"in window)||customElements.get(h))return;function t(s,i,n,r){if(!s)return r;const a=s.trim();if(!a)return r;const u=Number.parseFloat(a.endsWith("%")?a.slice(0,-1):a.endsWith("px")?a.slice(0,-2):a);if(!Number.isFinite(u))return r;if(a.endsWith("px"))return Math.max(0,u);const g=Math.min(i,n);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=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,n,r){n!==r&&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"),n=this.hasAttribute("height"),r=this.getBoundingClientRect(),a=Math.round(r.width),u=Math.round(r.height),g=i?Number(this.getAttribute("width")):a||d.width,p=n?Number(this.getAttribute("height")):u||d.height,T=Number(this.getAttribute("complete-percent")??d.percentToFinish),y=(this.getAttribute("brush-src")??"").trim(),I=t(this.getAttribute("brush-size"),g,p,d.brushSize),_=(this.getAttribute("mask-src")??"").trim(),k=(this.getAttribute("background-src")??"").trim(),m=[];if(y||m.push("brush-src"),_||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%",n?this.container.style.height=`${p}px`:this.container.style.height="100%",this.instance=new C(this.container,{width:g,height:p,percentToFinish:T,brushSrc:y,brushSize:I,imageMaskSrc:_,imageBackgroundSrc:k,onProgress:c=>{this.dispatchEvent(new CustomEvent("progress",{detail:{percent:c}}))},onComplete:()=>{this.dispatchEvent(new CustomEvent("complete",{detail:{percent:100}}))}}),this.instance.init().catch(c=>{const v=c instanceof Error?c.message:"ScratchReveal: init failed";this.renderError(v)}),(!i||!n)&&"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 A=t(this.getAttribute("brush-size"),v,x,d.brushSize);this.instance?.setBrushSize(A)}),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}})))}}customElements.define(h,e)}function P(h){E(),h.config.globalProperties.$scratchReveal=!0}o.installScratchReveal=P,o.registerScratchRevealElement=E,o.scratchRevealCssText=M,Object.defineProperty(o,Symbol.toStringTag,{value:"Module"})}));
@@ -1,5 +1,5 @@
1
1
  export declare function loadImage(src: string): Promise<HTMLImageElement>;
2
- export declare function rafThrottle<T extends (...args: any[]) => void>(fn: T): T;
2
+ export declare function rafThrottle<Args extends unknown[]>(fn: (...args: Args) => void): (...args: Args) => void;
3
3
  export declare function getOffset(element: HTMLElement): {
4
4
  left: number;
5
5
  top: number;
package/dist/options.d.ts CHANGED
@@ -6,7 +6,6 @@ export interface ScratchRevealOptions {
6
6
  brushSrc: string;
7
7
  brushSize: number;
8
8
  percentToFinish: number;
9
- enabledPercentUpdate: boolean;
10
9
  onProgress?: (percent: number) => void;
11
10
  onComplete?: () => void;
12
11
  }
@@ -1,5 +1,7 @@
1
1
  export declare const scratchRevealCssText: string;
2
2
  export declare function registerScratchRevealElement(tagName?: string): void;
3
3
  export declare function installScratchReveal(app: {
4
- config: any;
4
+ config: {
5
+ globalProperties: Record<string, unknown>;
6
+ };
5
7
  }): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scratch-reveal",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
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",