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
1,534 lines (1,100 loc) • 123 kB
JavaScript
// # EnhancedLabel factory
// TODO - document purpose and description
//
// To note: EnhancedLabel entitys will, if told to, break words across lines on hard (- U+2010) and soft (­ U+00AD) hyphens. It makes no effort to guess whether a word _can_ be broken at a given place, regardless of any [CSS settings for the web page/component](https://css-tricks.com/almanac/properties/h/hyphenate/) in which the SC canvas finds itself. For that sort of functionality, use a third party library like [Hyphenopoly](https://github.com/mnater/Hyphenopoly) to pre-process text before feeding it into the entity.
// #### Imports
import { artefact, asset, constructors, group, tween } from '../core/library.js';
import { makeState } from '../untracked-factory/state.js';
import { makeTextStyle } from '../untracked-factory/text-style.js';
import { makeCoordinate } from '../untracked-factory/coordinate.js';
import { currentGroup } from './canvas.js';
import { filterEngine } from '../helper/filter-engine.js';
import { importDomImage } from '../asset-management/image-asset.js';
import { releaseCell, requestCell } from '../untracked-factory/cell-fragment.js';
import { releaseCoordinate, requestCoordinate } from '../untracked-factory/coordinate.js';
import { releaseArray, requestArray } from '../helper/array-pool.js';
import baseMix from '../mixin/base.js';
import deltaMix from '../mixin/delta.js';
import filterMix from '../mixin/filter.js';
import textMix from '../mixin/text.js';
import { doCreate, isa_obj, mergeOver, pushUnique, removeItem, xta, λnull, Ωempty } from '../helper/utilities.js';
// Shared constants
import { _abs, _assign, _ceil, _computed, _cos, _create, _entries, _floor, _hypot, _isArray, _isFinite, _keys, _radian, _round, _setPrototypeOf, _sin, _values, ALPHABETIC, BOTTOM, CENTER, DESTINATION_OVER, END, ENTITY, FILL, GOOD_HOST, HANGING, IDEOGRAPHIC, IMG, LEFT, LTR, MIDDLE, NONE, NORMAL, PX0, RIGHT, ROUND, SOURCE_IN, SOURCE_OUT, SOURCE_OVER, SPACE, START, T_CELL, T_ENHANCED_LABEL, T_GROUP, TOP, ZERO_STR } from '../helper/shared-vars.js';
// Local constants
const DRAW = 'draw',
DRAW_AND_FILL = 'drawAndFill',
FILL_AND_DRAW = 'fillAndDraw',
FONT_VIEWPORT_LENGTH_REGEX = /[0-9.,]+(svh|lvh|dvh|vh|svw|lvw|dvw|vw|svmax|lvmax|dvmax|vmax|svmin|lvmin|dvmin|vmin|svb|lvb|dvb|vb|svi|lvi|dvi|vi)/i,
ROW = 'row',
SPACE_AROUND = 'space-around',
SPACE_BETWEEN = 'space-between',
T_ENHANCED_LABEL_LINE = 'EnhancedLabelLine',
T_ENHANCED_LABEL_UNIT = 'EnhancedLabelUnit',
T_ENHANCED_LABEL_UNITARRAY = 'EnhancedLabelUnitArray',
TEXT_HARD_HYPHEN_REGEX = /[-]/,
TEXT_LAYOUT_FLOW_COLUMNS = ['column', 'column-reverse'],
TEXT_LAYOUT_FLOW_REVERSE = ['row-reverse', 'column-reverse'],
TEXT_NO_BREAK_REGEX = /[\u2060]/,
TEXT_SOFT_HYPHEN_REGEX = /[\u00ad]/;
// Excludes \u00A0 (no-break-space) and includes \u200b
const TEXT_SPACES_REGEX = /[ \f\n\r\t\v\u2028\u2029\u200b]/,
TEXT_TYPE_CHARS = 'C',
TEXT_TYPE_HYPHEN = 'H',
TEXT_TYPE_NO_BREAK = 'B',
TEXT_TYPE_SOFT_HYPHEN = 'h',
TEXT_TYPE_SPACE = 'S',
TEXT_TYPE_ZERO_SPACE = 'Z',
TEXT_TYPE_TRUNCATE = 'T',
TEXT_ZERO_SPACE_REGEX = /[\u200b]/;
// #### EnhancedLabel constructor
const EnhancedLabel = function (items = Ωempty) {
this.makeName(items.name);
this.register();
this.state = makeState(Ωempty);
this.defaultTextStyle = makeTextStyle({
isDefaultTextStyle: true,
});
this.cache = null;
this.textUnitHitZones = [];
this.pivoted = [];
this.set(this.defs);
if (!items.group) items.group = currentGroup;
this.currentFontIsLoaded = false;
this.updateUsingFontParts = false;
this.updateUsingFontString = false;
this.usingViewportFontSizing = false;
this.useMimicDimensions = true;
this.useMimicFlip = true;
this.useMimicHandle = true;
this.useMimicOffset = true;
this.useMimicRotation = true;
this.useMimicScale = true;
this.useMimicStart = true;
this.delta = {};
this.deltaConstraints = {};
this.currentStampPosition = makeCoordinate();
this.textHandle = makeCoordinate();
this.textOffset = makeCoordinate();
this.lines = [];
this.textUnits = makeTextUnitArray();
this.underlinePaths = [];
this.overlinePaths = [];
this.highlightPaths = [];
this.guidelineDash = [];
this.dirtyStart = true;
this.dirtyHandle = true;
this.dirtyOffset = true;
this.dirtyRotation = true;
this.dirtyScale = true;
this.dirtyDimensions = true;
this.currentHost = null;
this.dirtyHost = true;
this.currentDimensions = [];
this.currentScale = 1;
this.currentStampHandlePosition = null;
this.pathObject = null;
this.accessibleTextHold = null;
this.accessibleTextHoldAttached = null;
this.guidelinesPath = null;
this.currentPathData = null;
this.filters = [];
this.currentFilters = [];
this.dirtyFilters = true;
this.dirtyFiltersCache = true;
this.dirtyImageSubscribers = true;
this.stashOutput = false;
this.stashOutputAsAsset = false;
this.stashedImageData = null;
this.stashedImage = null;
this.set(items);
this.dirtyFont = true;
this.currentFontIsLoaded = false;
return this;
};
// #### EnhancedLabel prototype
const P = EnhancedLabel.prototype = doCreate();
P.type = T_ENHANCED_LABEL;
P.lib = ENTITY;
P.isArtefact = true;
P.isAsset = false;
// #### Mixins
baseMix(P);
deltaMix(P);
filterMix(P);
textMix(P);
// #### EnhancedLabel attributes
const defaultAttributes = {
// __text__ - string.
// + Can include html/css styling data
text: ZERO_STR,
// __lineSpacing__ - number. The distance between lines of text, as a ratio of the default font height
// + Can be set/deltaSet in the normal way
// + Alternatively, can be set via the fontString attribute.
// + Default value is set to `1.5` for accessibility reasons
lineSpacing: 1.5,
// __layoutTemplate__ - artefact object, or artefact's string name attribute.
layoutTemplate: null,
// __useLayoutTemplateAsPath__ - boolean. If layout engine entity is a path-based entity, then we can either fit the text within it, or use its path for positioning.
useLayoutTemplateAsPath: false,
// __pathPosition__ - number. Where to start text positioning along the layout engine path.
pathPosition: 0,
constantSpeedAlongPath: true,
// __alignment__ - number. Rotational positioning of the text units along a path or guideline
alignment: 0,
// __alignTextUnitsToPath__ - boolean. Forces layout to take into account the path angle. When set to false, all text units will have the same alignment, whose value is set by the `alignment` attribute
alignTextUnitsToPath: true,
// __lineAdjustment__ - number. Determines the fine-scale positioning of the guidelines within a space
lineAdjustment: 0,
// __breakTextOnSpaces__ - boolean.
// + When `true` (default), the textUnits will consist of words which are stamped as a unit (which preserves ligatures and kerning within the word).
// + Set this attribute to `false` if the font's language, when written, (generally) doesn't include spaces (eg: Chinese, Japanese), or when there is a requirement to style individual characters within words
breakTextOnSpaces: true,
// __breakWordsOnHyphens__ - boolean.
// + When `true`, words that include hard or soft hyphens will be split into separate units for processing. Be aware that in highly ligatured fonts this may cause problems. The attribute defaults to `false`.
// + It is possible to style individual characters in a text that breaks on spaces by adding soft hyphens before and after the characters, but it may (will) lead to unnatural-looking word breaks at the end of the line.
// + Attribute has no effect if `breakTextOnSpaces` is `false`.
breakWordsOnHyphens: false,
// __justifyLine__ - string enum. Allowed values are 'start', 'end', 'center' (default), 'space-between', 'space-around'
// + Determines the positioning of text units along the space layout line. Has nothing to do with the `direction` attribute.
justifyLine: CENTER,
// __textUnitFlow__ - string enum. Allowed values are 'row' (default), 'row-reverse', 'column' (for vertical text), 'column-reverse'
// + Determines the ordering of text units along the space layout line. Has nothing to do with the `direction` attribute.
textUnitFlow: ROW,
// __truncateString__ - string.
truncateString: '…',
// __hyphenString__ - string.
hyphenString: '-',
// __textHandle__ - Coordinate.
textHandle: null,
textOffset: null,
// __showGuidelines__ - boolean.
showGuidelines: false,
guidelineStyle: 'rgb(0 0 0 / 0.5)',
guidelineWidth: 1,
guidelineDash: null,
// The EnhancedLabel entity does not use the [position](./mixin/position.html) or [entity](./mixin/entity.html) mixins (used by most other entitys) as its positioning is entirely dependent on the position, rotation, scale etc of its constituent Shape path entity struts.
//
// It does, however, use these attributes (alongside their setters and getters): __visibility__, __order__, __delta__, __host__, __group__, __anchor__.
visibility: true,
calculateOrder: 0,
stampOrder: 0,
host: null,
group: null,
method: FILL,
lockFillStyleToEntity: false,
lockStrokeStyleToEntity: false,
cacheOutput: true,
checkHitUseTemplate: true,
};
P.defs = mergeOver(P.defs, defaultAttributes);
// #### Packet management
P.packetExclusions = pushUnique(P.packetExclusions, ['pathObject', 'mimicked', 'pivoted', 'state']);
P.packetExclusionsByRegex = pushUnique(P.packetExclusionsByRegex, ['^(local|dirty|current)', 'Subscriber$']);
P.packetCoordinates = pushUnique(P.packetCoordinates, ['start', 'handle', 'offset']);
P.packetObjects = pushUnique(P.packetObjects, ['group', 'layoutTemplate']);
P.packetFunctions = pushUnique(P.packetFunctions, ['onEnter', 'onLeave', 'onDown', 'onUp']);
P.processPacketOut = function (key, value, inc) {
return this.processEntityPacketOut(key, value, inc);
};
// handles both anchor and button objects
P.handlePacketAnchor = function (copy) {
return copy;
}
P.processEntityPacketOut = function (key, value, incs) {
return this.processFactoryPacketOut(key, value, incs);
};
P.processFactoryPacketOut = function (key, value, incs) {
let result = true;
if(!incs.indexOf(key) && value === this.defs[key]) result = false;
return result;
};
// #### Clone management
P.postCloneAction = function(clone) {
return clone;
};
// #### Kill management
P.kill = function (flag1 = false, flag2 = false) {
const name = this.name;
// Remove artefact from all groups
_values(group).forEach(val => {
if (val.artefacts.includes(name)) val.removeArtefacts(name);
});
// If the artefact has an anchor, it needs to be removed
if (this.anchor) this.demolishAnchor();
// If the artefact has a button, it needs to be removed
if (this.button) this.demolishButton();
// Remove from other artefacts
_values(artefact).forEach(val => {
if (val.name !== name) {
if (val.pivot && val.pivot.name === name) val.set({ pivot: false});
if (val.mimic && val.mimic.name === name) val.set({ mimic: false});
if (val.path && val.path.name === name) val.set({ path: false});
if (val.generateAlongPath && val.generateAlongPath.name === name) val.set({ generateAlongPath: false});
if (val.generateInArea && val.generateInArea.name === name) val.set({ generateInArea: false});
if (val.artefact && val.artefact.name === name) val.set({ artefact: false});
if (_isArray(val.pins)) {
val.pins.forEach((item, index) => {
if (isa_obj(item) && item.name === name) val.removePinAt(index);
});
}
}
});
// Remove from tweens and actions targets arrays
_values(tween).forEach(val => {
if (val.checkForTarget(name)) val.removeFromTargets(this);
});
// Factory-specific actions required to complete the kill
this.factoryKill(flag1, flag2);
// Remove artefact from the Scrawl-canvas library
this.deregister();
return this;
};
// #### Get, Set, deltaSet
const G = P.getters,
S = P.setters,
D = P.deltaSetters;
// __group__ - copied over from the position mixin.
G.group = function () {
return (this.group) ? this.group.name : ZERO_STR;
};
S.group = function (item) {
let g;
if (item) {
if (this.group && this.group.type === T_GROUP) this.group.removeArtefacts(this.name);
if (item.substring) {
g = group[item];
if (g) this.group = g;
else this.group = item;
}
else this.group = item;
}
if (this.group && this.group.type === T_GROUP) this.group.addArtefacts(this.name);
};
// __layoutTemplate__ - TODO: documentation
S.layoutTemplate = function (item) {
if (item) {
const oldTemplate = this.layoutTemplate,
newTemplate = (item.substring) ? artefact[item] : item,
name = this.name;
if (newTemplate && newTemplate.name) {
if (oldTemplate && oldTemplate.name !== newTemplate.name) {
if (oldTemplate.mimicked) removeItem(oldTemplate.mimicked, name);
if (oldTemplate.pathed) removeItem(oldTemplate.pathed, name);
}
if (newTemplate.mimicked) pushUnique(newTemplate.mimicked, name);
if (newTemplate.pathed) pushUnique(newTemplate.pathed, name);
this.layoutTemplate = newTemplate;
this.dirtyPathObject = true;
this.dirtyLayout = true;
}
}
};
S.breakTextOnSpaces = function (item) {
this.breakTextOnSpaces = !!item;
this.dirtyText = true;
};
S.breakWordsOnHyphens = function (item) {
this.breakWordsOnHyphens = !!item;
this.dirtyText = true;
};
S.truncateString = function (item) {
if (item.substring) {
this.truncateString = this.convertTextEntityCharacters(item);
this.dirtyText = true;
}
};
S.hyphenString = function (item) {
if (item.substring) {
this.hyphenString = this.convertTextEntityCharacters(item);
this.dirtyText = true;
}
};
S.textHandleX = function (item) {
this.textHandle[0] = item;
this.dirtyLayout = true;
};
S.textHandleY = function (item) {
this.textHandle[1] = item;
this.dirtyLayout = true;
};
S.textHandle = function (item) {
if (_isArray(item) && item.length > 1) {
this.textHandle[0] = item[0];
this.textHandle[1] = item[1];
this.dirtyLayout = true;
}
};
S.guidelineDash = function (item) {
if (_isArray(item)) this.guidelineDash = item;
};
S.guidelineStyle = function (item) {
if (!item) this.guidelineStyle = this.defs.guidelineStyle;
else if (item.substring) this.guidelineStyle = item;
};
S.pathPosition = function (item) {
if (item < 0) item = _abs(item);
if (item > 1) item = item % 1;
this.pathPosition = parseFloat(item.toFixed(6));
this.dirtyTextLayout = true;
};
D.pathPosition = function (item) {
let pos = this.pathPosition + item
if (pos < 0) pos += 1;
if (pos > 1) pos = pos % 1;
this.pathPosition = parseFloat(pos.toFixed(6));
this.dirtyTextLayout = true;
};
S.textUnitFlow = function (item) {
this.textUnitFlow = item;
this.dirtyText = true;
};
G.textUnits = function () {
return this.textUnits;
};
G.textLines = function () {
return this.lines;
};
// #### Prototype functions
// `getTester` - Retrieve the DOM labelStylesCalculator <div> element
P.getTester = function () {
const controller = this.getControllerCell();
if (controller) return controller.labelStylesCalculator;
return null;
};
// `makeWorkingTextStyle` - Clone a TextStyle object
P.makeWorkingTextStyle = function (template) {
const workStyle = _create(template);
_assign(workStyle, template);
workStyle.isDefaultTextStyle = false;
return workStyle;
};
// `setEngineFromWorkingTextStyle` - Sets the state object to current working requirements, alongside directly updating the Cell's engine to match
P.setEngineFromWorkingTextStyle = function (worker, style, state, cell) {
this.updateWorkingTextStyle(worker, style);
state.set(worker);
cell.setEngine(this);
};
// `updateWorkingTextStyle` - Updates the working TextStyle object with a partial TextStyle object, and regenerates font strings from the updated data
// + Takes into account the layout entity's current scaling factor
P.updateWorkingTextStyle = function (worker, style) {
let scale = 1;
if (this.layoutTemplate) scale = this.layoutTemplate.currentScale;
worker.set(style, true);
this.updateCanvasFont(worker, scale);
this.updateFontString(worker);
};
// `getTextHandleX` - Calculate the horizontal offset required for a given TextUnit
P.getTextHandleX = function (val, dim, dir) {
if (val.toFixed) return val;
if (val === START) return (dir === LTR) ? 0 : dim;
if (val === CENTER) return dim / 2;
if (val === END) return (dir === LTR) ? dim : 0;
if (val === LEFT) return 0;
if (val === RIGHT) return dim;
if (!_isFinite(parseFloat(val))) return 0;
return (parseFloat(val) / 100) * dim;
};
// `getTextHandleY` - Calculate the vertical offset required for a given TextUnit
P.getTextHandleY = function (val, size, font) {
const meta = this.getFontMetadata(font);
const {
alphabeticRatio,
hangingRatio,
height,
ideographicRatio,
} = meta;
const ratio = size / 100;
let scale = 1;
if (this.layoutTemplate) scale = this.layoutTemplate.currentScale;
const dim = height * ratio;
if (val.toFixed) return val * scale;
if (val === TOP) return 0;
if (val === BOTTOM) return dim * scale;
if (val === CENTER) return (dim / 2) * scale;
if (val === ALPHABETIC) return dim * alphabeticRatio * scale;
if (val === HANGING) return dim * hangingRatio * scale;
if (val === IDEOGRAPHIC) return dim * ideographicRatio * scale;
if (val === MIDDLE) return (dim / 2) * scale;
if (!_isFinite(parseFloat(val))) return 0;
return (parseFloat(val) / 100) * dim;
};
// `getTextOffset` - Calculate the horizontal offset required for a given TextUnit
P.getTextOffset = function (val, dim) {
if (val.toFixed) return val;
if (!_isFinite(parseFloat(val))) return 0;
return (parseFloat(val) / 100) * dim;
};
P.dirtyCache = function () {
releaseCell(this.cache);
this.cache = null;
this.textUnitHitZones.length = 0;
if (this.pivoted.length) this.updatePivotSubscribers();
};
// #### Clean functions
// `cleanPathObject` - calculate the EnhancedLabel entity's __Path2D object__
P.cleanPathObject = function () {
const layout = this.layoutTemplate;
if (layout && this.dirtyPathObject && layout.pathObject) {
this.dirtyPathObject = false;
this.pathObject = new Path2D(layout.pathObject);
}
};
// `cleanLayout` - recalculate the positioning of all TextUnits in the space or along the path
P.cleanLayout = function () {
if (this.currentFontIsLoaded) {
this.dirtyCache();
this.dirtyLayout = false;
if (!this.useLayoutTemplateAsPath) this.calculateLines();
this.dirtyTextLayout = true;
}
};
// `calculateLines` - calculate the positions and lengths of multiple lines withing a layout entity's enclosed space.
P.calculateLines = function () {
const {
alignment,
defaultTextStyle,
layoutTemplate,
lineAdjustment,
lines,
lineSpacing,
textUnitFlow,
} = this;
const {
currentDimensions,
currentScale,
currentRotation,
currentStampPosition,
pathObject,
winding,
} = layoutTemplate;
const { fontSizeValue } = defaultTextStyle;
const rotation = (-alignment - currentRotation) * _radian;
const [layoutStartX, layoutStartY] = currentStampPosition;
const [layoutWidth, layoutHeight] = currentDimensions;
const coord = requestCoordinate();
const mycell = requestCell(),
engine = mycell.engine;
// Prepare canvas for work
mycell.rotateDestination(engine, layoutStartX, layoutStartY, layoutTemplate);
engine.rotate(rotation);
const rawLines = requestArray();
let isInLayout, check, sx, sy, ex, ey;
const step = _round(fontSizeValue * lineSpacing * currentScale);
const rrpX = _round(layoutStartX),
xLeft = _round(rrpX - (layoutWidth * currentScale * 2)),
xRight = _round(rrpX + (layoutWidth * currentScale * 2)),
rrpY = _round(layoutStartY + (lineAdjustment * currentScale)),
yTop = _round(rrpY - (layoutHeight * currentScale * 2)),
yBase = _round(rrpY + (layoutHeight * currentScale * 2));
if (step) {
for (let i = rrpY; i > yTop; i -= step) {
const rawLineData = requestArray();
isInLayout = false;
check = false;
for (let j = xLeft; j < xRight; j++) {
check = engine.isPointInPath(pathObject, j, i, winding);
if (check !== isInLayout) {
rawLineData.push([check === false ? j - 1 : j, i]);
isInLayout = check;
}
}
rawLines.push([i, [...rawLineData]]);
releaseArray(rawLineData);
}
for (let i = rrpY + step; i < yBase; i += step) {
const rawLineData = requestArray();
isInLayout = false;
check = false;
for (let j = xLeft; j < xRight; j++) {
check = engine.isPointInPath(pathObject, j, i, winding);
if (check !== isInLayout) {
rawLineData.push([check === false ? j - 1 : j, i]);
isInLayout = check;
}
}
rawLines.push([i, [...rawLineData]]);
releaseArray(rawLineData);
}
}
// Protecting against a zero value step
else {
const rawLineData = requestArray();
isInLayout = false;
check = false;
for (let j = xLeft; j < xRight; j++) {
check = engine.isPointInPath(pathObject, j, rrpY, winding);
if (check !== isInLayout) {
rawLineData.push([check === false ? j - 1 : j, rrpY]);
isInLayout = check;
}
}
rawLines.push([rrpY, [...rawLineData]]);
releaseArray(rawLineData);
}
const relevantLines = requestArray();
relevantLines.push(...rawLines.filter(l => l[1].length));
releaseArray(rawLines);
relevantLines.sort((a, b) => {
if (a[0] > b[0]) return 1;
if (a[0] < b[0]) return -1;
return 0;
});
const selectedLines = relevantLines.map(l => l[1]);
if (TEXT_LAYOUT_FLOW_REVERSE.includes(textUnitFlow)) selectedLines.reverse();
releaseArray(relevantLines);
selectedLines.forEach(data => {
data.forEach(d => {
coord.set(d).subtract(currentStampPosition).rotate(alignment + currentRotation).add(currentStampPosition);
d[0] = coord[0];
d[1] = coord[1];
});
});
releaseLine(...lines);
lines.length = 0;
selectedLines.forEach(data => {
for (let i = 0, iz = data.length; i < iz; i += 2) {
lines.push(requestLine().set({
startAt: data[i],
endAt: data[i + 1],
}));
}
});
let path = '';
lines.forEach(line => {
[sx, sy] = line.startAt;
[ex, ey] = line.endAt;
line.length = _hypot(sx - ex, sy - ey);
path += `M ${sx}, ${sy} ${ex}, ${ey} `;
});
this.guidelinesPath = new Path2D(path);
releaseCell(mycell);
releaseCoordinate(coord);
};
// `cleanText` - Break the entity's text into smaller TextUnit objects which can be positioned within, or along, the layout entity's shape
P.cleanText = function () {
if (this.currentFontIsLoaded) {
this.dirtyText = false;
const {
breakTextOnSpaces,
breakWordsOnHyphens,
defaultTextStyle,
text,
textUnitFlow,
textUnits,
} = this;
const textCharacters = [...text];
const languageDirectionIsLtr = (defaultTextStyle.direction === LTR);
const layoutFlowIsColumns = TEXT_LAYOUT_FLOW_COLUMNS.includes(textUnitFlow);
const unit = [];
let noBreak = false;
releaseUnit(...textUnits);
textUnits.length = 0;
let index = 0;
if (breakTextOnSpaces) {
// + Soft hyphens and truncation marking is deliberately suppressed for RTL fonts
if (languageDirectionIsLtr && breakWordsOnHyphens) {
textCharacters.forEach(c => {
if (TEXT_SPACES_REGEX.test(c)) {
textUnits.push(requestUnit({
[UNIT_CHARS]: unit.join(ZERO_STR),
[UNIT_TYPE]: TEXT_TYPE_CHARS,
index,
}));
index++;
textUnits.push(requestUnit({
[UNIT_CHARS]: c,
[UNIT_TYPE]: TEXT_TYPE_SPACE,
index,
}));
unit.length = 0;
index++;
}
else if (TEXT_HARD_HYPHEN_REGEX.test(c)) {
textUnits.push(requestUnit({
[UNIT_CHARS]: unit.join(ZERO_STR),
[UNIT_TYPE]: TEXT_TYPE_CHARS,
index,
}));
index++;
textUnits.push(requestUnit({
[UNIT_CHARS]: c,
[UNIT_TYPE]: TEXT_TYPE_HYPHEN,
index,
}));
unit.length = 0;
index++;
}
else if (TEXT_SOFT_HYPHEN_REGEX.test(c)) {
textUnits.push(requestUnit({
[UNIT_CHARS]: unit.join(ZERO_STR),
[UNIT_TYPE]: TEXT_TYPE_CHARS,
index,
}));
index++;
textUnits.push(requestUnit({
[UNIT_CHARS]: c,
[UNIT_TYPE]: TEXT_TYPE_SOFT_HYPHEN,
index,
}));
unit.length = 0;
index++;
}
else unit.push(c);
});
// Capturing the last word
if (unit.length) textUnits.push(requestUnit({
[UNIT_CHARS]: unit.join(ZERO_STR),
[UNIT_TYPE]: TEXT_TYPE_CHARS,
index,
}));
}
else {
textCharacters.forEach(c => {
if (TEXT_SPACES_REGEX.test(c)) {
textUnits.push(requestUnit({
[UNIT_CHARS]: unit.join(ZERO_STR),
[UNIT_TYPE]: TEXT_TYPE_CHARS,
index,
}));
index++;
textUnits.push(requestUnit({
[UNIT_CHARS]: c,
[UNIT_TYPE]: TEXT_TYPE_SPACE
}));
unit.length = 0;
index++;
}
else unit.push(c);
});
// Capturing the last word
if (unit.length) textUnits.push(requestUnit({
[UNIT_CHARS]: unit.join(ZERO_STR),
[UNIT_TYPE]: TEXT_TYPE_CHARS,
index,
}));
}
}
else {
textCharacters.forEach((c, i) => {
unit.push(c);
// Some Chinese/Japanese characters simply have to stick together (but not in columns)!
if (!layoutFlowIsColumns) {
noBreak = TEXT_NO_BREAK_REGEX.test(c) || TEXT_NO_BREAK_REGEX.test(textCharacters[i + 1]);
if (!noBreak) {
if (TEXT_SPACES_REGEX.test(c)) {
textUnits.push(requestUnit({
[UNIT_CHARS]: unit.join(ZERO_STR),
[UNIT_TYPE]: TEXT_TYPE_SPACE,
index,
}));
unit.length = 0;
index++;
}
else {
textUnits.push(requestUnit({
[UNIT_CHARS]: unit.join(ZERO_STR),
[UNIT_TYPE]: TEXT_TYPE_CHARS,
index,
}));
unit.length = 0;
index++;
}
}
}
else {
if (TEXT_SPACES_REGEX.test(c)) {
textUnits.push(requestUnit({
[UNIT_CHARS]: unit.join(ZERO_STR),
[UNIT_TYPE]: TEXT_TYPE_SPACE,
index,
}));
unit.length = 0;
index++;
}
else if (TEXT_NO_BREAK_REGEX.test(c)) {
textUnits.push(requestUnit({
[UNIT_CHARS]: unit.join(ZERO_STR),
[UNIT_TYPE]: TEXT_TYPE_NO_BREAK,
index,
}));
unit.length = 0;
index++;
}
else if (TEXT_ZERO_SPACE_REGEX.test(c)) {
textUnits.push(requestUnit({
[UNIT_CHARS]: unit.join(ZERO_STR),
[UNIT_TYPE]: TEXT_TYPE_ZERO_SPACE,
index,
}));
unit.length = 0;
index++;
}
else {
textUnits.push(requestUnit({
[UNIT_CHARS]: unit.join(ZERO_STR),
[UNIT_TYPE]: TEXT_TYPE_CHARS,
index,
}));
unit.length = 0;
index++;
}
}
});
}
this.assessTextForStyle();
this.measureTextUnits();
this.dirtyTextLayout = true;
}
};
// `assessTextForStyle` - Add styling details to each TextUnit
// + Note that styling on a per-TextUnit basis requires CSS code; there is no way to directly style a TextUnit in SC except by manually replacing its `style` attribute object in code (which is dangerous and definitely not guaranteed to work!)
P.assessTextForStyle = function () {
const tester = this.getTester();
// No calculator! Reset dirty flag and return
if (!tester) {
this.dirtyText = true;
return null;
}
// Local helper function `processNode`
// + recursively step through the text's HTML nodes
const processNode = (node) => {
if (node.nodeType !== 3) {
for (const item of node.childNodes) {
processNode(item);
}
}
else {
const unit = textUnits[getCharacterUnit(cursor)];
if (unit != null && unit.style == null) unit.style = makeTextStyle({});
cursor += node.textContent.length;
diffStyles(node, unit);
}
};
// Local helper function `getCharacterUnit`
// + called by `processNode`, maps char position to textUnit item
const getCharacterUnit = (pos) => {
let len = 0;
for (let i = 0, iz = textUnits.length; i < iz; i++) {
len += textUnits[i].chars.length;
if (pos < len) return i;
}
return null;
};
const localState = {
localHandleX: '',
localHandleY: '',
localOffsetX: 0,
localOffsetY: 0,
localAlignment: 0,
};
// Local helper function `diffStyles`
// + called by `processNode`, diffs required styles against existing ones
const diffStyles = (node, unit) => {
const nodeVals = _computed(node.parentNode);
const unitSet = {};
let oldVal, newVal;
oldVal = currentTextStyle.direction;
newVal = nodeVals.getPropertyValue('direction');
if (oldVal !== newVal) unitSet.direction = newVal;
oldVal = currentTextStyle.fontFamily;
newVal = nodeVals.getPropertyValue('font-family');
if (oldVal !== newVal) unitSet.fontFamily = newVal;
oldVal = currentTextStyle.fontKerning;
newVal = nodeVals.getPropertyValue('font-kerning');
if (oldVal !== newVal) unitSet.fontKerning = newVal;
oldVal = currentTextStyle.fontSize;
newVal = nodeVals.getPropertyValue('font-size');
if (oldVal !== newVal) {
unitSet.fontSize = newVal;
if (FONT_VIEWPORT_LENGTH_REGEX.test(newVal)) this.usingViewportFontSizing = true;
}
oldVal = currentTextStyle.fontStretch;
newVal = nodeVals.getPropertyValue('font-stretch');
if (newVal === '100%') newVal = NORMAL;
if (oldVal !== newVal) unitSet.fontStretch = newVal;
oldVal = currentTextStyle.fontStyle;
newVal = nodeVals.getPropertyValue('font-style');
if (oldVal !== newVal) unitSet.fontStyle = newVal;
oldVal = currentTextStyle.fontVariantCaps;
newVal = nodeVals.getPropertyValue('font-variant-caps');
if (oldVal !== newVal) unitSet.fontVariantCaps = newVal;
oldVal = currentTextStyle.fontWeight;
newVal = nodeVals.getPropertyValue('font-weight');
if (oldVal !== newVal) unitSet.fontWeight = newVal;
oldVal = currentTextStyle.get('letterSpacing');
newVal = nodeVals.getPropertyValue('letter-spacing');
if (newVal === NORMAL) newVal = PX0;
if (oldVal !== newVal) unitSet.letterSpacing = newVal;
oldVal = currentTextStyle.textRendering;
newVal = nodeVals.getPropertyValue('text-rendering');
if (oldVal !== newVal) unitSet.textRendering = newVal;
oldVal = currentTextStyle.get('wordSpacing');
newVal = nodeVals.getPropertyValue('word-spacing');
if (oldVal !== newVal) unitSet.wordSpacing = newVal;
oldVal = currentTextStyle.fillStyle;
newVal = nodeVals.getPropertyValue('--SC-fill-style');
if (oldVal !== newVal) unitSet.fillStyle = newVal;
oldVal = currentTextStyle.includeHighlight;
newVal = !!nodeVals.getPropertyValue('--SC-include-highlight');
if (oldVal !== newVal) unitSet.includeHighlight = newVal;
oldVal = currentTextStyle.highlightStyle;
newVal = nodeVals.getPropertyValue('--SC-highlight-style');
if (oldVal !== newVal) unitSet.highlightStyle = newVal;
oldVal = currentTextStyle.lineWidth;
newVal = parseFloat(nodeVals.getPropertyValue('--SC-stroke-width'));
if (oldVal !== newVal) unitSet.lineWidth = newVal;
oldVal = currentTextStyle.includeOverline;
newVal = !!nodeVals.getPropertyValue('--SC-include-overline');
if (oldVal !== newVal) unitSet.includeOverline = newVal;
oldVal = currentTextStyle.overlineOffset;
newVal = parseFloat(nodeVals.getPropertyValue('--SC-overline-offset'));
if (oldVal !== newVal) unitSet.overlineOffset = newVal;
oldVal = currentTextStyle.overlineStyle;
newVal = nodeVals.getPropertyValue('--SC-overline-style');
if (oldVal !== newVal) unitSet.overlineStyle = newVal;
oldVal = currentTextStyle.overlineWidth;
newVal = parseFloat(nodeVals.getPropertyValue('--SC-overline-width'));
if (oldVal !== newVal) unitSet.overlineWidth = newVal;
oldVal = currentTextStyle.includeUnderline;
newVal = !!nodeVals.getPropertyValue('--SC-include-underline');
if (oldVal !== newVal) unitSet.includeUnderline = newVal;
oldVal = currentTextStyle.underlineGap;
newVal = parseFloat(nodeVals.getPropertyValue('--SC-underline-gap'));
if (oldVal !== newVal) unitSet.underlineGap = newVal;
oldVal = currentTextStyle.underlineOffset;
newVal = parseFloat(nodeVals.getPropertyValue('--SC-underline-offset'));
if (oldVal !== newVal) unitSet.underlineOffset = newVal;
oldVal = currentTextStyle.underlineStyle;
newVal = nodeVals.getPropertyValue('--SC-underline-style');
if (oldVal !== newVal) unitSet.underlineStyle = newVal;
oldVal = currentTextStyle.underlineWidth;
newVal = parseFloat(nodeVals.getPropertyValue('--SC-underline-width'));
if (oldVal !== newVal) unitSet.underlineWidth = newVal;
oldVal = currentTextStyle.method;
newVal = nodeVals.getPropertyValue('--SC-method');
if (oldVal !== newVal) unitSet.method = newVal;
oldVal = localState.localHandleX;
newVal = nodeVals.getPropertyValue('--SC-local-handle-x');
if (oldVal !== newVal) unitSet.localHandleX = newVal;
oldVal = localState.localHandleY;
newVal = nodeVals.getPropertyValue('--SC-local-handle-y');
if (oldVal !== newVal) unitSet.localHandleY = newVal;
oldVal = localState.localOffsetX;
newVal = parseFloat(nodeVals.getPropertyValue('--SC-local-offset-x'));
if (oldVal !== newVal) unitSet.localOffsetX = newVal;
oldVal = localState.localOffsetY;
newVal = parseFloat(nodeVals.getPropertyValue('--SC-local-offset-y'));
if (oldVal !== newVal) unitSet.localOffsetY = newVal;
oldVal = localState.localAlignment;
newVal = parseFloat(nodeVals.getPropertyValue('--SC-local-alignment'));
if (oldVal !== newVal) unitSet.localAlignment = newVal;
unit.set(unitSet);
unit.style.set(unitSet, true);
currentTextStyle.set(unitSet, true);
};
// Local helper function `setupTester`
// + called by main function, assigns default text styles to the tester div
const setupTester = () => {
tester.style.setProperty('direction', defaultTextStyle.direction);
tester.style.setProperty('font-family', defaultTextStyle.fontFamily);
tester.style.setProperty('font-kerning', defaultTextStyle.fontKerning);
tester.style.setProperty('font-size', defaultTextStyle.fontSize);
tester.style.setProperty('font-stretch', defaultTextStyle.fontStretch);
tester.style.setProperty('font-style', defaultTextStyle.fontStyle);
tester.style.setProperty('font-variant-caps', defaultTextStyle.fontVariantCaps);
tester.style.setProperty('font-weight', defaultTextStyle.fontWeight);
tester.style.setProperty('letter-spacing', defaultTextStyle.get('letterSpacing'));
tester.style.setProperty('text-rendering', defaultTextStyle.textRendering);
tester.style.setProperty('word-spacing', defaultTextStyle.get('wordSpacing'));
tester.style.setProperty('--SC-fill-style', defaultTextStyle.fillStyle);
tester.style.setProperty('--SC-highlight-style', defaultTextStyle.highlightStyle);
tester.style.setProperty('--SC-overline-offset', defaultTextStyle.overlineOffset);
tester.style.setProperty('--SC-overline-style', defaultTextStyle.overlineStyle);
tester.style.setProperty('--SC-overline-width', defaultTextStyle.overlineWidth);
tester.style.setProperty('--SC-stroke-width', defaultTextStyle.lineWidth);
tester.style.setProperty('--SC-stroke-style', defaultTextStyle.strokeStyle);
tester.style.setProperty('--SC-underline-gap', defaultTextStyle.underlineGap);
tester.style.setProperty('--SC-underline-offset', defaultTextStyle.underlineOffset);
tester.style.setProperty('--SC-underline-style', defaultTextStyle.underlineStyle);
tester.style.setProperty('--SC-underline-width', defaultTextStyle.underlineWidth);
tester.style.setProperty('--SC-local-handle-x', localState.localHandleX);
tester.style.setProperty('--SC-local-handle-y', localState.localHandleY);
tester.style.setProperty('--SC-local-offset-x', localState.localOffsetX);
tester.style.setProperty('--SC-local-offset-y', localState.localOffsetY);
tester.style.setProperty('--SC-local-alignment', localState.localAlignment);
tester.style.setProperty('--SC-method', defaultTextStyle.method);
tester.className = this.name;
tester.innerHTML = rawText;
};
// Start processing data here
const { rawText, defaultTextStyle, textUnits } = this;
const currentTextStyle = this.makeWorkingTextStyle(defaultTextStyle);
let cursor = 0;
this.usingViewportFontSizing = FONT_VIEWPORT_LENGTH_REGEX.test(currentTextStyle.fontSize);
setupTester();
processNode(tester);
};
// `measureTextUnits` - TextUnit lengths represent the amount of space they will need to take along the line they will (eventually) be assigned to.
// + Takes into account the styling for each TextUnit, which can have a significant impact on the amount of space it requires on a line.
P.measureTextUnits = function () {
const {
defaultTextStyle,
hyphenString,
state,
textUnitFlow,
textUnits,
truncateString,
breakTextOnSpaces,
} = this;
const mycell = requestCell(),
engine = mycell.engine;
let res, chars, charType, style, len, nextUnit, nextStyle, nextChars, nextType, nextLen, unkernedLen;
const currentTextStyle = this.makeWorkingTextStyle(defaultTextStyle);
this.setEngineFromWorkingTextStyle(currentTextStyle, Ωempty, state, mycell);
const layoutFlowIsColumns = TEXT_LAYOUT_FLOW_COLUMNS.includes(textUnitFlow);
textUnits.forEach(t => {
({chars, charType, style} = t);
if (style) this.setEngineFromWorkingTextStyle(currentTextStyle, style, state, mycell);
res = engine.measureText(chars);
t.len = res.width;
// Add word spacing to space chars
if (charType === TEXT_TYPE_SPACE) {
t.len += currentTextStyle.wordSpaceValue;
}
// Prep soft hyphens
else if (charType === TEXT_TYPE_SOFT_HYPHEN) {
res = engine.measureText(hyphenString);
t.replaceLen = res.width;
}
// Prep truncation
else {
res = engine.measureText(truncateString);
t.replaceLen = res.width;
}
// No gaps between CJK chars and punctuation when textUnitFlow is columnar
if (layoutFlowIsColumns && !breakTextOnSpaces) {
if (charType === TEXT_TYPE_ZERO_SPACE || charType === TEXT_TYPE_NO_BREAK) t.height = 0;
else t.height = parseFloat(currentTextStyle.fontSize);
}
else t.height = parseFloat(currentTextStyle.fontSize);
});
// Gather kerning data (if required) - only applies to rows
if (this.useLayoutTemplateAsPath || !this.breakTextOnSpaces) {
// Reset things back to initial before starting the second walk-through
this.setEngineFromWorkingTextStyle(currentTextStyle, defaultTextStyle, state, mycell);
textUnits.forEach((unit, index) => {
({chars, charType, style, len} = unit);
if (style) this.setEngineFromWorkingTextStyle(currentTextStyle, style, state, mycell);
// Do we need to perform this work?
if (currentTextStyle.fontKerning !== NONE) {
nextUnit = textUnits[index + 1];
// No need to kern the last textUnit
if (nextUnit) {
({ style: nextStyle, chars: nextChars, charType: nextType, len: nextLen} = nextUnit);
// We don't need to kern anything next to a space, or the space itself
if (charType !== TEXT_TYPE_SPACE && nextType !== TEXT_TYPE_SPACE) {
// We won't kern anything that's changing style in significant ways
if (!nextStyle || !(nextStyle.fontFamily || nextStyle.fontSize || nextStyle.fontVariantCaps)) {
unkernedLen = len + nextLen;
res = engine.measureText(`${chars}${nextChars}`);
// the kerning applies the the next textUnit, not the current one
nextUnit.kernOffset = unkernedLen - res.width;
}
}
}
}
});
}
releaseCell(mycell);
};
// `layoutText` - initiate the process of laying out text into a space, or along a line
// + TODO: The assumption here is that if we are laying text along a path, there will only be one line with a length equal to the layout engine's path length. In such cases we won't need to care about soft hyphens, but will need to care about truncation (regardless of whether we allow the text to wrap itself along the line)
P.layoutText = function () {
if (this.currentFontIsLoaded) {
const { useLayoutTemplateAsPath, lines, textUnits, layoutTemplate } = this;
if (useLayoutTemplateAsPath) {
if (layoutTemplate && layoutTemplate.useAsPath) {
this.dirtyTextLayout = false;
releaseLine(...lines);
lines.length = 0;
lines.push(requestLine({
length: layoutTemplate.length,
isPathEntity: true,
}));
}
}
else {
if (lines.length && textUnits.length) {
this.dirtyTextLayout = false;
lines.forEach(line => {
line.unitData.length = 0;
});
textUnits.forEach(unit => {
unit.stampFlag = true;
unit.lineOffset = 0;
});
}
}
this.assignTextUnitsToLines();
this.positionTextUnits();
}
};
// `assignTextUnitsToLines` - Assign sufficient text units to each line to fill the line's length
P.assignTextUnitsToLines = function () {
const {
breakWordsOnHyphens,
defaultTextStyle,
layoutTemplate,
lines,
textUnitFlow,
textUnits,
} = this;
const languageDirectionIsLtr = (defaultTextStyle.direction === LTR);
const layoutFlowIsColumns = TEXT_LAYOUT_FLOW_COLUMNS.includes(textUnitFlow);
const currentScale = layoutTemplate.currentScale;
const unitArrayLength = textUnits.length;
let unitCursor = 0,
lengthRemaining,
i, unit, unitData, unitAfter, len, height, lineLength, charType, check, firstOnLineCheck;
const addUnit = function (val) {
lengthRemaining -= val;
unitData.push(unitCursor);
++unitCursor;
};
lines.forEach(line => {
({
length: lineLength,
unitData,
} = line);
lengthRemaining = _ceil(lineLength);
firstOnLineCheck = true;
for (i = unitCursor; i < unitArrayLength; i++) {
unit = textUnits[i];
({ len, height, charType } = unit);
// Check: is there room for the text unit
check = (layoutFlowIsColumns) ? height * currentScale : len;
// We need to discount the length of spaces that end up at the beginning of the line
if (firstOnLineCheck && !layoutFlowIsColumns && unit.charType === TEXT_TYPE_SPACE) check = 0;
if (check <= lengthRemaining) {
// Hyphens capture
// + Soft hyphens and truncation marking is deliberately suppressed for RTL fonts
// + We don't care about hyphens or truncation in columnar layouts
if (languageDirectionIsLtr && !layoutFlowIsColumns && breakWordsOnHyphens) {
// We need to do a look-forward for soft hyphens
unit = textUnits[i + 1];
// Next text unit is a soft hyphen
if (unit && unit.charType === TEXT_TYPE_SOFT_HYPHEN) {
unitAfter = textUnits[i + 2];
// Check: this text unit and the next significant one will fit on line
if (unitAfter && len + unitAfter.len < lengthRemaining) addUnit(len);
// Check: this text unit and the visible hyphen will fit on line
else if (len + unit.replaceLen < lengthRemaining) {
addUnit(check);
addUnit(unit.replaceLen);
unitData.push(TEXT_TYPE_SOFT_HYPHEN);
break;
}
// Check: there's no room for this text unit and its soft hyphen
else break;
}
// Next text unit is not a soft hyp