Skip to content

Instantly share code, notes, and snippets.

@AnoRebel
Last active May 14, 2023 12:02
Show Gist options
  • Save AnoRebel/e54d8ea2a6f796d3a6d81847e6c81414 to your computer and use it in GitHub Desktop.
Save AnoRebel/e54d8ea2a6f796d3a6d81847e6c81414 to your computer and use it in GitHub Desktop.
A Vue 3 Typescript composable remade from https://github.com/sirxemic/jquery.ripples
import { type Ref, ref } from "vue";
import type {
IConfig,
ILocations,
IOptions,
IRipples,
IUniforms,
} from "./types";
import {
createImageData,
extractUrl,
getOffset,
isDataUri,
isPercentage,
translateBackgroundPosition,
} from "./utils";
let gl: WebGLRenderingContext | null;
/**
* Load a configuration of GL settings which the browser supports.
* For example:
* - not all browsers support WebGL
* - not all browsers support floating point textures
* - not all browsers support linear filtering for floating point textures
* - not all browsers support rendering to floating point textures
* - some browsers *do* support rendering to half-floating point textures instead.
*/
const loadConfig = (): IConfig | null => {
const canvas = document.createElement("canvas");
gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
if (!gl) {
// Browser does not support WebGL.
return null;
}
// Load extensions
const extensions: Map<string, any> = new Map();
[
"OES_texture_float",
"OES_texture_half_float",
"OES_texture_float_linear",
"OES_texture_half_float_linear",
].forEach((name) => {
const extension = gl.getExtension(name);
if (extension) {
extensions.set(name, extension);
}
});
// If no floating point extensions are supported we can bail out early.
if (!extensions.has("OES_texture_float")) {
return null;
}
const configs: IConfig[] = [];
const createConfig = (type: string, glType: any, arrayType: any) => {
const name = "OES_texture_" + type,
nameLinear = name + "_linear",
linearSupport = extensions.has(nameLinear),
configExtensions = [name];
if (linearSupport) {
configExtensions.push(nameLinear);
}
return {
type: glType,
arrayType: arrayType,
linearSupport: linearSupport,
extensions: configExtensions,
};
};
configs.push(
createConfig("float", gl.FLOAT, Float32Array),
);
if (extensions.has("OES_texture_half_float")) {
configs.push(
// Array type should be Uint16Array, but at least on iOS that breaks. In that case we
// just initialize the textures with data=null, instead of data=new Uint16Array(...).
// This makes initialization a tad slower, but it's still negligible.
createConfig(
"half_float",
extensions.get("OES_texture_half_float").HALF_FLOAT_OES,
null,
),
);
}
// Setup the texture and framebuffer
const texture = gl.createTexture();
const framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
// Check for each supported texture type if rendering to it is supported
let config: IConfig | null = null;
for (let i = 0; i < configs.length; i++) {
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
32,
32,
0,
gl.RGBA,
configs[i].type,
null,
);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
texture,
0,
);
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE) {
config = configs[i];
break;
}
}
return config;
};
const createProgram = (
vertexSource: string,
fragmentSource: string,
uniformValues = null,
): {
id: WebGLProgram;
uniforms: IUniforms;
locations: ILocations; //Map<string, WebGLUniformLocation>;
} => {
const compileSource = (
type: number,
source: string,
): WebGLShader => {
const shader: WebGLShader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw new Error("compile error: " + gl.getShaderInfoLog(shader));
}
return shader;
};
const program: {
id: WebGLProgram;
uniforms: IUniforms;
locations: ILocations; //Map<string, WebGLUniformLocation>;
} = {};
program.id = gl.createProgram();
gl.attachShader(program.id, compileSource(gl.VERTEX_SHADER, vertexSource));
gl.attachShader(
program.id,
compileSource(gl.FRAGMENT_SHADER, fragmentSource),
);
gl.linkProgram(program.id);
if (!gl.getProgramParameter(program.id, gl.LINK_STATUS)) {
throw new Error("link error: " + gl.getProgramInfoLog(program.id));
}
// Fetch the uniform and attribute locations
program.uniforms = {};
program.locations = {};
gl.useProgram(program.id);
gl.enableVertexAttribArray(0);
let match: RegExpExecArray,
name: string;
const regex = /uniform (\w+) (\w+)/g,
shaderCode = vertexSource + fragmentSource;
while ((match = regex.exec(shaderCode)) != null) {
name = match[2];
program.locations[name] = gl.getUniformLocation(program.id, name);
}
return program;
};
const bindTexture = (texture: WebGLTexture, unit = 0) => {
gl.activeTexture(gl.TEXTURE0 + (unit || 0));
gl.bindTexture(gl.TEXTURE_2D, texture);
};
const transparentPixels = createImageData(32, 32);
// RIPPLES CLASS DEFINITION
// =========================
export class Ripples implements IRipples {
private element: HTMLElement;
private interactive: boolean = true;
private resolution: number = 256;
private perturbance: number = 0.03;
private dropRadius: number = 20;
private crossOrigin: string = "";
private imageUrl: string | null;
private textureDelta: Float32Array;
private canvas: HTMLCanvasElement;
private context: WebGLRenderingContext;
private textures: WebGLTexture[];
private framebuffers: WebGLFramebuffer[];
private bufferWriteIndex: number;
private bufferReadIndex: number;
private quad: WebGLBuffer;
visible: Ref<boolean>;
running: Ref<boolean>;
inited: Ref<boolean>;
destroyed: Ref<boolean>;
config: IConfig | null;
private backgroundWidth!: number;
private backgroundHeight!: number;
private originalCssBackgroundImage!: string;
private imageSource!: string;
private backgroundTexture!: WebGLTexture;
private renderProgram!: {
id: WebGLProgram;
uniforms: IUniforms;
locations: ILocations;
};
private updateProgram!: {
id: WebGLProgram;
uniforms: IUniforms;
locations: ILocations;
};
private dropProgram!: {
id: WebGLProgram;
uniforms: IUniforms;
locations: ILocations;
};
private originalInlineCss!: string;
constructor(
element: HTMLElement,
options: IOptions,
) {
this.element = element;
this.config = loadConfig();
// Init properties from options
// Whether mouse clicks and mouse movement triggers the effect.
this.interactive = options.interactive || true;
// The width and height of the WebGL texture to render to. The larger this value, the smoother the rendering and the slower the ripples will propagate.
this.resolution = options.resolution || 256;
this.textureDelta = new Float32Array([
1 / this.resolution,
1 / this.resolution,
]);
// Basically the amount of refraction caused by a ripple. 0 means there is no refraction.
this.perturbance = options.perturbance || 0.03;
// The size (in pixels) of the drop that results by clicking or moving the mouse over the canvas.
this.dropRadius = options.dropRadius || 20;
// The crossOrigin attribute to use for the affected image.
this.crossOrigin = options.crossOrigin || "";
// The URL of the image to use as the background.
this.imageUrl = options.imageUrl || null;
// Init WebGL canvas
this.canvas = document.createElement("canvas");
this.canvas.width = this.element.clientWidth;
this.canvas.height = this.element.clientHeight;
Object.assign(this.canvas.style, {
position: "absolute",
left: 0,
top: 0,
right: 0,
bottom: 0,
zIndex: -1,
});
this.element.style.position = "relative";
this.element.style.zIndex = "0";
this.element.append(this.canvas);
this.context = gl = this.canvas.getContext("webgl") ||
this.canvas.getContext("experimental-webgl");
// Load extensions
this.config.extensions.forEach((name: string) => gl.getExtension(name));
// Auto-resize when window size changes.
// this.updateSize = this.updateSize.bind(this);
window.addEventListener("resize", this.updateSize);
// Init rendertargets for ripple data.
this.textures = [];
this.framebuffers = [];
this.bufferWriteIndex = 0;
this.bufferReadIndex = 1;
const arrayType = this.config.arrayType;
const textureData = arrayType
? new arrayType(this.resolution * this.resolution * 4)
: null;
for (let i = 0; i < 2; i++) {
const texture = gl.createTexture();
const framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(
gl.TEXTURE_2D,
gl.TEXTURE_MIN_FILTER,
this.config.linearSupport ? gl.LINEAR : gl.NEAREST,
);
gl.texParameteri(
gl.TEXTURE_2D,
gl.TEXTURE_MAG_FILTER,
this.config.linearSupport ? gl.LINEAR : gl.NEAREST,
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
this.resolution,
this.resolution,
0,
gl.RGBA,
this.config.type,
textureData,
);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
texture,
0,
);
this.textures.push(texture);
this.framebuffers.push(framebuffer);
}
// Init GL stuff
this.quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([
-1,
-1,
+1,
-1,
+1,
+1,
-1,
+1,
]),
gl.STATIC_DRAW,
);
this.#initShaders();
this.#initTexture();
this.#setTransparentTexture();
// Load the image either from the options or CSS rules
this.#loadImage();
// Set correct clear color and blend mode (regular alpha blending)
gl.clearColor(0, 0, 0, 0);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
// Plugin is successfully initialized!
this.visible = ref(true);
this.running = ref(true);
this.inited = ref(true);
this.destroyed = ref(false);
this.#setupPointerEvents();
// Init animation
const step = () => {
if (!this.destroyed.value) {
this.#step();
requestAnimationFrame(step);
}
};
requestAnimationFrame(step);
}
// Set up pointer (mouse + touch) events
#setupPointerEvents() {
const pointerEventsEnabled = () =>
this.visible.value && this.running.value && this.interactive;
const dropAtPointer = (pointer: Touch | MouseEvent, big = false) => {
if (pointerEventsEnabled()) {
this.#dropAtPointer(
pointer,
this.dropRadius * (big ? 1.5 : 1),
big ? 0.14 : 0.01,
);
}
};
// Start listening to pointer events
// Create regular, small ripples for mouse move and touch events...
this.element.addEventListener("mousemove", (e) => dropAtPointer(e));
this.element.addEventListener("touchmove", (e) => {
const touches = e.changedTouches;
for (let i = 0; i < touches.length; i++) {
dropAtPointer(touches[i]);
}
});
this.element.addEventListener("touchstart", (e) => {
const touches = e.changedTouches;
for (let i = 0; i < touches.length; i++) {
dropAtPointer(touches[i]);
}
});
// ...and only a big ripple on mouse down events.
this.element.addEventListener("mousedown", (e) => dropAtPointer(e, true));
}
// Load the image either from the options or the element's CSS rules.
#loadImage() {
gl = this.context;
// NOTE: Known bug, will return 'auto' if style value is 'auto'
// const win = this.element.ownerDocument.defaultView;
// null means not to return pseudo styles
// win.getComputedStyle(this.element, null).color;
const newImageSource = this.imageUrl ||
extractUrl(this.originalCssBackgroundImage) ||
extractUrl(getComputedStyle(this.element)["backgroundImage"]);
// If image source is unchanged, don't reload it.
if (newImageSource == this.imageSource) {
return;
}
this.imageSource = newImageSource;
// Falsy source means no background.
if (!this.imageSource) {
this.#setTransparentTexture();
return;
}
// Load the texture from a new image.
const image = new Image();
image.onload = () => {
gl = this.context;
// Only textures with dimensions of powers of two can have repeat wrapping.
const isPowerOfTwo = (x: number) => (x & (x - 1)) == 0;
const wrapping = (isPowerOfTwo(image.width) && isPowerOfTwo(image.height))
? gl.REPEAT
: gl.CLAMP_TO_EDGE;
gl.bindTexture(gl.TEXTURE_2D, this.backgroundTexture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrapping);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrapping);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
image,
);
this.backgroundWidth = image.width;
this.backgroundHeight = image.height;
// Hide the background that we're replacing.
this.#hideCssBackground();
};
// Fall back to a transparent texture when loading the image failed.
image.onerror = () => {
gl = this.context;
this.#setTransparentTexture();
};
// Disable CORS when the image source is a data URI.
image.crossOrigin = isDataUri(this.imageSource) ? null : this.crossOrigin;
image.src = this.imageSource;
}
#step() {
gl = this.context;
if (!this.visible.value) {
return;
}
this.#computeTextureBoundaries();
if (this.running.value) {
this.update();
}
this.render();
}
#drawQuad() {
gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);
}
render() {
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
gl.enable(gl.BLEND);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.useProgram(this.renderProgram.id);
bindTexture(this.backgroundTexture, 0);
bindTexture(this.textures[0], 1);
gl.uniform1f(this.renderProgram.locations.perturbance, this.perturbance);
gl.uniform2fv(
this.renderProgram.locations.topLeft,
this.renderProgram.uniforms.topLeft,
);
gl.uniform2fv(
this.renderProgram.locations.bottomRight,
this.renderProgram.uniforms.bottomRight,
);
gl.uniform2fv(
this.renderProgram.locations.containerRatio,
this.renderProgram.uniforms.containerRatio,
);
gl.uniform1i(this.renderProgram.locations.samplerBackground, 0);
gl.uniform1i(this.renderProgram.locations.samplerRipples, 1);
this.#drawQuad();
gl.disable(gl.BLEND);
}
update() {
gl.viewport(0, 0, this.resolution, this.resolution);
gl.bindFramebuffer(
gl.FRAMEBUFFER,
this.framebuffers[this.bufferWriteIndex],
);
bindTexture(this.textures[this.bufferReadIndex]);
gl.useProgram(this.updateProgram.id);
this.#drawQuad();
this.#swapBufferIndices();
}
#swapBufferIndices() {
this.bufferWriteIndex = 1 - this.bufferWriteIndex;
this.bufferReadIndex = 1 - this.bufferReadIndex;
}
#computeTextureBoundaries() {
// NOTE: Known bug, will return 'auto' if style value is 'auto'
// const win = this.element.ownerDocument.defaultView;
// null means not to return pseudo styles
// win.getComputedStyle(this.element, null).color;
let backgroundSize = getComputedStyle(this.element)["background-size"];
const backgroundAttachment =
getComputedStyle(this.element)["background-attachment"];
const backgroundPosition = translateBackgroundPosition(
getComputedStyle(this.element)["background-position"],
);
// Here the 'container' is the element which the background adapts to
// (either the chrome window or some element, depending on attachment)
let container: { left: number; top: number; height: number; width: number };
if (backgroundAttachment == "fixed") {
container = {
left: (window.pageXOffset || window.scrollX),
top: (window.pageYOffset || window.scrollY),
};
container.width = window.document.documentElement.clientWidth ||
window.innerWidth;
container.height = window.document.documentElement.clientHeight ||
window.innerHeight;
} else {
container = getOffset(this.element);
container.width = this.element.clientWidth;
container.height = this.element.clientHeight;
}
// TODO: background-clip
if (backgroundSize == "cover") {
const scale = Math.max(
container.width / this.backgroundWidth,
container.height / this.backgroundHeight,
);
const backgroundWidth = this.backgroundWidth * scale;
const backgroundHeight = this.backgroundHeight * scale;
} else if (backgroundSize == "contain") {
const scale = Math.min(
container.width / this.backgroundWidth,
container.height / this.backgroundHeight,
);
const backgroundWidth = this.backgroundWidth * scale;
const backgroundHeight = this.backgroundHeight * scale;
} else {
backgroundSize = backgroundSize.split(" ");
let backgroundWidth = backgroundSize[0] || "";
let backgroundHeight = backgroundSize[1] || backgroundWidth;
if (isPercentage(backgroundWidth)) {
backgroundWidth = container.width * parseFloat(backgroundWidth) / 100;
} else if (backgroundWidth != "auto") {
backgroundWidth = parseFloat(backgroundWidth);
}
if (isPercentage(backgroundHeight)) {
backgroundHeight = container.height * parseFloat(backgroundHeight) /
100;
} else if (backgroundHeight != "auto") {
backgroundHeight = parseFloat(backgroundHeight);
}
if (backgroundWidth == "auto" && backgroundHeight == "auto") {
backgroundWidth = this.backgroundWidth;
backgroundHeight = this.backgroundHeight;
} else {
if (backgroundWidth == "auto") {
backgroundWidth = this.backgroundWidth *
(backgroundHeight / this.backgroundHeight);
}
if (backgroundHeight == "auto") {
backgroundHeight = this.backgroundHeight *
(backgroundWidth / this.backgroundWidth);
}
}
}
// Compute backgroundX and backgroundY in page coordinates
let backgroundX = backgroundPosition[0];
let backgroundY = backgroundPosition[1];
if (isPercentage(backgroundX)) {
backgroundX = String(
container.left +
(container.width - backgroundWidth) * parseFloat(backgroundX) / 100,
);
} else {
backgroundX = String(container.left + parseFloat(backgroundX));
}
if (isPercentage(backgroundY)) {
backgroundY = String(
container.top +
(container.height - backgroundHeight) * parseFloat(backgroundY) / 100,
);
} else {
backgroundY = String(container.top + parseFloat(backgroundY));
}
const elementOffset = getOffset(this.element);
this.renderProgram.uniforms.topLeft = new Float32Array([
(elementOffset.left - backgroundX) / backgroundWidth,
(elementOffset.top - backgroundY) / backgroundHeight,
]);
this.renderProgram.uniforms.bottomRight = new Float32Array([
this.renderProgram.uniforms.topLeft[0] +
this.element.clientWidth / backgroundWidth,
this.renderProgram.uniforms.topLeft[1] +
this.element.clientHeight / backgroundHeight,
]);
const maxSide = Math.max(this.canvas.width, this.canvas.height);
this.renderProgram.uniforms.containerRatio = new Float32Array([
this.canvas.width / maxSide,
this.canvas.height / maxSide,
]);
}
#initShaders() {
const vertexShader = [
"attribute vec2 vertex;",
"varying vec2 coord;",
"void main() {",
"coord = vertex * 0.5 + 0.5;",
"gl_Position = vec4(vertex, 0.0, 1.0);",
"}",
].join("\n");
this.dropProgram = createProgram(
vertexShader,
[
"precision highp float;",
"const float PI = 3.141592653589793;",
"uniform sampler2D texture;",
"uniform vec2 center;",
"uniform float radius;",
"uniform float strength;",
"varying vec2 coord;",
"void main() {",
"vec4 info = texture2D(texture, coord);",
"float drop = max(0.0, 1.0 - length(center * 0.5 + 0.5 - coord) / radius);",
"drop = 0.5 - cos(drop * PI) * 0.5;",
"info.r += drop * strength;",
"gl_FragColor = info;",
"}",
].join("\n"),
);
this.updateProgram = createProgram(
vertexShader,
[
"precision highp float;",
"uniform sampler2D texture;",
"uniform vec2 delta;",
"varying vec2 coord;",
"void main() {",
"vec4 info = texture2D(texture, coord);",
"vec2 dx = vec2(delta.x, 0.0);",
"vec2 dy = vec2(0.0, delta.y);",
"float average = (",
"texture2D(texture, coord - dx).r +",
"texture2D(texture, coord - dy).r +",
"texture2D(texture, coord + dx).r +",
"texture2D(texture, coord + dy).r",
") * 0.25;",
"info.g += (average - info.r) * 2.0;",
"info.g *= 0.995;",
"info.r += info.g;",
"gl_FragColor = info;",
"}",
].join("\n"),
);
gl.uniform2fv(this.updateProgram.locations.delta, this.textureDelta);
this.renderProgram = createProgram(
[
"precision highp float;",
"attribute vec2 vertex;",
"uniform vec2 topLeft;",
"uniform vec2 bottomRight;",
"uniform vec2 containerRatio;",
"varying vec2 ripplesCoord;",
"varying vec2 backgroundCoord;",
"void main() {",
"backgroundCoord = mix(topLeft, bottomRight, vertex * 0.5 + 0.5);",
"backgroundCoord.y = 1.0 - backgroundCoord.y;",
"ripplesCoord = vec2(vertex.x, -vertex.y) * containerRatio * 0.5 + 0.5;",
"gl_Position = vec4(vertex.x, -vertex.y, 0.0, 1.0);",
"}",
].join("\n"),
[
"precision highp float;",
"uniform sampler2D samplerBackground;",
"uniform sampler2D samplerRipples;",
"uniform vec2 delta;",
"uniform float perturbance;",
"varying vec2 ripplesCoord;",
"varying vec2 backgroundCoord;",
"void main() {",
"float height = texture2D(samplerRipples, ripplesCoord).r;",
"float heightX = texture2D(samplerRipples, vec2(ripplesCoord.x + delta.x, ripplesCoord.y)).r;",
"float heightY = texture2D(samplerRipples, vec2(ripplesCoord.x, ripplesCoord.y + delta.y)).r;",
"vec3 dx = vec3(delta.x, heightX - height, 0.0);",
"vec3 dy = vec3(0.0, heightY - height, delta.y);",
"vec2 offset = -normalize(cross(dy, dx)).xz;",
"float specular = pow(max(0.0, dot(offset, normalize(vec2(-0.6, 1.0)))), 4.0);",
"gl_FragColor = texture2D(samplerBackground, backgroundCoord + offset * perturbance) + specular;",
"}",
].join("\n"),
);
gl.uniform2fv(this.renderProgram.locations.delta, this.textureDelta);
}
#initTexture() {
this.backgroundTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.backgroundTexture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
}
#setTransparentTexture() {
gl.bindTexture(gl.TEXTURE_2D, this.backgroundTexture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
transparentPixels,
);
}
#hideCssBackground() {
// Check whether we're changing inline CSS or overriding a global CSS rule.
const inlineCss = this.element.style.backgroundImage;
if (inlineCss == "none") {
return;
}
this.originalInlineCss = inlineCss;
this.originalCssBackgroundImage =
getComputedStyle(this.element)["backgroundImage"];
this.element.style.backgroundImage = "none";
}
#restoreCssBackground() {
// Restore background by either changing the inline CSS rule to what it was, or
// simply remove the inline CSS rule if it never was inlined.
this.element.style.backgroundImage = this.originalInlineCss || "";
}
#dropAtPointer(
pointer: Touch | MouseEvent,
radius: number,
strength: number | Float32Array | number[] | Int32Array,
) {
const borderLeft =
parseInt(getComputedStyle(this.element)["border-left-width"]) || 0,
borderTop =
parseInt(getComputedStyle(this.element)["border-top-width"]) || 0;
this.drop(
pointer.pageX - getOffset(this.element).left - borderLeft,
pointer.pageY - getOffset(this.element).top - borderTop,
radius,
strength,
);
}
/**
* Public methods
*/
/**
* Manually add a drop at the element's relative coordinates (x, y). radius controls the drop's size and strength the amplitude of the resulting ripple.
*/
drop(
x: number,
y: number,
radius: number,
strength: number,
) {
gl = this.context;
const elWidth = this.element.clientWidth;
const elHeight = this.element.clientHeight;
const longestSide = Math.max(elWidth, elHeight);
radius = radius / longestSide;
const dropPosition = new Float32Array([
(2 * x - elWidth) / longestSide,
(elHeight - 2 * y) / longestSide,
]);
gl.viewport(0, 0, this.resolution, this.resolution);
gl.bindFramebuffer(
gl.FRAMEBUFFER,
this.framebuffers[this.bufferWriteIndex],
);
bindTexture(this.textures[this.bufferReadIndex]);
gl.useProgram(this.dropProgram.id);
gl.uniform2fv(this.dropProgram.locations.center, dropPosition);
gl.uniform1f(this.dropProgram.locations.radius, radius);
gl.uniform1f(this.dropProgram.locations.strength, strength);
this.#drawQuad();
this.#swapBufferIndices();
return this;
}
/**
* The effect resizes automatically when the width or height of the window changes. When the dimensions of the element changes, you need to call this method to update the size of the effect accordingly.
*/
updateSize() {
const newWidth = this.element.clientWidth,
newHeight = this.element.clientHeight;
if (newWidth != this.canvas.width || newHeight != this.canvas.height) {
this.canvas.width = newWidth;
this.canvas.height = newHeight;
}
return this;
}
/**
* Remove the effect from the element.
*/
destroy() {
this.element.removeEventListener(
"mousemove",
(e) => this.#dropAtPointer(e, this.dropRadius, 0.01),
);
this.element.removeEventListener("touchmove", (e) => {
const touches = e.changedTouches;
for (let i = 0; i < touches.length; i++) {
this.#dropAtPointer(touches[i], this.dropRadius, 0.01);
}
});
this.element.removeEventListener("touchstart", (e) => {
const touches = e.changedTouches;
for (let i = 0; i < touches.length; i++) {
this.#dropAtPointer(touches[i], this.dropRadius, 0.01);
}
});
// ...and only a big ripple on mouse down events.
this.element.removeEventListener(
"mousedown",
(e) => this.#dropAtPointer(e, this.dropRadius * 1.5, 0.14),
);
// this.element.classList.remove("jquery-ripples")
this.element.removeAttribute("data-ripples");
// Make sure the last used context is garbage-collected
gl = null;
window.removeEventListener("resize", this.updateSize);
this.canvas.remove();
this.#restoreCssBackground();
this.destroyed.value = true;
}
/**
* Toggle the effect's visibility to be shown. It will also effectively resume the simulation.
*/
show() {
this.visible.value = true;
this.canvas.style.display = ""; // block | flex
this.#hideCssBackground();
return this;
}
/**
* Toggle the effect's visibility to be hidden. It will also effectively pause the simulation.
*/
hide() {
this.visible.value = false;
this.canvas.style.display = "none";
this.#restoreCssBackground();
return this;
}
/**
* Toggle the simulation's state to pause.
*/
pause() {
this.running.value = false;
return this;
}
/**
* Toggle the simulation's state to play/resume.
*/
play() {
this.running.value = true;
return this;
}
/**
* Update properties of the effect.
*
* The properties can be either `dropRadius`, `perturbance`, `interactive`, `imageUrl` or `crossOrigin`
*/
set(property: string, value: string) {
switch (property) {
case "dropRadius":
this.dropRadius = Number(value);
break;
case "perturbance":
this.perturbance = Number(value);
break;
case "interactive":
this.interactive = Boolean(value);
break;
case "crossOrigin":
this.crossOrigin = value;
break;
case "imageUrl":
this.imageUrl = value;
this.#loadImage();
break;
}
}
}
// RIPPLES COMPOSABLE DEFINITION
// ==========================
const useRipples = (element: HTMLElement, options: IOptions) => {
const ripples = new Ripples(element, options);
if (!ripples.config) {
throw new Error(
"Your browser does not support WebGL, the OES_texture_float extension or rendering to floating point textures.",
);
}
element.setAttribute("ripples", { ripples });
return {
state: {
initialized: ripples.inited,
running: ripples.running,
visible: ripples.visible,
},
set: (name: string, value: any) => ripples.set(name, value),
destroy: () => ripples.destroy(),
pause: () => ripples.pause(),
play: () => ripples.play(),
hide: () => ripples.hide(),
show: () => ripples.show(),
drop: (x: number, y: number, radius: number, strength: number) =>
ripples.drop(x, y, radius, strength),
};
};
export default useRipples;
import type { Ref } from "vue";
export interface IUniforms {
topLeft?: Float32Array;
bottomRight?: Float32Array;
containerRatio?: Float32Array;
}
export interface ILocations {
perturbance?: WebGLUniformLocation;
topLeft?: WebGLUniformLocation;
bottomRight?: WebGLUniformLocation;
containerRatio?: WebGLUniformLocation;
samplerBackground?: WebGLUniformLocation;
samplerRipples?: WebGLUniformLocation;
delta?: WebGLUniformLocation;
center?: WebGLUniformLocation;
radius?: WebGLUniformLocation;
strength?: WebGLUniformLocation;
}
export interface IConfig {
type: any;
arrayType: any;
linearSupport: boolean;
extensions: string[];
}
export interface IOptions {
interactive?: boolean;
resolution?: number;
perturbance?: number;
dropRadius?: number;
crossOrigin?: string;
imageUrl?: string;
}
export interface IRipples {
element: HTMLElement;
interactive: boolean;
resolution: number;
perturbance: number;
dropRadius: number;
crossOrigin: string;
imageUrl: string | null;
textureDelta: Float32Array;
canvas: HTMLCanvasElement;
context: WebGLRenderingContext;
textures: WebGLTexture[];
framebuffers: WebGLFramebuffer[];
bufferWriteIndex: number;
bufferReadIndex: number;
quad: WebGLBuffer;
visible: Ref<boolean>;
running: Ref<boolean>;
inited: Ref<boolean>;
destroyed: Ref<boolean>;
backgroundWidth?: number;
backgroundHeight?: number;
originalCssBackgroundImage?: string;
imageSource?: string;
backgroundTexture?: WebGLTexture;
renderProgram?: {
id: WebGLProgram;
uniforms: IUniforms;
locations: ILocations;
};
updateProgram?: {
id: WebGLProgram;
uniforms: IUniforms;
locations: ILocations;
};
dropProgram?: {
id: WebGLProgram;
uniforms: IUniforms;
locations: ILocations;
};
originalInlineCss?: string;
}
export const isPercentage = (str: string) => str[str.length - 1] == "%";
export const getOffset = (el: HTMLElement) => {
const box = el.getBoundingClientRect();
return {
top: box.top + (window.pageYOffset || window.scrollY) -
document.documentElement.clientTop,
left: box.left + (window.pageXOffset || window.scrollX) -
document.documentElement.clientLeft,
};
};
export const createImageData = (width: number, height: number) => {
try {
return new ImageData(width, height);
} catch (e) {
// Fallback for IE
const canvas = document.createElement("canvas");
return canvas.getContext("2d").createImageData(width, height);
}
};
export const translateBackgroundPosition = (value: string) => {
const parts = value.split(" ");
if (parts.length === 1) {
switch (value) {
case "center":
return ["50%", "50%"];
case "top":
return ["50%", "0"];
case "bottom":
return ["50%", "100%"];
case "left":
return ["0", "50%"];
case "right":
return ["100%", "50%"];
default:
return [value, "50%"];
}
} else {
return parts.map((part: string) => {
switch (value) {
case "center":
return "50%";
case "top":
case "left":
return "0";
case "right":
case "bottom":
return "100%";
default:
return part;
}
});
}
};
export const extractUrl = (value: string) => {
const urlMatch = /url\(["']?([^"']*)["']?\)/.exec(value);
if (urlMatch == null) {
return null;
}
return urlMatch[1];
};
export const isDataUri = (url: string) => url.match(/^data:/);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment