@gitlab/ui
Version:
GitLab UI Components
158 lines (154 loc) • 6.04 kB
JavaScript
import { resolveColors } from './palette';
import { createColumns, measureGrid, advanceColumn, glyph } from './columns';
import { RUNNING_ATTRIBUTE, CANVAS_Z_INDEX, FADE_OUT_DURATION, CONFIG, FONT, DEFAULT_DURATION, MAX_FRAME_DELTA } from './constants';
const noop = () => {};
const measureStage = boundary => {
const rect = boundary instanceof Element ? boundary.getBoundingClientRect() : {
left: 0,
top: 0,
width: window.innerWidth,
height: window.innerHeight
};
return {
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height
};
};
const createCanvas = () => {
const canvas = document.createElement('canvas');
canvas.setAttribute('aria-hidden', 'true');
canvas.setAttribute(RUNNING_ATTRIBUTE, '');
Object.assign(canvas.style, {
position: 'fixed',
inset: '0',
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: String(CANVAS_Z_INDEX)
});
return canvas;
};
/**
* Rains `G` and `L` glyphs down the screen, then cleans up after itself.
*
* Every option has a default, so `matrix()` on its own rains over the whole viewport. Pass
* `boundary` to keep the rain inside an element. Each column falls at its own speed in one color
* from the brand ramp, with a head glyph in the default text color and a trail that fades behind it.
*
* Only one run happens at a time: a call made while another is still on screen does nothing and
* returns a no-op.
*
* Does nothing when the user prefers reduced motion, or outside a browser.
*
* The canvas is `aria-hidden`, so nobody using a screen reader will know it ran. When the rain is
* the only sign that something happened, the caller has to convey that in the accessibility tree
* as well.
*
* @param {Object} [options]
* @param {Number} [options.duration=6000] How long the rain runs, in milliseconds.
* @param {String[]} [options.colors] CSS colors for the columns, picked at random one per column.
* Defaults to the brand orange and purple and the red color tokens. The head glyph's text color
* resolves against `boundary` when given, so a `.gl-dark-scope` ancestor is honored.
* @param {Element} [options.boundary] Element to confine the rain to. Defaults to the viewport.
* @example
* const stop = matrix({ boundary: terminal, duration: 4000 });
* @returns {Function} Stops the rain and removes the canvas early.
*/
const matrix = function () {
var _window$matchMedia, _window;
let _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {},
_ref$duration = _ref.duration,
duration = _ref$duration === void 0 ? DEFAULT_DURATION : _ref$duration,
colors = _ref.colors,
boundary = _ref.boundary;
if (typeof window === 'undefined' || (_window$matchMedia = (_window = window).matchMedia) !== null && _window$matchMedia !== void 0 && _window$matchMedia.call(_window, '(prefers-reduced-motion: reduce)').matches || document.querySelector(`[${RUNNING_ATTRIBUTE}]`)) {
return noop;
}
const canvas = createCanvas();
const context = canvas.getContext('2d');
if (!context) {
return noop;
}
const stage = measureStage(boundary);
const resize = () => {
const ratio = window.devicePixelRatio || 1;
canvas.width = window.innerWidth * ratio;
canvas.height = window.innerHeight * ratio;
context.setTransform(ratio, 0, 0, ratio, 0, 0);
};
const _resolveColors = resolveColors(colors, boundary instanceof Element ? boundary : document.documentElement),
head = _resolveColors.head,
rain = _resolveColors.rain;
let columns = createColumns(stage, rain);
const erase = delta => {
context.globalCompositeOperation = 'destination-out';
context.fillStyle = `rgba(0, 0, 0, ${1 - Math.exp(-CONFIG.trail * delta)})`;
context.fillRect(0, 0, stage.width, stage.height);
context.globalCompositeOperation = 'source-over';
};
const paintColumn = (column, previousHead) => {
const x = column.column * CONFIG.cell;
if (previousHead !== null) {
context.fillStyle = column.color;
context.fillText(glyph(), x, previousHead * CONFIG.cell);
}
column.steps.forEach((row, index) => {
context.fillStyle = index === column.steps.length - 1 ? head : column.color;
context.fillText(glyph(), x, row * CONFIG.cell);
});
};
let frame = null;
let startedAt = null;
let previousAt = null;
const stop = () => {
if (frame !== null) {
window.cancelAnimationFrame(frame);
frame = null;
}
window.removeEventListener('resize', resize);
canvas.remove();
};
const render = now => {
var _startedAt, _previousAt;
(_startedAt = startedAt) !== null && _startedAt !== void 0 ? _startedAt : startedAt = now;
const elapsed = now - startedAt;
const delta = Math.min(now - ((_previousAt = previousAt) !== null && _previousAt !== void 0 ? _previousAt : now), MAX_FRAME_DELTA);
previousAt = now;
if (elapsed >= duration) {
stop();
return;
}
const grid = measureGrid(stage);
Object.assign(stage, measureStage(boundary));
const nextGrid = measureGrid(stage);
if (nextGrid.across !== grid.across || nextGrid.rows !== grid.rows) {
columns = createColumns(stage, rain);
}
canvas.style.opacity = String(Math.min(1, (duration - elapsed) / FADE_OUT_DURATION));
context.save();
context.beginPath();
context.rect(stage.x, stage.y, stage.width, stage.height);
context.clip();
context.translate(stage.x, stage.y);
erase(delta);
context.font = `${CONFIG.cell}px ${FONT}`;
context.textBaseline = 'top';
columns = columns.map(column => {
const next = advanceColumn(column, delta);
if (next.steps.length) {
paintColumn(next, column.head);
}
return next;
});
context.restore();
frame = window.requestAnimationFrame(render);
};
resize();
window.addEventListener('resize', resize);
document.body.append(canvas);
frame = window.requestAnimationFrame(render);
return stop;
};
export { matrix };