@nitesh-tyagi/vectorflow 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/vectorflow.umd.js +2 -0
- package/dist/vectorflow.umd.js.map +1 -0
- package/package.json +42 -0
- package/src/engine.js +134 -0
- package/src/index.d.ts +63 -0
- package/src/index.js +264 -0
- package/src/parser.js +226 -0
- package/src/player.js +246 -0
- package/src/utils.js +221 -0
package/src/player.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VectorFlow — Player
|
|
3
|
+
* Animation playback using anime.js, with cancel/interrupt support.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import anime from 'animejs/lib/anime.es.js';
|
|
7
|
+
import { readSnapshot, lerpColor, parseTransform, buildTransform, lerp } from './utils.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Animate a single step: transition all live parts to target state.
|
|
11
|
+
* Returns a Promise that resolves when the animation completes.
|
|
12
|
+
*
|
|
13
|
+
* @param {Map<string, Element>} liveParts — partName → live element
|
|
14
|
+
* @param {Map<string, object>} targetSnapshots — partName → snapshot for target state
|
|
15
|
+
* @param {number} ms — animation duration
|
|
16
|
+
* @param {AbortSignal} signal — for cancellation
|
|
17
|
+
* @returns {Promise<void>}
|
|
18
|
+
*/
|
|
19
|
+
export function playStep(liveParts, targetSnapshots, ms, signal) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
if (signal?.aborted) {
|
|
22
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const animations = [];
|
|
27
|
+
let completedCount = 0;
|
|
28
|
+
const totalParts = liveParts.size;
|
|
29
|
+
|
|
30
|
+
if (totalParts === 0) {
|
|
31
|
+
resolve();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const [partName, liveEl] of liveParts) {
|
|
36
|
+
const targetSnap = targetSnapshots.get(partName);
|
|
37
|
+
if (!targetSnap) continue;
|
|
38
|
+
|
|
39
|
+
// Read current snapshot from the live element
|
|
40
|
+
const currentSnap = readSnapshot(liveEl);
|
|
41
|
+
|
|
42
|
+
// Build anime.js animation targets
|
|
43
|
+
const animeConfig = {
|
|
44
|
+
targets: liveEl,
|
|
45
|
+
duration: ms,
|
|
46
|
+
easing: 'linear',
|
|
47
|
+
complete: () => {
|
|
48
|
+
completedCount++;
|
|
49
|
+
if (completedCount >= totalParts) {
|
|
50
|
+
resolve();
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Animate numeric attributes
|
|
56
|
+
for (const [attr, targetVal] of Object.entries(targetSnap.numericAttrs)) {
|
|
57
|
+
const currentVal = currentSnap.numericAttrs[attr] ?? targetVal;
|
|
58
|
+
if (currentVal !== targetVal) {
|
|
59
|
+
animeConfig[attr] = targetVal;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Animate colors using update callback
|
|
64
|
+
const colorAnimations = [];
|
|
65
|
+
for (const [prop, targetColor] of Object.entries(targetSnap.colors)) {
|
|
66
|
+
const currentColor = currentSnap.colors[prop];
|
|
67
|
+
if (currentColor && currentColor !== targetColor) {
|
|
68
|
+
colorAnimations.push({ prop, from: currentColor, to: targetColor });
|
|
69
|
+
} else if (!currentColor && targetColor) {
|
|
70
|
+
// Set directly if no current
|
|
71
|
+
liveEl.style.setProperty(prop, targetColor);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Animate transform
|
|
76
|
+
let transformAnim = null;
|
|
77
|
+
if (targetSnap.transform || currentSnap.transform) {
|
|
78
|
+
const fromT = parseTransform(currentSnap.transform);
|
|
79
|
+
const toT = parseTransform(targetSnap.transform);
|
|
80
|
+
if (JSON.stringify(fromT) !== JSON.stringify(toT)) {
|
|
81
|
+
transformAnim = { from: fromT, to: toT };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// If we have color or transform animations, use the update callback
|
|
86
|
+
if (colorAnimations.length > 0 || transformAnim) {
|
|
87
|
+
const progressObj = { t: 0 };
|
|
88
|
+
const colorTransformAnim = anime({
|
|
89
|
+
targets: progressObj,
|
|
90
|
+
t: 1,
|
|
91
|
+
duration: ms,
|
|
92
|
+
easing: 'linear',
|
|
93
|
+
update: () => {
|
|
94
|
+
const t = progressObj.t;
|
|
95
|
+
// Colors
|
|
96
|
+
for (const { prop, from, to } of colorAnimations) {
|
|
97
|
+
const interpolated = lerpColor(from, to, t);
|
|
98
|
+
liveEl.style.setProperty(prop, interpolated);
|
|
99
|
+
}
|
|
100
|
+
// Transform
|
|
101
|
+
if (transformAnim) {
|
|
102
|
+
const { from, to } = transformAnim;
|
|
103
|
+
const interpolated = {
|
|
104
|
+
translateX: lerp(from.translateX, to.translateX, t),
|
|
105
|
+
translateY: lerp(from.translateY, to.translateY, t),
|
|
106
|
+
scaleX: lerp(from.scaleX, to.scaleX, t),
|
|
107
|
+
scaleY: lerp(from.scaleY, to.scaleY, t),
|
|
108
|
+
rotate: lerp(from.rotate, to.rotate, t),
|
|
109
|
+
};
|
|
110
|
+
const transformStr = buildTransform(interpolated);
|
|
111
|
+
if (transformStr) {
|
|
112
|
+
liveEl.setAttribute('transform', transformStr);
|
|
113
|
+
} else {
|
|
114
|
+
liveEl.removeAttribute('transform');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
animations.push(colorTransformAnim);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Create the main numeric attributes animation
|
|
123
|
+
const hasNumericChanges = Object.keys(animeConfig).some(
|
|
124
|
+
k => !['targets', 'duration', 'easing', 'complete'].includes(k)
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (hasNumericChanges) {
|
|
128
|
+
const anim = anime(animeConfig);
|
|
129
|
+
animations.push(anim);
|
|
130
|
+
} else {
|
|
131
|
+
// If no numeric changes, still count this part as complete
|
|
132
|
+
animeConfig.complete();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Handle abort
|
|
137
|
+
const onAbort = () => {
|
|
138
|
+
for (const anim of animations) {
|
|
139
|
+
anime.remove(anim.targets || anim);
|
|
140
|
+
if (anim.pause) anim.pause();
|
|
141
|
+
}
|
|
142
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
if (signal) {
|
|
146
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Play a sequence of steps.
|
|
153
|
+
*
|
|
154
|
+
* @param {Array<{state: string, ms: number}>} steps
|
|
155
|
+
* @param {Map<string, Element>} liveParts
|
|
156
|
+
* @param {Map<string, Map<string, object>>} stateSnapshots — stateName → partName → snapshot
|
|
157
|
+
* @param {AbortSignal} signal
|
|
158
|
+
* @param {function(string)} onStateChange — callback when a step completes
|
|
159
|
+
* @returns {Promise<string>} — resolves with the final state
|
|
160
|
+
*/
|
|
161
|
+
export async function playSteps(steps, liveParts, stateSnapshots, signal, onStateChange) {
|
|
162
|
+
let lastState = null;
|
|
163
|
+
|
|
164
|
+
for (const step of steps) {
|
|
165
|
+
if (signal?.aborted) {
|
|
166
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const targetSnapshots = stateSnapshots.get(step.state);
|
|
170
|
+
if (!targetSnapshots) {
|
|
171
|
+
throw new Error(`VectorFlow: No snapshots for state "${step.state}"`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await playStep(liveParts, targetSnapshots, step.ms, signal);
|
|
175
|
+
lastState = step.state;
|
|
176
|
+
|
|
177
|
+
if (onStateChange) {
|
|
178
|
+
onStateChange(step.state);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return lastState;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Run a full action (sequence or loop).
|
|
187
|
+
*
|
|
188
|
+
* @param {object} action — normalized action object
|
|
189
|
+
* @param {string} currentState
|
|
190
|
+
* @param {object} routes
|
|
191
|
+
* @param {number} jsonMs
|
|
192
|
+
* @param {Set<string>} validStates
|
|
193
|
+
* @param {Map<string, Element>} liveParts
|
|
194
|
+
* @param {Map<string, Map<string, object>>} stateSnapshots
|
|
195
|
+
* @param {AbortSignal} signal
|
|
196
|
+
* @param {function(string)} onStateChange
|
|
197
|
+
* @param {object} expandFns — { expandSequence, expandDirect } from engine
|
|
198
|
+
* @returns {Promise<string>} — final state
|
|
199
|
+
*/
|
|
200
|
+
export async function runAction(
|
|
201
|
+
action, currentState, routes, jsonMs, validStates,
|
|
202
|
+
liveParts, stateSnapshots, signal, onStateChange, expandFns
|
|
203
|
+
) {
|
|
204
|
+
const { expandSequence, expandDirect } = expandFns;
|
|
205
|
+
|
|
206
|
+
if (action.type === 'loop') {
|
|
207
|
+
const count = action.count || 99999;
|
|
208
|
+
let current = currentState;
|
|
209
|
+
|
|
210
|
+
for (let i = 0; i < count; i++) {
|
|
211
|
+
if (signal?.aborted) {
|
|
212
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let result;
|
|
216
|
+
if (action.mode === 'direct') {
|
|
217
|
+
result = expandDirect(action.order, current, routes, action.ms, jsonMs, validStates);
|
|
218
|
+
} else {
|
|
219
|
+
result = expandSequence(action.order, current, routes, action.ms, jsonMs, validStates);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (result.steps.length > 0) {
|
|
223
|
+
const finalState = await playSteps(
|
|
224
|
+
result.steps, liveParts, stateSnapshots, signal, onStateChange
|
|
225
|
+
);
|
|
226
|
+
current = finalState || current;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return current;
|
|
231
|
+
} else {
|
|
232
|
+
// sequence (default)
|
|
233
|
+
const result = expandSequence(
|
|
234
|
+
action.order, currentState, routes, action.ms, jsonMs, validStates
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
if (result.steps.length > 0) {
|
|
238
|
+
const finalState = await playSteps(
|
|
239
|
+
result.steps, liveParts, stateSnapshots, signal, onStateChange
|
|
240
|
+
);
|
|
241
|
+
return finalState || currentState;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return currentState;
|
|
245
|
+
}
|
|
246
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VectorFlow — Utility helpers
|
|
3
|
+
* Color parsing, attribute reading, interpolation helpers.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse a hex color string to { r, g, b } (0-255).
|
|
8
|
+
* Supports #RGB, #RRGGBB.
|
|
9
|
+
*/
|
|
10
|
+
export function hexToRgb(hex) {
|
|
11
|
+
if (!hex || hex === 'none') return null;
|
|
12
|
+
hex = hex.trim().replace('#', '');
|
|
13
|
+
if (hex.length === 3) {
|
|
14
|
+
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
|
15
|
+
}
|
|
16
|
+
const num = parseInt(hex, 16);
|
|
17
|
+
if (isNaN(num)) return null;
|
|
18
|
+
return {
|
|
19
|
+
r: (num >> 16) & 255,
|
|
20
|
+
g: (num >> 8) & 255,
|
|
21
|
+
b: num & 255,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Convert { r, g, b } back to a hex string.
|
|
27
|
+
*/
|
|
28
|
+
export function rgbToHex({ r, g, b }) {
|
|
29
|
+
const toHex = (n) => Math.round(Math.max(0, Math.min(255, n)))
|
|
30
|
+
.toString(16)
|
|
31
|
+
.padStart(2, '0');
|
|
32
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Linearly interpolate between two numbers.
|
|
37
|
+
*/
|
|
38
|
+
export function lerp(a, b, t) {
|
|
39
|
+
return a + (b - a) * t;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Linearly interpolate between two colors (hex strings).
|
|
44
|
+
* Returns a hex string.
|
|
45
|
+
*/
|
|
46
|
+
export function lerpColor(colorA, colorB, t) {
|
|
47
|
+
const a = hexToRgb(colorA);
|
|
48
|
+
const b = hexToRgb(colorB);
|
|
49
|
+
if (!a || !b) return colorB || colorA || '#000000';
|
|
50
|
+
return rgbToHex({
|
|
51
|
+
r: lerp(a.r, b.r, t),
|
|
52
|
+
g: lerp(a.g, b.g, t),
|
|
53
|
+
b: lerp(a.b, b.b, t),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Numeric SVG attributes we want to interpolate.
|
|
59
|
+
*/
|
|
60
|
+
export const NUMERIC_ATTRS = ['x', 'y', 'width', 'height', 'rx', 'ry', 'cx', 'cy', 'r', 'opacity'];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Style properties that may contain colors.
|
|
64
|
+
*/
|
|
65
|
+
export const COLOR_PROPS = ['fill', 'stroke'];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read a numeric attribute from an SVG element.
|
|
69
|
+
* Falls back to computed style, then to defaultVal.
|
|
70
|
+
*/
|
|
71
|
+
export function readNumericAttr(el, attr, defaultVal = 0) {
|
|
72
|
+
// Try direct attribute first
|
|
73
|
+
const raw = el.getAttribute(attr);
|
|
74
|
+
if (raw !== null && raw !== '') {
|
|
75
|
+
const val = parseFloat(raw);
|
|
76
|
+
if (!isNaN(val)) return val;
|
|
77
|
+
}
|
|
78
|
+
return defaultVal;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read a color property from an SVG element.
|
|
83
|
+
* Checks inline style first, then attribute, then computed style.
|
|
84
|
+
* Returns a hex color string or null.
|
|
85
|
+
*/
|
|
86
|
+
export function readColorProp(el, prop) {
|
|
87
|
+
// 1. Inline style
|
|
88
|
+
const styleVal = el.style.getPropertyValue(prop);
|
|
89
|
+
if (styleVal && styleVal !== 'none') {
|
|
90
|
+
return normalizeColor(styleVal);
|
|
91
|
+
}
|
|
92
|
+
// 2. Direct attribute
|
|
93
|
+
const attrVal = el.getAttribute(prop);
|
|
94
|
+
if (attrVal && attrVal !== 'none') {
|
|
95
|
+
return normalizeColor(attrVal);
|
|
96
|
+
}
|
|
97
|
+
// 3. Computed style
|
|
98
|
+
const computed = window.getComputedStyle(el).getPropertyValue(prop);
|
|
99
|
+
if (computed && computed !== 'none') {
|
|
100
|
+
return normalizeColor(computed);
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Normalize a color value to hex. Handles:
|
|
107
|
+
* - hex: #fff, #ff0000
|
|
108
|
+
* - rgb(...): rgb(255, 0, 0)
|
|
109
|
+
*/
|
|
110
|
+
export function normalizeColor(val) {
|
|
111
|
+
if (!val || val === 'none') return null;
|
|
112
|
+
val = val.trim();
|
|
113
|
+
|
|
114
|
+
// Already hex
|
|
115
|
+
if (val.startsWith('#')) return val;
|
|
116
|
+
|
|
117
|
+
// rgb(r, g, b) or rgb(r,g,b)
|
|
118
|
+
const match = val.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/i);
|
|
119
|
+
if (match) {
|
|
120
|
+
return rgbToHex({
|
|
121
|
+
r: parseInt(match[1], 10),
|
|
122
|
+
g: parseInt(match[2], 10),
|
|
123
|
+
b: parseInt(match[3], 10),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Named colors — use a canvas trick
|
|
128
|
+
if (typeof document !== 'undefined') {
|
|
129
|
+
const ctx = document.createElement('canvas').getContext('2d');
|
|
130
|
+
ctx.fillStyle = val;
|
|
131
|
+
const resolved = ctx.fillStyle; // returns hex
|
|
132
|
+
if (resolved.startsWith('#')) return resolved;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return val;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Read all animatable snapshot from an SVG element.
|
|
140
|
+
* Returns { numericAttrs: {attr: value}, colors: {prop: hexString}, transform: string|null }
|
|
141
|
+
*/
|
|
142
|
+
export function readSnapshot(el) {
|
|
143
|
+
const snapshot = {
|
|
144
|
+
numericAttrs: {},
|
|
145
|
+
colors: {},
|
|
146
|
+
transform: null,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Numeric attributes
|
|
150
|
+
for (const attr of NUMERIC_ATTRS) {
|
|
151
|
+
const raw = el.getAttribute(attr);
|
|
152
|
+
if (raw !== null && raw !== '') {
|
|
153
|
+
const val = parseFloat(raw);
|
|
154
|
+
if (!isNaN(val)) {
|
|
155
|
+
snapshot.numericAttrs[attr] = val;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Colors
|
|
161
|
+
for (const prop of COLOR_PROPS) {
|
|
162
|
+
const color = readColorProp(el, prop);
|
|
163
|
+
if (color) {
|
|
164
|
+
snapshot.colors[prop] = color;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Transform
|
|
169
|
+
const transform = el.getAttribute('transform');
|
|
170
|
+
if (transform) {
|
|
171
|
+
snapshot.transform = transform;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return snapshot;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Parse a simple SVG transform string into components.
|
|
179
|
+
* Only handles translate, scale, rotate as simple cases.
|
|
180
|
+
* Returns an object like { translateX, translateY, scaleX, scaleY, rotate }.
|
|
181
|
+
*/
|
|
182
|
+
export function parseTransform(transformStr) {
|
|
183
|
+
const result = { translateX: 0, translateY: 0, scaleX: 1, scaleY: 1, rotate: 0 };
|
|
184
|
+
if (!transformStr) return result;
|
|
185
|
+
|
|
186
|
+
const translateMatch = transformStr.match(/translate\(\s*([-\d.]+)\s*[,\s]\s*([-\d.]+)\s*\)/);
|
|
187
|
+
if (translateMatch) {
|
|
188
|
+
result.translateX = parseFloat(translateMatch[1]);
|
|
189
|
+
result.translateY = parseFloat(translateMatch[2]);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const scaleMatch = transformStr.match(/scale\(\s*([-\d.]+)(?:\s*[,\s]\s*([-\d.]+))?\s*\)/);
|
|
193
|
+
if (scaleMatch) {
|
|
194
|
+
result.scaleX = parseFloat(scaleMatch[1]);
|
|
195
|
+
result.scaleY = scaleMatch[2] !== undefined ? parseFloat(scaleMatch[2]) : result.scaleX;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const rotateMatch = transformStr.match(/rotate\(\s*([-\d.]+)/);
|
|
199
|
+
if (rotateMatch) {
|
|
200
|
+
result.rotate = parseFloat(rotateMatch[1]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Build a transform string from parsed components.
|
|
208
|
+
*/
|
|
209
|
+
export function buildTransform({ translateX, translateY, scaleX, scaleY, rotate }) {
|
|
210
|
+
const parts = [];
|
|
211
|
+
if (translateX !== 0 || translateY !== 0) {
|
|
212
|
+
parts.push(`translate(${translateX},${translateY})`);
|
|
213
|
+
}
|
|
214
|
+
if (rotate !== 0) {
|
|
215
|
+
parts.push(`rotate(${rotate})`);
|
|
216
|
+
}
|
|
217
|
+
if (scaleX !== 1 || scaleY !== 1) {
|
|
218
|
+
parts.push(`scale(${scaleX},${scaleY})`);
|
|
219
|
+
}
|
|
220
|
+
return parts.length > 0 ? parts.join(' ') : null;
|
|
221
|
+
}
|