Source: interaction.js

  1. /*!
  2. * Module Interaction
  3. */
  4. /**
  5. * @namespace Interaction
  6. */
  7. const MODULE_NAME = 'Interaction';
  8. //###[ IMPORTS ]########################################################################################################
  9. import {assert, isFunction, isElement, orDefault, hasValue, Deferred} from './basic.js';
  10. import {findTextNodes} from './elements.js';
  11. import {applyStyles} from './css.js';
  12. //###[ DATA ]###########################################################################################################
  13. export const TAPPABLE_ELEMENTS_SELECTOR = 'a, button, .button, input[type=button], input[type=submit]';
  14. const NOT_AN_HTMLELEMENT_ERROR = 'given node/target is not an HTMLElement';
  15. //###[ EXPORTS ]########################################################################################################
  16. /**
  17. * @namespace Interaction:createSelection
  18. */
  19. /**
  20. * Programmatically create a text selection inside a node, possibly reaching across several child nodes,
  21. * but virtually only working with the raw text content. Can also be used to create a selection in text
  22. * inputs for example.
  23. *
  24. * Be aware that the endOffset is neither the length to select nor the last index, but the offset starting
  25. * from the last character in the node counting backwards (like a reverse startOffset). The reason for this wonkyness
  26. * is the fact that we have to implement three different ways of creating selections in this function, this
  27. * notation being the most compatible one. We assume the default use case for this method is to select all content
  28. * of a node with the possibility to skip one or two unwanted characters on each side of the content.
  29. *
  30. * Hint: You cannot create a selection spanning normal inline text into an input, ending there. To create a selection in
  31. * a text input, please target that element specifically or make sure the selection spans the whole input.
  32. * Furthermore, on mobile/iOS devices creation of selection ranges might only be possible in text inputs.
  33. *
  34. * @param {HTMLElement} node - the element to create a selection inside
  35. * @param {?Number} [startOffset=0] - characters to leave out at the beginning of the text content
  36. * @param {?Number} [endOffset=0] - characters to leave out at the end of the text content
  37. * @return {String} the selected text
  38. *
  39. * @memberof Interaction:createSelection
  40. * @alias createSelection
  41. * @see removeSelections
  42. * @example
  43. * const selectedText = createSelection(copytextElement, 12, 6);
  44. */
  45. export function createSelection(node, startOffset=0, endOffset=0){
  46. const __methodName__ = 'createSelection';
  47. startOffset = orDefault(startOffset, 0, 'int');
  48. endOffset = orDefault(endOffset, 0, 'int');
  49. assert(isElement(node), `${MODULE_NAME}:${__methodName__} | ${NOT_AN_HTMLELEMENT_ERROR}`);
  50. let selectionText, range, selection, rangeText;
  51. if( hasValue(node.selectionStart, node.selectionEnd) ){
  52. node.focus();
  53. node.select();
  54. rangeText = node.value;
  55. node.selectionStart = startOffset;
  56. node.selectionEnd = rangeText.length - endOffset;
  57. selectionText = rangeText.substring(node.selectionStart, node.selectionEnd);
  58. } else if( isFunction(window.getSelection) ){
  59. range = document.createRange();
  60. range.selectNodeContents(node);
  61. if( hasValue(startOffset) || hasValue(endOffset) ){
  62. const textNodes = findTextNodes(node);
  63. if( textNodes.length > 0 ){
  64. let
  65. startNodeIndex = 0,
  66. startNode = textNodes[startNodeIndex],
  67. endNodeIndex = textNodes.length - 1,
  68. endNode = textNodes[endNodeIndex]
  69. ;
  70. if( hasValue(startOffset) ){
  71. let
  72. remainingStartOffset = startOffset,
  73. startOffsetNodeFound = (remainingStartOffset <= startNode.length)
  74. ;
  75. while( !startOffsetNodeFound && hasValue(startNode) ){
  76. startNodeIndex++;
  77. if( hasValue(textNodes[startNodeIndex]) ){
  78. remainingStartOffset -= startNode.length;
  79. startNode = textNodes[startNodeIndex];
  80. startOffsetNodeFound = (remainingStartOffset <= startNode.length);
  81. } else {
  82. remainingStartOffset = startNode.length;
  83. startOffsetNodeFound = true;
  84. }
  85. }
  86. range.setStart(startNode, remainingStartOffset);
  87. }
  88. if( hasValue(endOffset) ){
  89. let
  90. remainingEndOffset = endOffset,
  91. endOffsetNodeFound = (remainingEndOffset <= endNode.length)
  92. ;
  93. while( !endOffsetNodeFound && hasValue(endNode) ){
  94. endNodeIndex--;
  95. if( hasValue(textNodes[endNodeIndex]) ){
  96. remainingEndOffset -= endNode.length;
  97. endNode = textNodes[endNodeIndex];
  98. endOffsetNodeFound = (remainingEndOffset <= endNode.length);
  99. } else {
  100. remainingEndOffset = endNode.length;
  101. endOffsetNodeFound = true;
  102. }
  103. }
  104. range.setEnd(endNode, endNode.length - remainingEndOffset);
  105. }
  106. }
  107. }
  108. selection = window.getSelection();
  109. selection.removeAllRanges();
  110. selection.addRange(range);
  111. selectionText = range.toString();
  112. } else if( isFunction(document.body.createTextRange) ){
  113. range = document.body.createTextRange();
  114. range.moveToElementText(node);
  115. if( hasValue(startOffset) ){
  116. range.moveStart('character', startOffset);
  117. }
  118. if( hasValue(endOffset) ){
  119. range.moveEnd('character', -endOffset);
  120. }
  121. range.select();
  122. selectionText = range.text;
  123. }
  124. return selectionText;
  125. }
  126. /**
  127. * @namespace Interaction:removeSelections
  128. */
  129. /**
  130. * Removes all text selections from the current window if possible.
  131. * Certain mobile devices like iOS devices might block this behaviour actively.
  132. *
  133. * @memberof Interaction:removeSelections
  134. * @alias removeSelections
  135. * @see createSelection
  136. * @example
  137. * removeSelections();
  138. */
  139. export function removeSelections(){
  140. if( isFunction(window.getSelection) ){
  141. window.getSelection().removeAllRanges();
  142. } else if( isFunction(document.getSelection) ){
  143. document.getSelection().removeAllRanges();
  144. }
  145. if( hasValue(document.selection) ){
  146. document.selection.empty();
  147. }
  148. }
  149. /**
  150. * @namespace Interaction:disableSelection
  151. */
  152. /**
  153. * Disables the possibility to create a selection in an element.
  154. *
  155. * @param {HTMLElement} node - the element to disable user selection for
  156. * @return {HTMLElement} the node with disabled selection
  157. *
  158. * @memberof Interaction:disableSelection
  159. * @alias disableSelection
  160. * @see enableSelection
  161. * @example
  162. * disableSelection(widget);
  163. */
  164. export function disableSelection(node){
  165. const __methodName__ = 'disableSelection';
  166. assert(isElement(node), `${MODULE_NAME}:${__methodName__} | ${NOT_AN_HTMLELEMENT_ERROR}`);
  167. node.onselectstart = () => false;
  168. node.unselectable = 'on';
  169. applyStyles(node, {'user-select' : 'none'}, true);
  170. applyStyles(node, {'-webkit-touch-callout' : 'none'});
  171. return node;
  172. }
  173. /**
  174. * @namespace Interaction:enableSelection
  175. */
  176. /**
  177. * (Re)enables the possibility to create a selection in an element. Most likely after having been disabled
  178. * using `disableSelection`.
  179. *
  180. * @param {HTMLElement} node - the element to (re)enable user selection for
  181. * @return {HTMLElement} the node with (re)enabled selection
  182. *
  183. * @memberof Interaction:enableSelection
  184. * @alias enableSelection
  185. * @see disableSelection
  186. * @example
  187. * enableSelection(widget);
  188. */
  189. export function enableSelection(node){
  190. const __methodName__ = 'disableSelection';
  191. assert(isElement(node), `${MODULE_NAME}:${__methodName__} | ${NOT_AN_HTMLELEMENT_ERROR}`);
  192. node.onselectstart = undefined;
  193. node.unselectable = 'off';
  194. applyStyles(node, {'user-select' : null}, true);
  195. applyStyles(node, {'-webkit-touch-callout' : null});
  196. return node;
  197. }
  198. /**
  199. * @namespace Interaction:obfuscatePrivateMailToLink
  200. */
  201. /**
  202. * Augment a link element to hold an obfuscated private mailto link, to be able to contact people
  203. * via their own mail address, without the need to openly write the address into the DOM permanently,
  204. * in a way crawlers could identify easily.
  205. *
  206. * The method takes all parts of the address as (hopefully) unidentifiable parameters and then applies them internally,
  207. * to build an email string with mailto protocol dynamically on mouse or focus interaction in the link's href,
  208. * offering normal link functionality from here on. If the interaction ends, the href is removed again immediately,
  209. * so the link is only and exclusively readable and complete during user interaction.
  210. *
  211. * You may set the link text yourself or set `setAsContent` to true, to let the function fill the link text with
  212. * the completed address. Be aware, that this, although still being obfuscated, lowers the level of security for this
  213. * solution.
  214. *
  215. * Although most parameters are technically optional, this function still expects `beforeAt` and `afterAtWithoutTld` to
  216. * be filled. While these parts are strictly necessary here: I'd always suggest to use all parts, since, the more
  217. * of an address is written together, the easier the address can be parsed.
  218. *
  219. * @param {HTMLElement} link - the link to augment, has to be a node where we can set a "href" attribute
  220. * @param {?Boolean} [setAsContent=false] - define if the address should be used as link text (still uses string obfuscation, but weaker against bot with JS execution)
  221. * @param {?String} [tld=''] - the top level domain to use
  222. * @param {?String} [afterAtWithoutTld=''] - the address part after the @ but before the tld
  223. * @param {?String} [beforeAt=''] - the address part before the @
  224. * @param {?String} [subject=''] - the subject the mail should have, if you want to preset this
  225. * @param {?String} [body=''] - the body text the mail should have initially, if you want to preset this
  226. * @param {?String} [ccTld=''] - the top level domain to use for the cc address
  227. * @param {?String} [ccAfterAtWithoutTld=''] - the address part after the @ but before the tld for the cc address
  228. * @param {?String} [ccBeforeAt=''] - the address part before the @ for the cc address
  229. * @throws error if beforeAt or afterAtWithoutTld are empty
  230. * @return {HTMLElement} the augmented link
  231. *
  232. * @memberof Interaction:obfuscatePrivateMailToLink
  233. * @alias obfuscatePrivateMailToLink
  234. * @example
  235. * obfuscatePrivateMailToLink(document.querySelector('a'), true, 'de', 'gmail', 'recipient', 'Hello there!', 'How are you these days?');
  236. */
  237. export function obfuscatePrivateMailToLink(
  238. link,
  239. setAsContent=false,
  240. tld='',
  241. afterAtWithoutTld='',
  242. beforeAt='',
  243. subject='',
  244. body='',
  245. ccTld='',
  246. ccAfterAtWithoutTld='',
  247. ccBeforeAt=''
  248. ){
  249. const __methodName__ = 'obfuscatePrivateMailToLink';
  250. setAsContent = orDefault(setAsContent, false, 'bool');
  251. subject = orDefault(subject, '', 'str');
  252. body = orDefault(body, '', 'str');
  253. beforeAt = orDefault(beforeAt, '', 'str');
  254. afterAtWithoutTld = orDefault(afterAtWithoutTld, '', 'str');
  255. tld = orDefault(tld, '', 'str');
  256. ccBeforeAt = orDefault(ccBeforeAt, '', 'str');
  257. ccAfterAtWithoutTld = orDefault(ccAfterAtWithoutTld, '', 'str');
  258. ccTld = orDefault(ccTld, '', 'str');
  259. assert((beforeAt !== '') && (afterAtWithoutTld !== ''), `${MODULE_NAME}:${__methodName__} | basic mail parts missing`);
  260. if( tld !== '' ){
  261. tld = `.${tld}`;
  262. }
  263. if( ccTld !== '' ){
  264. ccTld = `.${ccTld}`;
  265. }
  266. let optionParams = new URLSearchParams();
  267. if( subject !== '' ){
  268. optionParams.set('subject', subject);
  269. }
  270. if( body !== '' ){
  271. optionParams.set('body', body);
  272. }
  273. if( (ccBeforeAt !== '') && (ccAfterAtWithoutTld !== '') ){
  274. optionParams.set('cc', `${ccBeforeAt}@${ccAfterAtWithoutTld}${ccTld}`);
  275. }
  276. optionParams = optionParams.toString();
  277. if( optionParams !== '' ){
  278. optionParams = `?${optionParams.replaceAll('+', '%20')}`;
  279. }
  280. let interactionCount = 0;
  281. const fAddLinkUrl = () => {
  282. interactionCount++;
  283. link.setAttribute('href', `mailto:${beforeAt}@${afterAtWithoutTld}${tld}${optionParams}`);
  284. };
  285. link.addEventListener('mouseenter', fAddLinkUrl);
  286. link.addEventListener('focusin', fAddLinkUrl);
  287. const fRemoveLinkUrl = () => {
  288. interactionCount--;
  289. if( interactionCount <= 0 ){
  290. link.setAttribute('href', '');
  291. }
  292. };
  293. link.addEventListener('mouseleave', fRemoveLinkUrl);
  294. link.addEventListener('focusout', fRemoveLinkUrl);
  295. if( setAsContent ){
  296. link.innerHTML = (`${beforeAt}@${afterAtWithoutTld}${tld}`).replace(/(\w{1})/g, '$1&zwnj;');
  297. }
  298. return link;
  299. }
  300. /**
  301. * @namespace Interaction:obfuscatePrivateTelLink
  302. */
  303. /**
  304. * Augment a link element to hold an obfuscated private tel link, to be able to contact people
  305. * via their own phone number, without the need to openly write the number into the DOM permanently,
  306. * in a way crawlers could identify easily.
  307. *
  308. * The method takes all parts of the number as (hopefully) unidentifiable parameters and then applies them internally,
  309. * to build a number string with tel protocol dynamically on mouse or focus interaction in the link's href,
  310. * offering normal link functionality from here on. If the interaction ends, the href is removed again immediately,
  311. * so the link is only and exclusively readable and complete during user interaction.
  312. *
  313. * You may set the link text yourself or set `setAsContent` to true, to let the function fill the link text with
  314. * the completed address. Be aware, that this, although still being obfuscated, lowers the level of security for this
  315. * solution.
  316. *
  317. * Although most parameters are technically optional, this function still expects `secondTelPart` or `firstTelPart` to
  318. * be filled. While only one part is strictly necessary here: I'd always suggest to use all parts, since, the more
  319. * of a number is written together, the easier the number can be parsed.
  320. *
  321. * @param {HTMLElement} link - the link to augment, has to be a node where we can set a "href" attribute
  322. * @param {?Boolean} [setAsContent=false] - define if the number should be used as link text, being formatted according to DIN 5008 (still uses string obfuscation, but weaker against bot with JS execution)
  323. * @param {?Number|String} [secondTelPart=''] - second half of the main number +49 04 123(4-56)<-this; add a dash to signify where a base number ends and the personal part starts
  324. * @param {?Number|String} [firstTelPart=''] - first half of the main number +49 04 (123)<-this 4-56
  325. * @param {?Number|String} [regionPart=''] - the local part of the number after the country part e.g. +49(04)<-this 1234-56
  326. * @param {?Number|String} [countryPart=''] - the country identifier with or without + this->(+49) 04 1234-56 (do not prefix with a local 0!)
  327. * @throws error if `secondTelPart` and `firstTelPart` are empty
  328. * @return {HTMLElement} the augmented link
  329. *
  330. * @memberof Interaction:obfuscatePrivateTelLink
  331. * @alias obfuscatePrivateTelLink
  332. * @example
  333. * obfuscatePrivateTelLink(document.querySelector('a'), true, 123, 439, 40, '+49');
  334. */
  335. export function obfuscatePrivateTelLink(
  336. link,
  337. setAsContent=false,
  338. secondTelPart='',
  339. firstTelPart='',
  340. regionPart='',
  341. countryPart=''
  342. ){
  343. const __methodName__ = 'obfuscatePrivateTelLink';
  344. setAsContent = orDefault(setAsContent, false, 'bool');
  345. secondTelPart = orDefault(secondTelPart, '', 'str').replace(/[^0-9\-]/g, '');
  346. firstTelPart = orDefault(firstTelPart, '', 'str').replace(/[^0-9\-]/g, '');
  347. regionPart = orDefault(regionPart, '', 'str').replace(/[^0-9]/g, '');
  348. countryPart = orDefault(countryPart, '', 'str').replace(/[^0-9]/g, '');
  349. assert((firstTelPart !== '') || (secondTelPart !== ''), `${MODULE_NAME}:${__methodName__} | basic tel parts missing`);
  350. let interactionCount = 0;
  351. const fAddLinkUrl = () => {
  352. interactionCount++;
  353. link.setAttribute('href', `tel:+${countryPart}${regionPart}${firstTelPart.replace(/-/g, '')}${secondTelPart.replace(/-/g, '')}`);
  354. };
  355. link.addEventListener('mouseenter', fAddLinkUrl);
  356. link.addEventListener('focusin', fAddLinkUrl);
  357. const fRemoveLinkUrl = () => {
  358. interactionCount--;
  359. if( interactionCount <= 0 ){
  360. link.setAttribute('href', '');
  361. }
  362. };
  363. link.addEventListener('mouseleave', fRemoveLinkUrl);
  364. link.addEventListener('focusout', fRemoveLinkUrl);
  365. if( setAsContent ){
  366. link.innerHTML = (`+${countryPart} ${regionPart} ${firstTelPart}${secondTelPart}`).replace(/(\w{1})/g, '$1&zwnj;');
  367. }
  368. }
  369. /**
  370. * @namespace Interaction:setTappedState
  371. */
  372. /**
  373. * Sets a "tapped" state on an element (via a CSS class), which removes itself again after a short time.
  374. *
  375. * The sole reason for doing this, is to be able to apply styling to a tap/click action across devices without
  376. * trailing styles, which would result by using something like `:focus`.
  377. *
  378. * @param {HTMLElement} element - the link to augment, has to be a node where we can set a "href" attribute
  379. * @param {?String} [tappedClass='tapped'] - the CSS class to set on the element to signify the "tapped" state
  380. * @param {?Number} [tappedDuration=200] - the duration in milliseconds, the "tapped" state should last
  381. * @throws error if element is not an HTMLElement
  382. * @return {Basic.Deferred} resolves with the element, when the tapped state ends
  383. *
  384. * @memberof Interaction:setTappedState
  385. * @alias setTappedState
  386. * @example
  387. * setTappedState(link);
  388. * setTappedState(link, 'clicked', 500);
  389. */
  390. export function setTappedState(element, tappedClass='tapped', tappedDuration=200){
  391. const __methodName__ = 'setTappedState';
  392. tappedClass = orDefault(tappedClass, 'tapped', 'str');
  393. tappedDuration = orDefault(tappedDuration, 200, 'int');
  394. assert(isElement(element), `${MODULE_NAME}:${__methodName__} | ${NOT_AN_HTMLELEMENT_ERROR}`);
  395. const deferred = new Deferred();
  396. element.classList.add(tappedClass);
  397. window.setTimeout(() =>{
  398. element.classList.remove(tappedClass);
  399. element.blur();
  400. deferred.resolve(element);
  401. }, tappedDuration);
  402. return deferred;
  403. }
  404. /**
  405. * @namespace Interaction:setupAutoTappedStates
  406. */
  407. /**
  408. * This function registers a global event handler on the document body, to automatically add "tapped" states (as a CSS
  409. * class) to "tappable" elements on "tap".
  410. *
  411. * What is a "tap" you ask? Well, it's either a pointer click or a finger touch or anything resembling these actions
  412. * on your current device.
  413. *
  414. * The idea behind that is this: usually, on pointer devices, we have a `:hover` state to signify user interaction
  415. * with an element, while on touch devices, we only know that an interaction took place after a user touched an element
  416. * with his/her finger, "tapped" it so to speak. Styling a touch with CSS would only be possible via `:focus`, which
  417. * has the problems, that focus has a different meaning on pointer devices and the focus state does not end
  418. * automatically, resulting in trailing visual states.
  419. *
  420. * So, what we do instead, is that we just generally observe taps (via "click" event, which works across devices as
  421. * expected) and set a class on the element, for a short time, which removes itself automatically again, to be able
  422. * to define a visual state or a short animation for that class. So, for example, let's say the function has been
  423. * executed. After that, you can define something like `a.tapped { color: orange; }`, which would result in orange
  424. * coloring for a short time, after clicking/touching the element. Combine this with `:hover`, `:focus` definitions
  425. * in CSS to define a complete effect setup.
  426. *
  427. * @param {?HTMLElement} [element=document.body] - the element to use as delegation parent for events, should contain the tappable elements you'd like to target
  428. * @param {?String} [tappableElementsSelector='a, button, .button, input[type=button], input[type=submit]'] - selector to identify a tappable element by in a delegated event handler
  429. * @param {?String|Array<String>} [tapEvents='click'] - the DOM event(s) to register for taps
  430. * @param {?String} [tappedClass='tapped'] - the CSS class to set on the element to signify the "tapped" state
  431. * @param {?Number} [tappedDuration=200] - the duration in milliseconds, the "tapped" state should last
  432. * @throws error if element is not an HTMLElement
  433. *
  434. * @memberof Interaction:setupAutoTappedStates
  435. * @alias setupAutoTappedStates
  436. * @example
  437. * setupAutoTappedStates();
  438. * setupAutoTappedStates(document.body, 'a, button', 'customevent');
  439. */
  440. export function setupAutoTappedStates(
  441. element=null,
  442. tappableElementsSelector=TAPPABLE_ELEMENTS_SELECTOR,
  443. tapEvents='click',
  444. tappedClass='tapped',
  445. tappedDuration=200
  446. ){
  447. const __methodName__ = 'setupAutoTappedStates';
  448. // document.body not in function default to prevent errors on import in document-less contexts
  449. element = orDefault(element, document.body);
  450. tappableElementsSelector = orDefault(tappableElementsSelector, TAPPABLE_ELEMENTS_SELECTOR, 'str');
  451. tapEvents = orDefault(tapEvents, 'click', 'str');
  452. tapEvents = [].concat(tapEvents);
  453. assert(isElement(element), `${MODULE_NAME}:${__methodName__} | ${NOT_AN_HTMLELEMENT_ERROR}`);
  454. tapEvents.forEach(tapEvent => {
  455. element.addEventListener(tapEvent, e => {
  456. if(
  457. hasValue(e.target?.matches)
  458. && e.target.matches(tappableElementsSelector)
  459. ){
  460. setTappedState(e.target, tappedClass, tappedDuration);
  461. }
  462. });
  463. });
  464. }