utils.ts 8.1 KB

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