26f67d27ec
* Refactor: simplify linear element type * Refactor: dedupe scrollbar handling * First step towards binding - establish relationship and basic test for dragged lines * Refactor: use zoom from appstate * Refactor: generalize getElementAtPosition * Only consider bindable elements in hit test * Refactor: pull out pieces of hit test for reuse later * Refactor: pull out diamond from hit test for reuse later * Refactor: pull out text from hit test for reuse later * Suggest binding when hovering * Give shapes in regression test real size * Give shapes in undo/redo test real size * Keep bound element highlighted * Show binding suggestion for multi-point elements * Move binding to its on module with functions so that I can use it from actions, add support for binding end of multi-point elements * Use Id instead of ID * Improve boundary offset for non-squarish elements * Fix localStorage for binding on linear elements * Simplify dragging code and fix elements bound twice to the same shape * Fix binding for rectangles * Bind both ends at the end of the linear element creation, needed for focus points * wip * Refactor: Renames and reshapes for next commit * Calculate and store focus points and gaps, but dont use them yet * Focus points for rectangles * Dont blow up when canceling linear element * Stop suggesting binding when a non-compatible tool is selected * Clean up collision code * Using Geometric Algebra for hit tests * Correct binding for all shapes * Constant gap around polygon corners * Fix rotation handling * Generalize update and fix hit test for rotated elements * Handle rotation realtime * Handle scaling * Remove vibration when moving bound and binding element together * Handle simultenous scaling * Allow binding and unbinding when editing linear elements * Dont delete binding when the end point wasnt touched * Bind on enter/escape when editing * Support multiple suggested bindable elements in preparation for supporting linear elements dragging * Update binding when moving linear elements * Update binding when resizing linear elements * Dont re-render UI on binding hints * Update both ends when one is moved * Use distance instead of focus point for binding * Complicated approach for posterity, ignore this commit * Revert the complicated approach * Better focus point strategy, working for all shapes * Update snapshots * Dont break binding gap when mirroring shape * Dont break binding gap when grid mode pushes it inside * Dont bind draw elements * Support alt duplication * Fix alt duplication to * Support cmd+D duplication * All copy mechanisms are supported * Allow binding shapes to arrows, having arrows created first * Prevent arrows from disappearing for ellipses * Better binding suggestion highlight for shapes * Dont suggest second binding for simple elements when editing or moving them * Dont steal already bound linear elements when moving shapes * Fix highlighting diamonds and more precisely highlight other shapes * Highlight linear element edges for binding * Highlight text binding too * Handle deletion * Dont suggest second binding for simple linear elements when creating them * Dont highlight bound element during creation * Fix binding for rotated linear elements * Fix collision check for ellipses * Dont show suggested bindings for selected pairs * Bind multi-point linear elements when the tool is switched - important for mobile * Handle unbinding one of two bound edges correctly * Rename boundElement in state to startBoundElement * Dont double account for zoom when rendering binding highlight * Fix rendering of edited linear element point handles * Suggest binding when adding new point to a linear element * Bind when adding a new point to a linear element and dont unbind when moving middle elements * Handle deleting points * Add cmd modifier key to disable binding * Use state for enabling binding, fix not binding for linear elements during creation * Drop support for binding lines, only arrows are bindable * Reset binding mode on blur * Fix not binding lines
257 lines
7.3 KiB
TypeScript
257 lines
7.3 KiB
TypeScript
import { AppState } from "./types";
|
|
import { getZoomOrigin } from "./scene";
|
|
import {
|
|
CURSOR_TYPE,
|
|
FONT_FAMILY,
|
|
WINDOWS_EMOJI_FALLBACK_FONT,
|
|
} from "./constants";
|
|
import { FontFamily, FontString } from "./element/types";
|
|
|
|
export const SVG_NS = "http://www.w3.org/2000/svg";
|
|
|
|
let mockDateTime: string | null = null;
|
|
|
|
export const setDateTimeForTests = (dateTime: string) => {
|
|
mockDateTime = dateTime;
|
|
};
|
|
|
|
export const getDateTime = () => {
|
|
if (mockDateTime) {
|
|
return mockDateTime;
|
|
}
|
|
|
|
const date = new Date();
|
|
const year = date.getFullYear();
|
|
const month = `${date.getMonth() + 1}`.padStart(2, "0");
|
|
const day = `${date.getDate()}`.padStart(2, "0");
|
|
const hr = `${date.getHours()}`.padStart(2, "0");
|
|
const min = `${date.getMinutes()}`.padStart(2, "0");
|
|
|
|
return `${year}-${month}-${day}-${hr}${min}`;
|
|
};
|
|
|
|
export const capitalizeString = (str: string) =>
|
|
str.charAt(0).toUpperCase() + str.slice(1);
|
|
|
|
export const isToolIcon = (
|
|
target: Element | EventTarget | null,
|
|
): target is HTMLElement =>
|
|
target instanceof HTMLElement && target.className.includes("ToolIcon");
|
|
|
|
export const isInputLike = (
|
|
target: Element | EventTarget | null,
|
|
): target is
|
|
| HTMLInputElement
|
|
| HTMLTextAreaElement
|
|
| HTMLSelectElement
|
|
| HTMLBRElement
|
|
| HTMLDivElement =>
|
|
(target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
|
|
target instanceof HTMLBRElement || // newline in wysiwyg
|
|
target instanceof HTMLInputElement ||
|
|
target instanceof HTMLTextAreaElement ||
|
|
target instanceof HTMLSelectElement;
|
|
|
|
export const isWritableElement = (
|
|
target: Element | EventTarget | null,
|
|
): target is
|
|
| HTMLInputElement
|
|
| HTMLTextAreaElement
|
|
| HTMLBRElement
|
|
| HTMLDivElement =>
|
|
(target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
|
|
target instanceof HTMLBRElement || // newline in wysiwyg
|
|
target instanceof HTMLTextAreaElement ||
|
|
(target instanceof HTMLInputElement &&
|
|
(target.type === "text" || target.type === "number"));
|
|
|
|
export const getFontFamilyString = ({
|
|
fontFamily,
|
|
}: {
|
|
fontFamily: FontFamily;
|
|
}) => {
|
|
return `${FONT_FAMILY[fontFamily]}, ${WINDOWS_EMOJI_FALLBACK_FONT}`;
|
|
};
|
|
|
|
/** returns fontSize+fontFamily string for assignment to DOM elements */
|
|
export const getFontString = ({
|
|
fontSize,
|
|
fontFamily,
|
|
}: {
|
|
fontSize: number;
|
|
fontFamily: FontFamily;
|
|
}) => {
|
|
return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
|
|
};
|
|
|
|
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
|
|
export const measureText = (text: string, font: FontString) => {
|
|
const line = document.createElement("div");
|
|
const body = document.body;
|
|
line.style.position = "absolute";
|
|
line.style.whiteSpace = "pre";
|
|
line.style.font = font;
|
|
body.appendChild(line);
|
|
line.innerText = text
|
|
.split("\n")
|
|
// replace empty lines with single space because leading/trailing empty
|
|
// lines would be stripped from computation
|
|
.map((x) => x || " ")
|
|
.join("\n");
|
|
const width = line.offsetWidth;
|
|
const height = line.offsetHeight;
|
|
// Now creating 1px sized item that will be aligned to baseline
|
|
// to calculate baseline shift
|
|
const span = document.createElement("span");
|
|
span.style.display = "inline-block";
|
|
span.style.overflow = "hidden";
|
|
span.style.width = "1px";
|
|
span.style.height = "1px";
|
|
line.appendChild(span);
|
|
// Baseline is important for positioning text on canvas
|
|
const baseline = span.offsetTop + span.offsetHeight;
|
|
document.body.removeChild(line);
|
|
|
|
return { width, height, baseline };
|
|
};
|
|
|
|
export const debounce = <T extends any[]>(
|
|
fn: (...args: T) => void,
|
|
timeout: number,
|
|
) => {
|
|
let handle = 0;
|
|
let lastArgs: T;
|
|
const ret = (...args: T) => {
|
|
lastArgs = args;
|
|
clearTimeout(handle);
|
|
handle = window.setTimeout(() => fn(...args), timeout);
|
|
};
|
|
ret.flush = () => {
|
|
clearTimeout(handle);
|
|
fn(...lastArgs);
|
|
};
|
|
return ret;
|
|
};
|
|
|
|
export const selectNode = (node: Element) => {
|
|
const selection = window.getSelection();
|
|
if (selection) {
|
|
const range = document.createRange();
|
|
range.selectNodeContents(node);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
}
|
|
};
|
|
|
|
export const removeSelection = () => {
|
|
const selection = window.getSelection();
|
|
if (selection) {
|
|
selection.removeAllRanges();
|
|
}
|
|
};
|
|
|
|
export const distance = (x: number, y: number) => Math.abs(x - y);
|
|
|
|
export const resetCursor = () => {
|
|
document.documentElement.style.cursor = "";
|
|
};
|
|
|
|
export const setCursorForShape = (shape: string) => {
|
|
if (shape === "selection") {
|
|
resetCursor();
|
|
} else {
|
|
document.documentElement.style.cursor = CURSOR_TYPE.CROSSHAIR;
|
|
}
|
|
};
|
|
|
|
export const isFullScreen = () =>
|
|
document.fullscreenElement?.nodeName === "HTML";
|
|
|
|
export const allowFullScreen = () =>
|
|
document.documentElement.requestFullscreen();
|
|
|
|
export const exitFullScreen = () => document.exitFullscreen();
|
|
|
|
export const getShortcutKey = (shortcut: string): string => {
|
|
const isMac = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform);
|
|
if (isMac) {
|
|
return `${shortcut
|
|
.replace(/\bCtrlOrCmd\b/i, "Cmd")
|
|
.replace(/\bAlt\b/i, "Option")
|
|
.replace(/\bDel\b/i, "Delete")
|
|
.replace(/\b(Enter|Return)\b/i, "Enter")}`;
|
|
}
|
|
return `${shortcut.replace(/\bCtrlOrCmd\b/i, "Ctrl")}`;
|
|
};
|
|
export const viewportCoordsToSceneCoords = (
|
|
{ clientX, clientY }: { clientX: number; clientY: number },
|
|
appState: AppState,
|
|
canvas: HTMLCanvasElement | null,
|
|
scale: number,
|
|
) => {
|
|
const zoomOrigin = getZoomOrigin(canvas, scale);
|
|
const clientXWithZoom =
|
|
zoomOrigin.x +
|
|
(clientX - zoomOrigin.x - appState.offsetLeft) / appState.zoom;
|
|
const clientYWithZoom =
|
|
zoomOrigin.y +
|
|
(clientY - zoomOrigin.y - appState.offsetTop) / appState.zoom;
|
|
|
|
const x = clientXWithZoom - appState.scrollX;
|
|
const y = clientYWithZoom - appState.scrollY;
|
|
|
|
return { x, y };
|
|
};
|
|
|
|
export const sceneCoordsToViewportCoords = (
|
|
{ sceneX, sceneY }: { sceneX: number; sceneY: number },
|
|
appState: AppState,
|
|
canvas: HTMLCanvasElement | null,
|
|
scale: number,
|
|
) => {
|
|
const zoomOrigin = getZoomOrigin(canvas, scale);
|
|
const x =
|
|
zoomOrigin.x -
|
|
(zoomOrigin.x - sceneX - appState.scrollX - appState.offsetLeft) *
|
|
appState.zoom;
|
|
const y =
|
|
zoomOrigin.y -
|
|
(zoomOrigin.y - sceneY - appState.scrollY - appState.offsetTop) *
|
|
appState.zoom;
|
|
|
|
return { x, y };
|
|
};
|
|
|
|
export const getGlobalCSSVariable = (name: string) =>
|
|
getComputedStyle(document.documentElement).getPropertyValue(`--${name}`);
|
|
|
|
const RS_LTR_CHARS =
|
|
"A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF" +
|
|
"\u2C00-\uFB1C\uFDFE-\uFE6F\uFEFD-\uFFFF";
|
|
const RS_RTL_CHARS = "\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC";
|
|
const RE_RTL_CHECK = new RegExp(`^[^${RS_LTR_CHARS}]*[${RS_RTL_CHARS}]`);
|
|
/**
|
|
* Checks whether first directional character is RTL. Meaning whether it starts
|
|
* with RTL characters, or indeterminate (numbers etc.) characters followed by
|
|
* RTL.
|
|
* See https://github.com/excalidraw/excalidraw/pull/1722#discussion_r436340171
|
|
*/
|
|
export const isRTL = (text: string) => {
|
|
return RE_RTL_CHECK.test(text);
|
|
};
|
|
|
|
export function tupleToCoors(
|
|
xyTuple: readonly [number, number],
|
|
): { x: number; y: number } {
|
|
const [x, y] = xyTuple;
|
|
return { x, y };
|
|
}
|
|
|
|
/** use as a rejectionHandler to mute filesystem Abort errors */
|
|
export const muteFSAbortError = (error?: Error) => {
|
|
if (error?.name === "AbortError") {
|
|
return;
|
|
}
|
|
throw error;
|
|
};
|