Skip to content

Instantly share code, notes, and snippets.

@taylor8294
Last active June 10, 2020 23:15
Show Gist options
  • Select an option

  • Save taylor8294/9cb80f8f40704e8a5e540021ce11344d to your computer and use it in GitHub Desktop.

Select an option

Save taylor8294/9cb80f8f40704e8a5e540021ce11344d to your computer and use it in GitHub Desktop.
Javascript utility to highlight multiple elements in a HTML page whilst dimming the rest of the page, also smoothly transitions between highlighting different elements. Uses a canvas overlay (no box-shadow or z-index stuff) and no dependencies (but works nicely if passed a jQuery object).
(function(window,document,undefined){
var Dimmer = function(options){
this.options = Object.assign({}, Dimmer.defaults, options)
if(!this.isDomNode(this.options.parent)) this.options.parent = Dimmer.defaults.parent
if(this.options.opacity > 1 && this.options.opacity <= 100) this.options.opacity = this.options.opacity/100
else if(this.options.opacity > 1 && this.options.opacity <= 255) this.options.opacity = this.options.opacity/255
else if(this.options.opacity < 0 || (this.options.opacity > 1 && this.options.opacity > 255)) this.options.opacity = Dimmer.defaults.opacity
if(this.options.fadeDuration < 0) this.options.fadeDuration=Dimmer.defaults.fadeDuration;
else if(this.options.fadeDuration < 10) this.options.fadeDuration=this.options.fadeDuration*1000;
this.options.padding = parseFloat(this.options.padding) || parseFloat(this.options.padding)===0 ? parseFloat(this.options.padding) : Dimmer.defaults.padding;
this.options.borderRadius = parseFloat(this.options.borderRadius) || parseFloat(this.options.borderRadius)===0 ? Math.max(parseFloat(this.options.borderRadius),0) : Dimmer.defaults.borderRadius;
if(this.options.transitionDuration < 0) this.options.transitionDuration=Dimmer.defaults.transitionDuration;
else if(this.options.transitionDuration < 10) this.options.transitionDuration=this.options.transitionDuration*1000;
if(this.options.resizeDebounce < 0) this.options.resizeDebounce=Dimmer.defaults.resizeDebounce;
else if(this.options.resizeDebounce < 10) this.options.resizeDebounce=this.options.resizeDebounce*1000;
this.options.resizeDebounce=Math.max(this.options.resizeDebounce,100);
this.options.zIndex = parseInt(this.options.zIndex) || Dimmer.defaults.zIndex;
this.options.easing = ['none','linear','swing','default'].indexOf(this.options.easing.trim().toLowerCase()) ? this.options.easing.trim().toLowerCase() : Dimmer.defaults.easing;
this.init();
};
Dimmer.defaults = {
parent: document.body,
opacity: 0.5,
fadeDuration: 1000,
padding: 5,
borderRadius: 5,
transitionDuration: 1000,
resizeDebounce: 100,
zIndex: 99,
easing: 'swing'
};
(function() {
this.init = function(){
this.els = []
this.rects = []
// Create canvas element
this.canvas = document.createElement('canvas');
this.canvas.width = document.documentElement.clientWidth;
this.canvas.height = Math.max(document.documentElement.clientHeight,document.documentElement.scrollHeight);
this.ctx = this.canvas.getContext('2d');
this.canvas.style.position = 'absolute'
this.canvas.style.top = '0px'
this.canvas.style.left = '0px'
this.canvas.style.zIndex = this.options.zIndex
this.canvas.style.pointerEvents = 'none'
this.canvas.style.opacity = '0'
window.addEventListener('resize', (function(){
clearTimeout(this.resizeHandle);
this.resizeHandle = setTimeout(this.resize.bind(this),this.options.resizeDebounce)
}).bind(this));
this.options.parent.appendChild(this.canvas);
this.anims = [];
this.isFollowing = false
this.stopFollowAnimation = false
this.isVisible = false;
this.resizeHandle = null;
// Fix page resizing after load, bug
setTimeout(this.resize.bind(this),1000)
setTimeout(this.resize.bind(this),2000)
setTimeout(this.resize.bind(this),5000)
setTimeout(this.resize.bind(this),8000)
this.draw();
}
this.cleanup = function(){
}
this.clear = function(){
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
this.roundedRect = function(x, y, w, h, r, doFill, doStroke) {
if(!r && r !== 0) r = this.options.borderRadius;
if (typeof r === 'number') {
r = {tl: r, tr: r, br: r, bl: r};
} else {
var defaultR = {tl: this.options.borderRadius, tr: this.options.borderRadius, br: this.options.borderRadius, bl: this.options.borderRadius};
for (var side in defaultR) {
r[side] = r[side] || defaultR[side];
}
}
if(doFill === undefined) doFill = true;
this.ctx.beginPath();
this.ctx.moveTo(x + r.tl, y);
this.ctx.lineTo(x + w - r.tr, y);
this.ctx.quadraticCurveTo(x + w, y, x + w, y + r.tr);
this.ctx.lineTo(x + w, y + h - r.br);
this.ctx.quadraticCurveTo(x + w, y + h, x + w - r.br, y + h);
this.ctx.lineTo(x + r.bl, y + h);
this.ctx.quadraticCurveTo(x, y + h, x, y + h - r.bl);
this.ctx.lineTo(x, y + r.tl);
this.ctx.quadraticCurveTo(x, y, x + r.tl, y);
this.ctx.closePath();
if(doFill) this.ctx.fill();
if(doStroke) this.ctx.stroke();
}
this.isPercent = function(val){
return /^\s*\-?(\d+\.?\d*|\d*\.\d+)%\s*$/.test(val)
}
this.calcPercent = function(rect){
return {
x: this.isPercent(rect.x) ? this.canvas.width*parseFloat(rect.x)/100 : parseFloat(rect.x),
y: this.isPercent(rect.y) ? this.canvas.height*parseFloat(rect.y)/100 : parseFloat(rect.y),
width: this.isPercent(rect.width) ? this.canvas.width*parseFloat(rect.width)/100 : parseFloat(rect.width),
height: this.isPercent(rect.height) ? this.canvas.height*parseFloat(rect.height)/100 : parseFloat(rect.height)
}
}
this.draw = function(){
this.clear()
this.ctx.globalAlpha = this.options.opacity;
this.ctx.fillStyle = '#000000';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.globalAlpha = 1.0;
//this.ctx.fillStyle = '#FFFFFF';
this.ctx.globalCompositeOperation = 'destination-out';
this.rects.forEach((rect) => {
let r = this.calcPercent(rect),
x = Math.max(r.x-this.options.padding,0),
y = Math.max(r.y-this.options.padding,0),
w = Math.min(r.width+2*this.options.padding-(r.x<0?Math.abs(r.x):0),this.canvas.width-x),
h = Math.min(r.height+2*this.options.padding-(r.y<0?Math.abs(r.y):0),this.canvas.height-y)
this.roundedRect(x, y, w, h);
})
this.ctx.globalCompositeOperation = 'source-over';
}
this.ease = function(p,type) {
switch(type){
case 'none':
case 'linear':
return p;
case 'swing':
case 'default':
default:
return 0.5 - Math.cos( p * Math.PI ) / 2;
}
}
this.fadeOut = function(opts){
if(this.isFadingOut() || !this.isVisible) return;
let currentOpacity = parseFloat(this.canvas.style.opacity) || 1;
if(typeof opts == 'function') opts = {duration: this.options.fadeDuration, onComplete: opts}
else if(typeof opts == 'number') opts = {duration: opts}
else if(typeof opts != 'object') opts = {duration: this.options.fadeDuration}
let anim = {type:'fadeOut', start:0, stop:false};
this.anims.push(anim);
var step = (function(timestamp){
if (!anim.start) anim.start = timestamp;
let progress = opts.duration == 0 ? 1 : this.ease(Math.min((timestamp - anim.start)/opts.duration,1));
this.canvas.style.opacity = (1-progress)*currentOpacity;
if (this.canvas.style.opacity>0 && !anim.stop) requestAnimationFrame(step);
else {
if(!anim.stop) this.isVisible = false;
this.removeAnim(anim.start,anim.type);
if(opts.onComplete && typeof opts.onComplete == 'function') opts.onComplete()
}
}).bind(this)
this.stopFadeIn();
requestAnimationFrame(step)
}
this.fadeIn = function(opts){
let currentOpacity = !this.isVisible ? 0 : (parseFloat(this.canvas.style.opacity) || (this.isVisible ? 1 : 0));
if(this.isFadingIn() || currentOpacity >= 0.99) return false;
if(typeof opts == 'function') opts = {duration: this.options.fadeDuration, onComplete: opts}
else if(typeof opts == 'number') opts = {duration: opts}
else if(typeof opts != 'object') opts = {duration: this.options.fadeDuration}
if((!opts.duration && opts.duration!==0) || opts.duration < 0) opts.duration = this.options.fadeDuration
if(opts.duration<10) opts.duration = opts.duration*1000
let anim = {type:'fadeIn', start:0, stop:false, startY: window.scrollY+window.innerHeight/2, targetY: this.getAverageY()};
this.anims.push(anim);
this.isVisible = true;
var step = (function(timestamp){
if (!anim.start) anim.start = timestamp;
let progress = opts.duration == 0 ? 1 : this.ease(Math.min((timestamp - anim.start)/opts.duration,1));
this.canvas.style.opacity = currentOpacity + progress*(1-currentOpacity);
window.scrollTo(0,this.lerp(anim.startY-window.innerHeight/2,anim.targetY-window.innerHeight/2,progress))
if (this.canvas.style.opacity<1 && !anim.stop) requestAnimationFrame(step);
else {
this.removeAnim(anim.start,anim.type);
if(opts.onComplete && typeof opts.onComplete == 'function') opts.onComplete()
}
}).bind(this)
this.stopFadeOut();
requestAnimationFrame(step)
}
this.stop = function(start,type){
this.anims.filter((anim) => (start || start===0 ? anim.start == start : true) && (type ? anim.type == type : true)).forEach((anim) => anim.stop = true)
}
this.stopFadeOut = function(start){
this.stop(start,'fadeOut')
}
this.stopFadeIn = function(start){
this.stop(start,'fadeIn')
}
this.stopTransition = function(start){
this.stop(start,'transition')
}
this.removeAnim = function(start,type){
let i = this.anims.findIndex((anim) => (start || start===0 ? anim.start == start : true) && (type ? anim.type == type : true))
while(i >= 0){
this.anims.splice(i,1)
i = this.anims.findIndex((anim) => (start || start===0 ? anim.start == start : true) && (type ? anim.type == type : true))
}
}
this.isFadingOut = function(){
return this.anims.findIndex((anim) => anim.type == 'fadeOut' && !anim.stop) >= 0
}
this.isFadingIn = function(){
return this.anims.findIndex((anim) => anim.type == 'fadeIn' && !anim.stop) >= 0
}
this.isTransitioning = function(){
return this.anims.findIndex((anim) => anim.type == 'transition' && !anim.stop) >= 0
}
this.resize = function(){
// If the canvas is not visible, remove it so we get the right document size
if(!this.isVisible && this.canvas.parentElement){
this.canvas.parentElement.removeChild(this.canvas)
}
// Resize the canvas
this.canvas.width = document.documentElement.clientWidth;
this.canvas.height = Math.max(document.documentElement.clientHeight,document.documentElement.scrollHeight);
if(this.isVisible) this.transition();
else if(!this.canvas.parentElement) this.options.parent.appendChild(this.canvas)
}
this.isDomNode = function(el){
try {
if(el instanceof HTMLElement || el instanceof SVGElement)
return true
} catch(e){}
if(el && typeof el == 'object' && el.nodeType == 1) return true
return false
}
this.isDomNodeList = function(list){
try {
if(list instanceof NodeList)
return true
} catch(e){}
if(list && typeof list == 'object' && list.constructor.name == 'NodeList') return true
return false
}
this.isJqueryObject = function(obj){
if(typeof jQuery == 'undefined') return false
try {
if(obj instanceof jQuery) return true
} catch(e){}
if(obj && typeof obj == 'object' && obj.jquery) return true
return false
}
this.isWithinDoc = function(rect){
let r = this.calcPercent(rect)
if(r.width <= 0 || r.height <= 0 || r.x+r.width < 0 || r.x > this.canvas.width || r.y+r.height < 0 || r.y > this.canvas.height)
return false
return true
}
this.isOnScreen = function(rect){
let r = this.calcPercent(rect)
if(r.y < window.scrollY || r.y > window.scrollY + window.innerHeight - r.height/2)
return false
return true
}
this.getRect = function(el){
let r
if(this.isDomNode(el)) r = el.getBoundingClientRect()
else if(el.width !== undefined && el.height !== undefined) r = el
else r = false
if(r && r.top !== undefined) r.y += window.scrollY;
return r
}
this.getAverageY = function(){
let y = 0, area = 0;
this.els.forEach((el) => {
let r = this.calcPercent(this.getRect(el)), a=r.width*r.height
y += (r.y+r.height/2)*a
area += a
})
return y/area
}
this.lerp = function(a,b,p){
return a+p*(b-a)
}
this.highlight = function(els, opts){
if(!els) els = []
if(!Array.isArray(els)) els = [els]
let i = els.length-1
while(i >= 0){
if(this.isDomNodeList(els[i]) || this.isJqueryObject(els[i]))
els = els.slice(0,i).concat(Array.from(els[i])).concat(els.slice(i+1))
else if(typeof els[i] == 'string')
els = els.slice(0,i).concat(Array.from(document.querySelectorAll(els[i]))).concat(els.slice(i+1))
else if(!this.isDomNode(els[i]) && (!els[i] || !els[i].width>0 || !els[i].height>0))
els.splice(i,1)
i--;
}
let defaults = {
add: false,
duration: this.options.transitionDuration,
easing: this.options.easing,
isFollowCall: false
}
if(typeof opts == 'boolean') opts = {add: opts}
else if(typeof opts == 'number') opts = {duration: opts}
else if(typeof opts == 'function') opts = {onComplete: opts}
else if(typeof opts != 'object') opts = {}
opts = Object.assign(defaults,opts)
els = els.filter((e) => {
let r = this.getRect(e)
return this.isWithinDoc(r)
})
if(opts.add) this.els = this.els.concat(els)
else this.els = els
if(!opts.isFollowCall && this.isFollowing) this.stopFollowing()
let that = this;
if(!this.isVisible){
this.resize();
this.transition({duration: 0, onComplete: function(){ that.fadeIn({onComplete: opts.onComplete}) }, easing: opts.easing})
} else {
if(this.isFadingOut()) this.fadeIn()
this.transition({duration: opts.duration, onComplete: opts.onComplete, easing: opts.easing})
}
}
this.rehighlight = function(opts){
if(typeof opts == 'boolean') opts = {add: opts}
else if(typeof opts == 'number') opts = {duration: opts}
else if(typeof opts == 'function') opts = {onComplete: opts}
else if(typeof opts != 'object') opts = {}
opts.add = true
this.highlight(false,opts)
}
this.transition = function(opts){
let defaults = {
duration: this.options.transitionDuration,
easing: this.options.easing
}
if(typeof opts == 'number') opts = {duration: opts}
else if(typeof opts == 'boolean') opts = {follow: opts}
else if(typeof opts == 'function') opts = {onComplete: opts}
else if(typeof opts != 'object') opts = {}
opts = Object.assign(defaults,opts)
if((!opts.duration && opts.duration!==0) || opts.duration < 0) opts.duration = this.options.transitionDuration
if(opts.duration<10) opts.duration = opts.duration*1000
if(opts.easing == 'none' || opts.easing == 'linear') opts.easing = false;
this.stopTransition()
// add any new rects that we need
while(this.rects.length < this.els.length){
this.rects.push({x:this.canvas.width/2-this.options.padding,y:-this.options.padding*2,width:-2*this.options.padding,height:-2*this.options.padding})
}
// create as many targets as we need (one for each current rect)
let targetRectsUnsorted = []
this.rects.forEach((rect,i,arr)=>{
let target = this.els.length > i ? this.calcPercent(this.getRect(this.els[i])) :
{
x:this.canvas.width/2-this.options.padding,
y:-this.options.padding*2,
width:-2*this.options.padding,
height:-2*this.options.padding
}
targetRectsUnsorted.push(target)
})
// for each current rect, find distance to each target
let distances = []
this.rects.forEach((rect,i,arr1)=>{
targetRectsUnsorted.forEach((target,j,arr2)=>{
let d2 = Math.pow(rect.x-target.x,2)+Math.pow(rect.y-target.y,2)
distances.push({from:i,to:j,dist:d2})
})
})
// sort distances asc
distances.sort((a,b)=>{
return a.dist-b.dist
})
// find nearest pairs (keeping track of rects already paired up)
let usedRect = [], usedTarget = [], targetIndices = []
this.rects.forEach((rect)=>{
usedRect.push(false)
usedTarget.push(false)
targetIndices.push(-1)
})
distances.forEach((o)=>{
if(!usedRect[o.from] && !usedTarget[o.to]){
usedRect[o.from] = usedTarget[o.to] = true
targetIndices[o.from] = o.to
}
})
// sort target rects into order just found
let targetRects = []
targetIndices.forEach((idx)=>{
targetRects.push(targetRectsUnsorted[idx])
})
// create anim object with nicely paired targets
let anim = {type:'transition', start:0, stop:false, startRects: this.rects.slice(0), targetRects: targetRects, startY: window.scrollY+window.innerHeight/2, targetY: this.getAverageY()};
this.anims.push(anim);
// step function
var step = (function(timestamp){
if (!anim.start) anim.start = timestamp;
let progress = opts.duration == 0 ? 1 : Math.min((timestamp - anim.start)/opts.duration,1);
if(opts.easing) progress = this.ease(progress)
if(!anim.stop){
this.rects.forEach((rect,i,arr)=>{
let target = anim.targetRects[i]
arr[i].x = this.lerp(anim.startRects[i].x,target.x,progress)
arr[i].y = this.lerp(anim.startRects[i].y,target.y,progress)
arr[i].width = this.lerp(anim.startRects[i].width,target.width,progress)
arr[i].height = this.lerp(anim.startRects[i].height,target.height,progress)
})
if(opts.duration>0) window.scrollTo(0,this.lerp(anim.startY-window.innerHeight/2,anim.targetY-window.innerHeight/2,progress))
this.draw()
if (progress < 1) requestAnimationFrame(step);
}
if(anim.stop || progress >= 1) {
if(!anim.stop){
this.rects = this.rects.filter(this.isWithinDoc.bind(this))
this.draw()
}
this.removeAnim(anim.start,anim.type);
if(opts.onComplete && typeof opts.onComplete == 'function') opts.onComplete()
}
}).bind(this)
// start animating
requestAnimationFrame(step)
}
this.follow = function(els,addTo){
let followStep = function(){
if(!this.stopFollowAnimation){
this.rehighlight({duration:0,add:addTo,isFollowCall:true});
requestAnimationFrame(followStep)
} else {
this.isFollowing = false
this.stopFollowAnimation = false
}
}.bind(this)
this.isFollowing = true
this.highlight(els,{duration:0,add:addTo,isFollowCall:true});
requestAnimationFrame(followStep)
}
this.stopFollowing = function(){
if(this.isFollowing) this.stopFollowAnimation = true
}
}).call(Dimmer.prototype);
window.Dimmer = Dimmer;
})(this,this.document)
//let dimmer = new Dimmer({})
//dimmer.highlight('.panel');
//dimmer.follow('.carousel .item');
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment