css-doodle
Version:
A web component for drawing patterns with CSS
709 lines (635 loc) • 19.9 kB
JavaScript
import parse_css from './parser/parse-css.js';
import parse_grid from './parser/parse-grid.js';
import parse_shaders from './parser/parse-shaders.js';
import generate_css from './generator/css.js';
import generate_shader from './generator/shader.js';
import generate_pattern from './generator/pattern.js';
import generate_png from './generator/svg-to-png.js';
import get_rgba_color from './utils/get-rgba-color.js';
import create_animation from './utils/create-animation.js';
import { get_variable, get_all_variables } from './utils/variables.js';
import { NS, NSXHtml } from './utils/svg.js';
import { utime, UTime, umousex, umousey, uwidth, uheight } from './uniforms.js';
import { cell_id, is_nil, get_png_name, cache_image, is_safari, entity, un_entity } from './utils/index.js';
import { cache } from './cache.js';
const Expose = {
CSSDoodle: class {},
define(name, element) {
if (typeof customElements !== 'undefined' && !customElements.get(name)) {
customElements.define(name, element);
}
}
}
if (typeof HTMLElement !== 'undefined') {
Expose.CSSDoodle = class extends HTMLElement {
constructor() {
super();
this.doodle = this.attachShadow({ mode: 'open' });
this.animations = [];
this.extra = {
get_variable: name => get_variable(this, name),
get_rgba_color: value => get_rgba_color(this.shadowRoot, value),
};
}
connectedCallback(again) {
if (this.innerHTML) {
this.load(again);
} else {
setTimeout(() => this.load(again));
}
}
disconnectedCallback() {
this.cleanup();
}
triggerEvent(name, detail = {}) {
return this.dispatchEvent(
new CustomEvent(name, {
detail,
bubbles: true,
composed: true,
})
);
}
_update(styles) {
this.cleanup();
// Use old rules to update
if (!styles) {
styles = un_entity(this._code);
}
if (this._code !== styles) {
this._code = styles;
}
if (!this.grid_size) {
this.grid_size = this.get_grid();
}
const { x: gx, y: gy, z: gz } = this.grid_size;
const use = this.get_use();
let old_content = '';
let old_styles = {};
if (this.compiled) {
old_content = this.compiled.content;
old_styles = this.compiled.styles;
}
const compiled = this.generate(parse_css(use + styles, this.extra));
let grid = compiled.grid || this.get_grid();
let { x, y, z } = grid;
let should_rebuild = (
!this.shadowRoot.innerHTML
|| this.shadowRoot.querySelector('css-doodle')
|| (gx !== x || gy !== y || gz !== z)
|| (JSON.stringify(old_content) !== JSON.stringify(compiled.content))
|| (!old_styles.cells || !compiled.styles.cells)
);
Object.assign(this.grid_size, grid);
if (should_rebuild) {
compiled.grid
? this.build_grid(compiled, grid)
: this.build_grid(this.generate(parse_css(use + styles, this.extra)), grid);
} else {
this.bind_uniforms(compiled.uniforms);
let replace = this.replace(compiled);
if (compiled.props.has_animation) {
this.set_style(old_styles.all.replace(/animation/g, 'x'));
this.reflow();
}
this.set_style(replace(
get_basic_styles(this.grid_size) +
compiled.styles.all
));
}
setTimeout(() => {
this.triggerEvent('render');
this.triggerEvent('afterUpdate');
this.triggerEvent('update');
});
}
update(styles, options = {}) {
this.triggerEvent('beforeUpdate');
if (!document.startViewTransition) {
return this._update(styles);
}
if (!arguments.length) {
styles = '';
options = {};
}
if (typeof styles === 'object') {
options = styles;
styles = '';
}
let useAnimation = this.viewTransition;
if (useAnimation === undefined) {
useAnimation = this.hasAttribute('view-transition');
}
if (useAnimation) {
document.startViewTransition(() => {
this._update(styles);
});
} else {
this._update(styles);
}
}
pause() {
this.setAttribute('cssd-paused', true);
for (let am of this.animations) {
am.pause();
}
}
resume() {
this.removeAttribute('cssd-paused');
for (let am of this.animations) {
am.resume();
}
}
export({ scale, name, download, detail } = {}) {
return new Promise((resolve, reject) => {
let variables = get_all_variables(this);
let html = this.doodle.innerHTML;
let { width, height } = this.getBoundingClientRect();
scale = parseInt(scale) || 1;
let w = width * scale;
let h = height * scale;
let svg = `
<svg ${NS} preserveAspectRatio="none" viewBox="0 0 ${width} ${height}" ${is_safari() ? '' : `width="${w}px" height="${h}px"`}>
<foreignObject width="100%" height="100%">
<div class="host" ${NSXHtml} style="width:${width}px;height:${height}px">
<style>.host{${entity(variables)}}</style>
${html}
</div>
</foreignObject>
</svg>
`;
if (download || detail) {
generate_png(svg, w, h, scale)
.then(({ source, url, blob }) => {
resolve({
width: w, height: h, svg, blob, source
});
if (download) {
let a = document.createElement('a');
a.download = get_png_name(name);
a.href = url;
a.click();
}
})
.catch(error => {
reject(error);
});
} else {
resolve({
width: w, height: h, svg: svg
});
}
});
}
get grid() {
return Object.assign({}, this.grid_size);
}
set grid(grid) {
this.attr('grid', grid);
this.connectedCallback(true);
}
get seed() {
return this._seed_value;
}
set seed(seed) {
this.attr('seed', seed);
this.connectedCallback(true);
}
get use() {
return this.attr('use');
}
set use(use) {
this.attr('use', use);
this.connectedCallback(true);
}
get_max_grid() {
return this.hasAttribute('experimental') ? 256 : 64;
}
get_grid() {
return parse_grid(this.attr('grid'), this.get_max_grid());
}
get_use() {
let use = String(this.attr('use') || '').trim();
if (/^var\(/.test(use)) {
use = ` :${use};`;
}
return use;
}
cleanup() {
cache.clear();
if (this.compiled) {
for (let am of this.animations) {
am.cancel();
}
this.animations = [];
let { pattern, shaders } = this.compiled;
if (Object.keys(pattern).length || Object.keys(shaders).length) {
for (let el of this.shadowRoot.querySelectorAll('cell')) {
el.style.cssText = '';
}
}
}
}
attr(name, value) {
let len = arguments.length;
if (len === 1) {
return this.getAttribute(name);
}
if (len === 2) {
this.setAttribute(name, value);
return value;
}
}
generate(parsed) {
let grid = this.get_grid();
let seed = this.attr('seed') || this.attr('data-seed');
if (is_nil(seed)) {
seed = Date.now();
}
let compiled = this.compiled = generate_css(
parsed, grid, seed, this.get_max_grid()
);
this._seed_value = compiled.seed;
this._seed_random = compiled.random;
return compiled;
}
doodle_to_image(code, options, fn) {
if (typeof options === 'function') {
fn = options;
options = null;
}
code = ':doodle {width:100%;height:100%}' + code;
let parsed = parse_css(code, this.extra);
let _grid = parse_grid('');
let compiled = generate_css(parsed, _grid, this._seed_value, this.get_max_grid(), this._seed_random, options.upextra);
let grid = compiled.grid ? compiled.grid : _grid;
let viewBox = '';
if (options && options.arg) {
let v = parse_grid(options.arg, Infinity);
if (v.x && v.y) {
options.width = v.x + 'px';
options.height = v.y + 'px';
viewBox = `viewBox="0 0 ${v.x} ${v.y}"`;
}
}
let replace = this.replace(compiled);
let grid_container = create_grid(grid, compiled.content);
let size = (options && options.width && options.height)
? `width="${options.width}" height="${options.height}"`
: '';
replace(`
<svg ${size} ${NS} preserveAspectRatio="none" ${viewBox}>
<foreignObject width="100%" height="100%">
<div class="host" width="100%" height="100%" ${NSXHtml}>
<style>
--${utime.name} { syntax: "<integer>"; initial-value: 0; inherits: true; }
--${UTime.name} { syntax: "<integer>"; initial-value: 0; inherits: true; }
${get_basic_styles(grid)}
${compiled.styles.all}
</style>
${grid_container}
</div>
</foreignObject>
</svg>
`).then(result => {
let source =`data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(result)))}`;
if (is_safari()) {
if (size) {
generate_png(result, parseInt(options.width), parseInt(options.height), devicePixelRatio || 2).then(({ blob }) => {
let url = URL.createObjectURL(blob);
cache_image(url);
fn(url);
});
} else {
cache_image(source);
fn(source);
}
} else {
fn(source);
}
});
}
pattern_to_image({ code, cell, id }, fn) {
let shader = generate_pattern(code, this.extra);
this.shader_to_image({ shader, cell, id }, fn);
}
shader_to_image({ shader, cell, id }, fn) {
const element = this.doodle.getElementById(cell);
if (!element) {
return false;
}
let { width, height } = element.getBoundingClientRect();
let ratio = devicePixelRatio || 1;
let seed = this.seed;
let parsed = typeof shader === 'string' ? parse_shaders(shader) : shader;
parsed.width = width;
parsed.height = height;
let sources = parsed.textures;
let images = [];
const set_shader_prop = v => {
element.style.setProperty(id, `url(${v})`);
}
const tick = v => {
if (typeof v === 'function') {
this.animations.push(create_animation(t => {
set_shader_prop(v(t, width, height, images));
}));
} else {
set_shader_prop(v);
}
}
const transform = (sources, fn) => {
Promise.all(sources.map(({ name, value }) => {
return new Promise(resolve => {
this.doodle_to_image(value, {width, height}, src => {
let img = new Image();
img.width = width * ratio;
img.height = width * ratio;
img.onload = () => resolve({ name, value: img });
img.src = src;
});
});
})).then(fn);
}
if (!element.observer) {
element.observer = new ResizeObserver(() => {
let rect = element.getBoundingClientRect();
width = rect.width;
height = rect.height;
transform(sources, result => images = result);
});
element.observer.observe(element);
}
if (sources.length) {
transform(sources, result => {
parsed.textures = images = result;
parsed.width = width;
parsed.height = height;
generate_shader(parsed, seed).then(tick).then(fn);
});
} else {
generate_shader(parsed, seed).then(tick).then(fn);
}
}
load(again) {
this.cleanup();
let code = this._code || this.innerHTML;
let use = this.get_use();
let parsed = parse_css(use + un_entity(code), this.extra);
let compiled = this.generate(parsed);
if (!again) {
if (this.hasAttribute('click-to-update')) {
this.addEventListener('click', e => this.update());
}
}
this.grid_size = compiled.grid
? compiled.grid
: this.get_grid();
this.build_grid(compiled, this.grid_size);
this._code = code;
this.innerHTML = '';
setTimeout(() => {
this.triggerEvent('render');
});
}
replace({ doodles, shaders, pattern }) {
let doodle_ids = Object.keys(doodles);
let shader_ids = Object.keys(shaders);
let pattern_ids = Object.keys(pattern);
let length = doodle_ids.length + shader_ids.length + pattern_ids.length;
return input => {
if (!length) {
return Promise.resolve(input);
}
let mappings = [].concat(
doodle_ids.map(id => {
if (input.includes(id)) {
return new Promise(resolve => {
let { arg, doodle, upextra } = doodles[id];
this.doodle_to_image(doodle, { arg, upextra }, value => resolve({ id, value }));
});
} else {
return Promise.resolve('');
}
}),
shader_ids.map(id => {
if (input.includes(id)) {
return new Promise(resolve => {
this.shader_to_image(shaders[id], value => resolve({ id, value }));
});
} else {
return Promise.resolve('');
}
}),
pattern_ids.map(id => {
if (input.includes(id)) {
return new Promise(resolve => {
this.pattern_to_image(pattern[id], value => resolve({ id, value }));
});
} else {
return Promise.resolve('');
}
}),
);
return Promise.all(mappings).then(mapping => {
for (let {id, value} of mapping) {
/* default to data-uri for doodle and pattern */
let target = `url(${value})`;
/* shader uses css vars */
if (/^shader|^pattern/.test(id)) target = `var(--${id})`;
input = input.replaceAll('${' + id + '}', target);
}
return input;
});
}
}
reflow() {
this.shadowRoot.querySelector('grid').offsetWidth;
}
bind_uniforms({ time, mousex, mousey, width, height }) {
if (time) {
this.reg_utime();
}
if (mousex || mousey) {
this.reg_umouse(mousex, mousey);
} else {
this.off_umouse();
}
if (width || height) {
this.reg_usize(width, height);
} else {
this.off_usize();
}
}
build_grid(compiled, grid) {
const { has_transition, has_animation } = compiled.props;
let has_delay = (has_transition || has_animation);
const { uniforms, content, styles } = compiled;
this.doodle.innerHTML = `
<style>${get_basic_styles(grid) + styles.main}</style>
${(styles.cells || styles.container || Object.keys(content).length) ? create_grid(grid, content) : ''}
`;
if (has_delay) {
this.reflow();
}
let replace = this.replace(compiled);
this.set_style(replace(
get_basic_styles(grid) +
styles.all
));
this.bind_uniforms(uniforms);
}
reg_umouse(mousex, mousey) {
if (!this.umouse_fn) {
this.umouse_fn = e => {
let data = e.detail || e;
if (mousex || mousey) {
this.style.setProperty('--' + umousex.name, data.offsetX);
this.style.setProperty('--' + umousey.name, data.offsetY);
}
}
this.addEventListener('pointermove', this.umouse_fn);
let event = new CustomEvent('pointermove', { detail: { offsetX: 0, offsetY: 0}});
this.dispatchEvent(event);
} else {
if (!(mousex || mousey)) {
this.off_umouse();
}
}
}
off_umouse() {
if (this.umouse_fn) {
this.style.removeProperty('--' + umousex.name);
this.style.removeProperty('--' + umousey.name);
this.removeEventListener('pointermove', this.umouse_fn);
this.umouse_fn = null;
}
}
reg_usize(width, height) {
if (!this.usize_observer) {
this.usize_observer = new ResizeObserver(() => {
let box = this.getBoundingClientRect();
if (width || height) {
this.style.setProperty('--' + uwidth.name, box.width);
this.style.setProperty('--' + uheight.name, box.height);
}
});
this.usize_observer.observe(this);
} else {
if (!(width || height)) {
this.off_usize();
}
}
}
off_usize() {
if (this.usize_observer) {
this.style.removeProperty('--' + uwidth.name);
this.style.removeProperty('--' + uheight.name);
this.usize_observer.unobserve(this);
this.usize_observer = null;
}
}
reg_utime() {
if (!this.is_utime_set) {
try {
CSS.registerProperty({
name: '--' + utime.name,
syntax: '<integer>',
initialValue: 0,
inherits: true
});
CSS.registerProperty({
name: '--' + UTime.name,
syntax: '<integer>',
initialValue: 0,
inherits: true
});
} catch (e) {}
this.is_utime_set = true;
}
}
set_style(input) {
if (input instanceof Promise) {
input.then(v => {
this.set_style(v);
});
} else {
const el = this.shadowRoot.querySelector('style');
if (el) {
el.textContent = input.replace(/\n\s+/g, ' ');
}
}
}
}
}
function get_basic_styles(grid) {
let { x, y } = grid || {};
return `
*,*::after,*::before,:host,.host {
box-sizing: border-box;
}
:host,.host {
display: block;
visibility: visible;
width: auto;
height: auto;
contain: strict;
view-transition-name: css-doodle;
--${utime.name}: 0;
--${UTime.name}: 0
}
:host([hidden]),[hidden] {
display: none
}
:host([cssd-paused]),
:host([cssd-paused]) * {
animation-play-state: paused !important
}
grid, cell {
display: grid;
position: relative;
}
grid {
gap: inherit;
grid-template: repeat(${y},1fr)/repeat(${x},1fr)
}
cell {
place-items: center
}
svg {
position: absolute;
}
grid, svg {
width: 100%;
height: 100%
}
`;
}
function create_cell(x, y, z, content, child = '') {
let id = cell_id(x, y, z);
let head = content['#' + id] ?? '';
let tail = child ?? '';
return `<cell id="${id}" part="cell">${head}${tail}</cell>`;
}
function create_grid(grid_obj, content) {
let { x, y, z } = grid_obj || {};
let result = '';
if (z == 1) {
for (let j = 1; j <= y; ++j) {
for (let i = 1; i <= x; ++i) {
result += create_cell(i, j, 1, content);
}
}
}
else {
let child = '';
for (let i = z; i >= 1; i--) {
let cell = create_cell(1, 1, i, content, child);
child = cell;
}
result = child;
}
return `<grid part="grid">${result}</grid>`;
}
export const CSSDoodle = Expose.CSSDoodle;
export const define = Expose.define;