UNPKG

16.4 kBJavaScriptView Raw
1// @flow
2/**
3 * These objects store the data about the DOM nodes we create, as well as some
4 * extra data. They can then be transformed into real DOM nodes with the
5 * `toNode` function or HTML markup using `toMarkup`. They are useful for both
6 * storing extra properties on the nodes, as well as providing a way to easily
7 * work with the DOM.
8 *
9 * Similar functions for working with MathML nodes exist in mathMLTree.js.
10 *
11 * TODO: refactor `span` and `anchor` into common superclass when
12 * target environments support class inheritance
13 */
14import {scriptFromCodepoint} from "./unicodeScripts";
15import utils from "./utils";
16import svgGeometry from "./svgGeometry";
17import type Options from "./Options";
18import {DocumentFragment} from "./tree";
19
20import type {VirtualNode} from "./tree";
21
22
23/**
24 * Create an HTML className based on a list of classes. In addition to joining
25 * with spaces, we also remove empty classes.
26 */
27export const createClass = function(classes: string[]): string {
28 return classes.filter(cls => cls).join(" ");
29};
30
31const initNode = function(
32 classes?: string[],
33 options?: Options,
34 style?: CssStyle,
35) {
36 this.classes = classes || [];
37 this.attributes = {};
38 this.height = 0;
39 this.depth = 0;
40 this.maxFontSize = 0;
41 this.style = style || {};
42 if (options) {
43 if (options.style.isTight()) {
44 this.classes.push("mtight");
45 }
46 const color = options.getColor();
47 if (color) {
48 this.style.color = color;
49 }
50 }
51};
52
53/**
54 * Convert into an HTML node
55 */
56const toNode = function(tagName: string): HTMLElement {
57 const node = document.createElement(tagName);
58
59 // Apply the class
60 node.className = createClass(this.classes);
61
62 // Apply inline styles
63 for (const style in this.style) {
64 if (this.style.hasOwnProperty(style)) {
65 // $FlowFixMe Flow doesn't seem to understand span.style's type.
66 node.style[style] = this.style[style];
67 }
68 }
69
70 // Apply attributes
71 for (const attr in this.attributes) {
72 if (this.attributes.hasOwnProperty(attr)) {
73 node.setAttribute(attr, this.attributes[attr]);
74 }
75 }
76
77 // Append the children, also as HTML nodes
78 for (let i = 0; i < this.children.length; i++) {
79 node.appendChild(this.children[i].toNode());
80 }
81
82 return node;
83};
84
85/**
86 * Convert into an HTML markup string
87 */
88const toMarkup = function(tagName: string): string {
89 let markup = `<${tagName}`;
90
91 // Add the class
92 if (this.classes.length) {
93 markup += ` class="${utils.escape(createClass(this.classes))}"`;
94 }
95
96 let styles = "";
97
98 // Add the styles, after hyphenation
99 for (const style in this.style) {
100 if (this.style.hasOwnProperty(style)) {
101 styles += `${utils.hyphenate(style)}:${this.style[style]};`;
102 }
103 }
104
105 if (styles) {
106 markup += ` style="${utils.escape(styles)}"`;
107 }
108
109 // Add the attributes
110 for (const attr in this.attributes) {
111 if (this.attributes.hasOwnProperty(attr)) {
112 markup += ` ${attr}="${utils.escape(this.attributes[attr])}"`;
113 }
114 }
115
116 markup += ">";
117
118 // Add the markup of the children, also as markup
119 for (let i = 0; i < this.children.length; i++) {
120 markup += this.children[i].toMarkup();
121 }
122
123 markup += `</${tagName}>`;
124
125 return markup;
126};
127
128// Making the type below exact with all optional fields doesn't work due to
129// - https://github.com/facebook/flow/issues/4582
130// - https://github.com/facebook/flow/issues/5688
131// However, since *all* fields are optional, $Shape<> works as suggested in 5688
132// above.
133// This type does not include all CSS properties. Additional properties should
134// be added as needed.
135export type CssStyle = $Shape<{
136 backgroundColor: string,
137 borderBottomWidth: string,
138 borderColor: string,
139 borderRightWidth: string,
140 borderTopWidth: string,
141 bottom: string,
142 color: string,
143 height: string,
144 left: string,
145 marginLeft: string,
146 marginRight: string,
147 marginTop: string,
148 minWidth: string,
149 paddingLeft: string,
150 position: string,
151 top: string,
152 width: string,
153 verticalAlign: string,
154}> & {};
155
156export interface HtmlDomNode extends VirtualNode {
157 classes: string[];
158 height: number;
159 depth: number;
160 maxFontSize: number;
161 style: CssStyle;
162
163 hasClass(className: string): boolean;
164}
165
166// Span wrapping other DOM nodes.
167export type DomSpan = Span<HtmlDomNode>;
168// Span wrapping an SVG node.
169export type SvgSpan = Span<SvgNode>;
170
171export type SvgChildNode = PathNode | LineNode;
172export type documentFragment = DocumentFragment<HtmlDomNode>;
173
174
175/**
176 * This node represents a span node, with a className, a list of children, and
177 * an inline style. It also contains information about its height, depth, and
178 * maxFontSize.
179 *
180 * Represents two types with different uses: SvgSpan to wrap an SVG and DomSpan
181 * otherwise. This typesafety is important when HTML builders access a span's
182 * children.
183 */
184export class Span<ChildType: VirtualNode> implements HtmlDomNode {
185 children: ChildType[];
186 attributes: {[string]: string};
187 classes: string[];
188 height: number;
189 depth: number;
190 width: ?number;
191 maxFontSize: number;
192 style: CssStyle;
193
194 constructor(
195 classes?: string[],
196 children?: ChildType[],
197 options?: Options,
198 style?: CssStyle,
199 ) {
200 initNode.call(this, classes, options, style);
201 this.children = children || [];
202 }
203
204 /**
205 * Sets an arbitrary attribute on the span. Warning: use this wisely. Not
206 * all browsers support attributes the same, and having too many custom
207 * attributes is probably bad.
208 */
209 setAttribute(attribute: string, value: string) {
210 this.attributes[attribute] = value;
211 }
212
213 hasClass(className: string): boolean {
214 return utils.contains(this.classes, className);
215 }
216
217 toNode(): HTMLElement {
218 return toNode.call(this, "span");
219 }
220
221 toMarkup(): string {
222 return toMarkup.call(this, "span");
223 }
224}
225
226/**
227 * This node represents an anchor (<a>) element with a hyperlink. See `span`
228 * for further details.
229 */
230export class Anchor implements HtmlDomNode {
231 children: HtmlDomNode[];
232 attributes: {[string]: string};
233 classes: string[];
234 height: number;
235 depth: number;
236 maxFontSize: number;
237 style: CssStyle;
238
239 constructor(
240 href: string,
241 classes: string[],
242 children: HtmlDomNode[],
243 options: Options,
244 ) {
245 initNode.call(this, classes, options);
246 this.children = children || [];
247 this.setAttribute('href', href);
248 }
249
250 setAttribute(attribute: string, value: string) {
251 this.attributes[attribute] = value;
252 }
253
254 hasClass(className: string): boolean {
255 return utils.contains(this.classes, className);
256 }
257
258 toNode(): HTMLElement {
259 return toNode.call(this, "a");
260 }
261
262 toMarkup(): string {
263 return toMarkup.call(this, "a");
264 }
265}
266
267/**
268 * This node represents an image embed (<img>) element.
269 */
270export class Img implements VirtualNode {
271 src: string;
272 alt: string;
273 classes: string[];
274 height: number;
275 depth: number;
276 maxFontSize: number;
277 style: CssStyle;
278
279 constructor(
280 src: string,
281 alt: string,
282 style: CssStyle,
283 ) {
284 this.alt = alt;
285 this.src = src;
286 this.classes = ["mord"];
287 this.style = style;
288 }
289
290 hasClass(className: string): boolean {
291 return utils.contains(this.classes, className);
292 }
293
294 toNode(): Node {
295 const node = document.createElement("img");
296 node.src = this.src;
297 node.alt = this.alt;
298 node.className = "mord";
299
300 // Apply inline styles
301 for (const style in this.style) {
302 if (this.style.hasOwnProperty(style)) {
303 // $FlowFixMe
304 node.style[style] = this.style[style];
305 }
306 }
307
308 return node;
309 }
310
311 toMarkup(): string {
312 let markup = `<img src='${this.src} 'alt='${this.alt}' `;
313
314 // Add the styles, after hyphenation
315 let styles = "";
316 for (const style in this.style) {
317 if (this.style.hasOwnProperty(style)) {
318 styles += `${utils.hyphenate(style)}:${this.style[style]};`;
319 }
320 }
321 if (styles) {
322 markup += ` style="${utils.escape(styles)}"`;
323 }
324
325 markup += "'/>";
326 return markup;
327 }
328}
329
330const iCombinations = {
331 'î': '\u0131\u0302',
332 'ï': '\u0131\u0308',
333 'í': '\u0131\u0301',
334 // 'ī': '\u0131\u0304', // enable when we add Extended Latin
335 'ì': '\u0131\u0300',
336};
337
338/**
339 * A symbol node contains information about a single symbol. It either renders
340 * to a single text node, or a span with a single text node in it, depending on
341 * whether it has CSS classes, styles, or needs italic correction.
342 */
343export class SymbolNode implements HtmlDomNode {
344 text: string;
345 height: number;
346 depth: number;
347 italic: number;
348 skew: number;
349 width: number;
350 maxFontSize: number;
351 classes: string[];
352 style: CssStyle;
353
354 constructor(
355 text: string,
356 height?: number,
357 depth?: number,
358 italic?: number,
359 skew?: number,
360 width?: number,
361 classes?: string[],
362 style?: CssStyle,
363 ) {
364 this.text = text;
365 this.height = height || 0;
366 this.depth = depth || 0;
367 this.italic = italic || 0;
368 this.skew = skew || 0;
369 this.width = width || 0;
370 this.classes = classes || [];
371 this.style = style || {};
372 this.maxFontSize = 0;
373
374 // Mark text from non-Latin scripts with specific classes so that we
375 // can specify which fonts to use. This allows us to render these
376 // characters with a serif font in situations where the browser would
377 // either default to a sans serif or render a placeholder character.
378 // We use CSS class names like cjk_fallback, hangul_fallback and
379 // brahmic_fallback. See ./unicodeScripts.js for the set of possible
380 // script names
381 const script = scriptFromCodepoint(this.text.charCodeAt(0));
382 if (script) {
383 this.classes.push(script + "_fallback");
384 }
385
386 if (/[îïíì]/.test(this.text)) { // add ī when we add Extended Latin
387 this.text = iCombinations[this.text];
388 }
389 }
390
391 hasClass(className: string): boolean {
392 return utils.contains(this.classes, className);
393 }
394
395 /**
396 * Creates a text node or span from a symbol node. Note that a span is only
397 * created if it is needed.
398 */
399 toNode(): Node {
400 const node = document.createTextNode(this.text);
401 let span = null;
402
403 if (this.italic > 0) {
404 span = document.createElement("span");
405 span.style.marginRight = this.italic + "em";
406 }
407
408 if (this.classes.length > 0) {
409 span = span || document.createElement("span");
410 span.className = createClass(this.classes);
411 }
412
413 for (const style in this.style) {
414 if (this.style.hasOwnProperty(style)) {
415 span = span || document.createElement("span");
416 // $FlowFixMe Flow doesn't seem to understand span.style's type.
417 span.style[style] = this.style[style];
418 }
419 }
420
421 if (span) {
422 span.appendChild(node);
423 return span;
424 } else {
425 return node;
426 }
427 }
428
429 /**
430 * Creates markup for a symbol node.
431 */
432 toMarkup(): string {
433 // TODO(alpert): More duplication than I'd like from
434 // span.prototype.toMarkup and symbolNode.prototype.toNode...
435 let needsSpan = false;
436
437 let markup = "<span";
438
439 if (this.classes.length) {
440 needsSpan = true;
441 markup += " class=\"";
442 markup += utils.escape(createClass(this.classes));
443 markup += "\"";
444 }
445
446 let styles = "";
447
448 if (this.italic > 0) {
449 styles += "margin-right:" + this.italic + "em;";
450 }
451 for (const style in this.style) {
452 if (this.style.hasOwnProperty(style)) {
453 styles += utils.hyphenate(style) + ":" + this.style[style] + ";";
454 }
455 }
456
457 if (styles) {
458 needsSpan = true;
459 markup += " style=\"" + utils.escape(styles) + "\"";
460 }
461
462 const escaped = utils.escape(this.text);
463 if (needsSpan) {
464 markup += ">";
465 markup += escaped;
466 markup += "</span>";
467 return markup;
468 } else {
469 return escaped;
470 }
471 }
472}
473
474/**
475 * SVG nodes are used to render stretchy wide elements.
476 */
477export class SvgNode implements VirtualNode {
478 children: SvgChildNode[];
479 attributes: {[string]: string};
480
481 constructor(children?: SvgChildNode[], attributes?: {[string]: string}) {
482 this.children = children || [];
483 this.attributes = attributes || {};
484 }
485
486 toNode(): Node {
487 const svgNS = "http://www.w3.org/2000/svg";
488 const node = document.createElementNS(svgNS, "svg");
489
490 // Apply attributes
491 for (const attr in this.attributes) {
492 if (Object.prototype.hasOwnProperty.call(this.attributes, attr)) {
493 node.setAttribute(attr, this.attributes[attr]);
494 }
495 }
496
497 for (let i = 0; i < this.children.length; i++) {
498 node.appendChild(this.children[i].toNode());
499 }
500 return node;
501 }
502
503 toMarkup(): string {
504 let markup = "<svg";
505
506 // Apply attributes
507 for (const attr in this.attributes) {
508 if (Object.prototype.hasOwnProperty.call(this.attributes, attr)) {
509 markup += ` ${attr}='${this.attributes[attr]}'`;
510 }
511 }
512
513 markup += ">";
514
515 for (let i = 0; i < this.children.length; i++) {
516 markup += this.children[i].toMarkup();
517 }
518
519 markup += "</svg>";
520
521 return markup;
522
523 }
524}
525
526export class PathNode implements VirtualNode {
527 pathName: string;
528 alternate: ?string;
529
530 constructor(pathName: string, alternate?: string) {
531 this.pathName = pathName;
532 this.alternate = alternate; // Used only for tall \sqrt
533 }
534
535 toNode(): Node {
536 const svgNS = "http://www.w3.org/2000/svg";
537 const node = document.createElementNS(svgNS, "path");
538
539 if (this.alternate) {
540 node.setAttribute("d", this.alternate);
541 } else {
542 node.setAttribute("d", svgGeometry.path[this.pathName]);
543 }
544
545 return node;
546 }
547
548 toMarkup(): string {
549 if (this.alternate) {
550 return `<path d='${this.alternate}'/>`;
551 } else {
552 return `<path d='${svgGeometry.path[this.pathName]}'/>`;
553 }
554 }
555}
556
557export class LineNode implements VirtualNode {
558 attributes: {[string]: string};
559
560 constructor(attributes?: {[string]: string}) {
561 this.attributes = attributes || {};
562 }
563
564 toNode(): Node {
565 const svgNS = "http://www.w3.org/2000/svg";
566 const node = document.createElementNS(svgNS, "line");
567
568 // Apply attributes
569 for (const attr in this.attributes) {
570 if (Object.prototype.hasOwnProperty.call(this.attributes, attr)) {
571 node.setAttribute(attr, this.attributes[attr]);
572 }
573 }
574
575 return node;
576 }
577
578 toMarkup(): string {
579 let markup = "<line";
580
581 for (const attr in this.attributes) {
582 if (Object.prototype.hasOwnProperty.call(this.attributes, attr)) {
583 markup += ` ${attr}='${this.attributes[attr]}'`;
584 }
585 }
586
587 markup += "/>";
588
589 return markup;
590 }
591}
592
593export function assertSymbolDomNode(
594 group: HtmlDomNode,
595): SymbolNode {
596 if (group instanceof SymbolNode) {
597 return group;
598 } else {
599 throw new Error(`Expected symbolNode but got ${String(group)}.`);
600 }
601}
602
603export function assertSpan(
604 group: HtmlDomNode,
605): Span<HtmlDomNode> {
606 if (group instanceof Span) {
607 return group;
608 } else {
609 throw new Error(`Expected span<HtmlDomNode> but got ${String(group)}.`);
610 }
611}