UNPKG

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
// # 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 (&shy 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 &lt;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