utils.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. import { Zoom } from "./types";
  2. import {
  3. CURSOR_TYPE,
  4. FONT_FAMILY,
  5. WINDOWS_EMOJI_FALLBACK_FONT,
  6. } from "./constants";
  7. import { FontFamily, FontString } from "./element/types";
  8. export const SVG_NS = "http://www.w3.org/2000/svg";
  9. let mockDateTime: string | null = null;
  10. export const setDateTimeForTests = (dateTime: string) => {
  11. mockDateTime = dateTime;
  12. };
  13. export const getDateTime = () => {
  14. if (mockDateTime) {
  15. return mockDateTime;
  16. }
  17. const date = new Date();
  18. const year = date.getFullYear();
  19. const month = `${date.getMonth() + 1}`.padStart(2, "0");
  20. const day = `${date.getDate()}`.padStart(2, "0");
  21. const hr = `${date.getHours()}`.padStart(2, "0");
  22. const min = `${date.getMinutes()}`.padStart(2, "0");
  23. return `${year}-${month}-${day}-${hr}${min}`;
  24. };
  25. export const capitalizeString = (str: string) =>
  26. str.charAt(0).toUpperCase() + str.slice(1);
  27. export const isToolIcon = (
  28. target: Element | EventTarget | null,
  29. ): target is HTMLElement =>
  30. target instanceof HTMLElement && target.className.includes("ToolIcon");
  31. export const isInputLike = (
  32. target: Element | EventTarget | null,
  33. ): target is
  34. | HTMLInputElement
  35. | HTMLTextAreaElement
  36. | HTMLSelectElement
  37. | HTMLBRElement
  38. | HTMLDivElement =>
  39. (target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
  40. target instanceof HTMLBRElement || // newline in wysiwyg
  41. target instanceof HTMLInputElement ||
  42. target instanceof HTMLTextAreaElement ||
  43. target instanceof HTMLSelectElement;
  44. export const isWritableElement = (
  45. target: Element | EventTarget | null,
  46. ): target is
  47. | HTMLInputElement
  48. | HTMLTextAreaElement
  49. | HTMLBRElement
  50. | HTMLDivElement =>
  51. (target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
  52. target instanceof HTMLBRElement || // newline in wysiwyg
  53. target instanceof HTMLTextAreaElement ||
  54. (target instanceof HTMLInputElement &&
  55. (target.type === "text" || target.type === "number"));
  56. export const getFontFamilyString = ({
  57. fontFamily,
  58. }: {
  59. fontFamily: FontFamily;
  60. }) => {
  61. return `${FONT_FAMILY[fontFamily]}, ${WINDOWS_EMOJI_FALLBACK_FONT}`;
  62. };
  63. /** returns fontSize+fontFamily string for assignment to DOM elements */
  64. export const getFontString = ({
  65. fontSize,
  66. fontFamily,
  67. }: {
  68. fontSize: number;
  69. fontFamily: FontFamily;
  70. }) => {
  71. return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
  72. };
  73. // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
  74. export const measureText = (text: string, font: FontString) => {
  75. const line = document.createElement("div");
  76. const body = document.body;
  77. line.style.position = "absolute";
  78. line.style.whiteSpace = "pre";
  79. line.style.font = font;
  80. body.appendChild(line);
  81. line.innerText = text
  82. .split("\n")
  83. // replace empty lines with single space because leading/trailing empty
  84. // lines would be stripped from computation
  85. .map((x) => x || " ")
  86. .join("\n");
  87. const width = line.offsetWidth;
  88. const height = line.offsetHeight;
  89. // Now creating 1px sized item that will be aligned to baseline
  90. // to calculate baseline shift
  91. const span = document.createElement("span");
  92. span.style.display = "inline-block";
  93. span.style.overflow = "hidden";
  94. span.style.width = "1px";
  95. span.style.height = "1px";
  96. line.appendChild(span);
  97. // Baseline is important for positioning text on canvas
  98. const baseline = span.offsetTop + span.offsetHeight;
  99. document.body.removeChild(line);
  100. return { width, height, baseline };
  101. };
  102. export const debounce = <T extends any[]>(
  103. fn: (...args: T) => void,
  104. timeout: number,
  105. ) => {
  106. let handle = 0;
  107. let lastArgs: T;
  108. const ret = (...args: T) => {
  109. lastArgs = args;
  110. clearTimeout(handle);
  111. handle = window.setTimeout(() => fn(...args), timeout);
  112. };
  113. ret.flush = () => {
  114. clearTimeout(handle);
  115. fn(...lastArgs);
  116. };
  117. return ret;
  118. };
  119. export const selectNode = (node: Element) => {
  120. const selection = window.getSelection();
  121. if (selection) {
  122. const range = document.createRange();
  123. range.selectNodeContents(node);
  124. selection.removeAllRanges();
  125. selection.addRange(range);
  126. }
  127. };
  128. export const removeSelection = () => {
  129. const selection = window.getSelection();
  130. if (selection) {
  131. selection.removeAllRanges();
  132. }
  133. };
  134. export const distance = (x: number, y: number) => Math.abs(x - y);
  135. export const resetCursor = () => {
  136. document.documentElement.style.cursor = "";
  137. };
  138. export const setCursorForShape = (shape: string) => {
  139. if (shape === "selection") {
  140. resetCursor();
  141. } else {
  142. document.documentElement.style.cursor = CURSOR_TYPE.CROSSHAIR;
  143. }
  144. };
  145. export const isFullScreen = () =>
  146. document.fullscreenElement?.nodeName === "HTML";
  147. export const allowFullScreen = () =>
  148. document.documentElement.requestFullscreen();
  149. export const exitFullScreen = () => document.exitFullscreen();
  150. export const getShortcutKey = (shortcut: string): string => {
  151. const isMac = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform);
  152. if (isMac) {
  153. return `${shortcut
  154. .replace(/\bCtrlOrCmd\b/i, "Cmd")
  155. .replace(/\bAlt\b/i, "Option")
  156. .replace(/\bDel\b/i, "Delete")
  157. .replace(/\b(Enter|Return)\b/i, "Enter")}`;
  158. }
  159. return `${shortcut.replace(/\bCtrlOrCmd\b/i, "Ctrl")}`;
  160. };
  161. export const viewportCoordsToSceneCoords = (
  162. { clientX, clientY }: { clientX: number; clientY: number },
  163. {
  164. zoom,
  165. offsetLeft,
  166. offsetTop,
  167. scrollX,
  168. scrollY,
  169. }: {
  170. zoom: Zoom;
  171. offsetLeft: number;
  172. offsetTop: number;
  173. scrollX: number;
  174. scrollY: number;
  175. },
  176. ) => {
  177. const invScale = 1 / zoom.value;
  178. const x = (clientX - zoom.translation.x - offsetLeft) * invScale - scrollX;
  179. const y = (clientY - zoom.translation.y - offsetTop) * invScale - scrollY;
  180. return { x, y };
  181. };
  182. export const sceneCoordsToViewportCoords = (
  183. { sceneX, sceneY }: { sceneX: number; sceneY: number },
  184. {
  185. zoom,
  186. offsetLeft,
  187. offsetTop,
  188. scrollX,
  189. scrollY,
  190. }: {
  191. zoom: Zoom;
  192. offsetLeft: number;
  193. offsetTop: number;
  194. scrollX: number;
  195. scrollY: number;
  196. },
  197. ) => {
  198. const x = (sceneX + scrollX + offsetLeft) * zoom.value + zoom.translation.x;
  199. const y = (sceneY + scrollY + offsetTop) * zoom.value + zoom.translation.y;
  200. return { x, y };
  201. };
  202. export const getGlobalCSSVariable = (name: string) =>
  203. getComputedStyle(document.documentElement).getPropertyValue(`--${name}`);
  204. const RS_LTR_CHARS =
  205. "A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF" +
  206. "\u2C00-\uFB1C\uFDFE-\uFE6F\uFEFD-\uFFFF";
  207. const RS_RTL_CHARS = "\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC";
  208. const RE_RTL_CHECK = new RegExp(`^[^${RS_LTR_CHARS}]*[${RS_RTL_CHARS}]`);
  209. /**
  210. * Checks whether first directional character is RTL. Meaning whether it starts
  211. * with RTL characters, or indeterminate (numbers etc.) characters followed by
  212. * RTL.
  213. * See https://github.com/excalidraw/excalidraw/pull/1722#discussion_r436340171
  214. */
  215. export const isRTL = (text: string) => {
  216. return RE_RTL_CHECK.test(text);
  217. };
  218. export function tupleToCoors(
  219. xyTuple: readonly [number, number],
  220. ): { x: number; y: number } {
  221. const [x, y] = xyTuple;
  222. return { x, y };
  223. }
  224. /** use as a rejectionHandler to mute filesystem Abort errors */
  225. export const muteFSAbortError = (error?: Error) => {
  226. if (error?.name === "AbortError") {
  227. return;
  228. }
  229. throw error;
  230. };
  231. export const findIndex = <T>(
  232. array: readonly T[],
  233. cb: (element: T, index: number, array: readonly T[]) => boolean,
  234. fromIndex: number = 0,
  235. ) => {
  236. if (fromIndex < 0) {
  237. fromIndex = array.length + fromIndex;
  238. }
  239. fromIndex = Math.min(array.length, Math.max(fromIndex, 0));
  240. let i = fromIndex - 1;
  241. while (++i < array.length) {
  242. if (cb(array[i], i, array)) {
  243. return i;
  244. }
  245. }
  246. return -1;
  247. };
  248. export const findLastIndex = <T>(
  249. array: readonly T[],
  250. cb: (element: T, index: number, array: readonly T[]) => boolean,
  251. fromIndex: number = array.length - 1,
  252. ) => {
  253. if (fromIndex < 0) {
  254. fromIndex = array.length + fromIndex;
  255. }
  256. fromIndex = Math.min(array.length - 1, Math.max(fromIndex, 0));
  257. let i = fromIndex + 1;
  258. while (--i > -1) {
  259. if (cb(array[i], i, array)) {
  260. return i;
  261. }
  262. }
  263. return -1;
  264. };