UNPKG

15.6 kBJavaScriptView Raw
1/**
2 * Copyright (c) Facebook, Inc. and its affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @format
8 */
9
10'use strict';
11
12const Color = require('art/core/color');
13const Path = require('ARTSerializablePath');
14const Transform = require('art/core/transform');
15
16const React = require('React');
17const PropTypes = require('prop-types');
18const ReactNativeViewAttributes = require('ReactNativeViewAttributes');
19
20const createReactNativeComponentClass = require('createReactNativeComponentClass');
21const merge = require('merge');
22const invariant = require('invariant');
23
24// Diff Helpers
25
26function arrayDiffer(a, b) {
27 if (a == null || b == null) {
28 return true;
29 }
30 if (a.length !== b.length) {
31 return true;
32 }
33 for (let i = 0; i < a.length; i++) {
34 if (a[i] !== b[i]) {
35 return true;
36 }
37 }
38 return false;
39}
40
41function fontAndLinesDiffer(a, b) {
42 if (a === b) {
43 return false;
44 }
45 if (a.font !== b.font) {
46 if (a.font === null) {
47 return true;
48 }
49 if (b.font === null) {
50 return true;
51 }
52
53 if (
54 a.font.fontFamily !== b.font.fontFamily ||
55 a.font.fontSize !== b.font.fontSize ||
56 a.font.fontWeight !== b.font.fontWeight ||
57 a.font.fontStyle !== b.font.fontStyle
58 ) {
59 return true;
60 }
61 }
62 return arrayDiffer(a.lines, b.lines);
63}
64
65// Native Attributes
66
67const SurfaceViewAttributes = merge(ReactNativeViewAttributes.UIView, {
68 // This should contain pixel information such as width, height and
69 // resolution to know what kind of buffer needs to be allocated.
70 // Currently we rely on UIViews and style to figure that out.
71});
72
73const NodeAttributes = {
74 transform: {diff: arrayDiffer},
75 opacity: true,
76};
77
78const GroupAttributes = merge(NodeAttributes, {
79 clipping: {diff: arrayDiffer},
80});
81
82const RenderableAttributes = merge(NodeAttributes, {
83 fill: {diff: arrayDiffer},
84 stroke: {diff: arrayDiffer},
85 strokeWidth: true,
86 strokeCap: true,
87 strokeJoin: true,
88 strokeDash: {diff: arrayDiffer},
89});
90
91const ShapeAttributes = merge(RenderableAttributes, {
92 d: {diff: arrayDiffer},
93});
94
95const TextAttributes = merge(RenderableAttributes, {
96 alignment: true,
97 frame: {diff: fontAndLinesDiffer},
98 path: {diff: arrayDiffer},
99});
100
101// Native Components
102
103const NativeSurfaceView = createReactNativeComponentClass(
104 'ARTSurfaceView',
105 () => ({
106 validAttributes: SurfaceViewAttributes,
107 uiViewClassName: 'ARTSurfaceView',
108 }),
109);
110
111const NativeGroup = createReactNativeComponentClass('ARTGroup', () => ({
112 validAttributes: GroupAttributes,
113 uiViewClassName: 'ARTGroup',
114}));
115
116const NativeShape = createReactNativeComponentClass('ARTShape', () => ({
117 validAttributes: ShapeAttributes,
118 uiViewClassName: 'ARTShape',
119}));
120
121const NativeText = createReactNativeComponentClass('ARTText', () => ({
122 validAttributes: TextAttributes,
123 uiViewClassName: 'ARTText',
124}));
125
126// Utilities
127
128function childrenAsString(children) {
129 if (!children) {
130 return '';
131 }
132 if (typeof children === 'string') {
133 return children;
134 }
135 if (children.length) {
136 return children.join('\n');
137 }
138 return '';
139}
140
141// Surface - Root node of all ART
142
143class Surface extends React.Component {
144 static childContextTypes = {
145 isInSurface: PropTypes.bool,
146 };
147
148 getChildContext() {
149 return {isInSurface: true};
150 }
151
152 render() {
153 const height = extractNumber(this.props.height, 0);
154 const width = extractNumber(this.props.width, 0);
155
156 return (
157 <NativeSurfaceView style={[this.props.style, {height, width}]}>
158 {this.props.children}
159 </NativeSurfaceView>
160 );
161 }
162}
163
164// Node Props
165
166// TODO: The desktop version of ART has title and cursor. We should have
167// accessibility support here too even though hovering doesn't work.
168
169function extractNumber(value, defaultValue) {
170 if (value == null) {
171 return defaultValue;
172 }
173 return +value;
174}
175
176const pooledTransform = new Transform();
177
178function extractTransform(props) {
179 const scaleX =
180 props.scaleX != null ? props.scaleX : props.scale != null ? props.scale : 1;
181 const scaleY =
182 props.scaleY != null ? props.scaleY : props.scale != null ? props.scale : 1;
183
184 pooledTransform
185 .transformTo(1, 0, 0, 1, 0, 0)
186 .move(props.x || 0, props.y || 0)
187 .rotate(props.rotation || 0, props.originX, props.originY)
188 .scale(scaleX, scaleY, props.originX, props.originY);
189
190 if (props.transform != null) {
191 pooledTransform.transform(props.transform);
192 }
193
194 return [
195 pooledTransform.xx,
196 pooledTransform.yx,
197 pooledTransform.xy,
198 pooledTransform.yy,
199 pooledTransform.x,
200 pooledTransform.y,
201 ];
202}
203
204function extractOpacity(props) {
205 // TODO: visible === false should also have no hit detection
206 if (props.visible === false) {
207 return 0;
208 }
209 if (props.opacity == null) {
210 return 1;
211 }
212 return +props.opacity;
213}
214
215// Groups
216
217// Note: ART has a notion of width and height on Group but AFAIK it's a noop in
218// ReactART.
219
220class Group extends React.Component {
221 static contextTypes = {
222 isInSurface: PropTypes.bool.isRequired,
223 };
224
225 render() {
226 const props = this.props;
227 invariant(
228 this.context.isInSurface,
229 'ART: <Group /> must be a child of a <Surface />',
230 );
231 return (
232 <NativeGroup
233 opacity={extractOpacity(props)}
234 transform={extractTransform(props)}>
235 {this.props.children}
236 </NativeGroup>
237 );
238 }
239}
240
241class ClippingRectangle extends React.Component {
242 render() {
243 const props = this.props;
244 const x = extractNumber(props.x, 0);
245 const y = extractNumber(props.y, 0);
246 const w = extractNumber(props.width, 0);
247 const h = extractNumber(props.height, 0);
248 const clipping = [x, y, w, h];
249 // The current clipping API requires x and y to be ignored in the transform
250 const propsExcludingXAndY = merge(props);
251 delete propsExcludingXAndY.x;
252 delete propsExcludingXAndY.y;
253 return (
254 <NativeGroup
255 clipping={clipping}
256 opacity={extractOpacity(props)}
257 transform={extractTransform(propsExcludingXAndY)}>
258 {this.props.children}
259 </NativeGroup>
260 );
261 }
262}
263
264// Renderables
265
266const SOLID_COLOR = 0;
267const LINEAR_GRADIENT = 1;
268const RADIAL_GRADIENT = 2;
269const PATTERN = 3;
270
271function insertColorIntoArray(color, targetArray, atIndex) {
272 const c = new Color(color);
273 targetArray[atIndex + 0] = c.red / 255;
274 targetArray[atIndex + 1] = c.green / 255;
275 targetArray[atIndex + 2] = c.blue / 255;
276 targetArray[atIndex + 3] = c.alpha;
277}
278
279function insertColorsIntoArray(stops, targetArray, atIndex) {
280 let i = 0;
281 if ('length' in stops) {
282 while (i < stops.length) {
283 insertColorIntoArray(stops[i], targetArray, atIndex + i * 4);
284 i++;
285 }
286 } else {
287 for (const offset in stops) {
288 insertColorIntoArray(stops[offset], targetArray, atIndex + i * 4);
289 i++;
290 }
291 }
292 return atIndex + i * 4;
293}
294
295function insertOffsetsIntoArray(stops, targetArray, atIndex, multi, reverse) {
296 let offsetNumber;
297 let i = 0;
298 if ('length' in stops) {
299 while (i < stops.length) {
300 offsetNumber = (i / (stops.length - 1)) * multi;
301 targetArray[atIndex + i] = reverse ? 1 - offsetNumber : offsetNumber;
302 i++;
303 }
304 } else {
305 for (const offsetString in stops) {
306 offsetNumber = +offsetString * multi;
307 targetArray[atIndex + i] = reverse ? 1 - offsetNumber : offsetNumber;
308 i++;
309 }
310 }
311 return atIndex + i;
312}
313
314function insertColorStopsIntoArray(stops, targetArray, atIndex) {
315 const lastIndex = insertColorsIntoArray(stops, targetArray, atIndex);
316 insertOffsetsIntoArray(stops, targetArray, lastIndex, 1, false);
317}
318
319function insertDoubleColorStopsIntoArray(stops, targetArray, atIndex) {
320 let lastIndex = insertColorsIntoArray(stops, targetArray, atIndex);
321 lastIndex = insertColorsIntoArray(stops, targetArray, lastIndex);
322 lastIndex = insertOffsetsIntoArray(stops, targetArray, lastIndex, 0.5, false);
323 insertOffsetsIntoArray(stops, targetArray, lastIndex, 0.5, true);
324}
325
326function applyBoundingBoxToBrushData(brushData, props) {
327 const type = brushData[0];
328 const width = +props.width;
329 const height = +props.height;
330 if (type === LINEAR_GRADIENT) {
331 brushData[1] *= width;
332 brushData[2] *= height;
333 brushData[3] *= width;
334 brushData[4] *= height;
335 } else if (type === RADIAL_GRADIENT) {
336 brushData[1] *= width;
337 brushData[2] *= height;
338 brushData[3] *= width;
339 brushData[4] *= height;
340 brushData[5] *= width;
341 brushData[6] *= height;
342 } else if (type === PATTERN) {
343 // todo
344 }
345}
346
347function extractBrush(colorOrBrush, props) {
348 if (colorOrBrush == null) {
349 return null;
350 }
351 if (colorOrBrush._brush) {
352 if (colorOrBrush._bb) {
353 // The legacy API for Gradients allow for the bounding box to be used
354 // as a convenience for specifying gradient positions. This should be
355 // deprecated. It's not properly implemented in canvas mode. ReactART
356 // doesn't handle update to the bounding box correctly. That's why we
357 // mutate this so that if it's reused, we reuse the same resolved box.
358 applyBoundingBoxToBrushData(colorOrBrush._brush, props);
359 colorOrBrush._bb = false;
360 }
361 return colorOrBrush._brush;
362 }
363 const c = new Color(colorOrBrush);
364 return [SOLID_COLOR, c.red / 255, c.green / 255, c.blue / 255, c.alpha];
365}
366
367function extractColor(color) {
368 if (color == null) {
369 return null;
370 }
371 const c = new Color(color);
372 return [c.red / 255, c.green / 255, c.blue / 255, c.alpha];
373}
374
375function extractStrokeCap(strokeCap) {
376 switch (strokeCap) {
377 case 'butt':
378 return 0;
379 case 'square':
380 return 2;
381 default:
382 return 1; // round
383 }
384}
385
386function extractStrokeJoin(strokeJoin) {
387 switch (strokeJoin) {
388 case 'miter':
389 return 0;
390 case 'bevel':
391 return 2;
392 default:
393 return 1; // round
394 }
395}
396
397// Shape
398
399// Note: ART has a notion of width and height on Shape but AFAIK it's a noop in
400// ReactART.
401
402export type ShapeProps = {|
403 fill?: mixed,
404 stroke?: mixed,
405 strokeCap?: mixed,
406 strokeDash?: mixed,
407 strokeJoin?: mixed,
408 strokeWidth?: mixed,
409 x?: number,
410 y?: number,
411 opacity?: mixed,
412|};
413
414class Shape extends React.Component<ShapeProps> {
415 render() {
416 const props = this.props;
417 const path = props.d || childrenAsString(props.children);
418 const d = (path instanceof Path ? path : new Path(path)).toJSON();
419 return (
420 <NativeShape
421 fill={extractBrush(props.fill, props)}
422 opacity={extractOpacity(props)}
423 stroke={extractColor(props.stroke)}
424 strokeCap={extractStrokeCap(props.strokeCap)}
425 strokeDash={props.strokeDash || null}
426 strokeJoin={extractStrokeJoin(props.strokeJoin)}
427 strokeWidth={extractNumber(props.strokeWidth, 1)}
428 transform={extractTransform(props)}
429 d={d}
430 />
431 );
432 }
433}
434
435// Text
436
437const cachedFontObjectsFromString = {};
438
439const fontFamilyPrefix = /^[\s"']*/;
440const fontFamilySuffix = /[\s"']*$/;
441
442function extractSingleFontFamily(fontFamilyString) {
443 // ART on the web allows for multiple font-families to be specified.
444 // For compatibility, we extract the first font-family, hoping
445 // we'll get a match.
446 return fontFamilyString
447 .split(',')[0]
448 .replace(fontFamilyPrefix, '')
449 .replace(fontFamilySuffix, '');
450}
451
452function parseFontString(font) {
453 if (cachedFontObjectsFromString.hasOwnProperty(font)) {
454 return cachedFontObjectsFromString[font];
455 }
456 const regexp = /^\s*((?:(?:normal|bold|italic)\s+)*)(?:(\d+(?:\.\d+)?)[ptexm\%]*(?:\s*\/.*?)?\s+)?\s*\"?([^\"]*)/i;
457 const match = regexp.exec(font);
458 if (!match) {
459 return null;
460 }
461 const fontFamily = extractSingleFontFamily(match[3]);
462 const fontSize = +match[2] || 12;
463 const isBold = /bold/.exec(match[1]);
464 const isItalic = /italic/.exec(match[1]);
465 cachedFontObjectsFromString[font] = {
466 fontFamily: fontFamily,
467 fontSize: fontSize,
468 fontWeight: isBold ? 'bold' : 'normal',
469 fontStyle: isItalic ? 'italic' : 'normal',
470 };
471 return cachedFontObjectsFromString[font];
472}
473
474function extractFont(font) {
475 if (font == null) {
476 return null;
477 }
478 if (typeof font === 'string') {
479 return parseFontString(font);
480 }
481 const fontFamily = extractSingleFontFamily(font.fontFamily);
482 const fontSize = +font.fontSize || 12;
483 const fontWeight =
484 font.fontWeight != null ? font.fontWeight.toString() : '400';
485 return {
486 // Normalize
487 fontFamily: fontFamily,
488 fontSize: fontSize,
489 fontWeight: fontWeight,
490 fontStyle: font.fontStyle,
491 };
492}
493
494const newLine = /\n/g;
495function extractFontAndLines(font, text) {
496 return {font: extractFont(font), lines: text.split(newLine)};
497}
498
499function extractAlignment(alignment) {
500 switch (alignment) {
501 case 'right':
502 return 1;
503 case 'center':
504 return 2;
505 default:
506 return 0;
507 }
508}
509
510class Text extends React.Component {
511 render() {
512 const props = this.props;
513 const path = props.path;
514 const textPath = path
515 ? (path instanceof Path ? path : new Path(path)).toJSON()
516 : null;
517 const textFrame = extractFontAndLines(
518 props.font,
519 childrenAsString(props.children),
520 );
521 return (
522 <NativeText
523 fill={extractBrush(props.fill, props)}
524 opacity={extractOpacity(props)}
525 stroke={extractColor(props.stroke)}
526 strokeCap={extractStrokeCap(props.strokeCap)}
527 strokeDash={props.strokeDash || null}
528 strokeJoin={extractStrokeJoin(props.strokeJoin)}
529 strokeWidth={extractNumber(props.strokeWidth, 1)}
530 transform={extractTransform(props)}
531 alignment={extractAlignment(props.alignment)}
532 frame={textFrame}
533 path={textPath}
534 />
535 );
536 }
537}
538
539// Declarative fill type objects - API design not finalized
540
541function LinearGradient(stops, x1, y1, x2, y2) {
542 const type = LINEAR_GRADIENT;
543
544 if (arguments.length < 5) {
545 const angle = ((x1 == null ? 270 : x1) * Math.PI) / 180;
546
547 let x = Math.cos(angle);
548 let y = -Math.sin(angle);
549 const l = (Math.abs(x) + Math.abs(y)) / 2;
550
551 x *= l;
552 y *= l;
553
554 x1 = 0.5 - x;
555 x2 = 0.5 + x;
556 y1 = 0.5 - y;
557 y2 = 0.5 + y;
558 this._bb = true;
559 } else {
560 this._bb = false;
561 }
562
563 const brushData = [type, +x1, +y1, +x2, +y2];
564 insertColorStopsIntoArray(stops, brushData, 5);
565 this._brush = brushData;
566}
567
568function RadialGradient(stops, fx, fy, rx, ry, cx, cy) {
569 if (ry == null) {
570 ry = rx;
571 }
572 if (cx == null) {
573 cx = fx;
574 }
575 if (cy == null) {
576 cy = fy;
577 }
578 if (fx == null) {
579 // As a convenience we allow the whole radial gradient to cover the
580 // bounding box. We should consider dropping this API.
581 fx = fy = rx = ry = cx = cy = 0.5;
582 this._bb = true;
583 } else {
584 this._bb = false;
585 }
586 // The ART API expects the radial gradient to be repeated at the edges.
587 // To simulate this we render the gradient twice as large and add double
588 // color stops. Ideally this API would become more restrictive so that this
589 // extra work isn't needed.
590 const brushData = [RADIAL_GRADIENT, +fx, +fy, +rx * 2, +ry * 2, +cx, +cy];
591 insertDoubleColorStopsIntoArray(stops, brushData, 7);
592 this._brush = brushData;
593}
594
595function Pattern(url, width, height, left, top) {
596 this._brush = [PATTERN, url, +left || 0, +top || 0, +width, +height];
597}
598
599const ReactART = {
600 LinearGradient: LinearGradient,
601 RadialGradient: RadialGradient,
602 Pattern: Pattern,
603 Transform: Transform,
604 Path: Path,
605 Surface: Surface,
606 Group: Group,
607 ClippingRectangle: ClippingRectangle,
608 Shape: Shape,
609 Text: Text,
610};
611
612module.exports = ReactART;