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
629 lines (438 loc) • 18.4 kB
JavaScript
// # Label factory
// TODO - document purpose and description
// #### Imports
import { constructors } from '../core/library.js';
import { doCreate, mergeOver, λnull, Ωempty } from '../helper/utilities.js';
import { makeState } from '../untracked-factory/state.js';
import { makeTextStyle } from '../untracked-factory/text-style.js';
import { currentGroup } from '../factory/canvas.js';
import { releaseCell, requestCell } from '../untracked-factory/cell-fragment.js';
import baseMix from '../mixin/base.js';
import entityMix from '../mixin/entity.js';
import textMix from '../mixin/text.js';
// Shared constants
import { _abs, _ceil, _floor, _isFinite, ALPHABETIC, BLACK, BOTTOM, CENTER, DESTINATION_OUT, END, ENTITY, HANGING, IDEOGRAPHIC, LEFT, LTR, MIDDLE, MOUSE, PARTICLE, RIGHT, ROUND, SOURCE_OVER, START, T_LABEL, TOP, ZERO_STR } from '../helper/shared-vars.js';
// Local constants (none defined)
// #### Label constructor
const Label = function (items = Ωempty) {
this.entityInit(items);
return this;
};
// #### Label prototype
const P = Label.prototype = doCreate();
P.type = T_LABEL;
P.lib = ENTITY;
P.isArtefact = true;
P.isAsset = false;
// #### Mixins
baseMix(P);
entityMix(P);
textMix(P);
// #### Label attributes
const defaultAttributes = {
// __text__ - string.
text: ZERO_STR,
showBoundingBox: false,
boundingBoxStyle: BLACK,
boundingBoxLineWidth: 1,
boundingBoxLineDash: null,
boundingBoxLineDashOffset: 0,
};
P.defs = mergeOver(P.defs, defaultAttributes);
// #### Packet management
// No additional packet management functionality required
// #### Clone management
// No additional clone functionality required
// #### Kill management
// No additional kill functionality required
// #### Get, Set, deltaSet
const G = P.getters,
S = P.setters,
D = P.deltaSetters;
// __Note that__ dimensions (width, height) cannot be set on labels as the entity's dimensional values will depend entirely on the `font`, `text` and `scale` attributes
G.width = function () {
return this.currentDimensions[0];
};
S.width = λnull;
D.width = λnull;
G.height = function () {
return this.currentDimensions[1];
};
S.height = λnull;
D.height = λnull;
G.dimensions = function () {
return [...this.currentDimensions];
};
S.dimensions = λnull;
D.dimensions = λnull;
// #### Prototype functions
// `entityInit` - overwrites the mixin/entity.js function
P.entityInit = function (items = Ωempty) {
this.modifyConstructorInputForAnchorButton(items);
this.makeName(items.name);
this.register();
this.initializePositions();
this.state = makeState(Ωempty);
this.defaultTextStyle = makeTextStyle({
name: `${this.name}_default-textstyle`,
isDefaultTextStyle: true,
});
this.filters = [];
this.currentFilters = [];
this.dirtyFilters = false;
this.dirtyFiltersCache = false;
this.dirtyImageSubscribers = false;
this.accessibleTextHold = null;
this.accessibleTextHoldAttached = null;
this.set(this.defs);
if (!items.group) items.group = currentGroup;
this.onEnter = λnull;
this.onLeave = λnull;
this.onDown = λnull;
this.onUp = λnull;
this.currentFontIsLoaded = false;
this.updateUsingFontParts = false;
this.updateUsingFontString = false;
this.usingViewportFontSizing = true;
this.letterSpaceValue = 0;
this.wordSpaceValue = 0;
this.alphabeticBaseline = 0;
this.hangingBaseline = 0;
this.ideographicBaseline = 0;
this.fontVerticalOffset = 0;
this.delta = {};
this.set(items);
this.midInitActions(items);
if (this.purge) this.purgeArtefact(this.purge);
this.dirtyFont = true;
this.currentFontIsLoaded = false;
};
// `measureFont` - gather font metadata (uses `getFontMetadata` from text mixin)
P.measureFont = function () {
const { defaultTextStyle, currentScale, dimensions } = this;
const { fontFamily, fontSizeValue, letterSpaceValue, wordSpaceValue } = defaultTextStyle;
defaultTextStyle.letterSpacing = `${letterSpaceValue * currentScale}px`;
defaultTextStyle.wordSpacing = `${wordSpaceValue * currentScale}px`;
const mycell = requestCell();
const engine = mycell.engine;
engine.font = defaultTextStyle.canvasFont;
engine.fontKerning = defaultTextStyle.fontKerning;
engine.fontStretch = defaultTextStyle.fontStretch;
engine.fontVariantCaps = defaultTextStyle.fontVariantCaps;
engine.textRendering = defaultTextStyle.textRendering;
engine.letterSpacing = defaultTextStyle.letterSpacing;
engine.wordSpacing = defaultTextStyle.wordSpacing;
engine.direction = defaultTextStyle.direction;
engine.textAlign = LEFT;
engine.textBaseline = TOP;
const metrics = engine.measureText(this.text);
releaseCell(mycell);
const { actualBoundingBoxLeft, actualBoundingBoxRight } = metrics;
const meta = this.getFontMetadata(fontFamily);
const ratio = fontSizeValue / 100;
if (dimensions) {
dimensions[0] = _ceil(_abs(actualBoundingBoxLeft) + _abs(actualBoundingBoxRight));
dimensions[1] = meta.height * ratio * currentScale;
}
const offset = meta.verticalOffset * ratio;
this.alphabeticBaseline = ((meta.alphabeticBaseline * ratio) + offset) * currentScale;
this.hangingBaseline = ((meta.hangingBaseline * ratio) + offset) * currentScale;
this.ideographicBaseline = ((meta.ideographicBaseline * ratio) + offset) * currentScale;
this.fontVerticalOffset = offset;
this.dirtyPathObject = true;
this.dirtyDimensions = true;
};
// #### Clean functions
// `cleanPathObject` - calculate the Label entity's __Path2D object__
P.cleanPathObject = function () {
this.dirtyPathObject = false;
const p = this.pathObject = new Path2D();
const handle = this.currentHandle,
dims = this.currentDimensions;
const [x, y] = handle;
const [w, h] = dims;
p.rect(-x, -y, w, h);
};
// `cleanDimensions` - calculate the entity's __currentDimensions__ Array
P.cleanDimensions = function () {
this.dirtyDimensions = false;
const dims = this.dimensions,
curDims = this.currentDimensions;
const [oldW, oldH] = curDims;
curDims[0] = dims[0];
curDims[1] = dims[1];
this.dirtyStart = true;
this.dirtyHandle = true;
this.dirtyOffset = true;
if (oldW !== curDims[0] || oldH !== curDims[1]) this.dirtyPositionSubscribers = true;
if (this.mimicked && this.mimicked.length) this.dirtyMimicDimensions = true;
this.dirtyFilterIdentifier = true;
};
P.cleanHandle = function () {
this.dirtyHandle = false;
const { handle, currentHandle, currentDimensions, mimicked, defaultTextStyle, alphabeticBaseline, hangingBaseline, ideographicBaseline } = this;
const [hx, hy] = handle;
const [dx, dy] = currentDimensions;
const direction = defaultTextStyle.direction || LTR;
// horizontal
if (hx.toFixed) currentHandle[0] = hx;
else if (hx === LEFT) currentHandle[0] = 0;
else if (hx === RIGHT) currentHandle[0] = dx;
else if (hx === CENTER) currentHandle[0] = dx / 2;
else if (hx === START) currentHandle[0] = (direction === LTR) ? 0 : dx;
else if (hx === END) currentHandle[0] = (direction === LTR) ? dx : 0;
else if (!_isFinite(parseFloat(hx))) currentHandle[0] = 0;
else currentHandle[0] = (parseFloat(hx) / 100) * dx;
// vertical
if (hy.toFixed) currentHandle[1] = hy;
else if (hy === TOP) currentHandle[1] = 0;
else if (hy === BOTTOM) currentHandle[1] = dy;
else if (hy === CENTER) currentHandle[1] = dy / 2;
else if (hy === MIDDLE) currentHandle[1] = dy / 2;
else if (hy === HANGING) currentHandle[1] = (_isFinite(hangingBaseline)) ? hangingBaseline : 0;
else if (hy === ALPHABETIC) currentHandle[1] = (_isFinite(alphabeticBaseline)) ? alphabeticBaseline : 0;
else if (hy === IDEOGRAPHIC) currentHandle[1] = (_isFinite(ideographicBaseline)) ? ideographicBaseline : 0;
else if (!_isFinite(parseFloat(hy))) currentHandle[1] = 0;
else currentHandle[1] = (parseFloat(hy) / 100) * dy;
this.dirtyFilterIdentifier = true;
this.dirtyStampHandlePositions = true;
if (mimicked && mimicked.length) this.dirtyMimicHandle = true;
};
// #### Display cycle functions
P.prepareStamp = function() {
if (this.dirtyHost) this.dirtyHost = false;
if (this.dirtyScale || this.dirtyDimensions || this.dirtyStart || this.dirtyOffset || this.dirtyHandle) this.dirtyPathObject = true;
if (this.dirtyScale) this.cleanScale();
if (this.dirtyText) this.updateAccessibleTextHold();
if (this.dirtyFont) this.cleanFont();
if (this.dirtyDimensions) this.cleanDimensions();
if (this.dirtyLock) this.cleanLock();
if (this.dirtyStart) this.cleanStart();
if (this.dirtyOffset) this.cleanOffset();
if (this.dirtyHandle) this.cleanHandle();
if (this.dirtyRotation) this.cleanRotation();
if (this.isBeingDragged || this.lockTo.includes(MOUSE) || this.lockTo.includes(PARTICLE)) {
this.dirtyStampPositions = true;
this.dirtyStampHandlePositions = true;
}
if (this.dirtyStampPositions) this.cleanStampPositions();
if (this.dirtyStampHandlePositions) this.cleanStampHandlePositions();
if (this.dirtyPathObject) this.cleanPathObject();
if (this.dirtyPositionSubscribers) this.updatePositionSubscribers();
this.prepareStampTabsHelper();
};
// ##### Stamp methods
// `regularStamp` - overwrites mixin/entity.js function.
// + If decide to pass host instead of host.engine to method functions for all entitys, then this may be a temporary fix
P.regularStamp = function () {
const dest = this.currentHost,
textStyle = this.defaultTextStyle;
if (dest && textStyle) {
const engine = dest.engine;
const [x, y] = this.currentStampPosition;
// Get the Cell wrapper to perform required transformations on its <canvas> element's 2D engine
dest.rotateDestination(engine, x, y, this);
// Get the Cell wrapper to update its 2D engine's attributes to match the entity's requirements
if (!this.noCanvasEngineUpdates) {
this.state.set(this.defaultTextStyle);
dest.setEngine(this);
}
this.setImageSmoothing(dest.engine);
// Invoke the appropriate __stamping method__ (below)
this[textStyle.method](dest);
}
};
// `stampPositioningHelper` - internal helper function
P.stampPositioningHelper = function () {
const { currentHandle, currentScale, text, fontVerticalOffset } = this;
const x = -currentHandle[0],
y = -currentHandle[1] + fontVerticalOffset * currentScale;
return [text, _floor(x), _floor(y)];
}
// `underlineEngine` - internal helper function
P.underlineEngine = function (host, pos) {
// Setup constants
const {
currentDimensions,
currentScale,
currentStampPosition,
defaultTextStyle,
fontVerticalOffset,
} = this;
const {
underlineGap,
underlineOffset,
underlineStyle,
underlineWidth,
} = defaultTextStyle;
const [, x, y] = pos;
const [localWidth, localHeight] = currentDimensions;
const underlineStartY = y + (underlineOffset * localHeight) - fontVerticalOffset * currentScale;
const underlineDepth = underlineWidth * currentScale;
// Setup the cell parts
const { element, engine } = host;
const mycell = requestCell(element.width, element.height);
const {
element: underlineElement,
engine: underlineEngine,
} = mycell;
mycell.rotateDestination(underlineEngine, ...currentStampPosition, this);
// Setup the underline context
underlineEngine.fillStyle = BLACK;
underlineEngine.strokeStyle = BLACK;
underlineEngine.font = defaultTextStyle.canvasFont;
underlineEngine.fontKerning = defaultTextStyle.fontKerning;
underlineEngine.fontStretch = defaultTextStyle.fontStretch;
underlineEngine.fontVariantCaps = defaultTextStyle.fontVariant;
underlineEngine.textRendering = defaultTextStyle.textRendering;
underlineEngine.letterSpacing = defaultTextStyle.letterSpacing;
underlineEngine.lineCap = ROUND;
underlineEngine.lineJoin = ROUND;
underlineEngine.wordSpacing = defaultTextStyle.wordSpacing;
underlineEngine.direction = defaultTextStyle.direction;
underlineEngine.textAlign = LEFT;
underlineEngine.textBaseline = TOP;
underlineEngine.lineWidth = (underlineGap * 2) * currentScale;
this.setImageSmoothing(underlineEngine);
// Underlines can take their own styling, or use the fillStyle set on the Label entity
const uStyle = this.getStyle(underlineStyle, 'fillStyle', mycell);
// Generate the underline
underlineEngine.strokeText(...pos);
underlineEngine.fillText(...pos);
underlineEngine.globalCompositeOperation = 'source-out';
underlineEngine.fillStyle = uStyle;
underlineEngine.fillRect(x, underlineStartY, localWidth, underlineDepth);
// Copy the underline over to the real cell
engine.save();
engine.resetTransform();
this.setImageSmoothing(engine);
engine.drawImage(underlineElement, 0, 0);
engine.restore();
// Release the temporary cell
releaseCell(mycell);
};
// `drawBoundingBox` - internal helper function called by `method` functions
P.drawBoundingBox = function (host) {
if (this.pathObject) {
const uStroke = this.getStyle(this.boundingBoxStyle, 'fillStyle', host);
const engine = host.engine;
engine.save();
engine.strokeStyle = uStroke;
engine.lineWidth = this.boundingBoxLineWidth;
engine.setLineDash(this.boundingBoxLineDash || []);
engine.lineDashOffset = this.boundingBoxLineDashOffset || 0;
engine.globalCompositeOperation = SOURCE_OVER;
engine.globalAlpha = 1;
engine.shadowOffsetX = 0;
engine.shadowOffsetY = 0;
engine.shadowBlur = 0;
this.setImageSmoothing(engine);
engine.stroke(this.pathObject);
engine.restore();
}
};
// `draw` - stroke the entity outline with the entity's `strokeStyle` color, gradient or pattern - including shadow
P.draw = function (host) {
if (this.currentFontIsLoaded) {
const engine = host.engine;
const pos = this.stampPositioningHelper();
if (this.defaultTextStyle && this.defaultTextStyle.includeUnderline) this.underlineEngine(host, pos);
engine.strokeText(...pos);
if (this.showBoundingBox) this.drawBoundingBox(host);
}
};
// `fill` - fill the entity with the entity's `fillStyle` color, gradient or pattern - including shadow
P.fill = function (host) {
if (this.currentFontIsLoaded) {
const engine = host.engine;
const pos = this.stampPositioningHelper();
if (this.defaultTextStyle && this.defaultTextStyle.includeUnderline) this.underlineEngine(host, pos);
engine.fillText(...pos);
if (this.showBoundingBox) this.drawBoundingBox(host);
}
};
// `drawAndFill` - stamp the entity stroke, then fill, then remove shadow and repeat
P.drawAndFill = function (host) {
if (this.currentFontIsLoaded) {
const engine = host.engine;
const pos = this.stampPositioningHelper();
if (this.defaultTextStyle && this.defaultTextStyle.includeUnderline) this.underlineEngine(host, pos);
engine.strokeText(...pos);
engine.fillText(...pos);
this.currentHost.clearShadow();
engine.strokeText(...pos);
engine.fillText(...pos);
if (this.showBoundingBox) this.drawBoundingBox(host);
}
};
// `drawAndFill` - stamp the entity fill, then stroke, then remove shadow and repeat
P.fillAndDraw = function (host) {
if (this.currentFontIsLoaded) {
const engine = host.engine;
const pos = this.stampPositioningHelper();
if (this.defaultTextStyle && this.defaultTextStyle.includeUnderline) this.underlineEngine(host, pos);
engine.fillText(...pos);
engine.strokeText(...pos);
this.currentHost.clearShadow();
engine.fillText(...pos);
engine.strokeText(...pos);
if (this.showBoundingBox) this.drawBoundingBox(host);
}
};
// `drawThenFill` - stroke the entity's outline, then fill it (shadow applied twice)
P.drawThenFill = function (host) {
if (this.currentFontIsLoaded) {
const engine = host.engine;
const pos = this.stampPositioningHelper();
if (this.defaultTextStyle && this.defaultTextStyle.includeUnderline) this.underlineEngine(host, pos);
engine.strokeText(...pos);
engine.fillText(...pos);
if (this.showBoundingBox) this.drawBoundingBox(host);
}
};
// `fillThenDraw` - fill the entity's outline, then stroke it (shadow applied twice)
P.fillThenDraw = function (host) {
if (this.currentFontIsLoaded) {
const engine = host.engine;
const pos = this.stampPositioningHelper();
if (this.defaultTextStyle && this.defaultTextStyle.includeUnderline) this.underlineEngine(host, pos);
engine.fillText(...pos);
engine.strokeText(...pos);
if (this.showBoundingBox) this.drawBoundingBox(host);
}
};
// `clip` - restrict drawing activities to the entity's enclosed area
P.clip = function (host) {
const engine = host.engine;
engine.clip(this.pathObject, this.winding);
};
// `clear` - remove everything that would have been covered if the entity had performed fill (including shadow)
P.clear = function (host) {
if (this.currentFontIsLoaded) {
const engine = host.engine;
const gco = engine.globalCompositeOperation;
const pos = this.stampPositioningHelper();
engine.globalCompositeOperation = DESTINATION_OUT;
engine.fillText(...pos);
engine.globalCompositeOperation = gco;
if (this.showBoundingBox) this.drawBoundingBox(host);
}
};
// `none` - perform all the calculations required, but don't perform the final stamping
P.none = λnull;
// #### Factory
// ```
// scrawl.makeLabel({
//
// name: 'mylabel-fill',
//
// }).clone({
//
// name: 'mylabel-draw',
// });
// ```
export const makeLabel = function (items) {
if (!items) return false;
return new Label(items);
};
constructors.Label = Label;