UNPKG

css-doodle

Version:

A web component for visual art & creative coding

918 lines (836 loc) 26.9 kB
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_shaders from './generator/shaders.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, un_entity, debounce } from './utils/index.js'; import { cache } from './cache.js'; import { loadGoogleFontEmbed, loadGoogleFontLink } from './utils/google-font.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 { static observedAttributes = [ 'grid', 'seed', 'use', 'experimental', 'click-to-update', 'click:update', 'auto:update', ]; constructor() { super(); this.doodle = this.attachShadow({ mode: 'open' }); this.animations = []; this.observers = new Map(); this.shader_renders = new Map(); this.extra = { get_variable: name => get_variable(this, name), get_rgba_color: value => get_rgba_color(this.shadowRoot, value), }; } dispatchCellClick(event) { let cell = event.composedPath().find(el => el.tagName === 'CSSD-CELL'); if (!cell) return; let match = /^c-(\d+)-(\d+)-(\d+)$/.exec(cell.id); if (!match) return; this.triggerEvent('click:cell', { x: Number(match[1]), y: Number(match[2]), z: Number(match[3]), element: cell, originalEvent: event, }); } connectedCallback(again) { if (this.innerHTML) { this.load(again); this._rendering = true; } else { setTimeout(() => { this.load(again); this._rendering = true; });; } } 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) || (old_styles.backdrop !== compiled.styles.backdrop) ); 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(); } if (compiled.styles.gf) { loadGoogleFontLink(compiled.styles.gf); } this.set_style(replace( compiled.styles.top + 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); } if (!options.auto && (this.hasAttribute('auto:update') || this._auto_update_timer)) { this.autoUpdate(); } } 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(); } } async export({ scale, name, download, detail } = {}) { 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 fonts = await loadGoogleFontEmbed(); 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><![CDATA[ ${fonts} .host{${variables}} ]]></style> ${html} </div> </foreignObject> </svg> `; if (download || detail) { let { source, url, blob } = await generate_png(svg, w, h, scale); if (download) { let a = document.createElement('a'); a.download = get_png_name(name); a.href = url; a.click(); } return { width: w, height: h, svg, blob, source }; } return { width: w, height: h, svg }; } get grid() { return Object.assign({}, this.grid_size); } set grid(grid) { this.attr('grid', grid); } get seed() { return this._seed_value; } set seed(seed) { this.attr('seed', seed); } get use() { return this.attr('use'); } set use(use) { this.attr('use', use); } attributeChangedCallback(name, oldValue, newValue) { if (oldValue !== newValue && Expose.CSSDoodle.observedAttributes.includes(name)) { if (name === 'click-to-update' || name === 'click:update') { if (newValue === null) { this.removeEventListener('click', this.bindClickToUpdate); this.removeAttribute('click-to-update'); this.removeAttribute('click:update'); } else if(oldValue === null) { this.addEventListener('click', this.bindClickToUpdate); } } else if (name === 'auto:update') { if (newValue !== null) { this.autoUpdate(); } else { this.cancelAutoUpdate(); } } else { 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_auto_update_interval(interval) { const MIN = 500; const DEFAULT = 2000; if (is_nil(interval)) { interval = this.dataset.interval || this.attr('auto:update') || DEFAULT; } interval = String(interval).trim(); if (/^([\d.]+)m$/.test(interval)) { interval = parseFloat(interval) * 60 * 1000; } else if (/^([\d.]+)s$/.test(interval)) { interval = parseFloat(interval) * 1000; } else { interval = parseFloat(interval); } if (isNaN(interval)) { return DEFAULT; } return Math.max(interval, MIN); } autoUpdate(interval) { clearInterval(this._auto_update_timer); if (!is_nil(interval)) { this.dataset.interval = interval; } this._auto_update_timer = setInterval( () => this.update({ auto: true }), this._get_auto_update_interval(interval) ); } cancelAutoUpdate(options = {}) { clearInterval(this._auto_update_timer); this._auto_update_timer = null; this.removeAttribute('auto:update'); this.removeAttribute('data-interval'); } get_use() { let use = String(this.attr('use') || '').trim(); if (/^var\(/.test(use)) { 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('cssd-cell')) { el.style.cssText = ''; } this.observers.forEach((observer, target) => { observer.disconnect(); }); } // Clear shader CSS variables for (let id of Object.keys(shaders)) { this.style.removeProperty('--' + id); } for (let id of Object.keys(pattern)) { this.style.removeProperty('--' + id); } } this.observers.clear(); this.shader_renders.forEach(({ canvas }) => { if (canvas && canvas.loseContext) { canvas.loseContext(); } }); this.shader_renders.clear(); this.style.background = ''; } 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 styles = compiled.styles || {}; 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); let size = (options && options.width && options.height) ? `width="${options.width}" height="${options.height}"` : ''; loadGoogleFontEmbed(styles.gf || []) .then(importedFonts => replace(` <svg ${size} ${NS} preserveAspectRatio="none" ${viewBox}> <foreignObject width="100%" height="100%"> <div class="host" width="100%" height="100%" ${NSXHtml}> <style><![CDATA[ ${importedFonts} ${styles.top} @property --${utime.name} { syntax: "<integer>"; initial-value: 0; inherits: true; } @property --${UTime.name} { syntax: "<integer>"; initial-value: 0; inherits: true; } ${get_basic_styles(grid)} ${styles.all} ]]></style> ${grid_container} </div> </foreignObject> </svg> `)) .then(result => { let source = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(result)))}`; if (is_safari() && size) { return generate_png(result, parseInt(options.width), parseInt(options.height), devicePixelRatio || 2) .then(({ blob }) => { let url = URL.createObjectURL(blob); cache_image(url); return url; }); } if (is_safari()) { cache_image(source); } return source; }) .then(fn) .catch(err => { console.error(err); fn(''); }); } pattern_to_image({ code, cell, id, arg, target }, fn) { let shader = generate_pattern(code, this.extra); this.shader_to_image({ shader, cell, id, arg, target }, fn); } shader_to_image({ shader, cell, id, arg, target }, fn) { let element; if (target.selector === ':host') { element = this; } else if (target.selector === ':container') { element = this.shadowRoot.querySelector('cssd-grid'); } else { element = this.doodle.getElementById(cell); } let { width, height } = element.getBoundingClientRect(); let cs; if (arg) { cs = parse_grid(arg, Infinity); if (cs.x && cs.y) { width = Math.min(cs.x, width); height = Math.min(cs.y, height); } } 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 => { this.style.setProperty(id, 'url("' + v + '")'); } const tick = ([render, animated, canvas]) => { // Destroy any existing render for this target first let existing = this.shader_renders.get(target.selector); if (existing && existing.canvas && existing.canvas.loseContext) { existing.canvas.loseContext(); } if (target.type === 'content') { render(0, width, height, this._umouse, images); element.replaceChildren(canvas); if (animated) { this.animations.push(create_animation(t => { render(t, width, height, this._umouse, images); })); } else { this.shader_renders.set(target.selector, { render, canvas }); } } else { if (animated) { this.animations.push(create_animation(t => { render(t, width, height, this._umouse, images); set_shader_prop(canvas.toDataURL()); })); } else { this.shader_renders.set(target.selector, { render, canvas }); render(0, width, height, this._umouse, images); set_shader_prop(canvas.toDataURL()); } } } const transform = (sources, fn) => { let dpr = devicePixelRatio || 1; Promise.all(sources.map(({ name, value }) => { return new Promise(resolve => { this.doodle_to_image(value, {width, height}, src => { if (!src) { resolve({ name, value: null }); return; } let img = new Image(); img.width = width * dpr; img.height = height * dpr; img.onload = () => resolve({ name, value: img }); img.onerror = () => resolve({ name, value: null }); img.src = src; }); }); })).then(fn); } if (!this.observers.has(target.selector)) { let observer = new ResizeObserver(debounce(() => { let rect = element.getBoundingClientRect(); width = rect.width; height = rect.height; if (cs && cs.x && cs.y) { width = Math.min(cs.x, width); height = Math.min(cs.y, height); } transform(sources, result => { images = result; let render = this.shader_renders.get(target.selector); if (render) { render.render(0, width, height, this._umouse, images); if (target.type === 'background') { set_shader_prop(render.canvas.toDataURL()); } } }); })); observer.observe(element); this.observers.set(target.selector, observer); } const render_shaders = () => generate_shaders(parsed, seed, target.type) .then(tick) .then(fn) .catch(err => { console.error(err); fn(''); }); if (sources.length) { transform(sources, result => { parsed.textures = images = result; parsed.width = width; parsed.height = height; render_shaders(); }); } else { render_shaders(); } } bindClickToUpdate() { this.update(); } load(again) { if (this._rendering) { return false; } 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.hasAttribute('click:update')) { this.addEventListener('click', this.bindClickToUpdate); } this.addEventListener('click', this.dispatchCellClick); } this.grid_size = compiled.grid ? compiled.grid : this.get_grid(); this.build_grid(compiled, this.grid_size); this._code = code; this.innerHTML = ''; setTimeout(() => { this._rendering = false; 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; }).catch(err => { console.error(err); return input; }); } } reflow() { this.shadowRoot.querySelector('cssd-grid').offsetWidth; } bind_uniforms({ time, mousex, mousey, mouse, width, height }) { if (time) { this.reg_utime(); } if (mousex || mousey || mouse) { this.reg_umouse(mousex, mousey, mouse); } 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; const { uniforms, content, styles } = compiled; let has_delay = (has_transition || has_animation); let has_content = Object.keys(content).length; this.doodle.innerHTML = ` <style>${get_basic_styles(grid) + styles.main}</style> ${(styles.cells || styles.container || has_content) ? create_grid(grid, compiled) : ''} `; if (has_delay) { this.reflow(); } if (styles.gf) { loadGoogleFontLink(styles.gf); } let replace = this.replace(compiled); this.set_style(replace( styles.top + get_basic_styles(grid) + styles.all )); if (has_content) { replace(Object.values(compiled.content).join(' ')); } this.bind_uniforms(uniforms); } reg_umouse(mousex, mousey, mouse) { if (!this.umouse_fn) { this.umouse_fn = e => { let data = e.detail || e; if (mouse) { this._umouse = { x: data.offsetX, y: data.offsetY }; } 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 || mouse)) { 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; delete this._umouse; } } 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)).catch(console.error); } 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: fit-content; height: fit-content; contain: content; 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 } cssd-grid, cssd-cell { display: grid; position: relative; } cssd-grid { gap: inherit; grid-template: repeat(${y},1fr)/repeat(${x},1fr) } b { position: absolute; inset: 0; pointer-events: none; } cssd-cell { place-items: center; min-height: 0; min-width: 0; } svg, canvas { position: absolute; } cssd-grid, svg, canvas { width: 100%; height: 100% } canvas { object-fit: cover; } `; } function create_cell(x, y, z, content, child = '') { let id = cell_id(x, y, z); let tail = child ?? ''; let head = content['#' + id] ?? ''; if (head.startsWith('${shader')) { head = ''; } return `<cssd-cell id="${id}" part="cell">${head}${tail}</cssd-cell>`; } function create_grid(grid_obj, compiled) { let { x, y, z } = grid_obj || {}; let { content, styles } = compiled; 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; } let html = `<cssd-grid part="grid">${result}</cssd-grid>`; if (styles.backdrop) { html += '<b></b>' } return html; } export const CSSDoodle = Expose.CSSDoodle; export const define = Expose.define;