jlicon.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import { UUID } from '@lumino/coreutils';
  4. import React from 'react';
  5. import ReactDOM from 'react-dom';
  6. import { Text } from '@jupyterlab/coreutils';
  7. import { iconStyle, IIconStyle } from '../style/icon';
  8. import { getReactAttrs, classes, classesDedupe } from '../utils';
  9. import badSvg from '../../style/debug/bad.svg';
  10. import blankSvg from '../../style/debug/blank.svg';
  11. const blankDiv = document.createElement('div');
  12. export class JLIcon {
  13. private static _debug: boolean = false;
  14. private static _instances = new Map<string, JLIcon>();
  15. /**
  16. * Get any existing JLIcon instance by name.
  17. *
  18. * @param name - name of the JLIcon instance to fetch
  19. *
  20. * @param fallback - optional default JLIcon instance to use if
  21. * name is not found
  22. *
  23. * @returns A JLIcon instance
  24. */
  25. private static _get(name: string, fallback?: JLIcon): JLIcon | undefined {
  26. for (let className of name.split(/\s+/)) {
  27. if (JLIcon._instances.has(className)) {
  28. return JLIcon._instances.get(className);
  29. }
  30. }
  31. // lookup failed
  32. if (JLIcon._debug) {
  33. // fail noisily
  34. console.error(`Invalid icon name: ${name}`);
  35. return badIcon;
  36. }
  37. // fail silently
  38. return fallback;
  39. }
  40. /**
  41. * Get any existing JLIcon instance by name, construct a DOM element
  42. * from it, then return said element.
  43. *
  44. * @param name - name of the JLIcon instance to fetch
  45. *
  46. * @param fallback - if left undefined, use automatic fallback to
  47. * icons-as-css-background behavior: elem will be constructed using
  48. * a blank icon with `elem.className = classes(name, props.className)`,
  49. * where elem is the return value. Otherwise, fallback can be used to
  50. * define the default JLIcon instance, returned whenever lookup fails
  51. *
  52. * @param props - passed directly to JLIcon.element
  53. *
  54. * @returns an SVGElement
  55. */
  56. static getElement({
  57. name,
  58. fallback,
  59. ...props
  60. }: { name: string; fallback?: JLIcon } & JLIcon.IProps) {
  61. let icon = JLIcon._get(name, fallback);
  62. if (!icon) {
  63. icon = blankIcon;
  64. props.className = classesDedupe(name, props.className);
  65. }
  66. return icon.element(props);
  67. }
  68. /**
  69. * Get any existing JLIcon instance by name, construct a React element
  70. * from it, then return said element.
  71. *
  72. * @param name - name of the JLIcon instance to fetch
  73. *
  74. * @param fallback - if left undefined, use automatic fallback to
  75. * icons-as-css-background behavior: elem will be constructed using
  76. * a blank icon with `elem.className = classes(name, props.className)`,
  77. * where elem is the return value. Otherwise, fallback can be used to
  78. * define the default JLIcon instance, used to construct the return
  79. * elem whenever lookup fails
  80. *
  81. * @param props - passed directly to JLIcon.react
  82. *
  83. * @returns a React element
  84. */
  85. static getReact({
  86. name,
  87. fallback,
  88. ...props
  89. }: { name: string; fallback?: JLIcon } & JLIcon.IReactProps) {
  90. let icon = JLIcon._get(name, fallback);
  91. if (!icon) {
  92. icon = blankIcon;
  93. props.className = classesDedupe(name, props.className);
  94. }
  95. return <icon.react {...props} />;
  96. }
  97. /**
  98. * Toggle icon debug from off-to-on, or vice-versa.
  99. *
  100. * @param debug - optional boolean to force debug on or off
  101. */
  102. static toggleDebug(debug?: boolean) {
  103. JLIcon._debug = debug ?? !JLIcon._debug;
  104. }
  105. constructor({ name, svgstr }: JLIcon.IOptions) {
  106. this.name = name;
  107. this._className = Private.nameToClassName(name);
  108. this.svgstr = svgstr;
  109. this.react = this._initReact();
  110. JLIcon._instances.set(this.name, this);
  111. JLIcon._instances.set(this._className, this);
  112. }
  113. class({ className, ...propsStyle }: { className?: string } & IIconStyle) {
  114. return classesDedupe(className, iconStyle(propsStyle));
  115. }
  116. element({
  117. className,
  118. container,
  119. label,
  120. title,
  121. tag = 'div',
  122. ...propsStyle
  123. }: JLIcon.IProps = {}): HTMLElement {
  124. // check if icon element is already set
  125. const maybeSvgElement = container?.firstChild as HTMLElement;
  126. if (maybeSvgElement?.dataset?.iconId === this._uuid) {
  127. // return the existing icon element
  128. return maybeSvgElement;
  129. }
  130. // ensure that svg html is valid
  131. const svgElement = this.resolveSvg();
  132. if (!svgElement) {
  133. // bail if failing silently
  134. return blankDiv;
  135. }
  136. let ret: HTMLElement;
  137. if (container) {
  138. // take ownership by removing any existing children
  139. while (container.firstChild) {
  140. container.firstChild.remove();
  141. }
  142. ret = svgElement;
  143. } else {
  144. // create a container if needed
  145. container = document.createElement(tag);
  146. ret = container;
  147. }
  148. if (label != null) {
  149. container.textContent = label;
  150. }
  151. this._initContainer({ container, className, propsStyle, title });
  152. // add the svg node to the container
  153. container.appendChild(svgElement);
  154. return ret;
  155. }
  156. recycle({
  157. className,
  158. container,
  159. ...propsStyle
  160. }: { className?: string; container: HTMLElement } & IIconStyle): HTMLElement {
  161. // clean up all children
  162. while (container.firstChild) {
  163. container.firstChild.remove();
  164. }
  165. // clean up any icon-related class names
  166. const cls = this.class({ className, ...propsStyle });
  167. if (cls) {
  168. container.classList.remove(cls);
  169. }
  170. return container;
  171. }
  172. render(host: HTMLElement, props: JLIcon.IProps = {}): void {
  173. // TODO: move this title fix to the Lumino side
  174. host.removeAttribute('title');
  175. return ReactDOM.render(<this.react container={host} {...props} />, host);
  176. }
  177. resolveSvg(title?: string): HTMLElement | null {
  178. const svgDoc = new DOMParser().parseFromString(
  179. this._svgstr,
  180. 'image/svg+xml'
  181. );
  182. const svgElement = svgDoc.documentElement;
  183. // structure of error element varies by browser, search at top level
  184. if (svgDoc.getElementsByTagName('parsererror').length > 0) {
  185. const errmsg = `SVG HTML was malformed for JLIcon instance.\nname: ${name}, svgstr: ${this._svgstr}`;
  186. // parse failed, svgElement will be an error box
  187. if (JLIcon._debug) {
  188. // fail noisily, render the error box
  189. console.error(errmsg);
  190. return svgElement;
  191. } else {
  192. // bad svg is always a real error, fail silently but warn
  193. console.warn(errmsg);
  194. return null;
  195. }
  196. } else {
  197. // parse succeeded
  198. svgElement.dataset.icon = this.name;
  199. svgElement.dataset.iconId = this._uuid;
  200. if (title) {
  201. Private.setTitleSvg(svgElement, title);
  202. }
  203. return svgElement;
  204. }
  205. }
  206. get svgstr() {
  207. return this._svgstr;
  208. }
  209. set svgstr(svgstr: string) {
  210. this._svgstr = svgstr;
  211. // associate a unique id with this particular svgstr
  212. this._uuid = UUID.uuid4();
  213. }
  214. unrender(host: HTMLElement): void {
  215. ReactDOM.unmountComponentAtNode(host);
  216. }
  217. protected _initContainer({
  218. container,
  219. className,
  220. propsStyle,
  221. title
  222. }: {
  223. container: HTMLElement;
  224. className?: string;
  225. propsStyle?: IIconStyle;
  226. title?: string;
  227. }) {
  228. const classStyle = iconStyle(propsStyle);
  229. if (className != null) {
  230. // override the container class with explicitly passed-in class + style class
  231. container.className = classes(className, classStyle);
  232. } else if (classStyle) {
  233. // add the style class to the container class
  234. container.classList.add(classStyle);
  235. }
  236. if (title != null) {
  237. container.title = title;
  238. }
  239. }
  240. protected _initReact() {
  241. const component = React.forwardRef(
  242. (
  243. {
  244. className,
  245. container,
  246. label,
  247. title,
  248. tag = 'div',
  249. ...propsStyle
  250. }: JLIcon.IProps = {},
  251. ref: React.RefObject<SVGElement>
  252. ) => {
  253. const Tag = tag;
  254. // ensure that svg html is valid
  255. const svgElement = this.resolveSvg();
  256. if (!svgElement) {
  257. // bail if failing silently
  258. return <></>;
  259. }
  260. const svgComponent = (
  261. <svg
  262. {...getReactAttrs(svgElement)}
  263. dangerouslySetInnerHTML={{ __html: svgElement.innerHTML }}
  264. ref={ref}
  265. />
  266. );
  267. if (container) {
  268. this._initContainer({ container, className, propsStyle, title });
  269. return (
  270. <React.Fragment>
  271. {svgComponent}
  272. {label}
  273. </React.Fragment>
  274. );
  275. } else {
  276. return (
  277. <Tag className={classes(className, iconStyle(propsStyle))}>
  278. {svgComponent}
  279. {label}
  280. </Tag>
  281. );
  282. }
  283. }
  284. );
  285. component.displayName = `JLIcon_${this.name}`;
  286. return component;
  287. }
  288. readonly name: string;
  289. readonly react: JLIcon.IReact;
  290. protected _className: string;
  291. protected _svgstr: string;
  292. protected _uuid: string;
  293. }
  294. /**
  295. * A namespace for JLIcon statics.
  296. */
  297. export namespace JLIcon {
  298. /**
  299. * The type of the JLIcon contructor params
  300. */
  301. export interface IOptions {
  302. name: string;
  303. svgstr: string;
  304. }
  305. /**
  306. * The input props for creating a new JLIcon
  307. */
  308. export interface IProps extends IIconStyle {
  309. /**
  310. * Extra classNames. Used in addition to the typestyle className to
  311. * set the className of the icon's outermost container node
  312. */
  313. className?: string;
  314. /**
  315. * The icon's outermost node, which acts as a container for the actual
  316. * svg node. If container is not supplied, it will be created
  317. */
  318. container?: HTMLElement;
  319. /**
  320. * Optional text label that will be added as a sibling to the icon's
  321. * svg node
  322. */
  323. label?: string;
  324. /**
  325. * HTML element tag used to create the icon's outermost container node,
  326. * if no container is passed in
  327. */
  328. tag?: 'div' | 'span';
  329. /**
  330. * Optional title that will be set on the icon's outermost container node
  331. */
  332. title?: string;
  333. }
  334. export type IReactProps = IProps & React.RefAttributes<SVGElement>;
  335. export type IReact = React.ForwardRefExoticComponent<IReactProps>;
  336. }
  337. namespace Private {
  338. export function nameToClassName(name: string): string {
  339. return 'jp-' + Text.camelCase(name, true) + 'Icon';
  340. }
  341. export function setTitleSvg(svgNode: HTMLElement, title: string): void {
  342. // add a title node to the top level svg node
  343. let titleNodes = svgNode.getElementsByTagName('title');
  344. if (titleNodes.length) {
  345. titleNodes[0].textContent = title;
  346. } else {
  347. let titleNode = document.createElement('title');
  348. titleNode.textContent = title;
  349. svgNode.appendChild(titleNode);
  350. }
  351. }
  352. }
  353. // need to be at the bottom since constructor depends on Private
  354. export const badIcon = new JLIcon({ name: 'bad', svgstr: badSvg });
  355. export const blankIcon = new JLIcon({ name: 'blank', svgstr: blankSvg });