scrawl-canvas
Version:
Responsive, interactive and more accessible HTML5 canvas elements. Scrawl-canvas is a JavaScript library designed to make using the HTML5 canvas element easier, and more fun
758 lines (539 loc) • 27.2 kB
JavaScript
// # Styles mixin
// The styles mixin contains most of the code required for the [Gradient](../factory/gradient.html), [RadialGradient](../factory/radialGradient.html) an [ConicGradient](../factory/conicGradient.html) styles factories. It is not used by the other styles objects ([Color](../factory/color.html), [Pattern](../factory/pattern.html)).
// + the __start__ and __end__ positioning attributes are defined here rather than in the factories
// + gradient-type styles manage their color stops in [Palette factory](../factory/palette.html) objects; that functionality is entirely defined here
//
// The Canvas API CanvasRenderingContext2D interface defines three types of gradient: the widely supported [linear](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/createLinearGradient) and [radial](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/createRadialGradient) gradients, and the [conic](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/createConicGradient) which, while widely supported as a CSS feature, is very much an experimental technology for CanvasRenderingContext2D engines.
// + the `createLinearGradient` method creates a gradient along the line connecting two given coordinates (__start__ and __end__) which are absolute values (measured in pixels) from the <canvas> elements top-left corner
// + the `createRadialGradient` method creates a radial gradient using the size and coordinates of two circles.
// + the `createConicGradient` method creates a conic gradient as a 360° sweep around the gradient's start coordinate. For browsers which don't support this gradient out of the box, the method will apply no gradient to the entity.
//
// Common to linear and radial types of gradient is the idea of a start coordinate and an end coordinate, supplied in pixels.
// + Scrawl-canvas extends this idea so that the coordinates can be supplied as a percentage value (String%) of the host Cell's dimensions.
// + Furthermore Scrawl-canvas allows each entity that uses a Gradient-type style to indicate whether the reference box should be that of the host Cell, or of the entity itself, through their `lockFillStyleToEntity` and `lockStrokeStyleToEntity` attribute flags.
// #### Imports
import { entity, styles, stylesnames } from '../core/library.js';
import { addStrings, isa_obj, mergeDiscard, mergeOver, xt, λnull, Ωempty } from '../helper/utilities.js';
import { makeAnimation } from '../factory/animation.js';
import { makeCoordinate } from '../untracked-factory/coordinate.js';
import { makePalette } from '../untracked-factory/palette.js';
// Shared constants
import { _floor, _isArray, _isFinite, _keys, _values, BLACK, BLANK, BOTTOM, CENTER, END, LEFT, LINEAR, NAME, RGB, RIGHT, START, T_PALETTE, TOP, UNDEF, WHITE } from '../helper/shared-vars.js';
// Local constants
const COLORS = 'colors',
PALETTE_KEYS = ['colors', 'stops'];
// Create an animation to handle automated delta gradient animation
makeAnimation({
name: 'SC-core-gradient-delta-animation',
fn: () => {
stylesnames.forEach(name => {
const style = styles[name];
if (style && style.animateByDelta) style.updateByDelta();
});
},
});
// #### Export function
export default function (P = Ωempty) {
// #### Shared attributes
const defaultAttributes = {
// __start__, __end__ - Gradient-type styles use Coordinate factory Arrays to hold details of their start and end coordinates. The following _pseudo-attributes_ can also be used to reference these values:
// + for the start coordinate, __startX__ and __startY__
// + for the end coordinate, __endX__ and __endY__
//
// In all cases, the attribute values can be Numbers, which indicate absolute pixel coordinates, or String% values for coordinates calculated relative to either Cell or entity current dimensions
// + for the `x` coordinate, the Strings __left__, __center__ and __right__ are also supported
// + for the `y` coordinate, the Strings __top__, __center__ and __bottom__ are also supported
start: null,
end: null,
// __palette__ - Every gradient requires a Palette object containing color stop instructions. Generation of the object is automated and should ot be tampered with. Add and remove colors to the gradient using the `updateColor` and `removeColor` functions.
palette: null,
// __paletteStart__, __paletteEnd__ - We don't need to use the entire palette when building a gradient; we can restrict the palette using these start and end attributes.
paletteStart: 0,
paletteEnd: 999,
// The __cyclePalette__ attribute tells the Palette object how to handle situations where the paletteStart value is greater than the paletteEnd value:
// + when false, we reverse the color stops
// + when true, we keep the normal order of color stops and pass through the 1/0 border
cyclePalette: false,
// The __animateByDelta__ attribute, when true, will delta animate the gradient at the start of each Display cycle. When the gradient is used in the `mapToGradient`` filter, setting this attribute to `false` (default) should speed up the filter
animateByDelta: false,
// The __delta__ object is not stored in the defs object; it acts in a similar way to the artefact delta object - though it is restricted to adding delta values to Number and 'String%' attributes.
// + Unlike artefacts, where delta animation will be applied to artefacts by default as part of each Display cycle, gradient delta animations need to be explicitly invoked: `my_gradient.updateByDelta();`
//
// The __colors__ _pseudo-attribute_ can be used to pass through an array of palette color objects to the Palette object. The data is not retained by the gradient object.
// + A better approach to managing gradient colors is to use the `updateColor` and `removeColor` functions
// The __easing__ _pseudo-attribute_ represents a transformation that will be applied to a copy of the color stops Array - this allows us to create non-linear gradients. Value is passed through to the Palette object
// The __precision__ _pseudo-attribute_ - value is passed through to the Palette object
// The __colorSpace__ - String _pseudo-attribute_ defines the color space to be used by the Palette's Color object for its internal calculations - value is passed through to the Palette object
// + Accepted values from: `'RGB', 'HSL', 'HWB', 'XYZ', 'LAB', 'LCH', 'OKLAB', 'OKLCH'` with `RGB` as the default
//
// The __returnColorAs__ - String _pseudo-attribute_ defines the type of color String the Palette's Color object will return - value is passed through to the Palette object
// + Accepted values from: `'RGB', 'HSL', 'HWB', 'LAB', 'LCH', 'OKLAB', 'OKLCH'` with `RGB` as the default
};
P.defs = mergeOver(P.defs, defaultAttributes);
// #### Packet management
P.finalizePacketOut = function (copy, items) {
if (items.colors) copy.colors = items.colors;
else if (this.palette) copy.colors = this.palette.get(COLORS);
else copy.colors = [[0, BLACK], [999, WHITE]];
// TODO: don't think this will return bespoke easing functions - need to think further on how to accomplish this
if (items.easing) copy.easing = items.easing;
else if (this.palette && this.palette.easing) copy.easing = this.palette.easing;
else copy.easing = LINEAR;
if (xt(items.precision)) copy.precision = items.precision;
else if (this.palette && xt(this.palette.precision)) copy.precision = this.palette.precision;
else copy.precision = 25;
if (items.colorSpace) copy.colorSpace = items.colorSpace;
else if (this.palette) copy.colorSpace = this.palette.getColorSpace();
else copy.colorSpace = RGB;
if (items.returnColorAs) copy.returnColorAs = items.returnColorAs;
else if (this.palette) copy.returnColorAs = this.palette.getReturnColorAs();
else copy.returnColorAs = RGB;
return copy;
};
// #### Clone management
// No additional clone functionality defined here
// #### Kill management
P.kill = function () {
const myname = this.name;
if (this.palette && this.palette.kill) this.palette.kill();
// Remove style from all entity state objects
_values(entity).forEach(ent => {
const state = ent.state;
if (state) {
const fill = state.fillStyle,
stroke = state.strokeStyle;
if (isa_obj(fill) && fill.name === myname) state.fillStyle = state.defs.fillStyle;
if (isa_obj(stroke) && stroke.name === myname) state.strokeStyle = state.defs.strokeStyle;
}
});
// Remove style from the Scrawl-canvas library
this.deregister();
return this;
};
// #### Get, Set, deltaSet
const G = P.getters,
S = P.setters,
D = P.deltaSetters;
// `start`, `startX`, `startY`
G.startX = function () {
return this.currentStart[0];
};
G.startY = function () {
return this.currentStart[1];
};
S.startX = function (coord) {
if (coord != null) {
this.start[0] = coord;
this.dirtyStart = true;
}
};
S.startY = function (coord) {
if (coord != null) {
this.start[1] = coord;
this.dirtyStart = true;
}
};
S.start = function (x, y) {
this.setCoordinateHelper(START, x, y);
this.dirtyStart = true;
};
D.startX = function (coord) {
const c = this.start;
c[0] = addStrings(c[0], coord);
this.dirtyStart = true;
};
D.startY = function (coord) {
const c = this.start;
c[1] = addStrings(c[1], coord);
this.dirtyStart = true;
};
D.start = function (x, y) {
this.setDeltaCoordinateHelper(START, x, y);
this.dirtyStart = true;
};
// `end`, `endX`, `endY`
G.endX = function () {
return this.currentEnd[0];
};
G.endY = function () {
return this.currentEnd[1];
};
S.endX = function (coord) {
if (coord != null) {
this.end[0] = coord;
this.dirtyEnd = true;
}
};
S.endY = function (coord) {
if (coord != null) {
this.end[1] = coord;
this.dirtyEnd = true;
}
};
S.end = function (x, y) {
this.setCoordinateHelper(END, x, y);
this.dirtyEnd = true;
};
D.endX = function (coord) {
const c = this.end;
c[0] = addStrings(c[0], coord);
this.dirtyEnd = true;
};
D.endY = function (coord) {
const c = this.end;
c[1] = addStrings(c[1], coord);
this.dirtyEnd = true;
};
D.end = function (x, y) {
this.setDeltaCoordinateHelper(END, x, y);
this.dirtyEnd = true;
};
// `palette` - argument has to be a Palette object
S.palette = function (item = Ωempty) {
if(item.type === T_PALETTE) {
item.dirtyPalette = true;
this.palette = item;
}
};
// `paletteStart` - argument must be a positive integer Number in the range 0 - 999
S.paletteStart = function (item) {
if (_isFinite(item) && this.palette) {
let p = _floor(item);
if (p < 0 || p > 999) p = (p > 500) ? 999 : 0;
this.paletteStart = p;
this.palette.updateData();
}
};
D.paletteStart = function (item) {
if (_isFinite(item) && this.palette) {
let p = _floor(this.paletteStart + item);
if (p < 0 || p > 999) {
if (this.cyclePalette) p = ((p % 1000) + 1000) % 1000;
else p = (p > 500) ? 999 : 0;
}
this.paletteStart = p;
this.palette.updateData();
}
};
// `paletteEnd` - argument must be a positive integer Number in the range 0 - 999
S.paletteEnd = function (item) {
if (_isFinite(item) && this.palette) {
let p = _floor(item);
if (p < 0 || p > 999) p = (p > 500) ? 999 : 0;
this.paletteEnd = p;
this.palette.updateData();
}
};
D.paletteEnd = function (item) {
if (_isFinite(item) && this.palette) {
let p = _floor(this.paletteEnd + item);
if (p < 0 || p > 999) {
if (this.cyclePalette) p = ((p % 1000) + 1000) % 1000;
else p = (p > 500) ? 999 : 0;
}
this.paletteEnd = p;
this.palette.updateData();
}
};
S.cyclePalette = function (item) {
if (this.palette) {
this.cyclePalette = !!item;
this.palette.updateData();
}
};
// `colors` - We can pass through an array of palette color arrays to the Palette object by setting it on the gradient-type styles object. Each palette array is in the form `[Number, String]` where:
// + Number is a positive integer in the range 0-999
// + String is any legitimate CSS color string value
S.colors = function (item) {
if (_isArray(item) && this.palette) this.palette.set({ colors: item });
};
// `easing`, `easingFunction` - the easing to be applied to the gradient
// + Can accept a String value identifying an SC pre-defined easing function (default: `linear`)
// + Can also accept a function accepting a single Number argument (a value between 0-1) and returning an eased Number (again, between 0-1)
S.easing = function (item) {
if (this.palette) {
this.palette.set({ easing: item });
}
};
S.easingFunction = S.easing;
// `colorSpace`, `returnColorAs` - Pass through a color space String to the Palette object
S.colorSpace = function (item) {
if (this.palette) this.palette.set({ colorSpace: item });
};
S.returnColorAs = function (item) {
if (this.palette) this.palette.set({ returnColorAs: item });
};
// `precision` - Pass through a positive integer Number value between 0 and 50 to the Palette object. If value is `0` (default) no easing will be applied to the gradient; values above 0 apply the easing to the gradient; higher values will give a quicker, but less precise, mapping.
S.precision = function (item) {
if (this.palette) this.palette.set({ precision: item });
};
// `delta` - Gradient-type styles objects support the delta attribute, and can be delta-animated using its attributes
S.delta = function (items = Ωempty) {
if (items) this.delta = mergeDiscard(this.delta, items);
};
// #### Prototype functions
// `get` - Overwrites function defined in mixin/base.js - takes into account Palette object attributes
P.get = function (item) {
const getter = this.getters[item],
palette = this.palette;
if (getter) return getter.call(this);
else {
let def = this.defs[item],
val;
if (typeof def !== UNDEF) {
val = this[item];
return (typeof val !== UNDEF) ? val : def;
}
if (palette) {
def = palette.defs[item];
if (typeof def !== UNDEF) {
val = palette[item];
return (typeof val !== UNDEF) ? val : def;
}
}
}
return undefined
};
// `set` - Overwrites function defined in mixin/base.js - takes into account Palette object attributes
P.set = function (items = Ωempty) {
const keys = _keys(items),
keysLen = keys.length;
if (keysLen) {
const setters = this.setters,
defs = this.defs,
palette = this.palette;
let paletteSetters, paletteDefs, predefined, i, key, value;
if (palette) {
paletteSetters = palette.setters || Ωempty;
paletteDefs = palette.defs || Ωempty;
}
for (i = 0; i < keysLen; i++) {
key = keys[i];
value = items[key];
if (key && key !== NAME && value != null) {
if (!PALETTE_KEYS.includes(key)) {
predefined = setters[key];
if (predefined) predefined.call(this, value);
else if (typeof defs[key] !== UNDEF) this[key] = value;
}
else {
predefined = paletteSetters[key];
if (predefined) predefined.call(palette, value);
else if (typeof paletteDefs[key] !== UNDEF) palette[key] = value;
}
}
}
this.dirtyFilterIdentifier = true;
}
return this;
};
// `setDelta` - Overwrites function defined in mixin/base.js - takes into account Palette object attributes
P.setDelta = function (items = Ωempty) {
const keys = _keys(items),
keysLen = keys.length;
if (keysLen) {
const setters = this.deltaSetters,
defs = this.defs,
palette = this.palette;
let paletteSetters, paletteDefs, predefined, i, key, value;
if (palette) {
paletteSetters = palette.deltaSetters || Ωempty;
paletteDefs = palette.defs || Ωempty;
}
for (i = 0; i < keysLen; i++) {
key = keys[i];
value = items[key];
if (key && key !== NAME && value != null) {
if (!PALETTE_KEYS.includes(key)) {
predefined = setters[key];
if (predefined) predefined.call(this, value);
else if (typeof defs[key] !== UNDEF) this[key] = addStrings(this[key], value);
}
else {
predefined = paletteSetters[key];
if (predefined) predefined.call(palette, value);
else if (typeof paletteDefs[key] !== UNDEF) palette[key] = addStrings(palette[key], value);
}
}
}
this.dirtyFilterIdentifier = true;
}
return this;
};
// `setCoordinateHelper` - internal helper function
P.setCoordinateHelper = function (label, x, y) {
const c = this[label];
if (_isArray(x)) {
c[0] = x[0];
c[1] = x[1];
}
else {
c[0] = x;
c[1] = y;
}
};
// `setDeltaCoordinateHelper` - internal helper function
P.setDeltaCoordinateHelper = function (label, x, y) {
const c = this[label],
myX = c[0],
myY = c[1];
if (_isArray(x)) {
c[0] = addStrings(myX, x[0]);
c[1] = addStrings(myY, x[1]);
}
else {
c[0] = addStrings(myX, x);
c[1] = addStrings(myY, y);
}
};
// `updateByDelta` - manually force the gradient-type styles object to update its attributes by the values supplied in its delta attribute
P.updateByDelta = function () {
this.setDelta(this.delta);
return this;
};
// `stylesInit` - common functionality invoked by gradient-type factory constructors
P.stylesInit = function (items = Ωempty) {
this.makeName(items.name);
this.register();
this.gradientArgs = [];
this.start = makeCoordinate();
this.end = makeCoordinate();
this.currentStart = makeCoordinate();
this.currentEnd = makeCoordinate();
this.palette = makePalette({
name: `${this.name}_palette`,
});
this.delta = {};
this.set(this.defs);
this.set(items);
};
// `getData` - Every styles object (Gradient, RadialGradient, ConicGradient, Pattern, Color, Cell) needs to include a __getData__ function. This is invoked by Cell objects during the Display cycle `compile` step, when it takes an entity State object and updates its <canvas> element's context engine to bring it into alignment with requirements.
P.getData = function (entity, cell) {
// Step 1: recalculate current start and end points
this.cleanStyle(entity, cell);
// Step 2: finalize the coordinates to use for creating the gradient in relation to the current entity's position and requirements on the canvas
this.finalizeCoordinates(entity);
// Step 3: create, populate and return gradient/pattern object
return this.buildStyle(cell);
};
// `cleanStyle` - internal function invoked as part of the gradient-type object's `getData` function. The style has to be cleaned every time it is applied to a Cell's engine because it can never know which Cell is invoking it, or for which entity it is to be used.
P.cleanStyle = function (entity = Ωempty, cell = Ωempty) {
let dims, w, h, scale;
if (entity.lockFillStyleToEntity || entity.lockStrokeStyleToEntity) {
dims = entity.currentDimensions;
scale = entity.currentScale;
w = dims[0] * scale;
h = dims[1] * scale;
}
else {
dims = cell.currentDimensions || [cell.element.width, cell.element.height];
w = dims[0];
h = dims[1];
}
this.cleanPosition(this.currentStart, this.start, [w, h]);
this.cleanPosition(this.currentEnd, this.end, [w, h]);
this.cleanRadius(w);
};
// `cleanPosition` - internal function invoked as part of the gradient-type object's `cleanStyle` function.
P.cleanPosition = function (current, source, dimensions) {
let val, dim;
for (let i = 0; i < 2; i++) {
val = source[i];
dim = dimensions[i];
if (val.toFixed) current[i] = val;
else if (val === LEFT || val === TOP) current[i] = 0;
else if (val === RIGHT || val === BOTTOM) current[i] = dim;
else if (val === CENTER) current[i] = dim / 2;
else if (!_isFinite(parseFloat(val))) current[i] = 0;
else current[i] = (parseFloat(val) / 100) * dim;
}
};
// `finalizeCoordinates` - internal function invoked as part of the gradient-type object's `getData` function.
P.finalizeCoordinates = function (entity = Ωempty) {
const entityStampPosition = entity.currentStampPosition,
entityStampHandlePosition = entity.currentStampHandlePosition,
entityScale = entity.currentScale;
let correctX, correctY;
if (entity.lockFillStyleToEntity || entity.lockStrokeStyleToEntity) {
correctX = -(entityStampHandlePosition[0] * entityScale) || 0;
correctY = -(entityStampHandlePosition[1] * entityScale) || 0;
}
else {
correctX = -entityStampPosition[0] || 0;
correctY = -entityStampPosition[1] || 0;
}
this.updateGradientArgs(correctX, correctY);
};
// `cleanRadius` - overwritten by the RadialGradient factory
P.cleanRadius = λnull;
// `buildStyle` - Just in case something went wrong with loading other styles Factory modules, which must overwrite this function, we can return transparent color here
P.buildStyle = function () {
return BLANK;
};
// `addStopsToGradient` - internal function, called by the `buildStyle` function (which is overwritten by the various gradient factories)
P.addStopsToGradient = function (gradient, start, stop, cycle) {
if (this.palette) return this.palette.addStopsToGradient(gradient, start, stop, cycle);
return gradient;
};
// #### Gradients and color stops
// The [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API) uses a rather convoluted way to add color data to a [CanvasGradient](https://developer.mozilla.org/en-US/docs/Web/API/CanvasGradient) interface object:
// + the object is created first on the <canvas> context engine where it is to be applied, with __start__ and __end__ coordinates,
// + then color stops are _individually_ added to it afterwards.
// + This needs to be done for every gradient applied to a context engine before any fill or stroke operation using that gradient.
// + And only one gradient may be applied to the context engine at any time.
//
// The specificity of the above requirements - in particular relating to position coordinates - and the inability to update the CanvasGradient beyond adding color stops to it, means that storing these objects for future use is not a useful proposition ... especially in a dynamic environment where we want the gradient to move in-step with an entity, or animate its colors in some way.
//
// Scrawl-canvas overcomes this problem through the use of [Palette objects](../factory/palette.html) which separate a gradient-type style's color-stop data from its positioning data. We treat Canvas API `CanvasGradient` objects as use-once-and-dispose objects, generating them in a just-in-time fashion for each entity's `stamp` operation in the Display cycle.
//
// Palette objects store their color data in a `colors` attribute object:
// ```
// {
// name: "mygradient_palette",
// colors: {
// "0 ": [0, 0, 0, 1],
// "350 ": [255, 0, 0, 1],
// "650 ": [0, 0, 255, 1],
// "999 ": [255, 255, 255, 1],
// "index-label-between-0-and-999 ": [redValue, greenValue, blueValue, alphaValue]
// },
// }
// ```
// To `set` the Palette object's `colors` object, either when creating the gradient-type style or at some point afterwards, we can use CSS color Strings instead of an array of values for each color. Note that:
// + the color object attribute labels MUST include a space after the String number; and
// + the number itself must be a positive integer in the range 0-999:
//
// ```
// myGradient.set({
//
// colors: {
//
// '0 ': 'black',
// '350 ': 'red',
// '650 ': 'blue',
// '999 ': 'white'
// },
// });
// ```
//
// The following __convenience functions__ are supplied to make adding, deleting and managing Palette object color stop data easier than the above:
// `updateColor` - add or update a gradient-type style's Palette object with a color.
// + __index__ - positive integer Number between 0 and 999 inclusive
// + __color__ - CSS color String
P.updateColor = function (index, color) {
if (this.palette) {
this.dirtyFilterIdentifier = true;
this.palette.updateColor(index, color);
}
return this;
};
// `removeColor` - remove a gradient-type style's Palette object color from a specified index
// + __index__ - positive integer number between 0 and 999 inclusive
P.removeColor = function (index) {
if (this.palette) {
this.dirtyFilterIdentifier = true;
this.palette.removeColor(index);
}
return this;
};
}