css-doodle
Version:
A web component for drawing patterns with CSS
1,025 lines (946 loc) • 29 kB
JavaScript
import Func from '../function.js';
import Property from '../property.js';
import Selector from '../selector.js';
import parse_value_group from '../parser/parse-value-group.js';
import calc from '../calc.js';
import seedrandom from '../lib/seedrandom.js';
import { utime, UTime } from '../uniforms.js';
import { cell_id, is_nil, get_value, lerp, unique_id, join, make_array, remove_empty_values, hash } from '../utils/index.js';
const DELAY = new Date().setHours(0, 0, 0, 0) - Date.now();
function is_host_selector(s) {
return /^\:(host|doodle)/.test(s);
}
function is_parent_selector(s) {
return /^\:container/.test(s);
}
function is_special_selector(s) {
return is_host_selector(s) || is_parent_selector(s);
}
function is_pseudo_selector(s) {
return /\:before|\:after/.test(s);
}
const MathFunc = {};
for (let name of Object.getOwnPropertyNames(Math)) {
MathFunc[name] = () => (...args) => {
if (typeof Math[name] === 'number') {
return Math[name];
}
args = args.map(n => calc(get_value(n)));
return Math[name](...args);
}
}
class Rules {
constructor(tokens) {
this.tokens = tokens;
this.rules = {};
this.props = {};
this.keyframes = {};
this.grid = null;
this.seed = null;
this.is_grid_set = false;
this.is_gap_set = false;
this.coords = [];
this.doodles = {};
this.pattern = {};
this.shaders = {};
this.vars = {};
this.uniforms = {};
this.content = {};
this.reset();
}
reset() {
this.styles = {
host: '',
container: '',
cells: '',
keyframes: ''
}
this.coords = [];
this.doodles = {};
this.pattern = {};
this.shaders = {};
this.content = {};
this.vars = {};
for (let key in this.rules) {
if (key.startsWith('#c')) {
delete this.rules[key];
}
}
}
add_rule(selector, rule) {
let rules = this.rules[selector];
if (!rules) {
rules = this.rules[selector] = [];
}
rules.push.apply(rules, make_array(rule));
}
pick_func(name) {
if (name.startsWith('$')) name = 'calc';
return Func[name] || MathFunc[name];
}
apply_func(fn, coords, args, fname, contextVariable = {}) {
let _fn = fn(...make_array(coords));
let input = [];
args.forEach(arg => {
let type = typeof arg.value;
if (!arg.cluster && ((type === 'number' || type === 'string'))) {
input.push(...parse_value_group(arg.value, { noSpace: true }));
}
else if (typeof arg === 'function') {
input.push(arg);
}
else if (!is_nil(arg.value)) {
let value = get_value(arg.value);
input.push(value);
}
});
input = make_array(remove_empty_values(input));
if (typeof _fn === 'function') {
if (fname.startsWith('$')) {
let group = Object.assign({},
this.vars['host'],
this.vars['container'],
this.vars[coords.count],
contextVariable
);
let context = {};
let unit = '';
for (let [name, key] of Object.entries(group)) {
context[name.substr(2)] = key;
}
if (fname.length > 1) {
unit = fname.split('$')[1] ?? '';
}
return _fn(input, context) + unit;
}
return _fn(...input);
}
return _fn;
}
compose_aname(...args) {
return args.join('-');
}
compose_selector({ x, y, z}, pseudo = '') {
return `#${cell_id(x, y, z)}${pseudo}`;
}
is_composable(name) {
return ['doodle', 'shaders', 'pattern'].includes(name);
}
read_var(value, coords, contextVariable) {
let count = coords.count;
let group = Object.assign({},
this.vars['host'],
this.vars['container'],
this.vars[count],
contextVariable
);
if (group[value] !== undefined) {
let result = String(group[value]).trim();
if (result[0] == '(') {
let last = result[result.length - 1];
if (last === ')') {
result = result.substring(1, result.length - 1);
}
}
return result.replace(/;+$/g, '');
}
return value;
}
compose_argument(argument, coords, extra = [], parent, contextVariable) {
if (!coords.extra) coords.extra = [];
coords.extra.push(extra);
let result = argument.map(arg => {
if (arg.type === 'text') {
if (/^\-\-\w/.test(arg.value)) {
if (parent && parent.name === '@var') {
return arg.value;
}
return this.read_var(arg.value, coords, contextVariable);
}
return arg.value;
}
else if (arg.type === 'func') {
let fname = arg.name.substr(1);
let fn = this.pick_func(fname);
if (typeof fn === 'function') {
this.check_uniforms(fname);
if (this.is_composable(fname)) {
let value = get_value((arg.arguments[0] || [])[0]);
let temp;
if (fname === 'doodle' && /^\d/.test(value)) {
temp = value;
value = get_value((arg.arguments[1] || [])[0]);
}
if (!is_nil(value)) {
switch (fname) {
case 'doodle':
return this.compose_doodle(this.inject_variables(value, coords.count), temp, structuredClone(coords.extra));
case 'shaders':
return this.compose_shaders(value, coords);
case 'pattern':
return this.compose_pattern(value, coords);
}
}
}
coords.position = arg.position;
let args = arg.arguments.map(n => {
return fn.lazy
? (...extra) => this.compose_argument(n, coords, extra, arg, contextVariable)
: this.compose_argument(n, coords, extra, arg, contextVariable);
});
return this.apply_func(fn, coords, args, fname, contextVariable);
} else {
return arg.name;
}
}
});
coords.extra.pop();
return {
cluster: argument.cluster,
value: (result.length >= 2 ? ({ value: result.join('') }) : result[0])
}
}
compose_doodle(doodle, arg, upextra) {
let id = unique_id('doodle');
this.doodles[id] = {doodle, arg, upextra};
return '${' + id + '}';
}
;
compose_shaders(shader, {x, y, z}) {
let id = unique_id('shader');
this.shaders[id] = {
shader,
id: '--' + id,
cell: cell_id(x, y, z)
};
return '${' + id + '}';
}
compose_pattern(code, {x, y, z}) {
let id = unique_id('pattern');
this.pattern[id] = {
code,
id: '--' + id,
cell: cell_id(x, y, z)
};
return '${' + id + '}';
}
check_uniforms(name) {
switch (name) {
case 'ut': case 'UT': case 't': case 'T': case 'ts': case 'TS':
this.uniforms.time = true; break;
case 'ux': this.uniforms.mousex = true; break;
case 'uy': this.uniforms.mousey = true; break;
case 'uw': this.uniforms.width = true; break;
case 'uh': this.uniforms.height = true; break;
}
}
inject_variables(value, count) {
let group = Object.assign({},
this.vars['host'],
this.vars['container'],
this.vars[count]
);
let variables = [];
for (let [name, key] of Object.entries(group)) {
variables.push(`${name}: ${key};`);
}
variables = variables.join('');
if (variables.length) {
return `:doodle {${variables}}` + value;
}
return value;
}
compose_variables(variables, coords, result = {}) {
for (let [name, value] of Object.entries(variables)) {
result[name] = this.get_composed_value(value, coords, result).value;
}
return result;
}
compose_value(value, coords, contextVariable = {}) {
if (!Array.isArray(value)) {
return {
value: '',
extra: '',
}
}
let extra = '';
let output = value.reduce((result, val) => {
switch (val.type) {
case 'text': {
result += val.value;
break;
}
case 'func': {
let fname = val.name.substr(1);
let fn = this.pick_func(fname);
if (typeof fn === 'function') {
this.check_uniforms(fname);
if (this.is_composable(fname)) {
let value = get_value((val.arguments[0] || [])[0]);
let temp;
if (fname === 'doodle') {
if (/^\d/.test(value)) {
temp = value;
value = get_value((val.arguments[1] || [])[0]);
}
}
if (!is_nil(value)) {
switch (fname) {
case 'doodle':
result += this.compose_doodle(this.inject_variables(value, coords.count), temp, structuredClone(coords.extra)); break;
case 'shaders':
result += this.compose_shaders(value, coords); break;
case 'pattern':
result += this.compose_pattern(value, coords); break;
}
}
} else {
coords.position = val.position;
if (val.variables) {
this.compose_variables(val.variables, coords, contextVariable);
}
let args = val.arguments.map(arg => {
return fn.lazy
? (...extra) => this.compose_argument(arg, coords, extra, val, contextVariable)
: this.compose_argument(arg, coords, [], val, contextVariable);
});
let output = this.apply_func(fn, coords, args, fname, contextVariable);
if (!is_nil(output)) {
result += output;
if (output.extra) {
extra = output.extra;
}
}
}
} else {
result += val.name;
}
}
}
return result;
}, '');
return {
value: output,
extra: extra,
}
}
get_composed_value(value, coords, context) {
let extra, group = [];
if (Array.isArray(value)) {
group = value.reduce((ret, v) => {
let composed = this.compose_value(v, coords, context || {});
if (composed) {
if (composed.value) ret.push(composed.value);
if (composed.extra) extra = composed.extra;
}
return ret;
}, []);
}
return {
extra, group, value: group.join(',')
}
}
add_grid_style({
fill, clip, rotate, hueRotate, scale, translate, enlarge, skew, persp,
flex, p3d, border, borderLegacy, gap, backdropFilter
}) {
if (fill) {
this.add_rule(':host', `background:${fill};`);
}
if (!clip) {
this.add_rule(':host', 'contain:none;');
}
if (rotate) {
if (/[0-9]$/.test(rotate)) {
rotate += 'deg';
}
this.add_rule(':container', `rotate:${rotate};`);
}
if (hueRotate) {
if (/[0-9]$/.test(hueRotate)) {
hueRotate += 'deg';
}
this.add_rule(':host', `filter:hue-rotate(${hueRotate});`);
}
if (scale) {
this.add_rule(':container', `scale:${scale};`);
}
if (translate) {
this.add_rule(':container', `translate:${translate};`);
}
if (persp) {
let [value, ...origin] = persp;
this.add_rule(':host', `perspective:${value};`);
if (origin.length) {
this.add_rule(':host', `perspective-origin:${origin.join(' ')};`);
}
}
if (enlarge) {
this.add_rule(':container', `
width:calc(${enlarge} * 100%);
height:calc(${enlarge} * 100%);
left: 50%;
top: 50%;
transform-origin: 0 0;
transform: translate(-50%, -50%);
`);
}
if (flex) {
this.add_rule(':container', 'display:flex;');
this.add_rule('cell', 'flex: 1;');
if (flex === 'column') {
this.add_rule(':container', 'flex-direction:column;');
}
}
if (p3d) {
let s = 'transform-style:preserve-3d;';
this.add_rule(':host', s);
this.add_rule(':container', s);
}
if (borderLegacy !== undefined) {
this.add_rule(':host', `border: 1px solid ${borderLegacy};`);
}
if (border !== undefined) {
this.add_rule(':host', `border: ${border};`);
}
if (gap) {
this.add_rule(':container', `gap: ${gap};`);
}
if (backdropFilter) {
this.add_rule(':host', `
&:after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
backdrop-filter: ${backdropFilter};
}
`);
}
}
compose_rule(token, _coords, selector) {
let coords = Object.assign({}, _coords);
let prop = token.property;
if (prop === '@seed') {
return '';
}
let composed = this.get_composed_value(token.value, coords);
let extra = composed.extra;
let value = composed.value;
if (/^animation(\-name)?$/.test(prop)) {
this.props.has_animation = true;
if (is_host_selector(selector)) {
let n = 'animation-name';
let prefix = utime['n'] + ',' + UTime['n'];
if (prefix && value) {
value = prefix + ',' + value;
}
}
if (coords.count > 1) {
let { count } = coords;
switch (prop) {
case 'animation-name': {
value = composed.group
.map(n => this.compose_aname(n, count))
.join(',');
break;
}
case 'animation': {
value = composed.group
.map(n => {
let group = (n || '').split(/\s+/);
group[0] = this.compose_aname(group[0], count);
return group.join(' ');
})
.join(',');
}
}
}
}
if (prop === 'content') {
if (!/["']|^none\s?$|^(var|counter|counters|attr|url)\(/.test(value)) {
value = `'${value}'`;
}
let reset = new Map();
value = value.replace(/var\(\-\-cssd\-u(time|mousex|mousey|width|height)\)/gi, (n, v) => {
reset.set(v, `${v} calc(${n})`);
return `counter(${v})`;
});
return `
${reset.size ? `counter-reset:${Array.from(reset.values()).join(' ')};` : ''}
content:${value};
`;
}
if (prop === 'transition') {
this.props.has_transition = true;
}
let rule = `${prop}:${value};`
if (prop === 'width' || prop === 'height') {
if (!is_special_selector(selector)) {
rule += `--_cell-${prop}:${value};`;
}
}
let is_image = (
/^background(\-image)?$/.test(prop) &&
/\$\{(shader|pattern)/.test(value)
);
if (is_image) {
rule += 'background-size: 100% 100%;';
}
if (/^\-\-/.test(prop)) {
this.compose_vars(_coords, selector, prop, value);
}
if (/^@/.test(prop) && Property[prop.substr(1)]) {
let name = prop.substr(1);
let transformed = Property[name](value, {
is_special_selector: is_special_selector(selector),
grid: coords.grid,
max_grid: coords.max_grid,
extra
});
switch (name) {
case 'grid': {
if (is_host_selector(selector)) {
rule = transformed.size || '';
this.add_grid_style(transformed);
} else {
rule = '';
if (!this.is_grid_set) {
transformed = Property[name](value, {
is_special_selector: true,
grid: coords.grid,
max_grid: coords.max_grid
});
this.add_rule(':host', transformed.size || '');
this.add_grid_style(transformed);
}
}
this.grid = coords.grid;
this.is_grid_set = true;
break;
}
case 'gap': {
rule = '';
if (!this.is_gap_set) {
this.add_rule(':container', `gap:${transformed};`);
this.is_gap_set = true;
}
break;
}
case 'content': {
rule = '';
let key = this.compose_selector(coords);
if (transformed !== undefined && !is_pseudo_selector(selector) && !is_parent_selector(selector)) {
this.content[key] = remove_quotes(String(transformed));
}
this.content[key] = Func.raw({
rules: {
doodles: this.doodles
}
})(this.content[key] || '');
}
case 'seed': {
rule = '';
break;
}
case 'place-cell':
case 'place':
case 'position':
case 'offset': {
if (!is_host_selector(selector)) {
rule = transformed;
}
break;
}
case 'use': {
if (token.value.length) {
this.compose(coords, token.value);
}
rule = '';
break;
}
default: {
rule = transformed;
}
}
}
if (/^grid/.test(prop) && is_host_selector(selector)) {
this.add_rule(':container', `${prop}:${value};`);
rule = '';
}
return rule;
}
get_raw_value(token) {
let raw = token.raw() ?? '';
let [_, ...rest] = raw.split(token.property);
// It's not accurate, will be solved after the rewrite of css parser.
rest = rest.join(token.property)
.replace(/^\s*:\s*/, '')
.replace(/[;}<]$/, '').trim()
.replace(/[;}<]$/, '');
return rest;
}
compose_vars(coords, selector, prop, value) {
let key = coords.count;
if (is_parent_selector(selector)) {
key = 'container';
}
if (is_host_selector(selector)) {
key = 'host';
}
if (!this.vars[key]) {
this.vars[key] = {};
}
this.vars[key][prop] = value;
}
pre_compose_rule(token, _coords, selector) {
let coords = Object.assign({}, _coords);
let prop = token.property;
let context = Object.assign({},
this.vars['host'],
this.vars['container'],
this.vars[coords.count],
);
if (/^\-\-/.test(prop)) {
let value = this.get_composed_value(token.value, coords, context).value;
this.compose_vars(_coords, selector, prop, value);
}
switch (prop) {
case '@grid': {
let value = this.get_composed_value(token.value, coords, context).value;
let name = prop.substr(1);
let transformed = Property[name](value, {
max_grid: _coords.max_grid
});
this.grid = transformed.grid;
break;
}
case '@use': {
if (token.value.length) {
this.pre_compose(coords, token.value);
}
break;
}
}
}
pre_compose(coords, tokens) {
if (is_nil(this.seed)) {
// get seed first
;(tokens || this.tokens).forEach(token => {
if (token.type === 'rule' && token.property === '@seed') {
this.seed = this.get_raw_value(token);
}
if (token.type === 'pseudo' && is_host_selector(token.selector)) {
for (let t of make_array(token.styles)) {
if (t.type === 'rule' && t.property === '@seed') {
this.seed = this.get_raw_value(t);
}
}
}
});
if (is_nil(this.seed)) {
//this.seed = coords.seed_value;
} else {
coords.update_random(this.seed);
}
}
;(tokens || this.tokens).forEach(token => {
switch (token.type) {
case 'rule': {
this.pre_compose_rule(token, coords)
break;
}
case 'pseudo': {
if (is_host_selector(token.selector)) {
(token.styles || []).forEach(token => {
this.pre_compose_rule(token, coords, token.selector);
});
}
break;
}
}
});
}
compose(coords, tokens, initial) {
this.coords.push(coords);
(tokens || this.tokens).forEach((token, i) => {
if (token.skip) return false;
if (initial && this.grid) return false;
if (token.property === '@gap' && this.is_gap_set) {
return false;
}
if (token.property === '@grid' && this.is_grid_set) {
return false;
}
switch (token.type) {
case 'rule': {
this.add_rule(
this.compose_selector(coords),
this.compose_rule(token, coords)
);
break;
}
case 'pseudo': {
if (token.selector.startsWith(':doodle')) {
token.selector = token.selector.replace(/^\:+doodle/, ':host');
}
let special = is_special_selector(token.selector);
if (special) {
token.skip = true;
}
parse_value_group(token.selector).forEach(selector => {
let composed = special
? selector
: this.compose_selector(coords, selector);
token.styles.forEach(s => {
if (s.type === 'rule') {
this.add_rule(composed, this.compose_rule(s, coords, selector));
}
if (s.type === 'pseudo') {
let result = s.styles.map(_s =>
this.compose_rule(_s, coords, composed)
);
let selector = (composed + s.selector);
this.add_rule(selector, result);
}
if (s.type === 'cond' && s.name.startsWith('&')) {
let result = s.styles.map(_s =>
this.compose_rule(_s, coords, composed)
).join('');
this.add_rule(composed, s.name + '{' + result + '}');
}
});
});
break;
}
case 'cond': {
let name = token.name.substr(1);
let fn = Selector[name];
let args = [];
if (fn) {
let firstGroup = token.segments.find(n => n.arguments);
if (firstGroup && firstGroup.arguments) {
args = firstGroup.arguments.map(arg => {
return this.compose_argument(arg, coords);
});
}
let cond = this.apply_func(fn, coords, args, name);
if (token.segments && token.segments[0] && token.segments[0].keyword === 'not') {
cond = !cond;
}
if (cond) {
if (cond.selector) {
token.styles.forEach(_token => {
if (_token.type === 'rule') {
this.add_rule(
cond.selector.replaceAll('$', this.compose_selector(coords)),
this.compose_rule(_token, coords)
)
}
if (_token.type === 'pseudo' && _token.selector) {
_token.selector.split(',').forEach(selector => {
let pseudo = _token.styles.map(s =>
this.compose_rule(s, coords, selector)
);
this.add_rule(
(cond.selector + selector).replaceAll('$', this.compose_selector(coords)),
pseudo
);
});
}
});
} else {
this.compose(coords, token.styles);
}
}
} else {
let composed_selector = token.name + ' ' + token.segments.map(n => {
if (n.keyword) return n.keyword;
if (Array.isArray(n.arguments)) {
return '(' + n.arguments[0][0].value + ')'
}
return '';
}).join(' ');
let rules = '';
token.styles.forEach(_token => {
if (_token.type === 'rule') {
rules += `${composed_selector} {${this.compose_rule(_token, coords)}}`;
}
if (_token.type === 'pseudo') {
_token.name.split(',').forEach(selector => {
let pseudo = _token.styles.map(s =>
this.compose_rule(s, coords, selector)
);
rules += `${(cond.selector + selector).replaceAll('$', this.compose_selector(coords))} {${pseudo}}`;
});
}
});
this.add_rule(this.compose_selector(coords), rules);
}
break;
}
case 'keyframes': {
if (!this.keyframes[token.name]) {
this.keyframes[token.name] = coords => `
${join(token.steps.map(step => `
${this.get_composed_value(step.name, coords).value} {
${join(step.styles.map(s => this.compose_rule(s, coords)))}
}
`))}
`;
}
}
}
});
}
output() {
for (let [selector, rule] of Object.entries(this.rules)) {
if (is_parent_selector(selector)) {
let name = selector.replace(/^:container\(?/, 'grid').replace(/\)?$/, '');
this.styles.container += `${name} {${join(rule)}}`;
} else {
let target = is_host_selector(selector) ? 'host' : 'cells';
let value = join(rule).trim();
if (value.length) {
let name = (target === 'host') ? `${selector},.host` : selector;
this.styles[target] += `${name} {${value}}`;
}
}
}
if (this.uniforms.time) {
let n = 'animation-name';
let t = utime.ticks;
let un = utime.name;
let Un = UTime.name;
this.styles.container += `
:host,.host {
animation:${utime.animation()},${UTime.animation(DELAY + 'ms')};
}
`;
this.styles.keyframes += `
@keyframes ${utime[n]} {
from {--${un}:0} to {--${un}:${t}}
}
@keyframes ${UTime[n]} {
from {--${Un}:0} to {--${Un}:${t}}
}
`;
}
this.coords.forEach((coords, i) => {
for (let [name, keyframe] of Object.entries(this.keyframes)) {
let aname = this.compose_aname(name, coords.count);
this.styles.keyframes += `
${ i === 0 ? `@keyframes ${name} {${keyframe(coords)}}` : ''}
@keyframes ${aname} {${keyframe(coords)}}
`;
}
});
let { keyframes, host, container, cells } = this.styles;
let main = keyframes + host + container;
return {
props: this.props,
styles: { main, cells, container, all: main + cells },
grid: this.grid,
seed: this.seed,
random: this.random,
doodles: this.doodles,
shaders: this.shaders,
pattern: this.pattern,
uniforms: this.uniforms,
content: this.content,
}
}
}
function remove_quotes(input) {
let remove = (input.startsWith('"') && input.endsWith('"'))
|| (input.startsWith("'") && input.endsWith("'"));
if (remove) {
return input.substring(1, input.length - 1);
}
return input;
}
export default function generate_css(tokens, grid_size, seed_value, max_grid, seed_random, upextra = []) {
let rules = new Rules(tokens);
let random = seed_random || seedrandom(String(seed_value));
let context = {};
function update_random(seed) {
random = seedrandom(String(seed));
}
function rand(start = 0, end = 1) {
if (arguments.length == 1) {
[start, end] = [0, start];
}
return lerp(random(), start, end);
}
function pick(...items) {
let args = items.reduce((acc, n) => acc.concat(n), []);
return args[~~(random() * args.length)];
}
function shuffle(arr) {
let ret = [...arr];
let m = arr.length;
while (m) {
let i = ~~(random() * m--);
let t = ret[m];
ret[m] = ret[i];
ret[i] = t;
}
return ret;
}
rules.pre_compose({
x: 1, y: 1, z: 1, count: 1, context: {},
grid: { x: 1, y: 1, z: 1, count: 1 },
random, rand, pick, shuffle,
max_grid, update_random,
seed_value,
rules,
upextra,
});
let { grid, seed } = rules.output();
if (grid) {
grid_size = grid;
}
if (seed) {
seed = String(seed);
random = seedrandom(seed);
} else {
seed = seed_value;
}
if (is_nil(seed)) {
seed = Date.now();
random = seedrandom(seed);
}
seed = String(seed);
rules.seed = seed;
rules.random = random;
rules.reset();
if (grid_size.z == 1) {
for (let y = 1, count = 0; y <= grid_size.y; ++y) {
for (let x = 1; x <= grid_size.x; ++x) {
rules.compose({
x, y, z: 1,
count: ++count, grid: grid_size, context,
rand, pick, shuffle,
random, seed,
max_grid,
upextra,
rules,
});
}
}
}
else {
for (let z = 1, count = 0; z <= grid_size.z; ++z) {
rules.compose({
x: 1, y: 1, z,
count: ++count, grid: grid_size, context,
rand, pick, shuffle,
random, seed,
max_grid,
rules,
upextra,
});
}
}
return rules.output();
}