Files
excalidraw/packages/common/src/constants.ts
Mark Tolmacs dc7025e33e Fixed point binding for simple arrows
Tests added

Fix binding

Remove unneeded params

Unfinished simple arrow avoidance

Fix newly created jumping arrow when gets outside

Do not apply the jumping logic to elbow arrows for new elements

Existing arrows now jump out

Type updates to support fixed binding for simple arrows

Fix crash for elbow arrws in mutateElement()

Refactored simple arrow creation

Updating tests

No confirm threshold when inside biding range

Fix multi-point arrow grid off

Make elbow arrows respect grids

Unbind arrow if bound and moved at shaft of arrow key

Fix binding test

Fix drag unbind when the bound element is in the selection

Do not move mid point for simple arrows bound on both ends

Add test for mobing mid points for simple arrows when bound on the same element on both ends

Fix linear editor bug when both midpoint and endpoint is moved

Fix all point multipoint arrow highlight and binding

Arrow dragging gets a little drag to avoid accidental unbinding

Fixed point binding for simple arrows when the arrow doesn't point to the element

Fix binding disabled use-case triggering arrow editor

Timed binding mode change for simple arrows

Apply fixes

Remove code to unbind on drag

Update simple arrow fixed point when arrow is dragged or moved by arrow keys

Binding highlight fixes

Change bind mode timeout logic

Fix tests

Add Alt bindMode switch

 No dragging of arrows when bound, similar to elbow

Fix timeout not taking effect immediately

Bumop z-index for arrows when dragged

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Only transparent bindables allow binding fallthrough

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix point click array creation interaction with fixed point binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Restrict new behavior to arrows only

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Allow binding inside images

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix already existing fixed binding retention

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Refactor and implement fixed point binding for unfilled elements

Restore drag

Removed point binding

Binding code refactor

Added centered focus point

Binding & focus point debug

Add invariants to check binding integrity in elements

Binding fixes

Small refactors

Completely rewritten binding

Include point updates after binding update

Fix point updates when endpoint dragged and opposite endpoint orbits

centered focus point only for new arrows

Make z-index arrow reorder on bind

Turn off inside binding mode after leaving a shape

Remove invariants from debug

feat: expose `applyTo` options, don't commit empty text element (#9744)

* Expose applyTo options, skip re-draw for empty text

* Don't commit empty text elements

test: added test file for distribute (#9754)

z-index update

Bind mode on precise binding

Fix binding to inside element

Fix initial arrow not following cursor (white dot)

Fix elbow arrow

Fix z-index so it works on hover

Fix fixed angle orbiting

Move point click arrow creation over to common strategy

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Add binding strategy for drag arrow creation

Fix elbow arrow

Fix point handles

Snap to center

Fix transparent shape binding

Internal arrow creation fix

Fix point binding

Fix selection bug

Fix new arrow focus point

Images now always bind inside

Flashing arrow creation on binding band

Add watchState debug method to window.h

Fix debug canvas crash

Remove non-needed bind mode

Fix restore

No keyboard movement when bound

Add actionFinalize when arrow in edit mode

Add drag to the Stats panel when bound arrow is moved

Further simplify curve tracking

Add typing to action register()

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix point at finalize

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix type errors

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

New arrow binding rules

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix cyclical dep

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix jiggly arrows

Fix jiggly arrow x2

Long inside-other binding

Click-click binding

Fix arrows

Performance

[PERF] Replace in-place Jacobian derivation with analytical version

Different approach to inside binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fixes

Fix inconsistent arrow start jump out

Change how images are bound to on new arrow creation

Lower timeout

Small insurance fix

Fix curve test

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

No center focus point

90% inside center binding

Fixing tests

fix: Elbow arrow fixes

fix: More arrow fixes

Do not trigger arrow binding for linear elements

fix: Linear elements

fix: Refactor actionFinalize for linear

Binding tests updated

fix: Jump when cursor not moved

fix: history tests

Fix history snapshot

Fix undo issue

fix(eraser): Remove binding from the other element

fix(tests): Update tests

chore: Attempt filtering new set state

Fix excessive history recording

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-08-22 19:28:06 +02:00

529 lines
15 KiB
TypeScript

import type {
ExcalidrawElement,
FontFamilyValues,
} from "@excalidraw/element/types";
import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
import { COLOR_PALETTE } from "./colors";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
export const isWindows = /^Win/.test(navigator.platform);
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
export const isFirefox =
typeof window !== "undefined" &&
"netscape" in window &&
navigator.userAgent.indexOf("rv:") > 1 &&
navigator.userAgent.indexOf("Gecko") > 1;
export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
export const isSafari =
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
export const isIOS =
/iPad|iPhone/i.test(navigator.platform) ||
// iPadOS 13+
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
// keeping function so it can be mocked in test
export const isBrave = () =>
(navigator as any).brave?.isBrave?.name === "isBrave";
export const isMobile =
isIOS ||
/android|webos|ipod|blackberry|iemobile|opera mini/i.test(
navigator.userAgent,
) ||
/android|ios|ipod|blackberry|windows phone/i.test(navigator.platform);
export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window;
export const APP_NAME = "Excalidraw";
// distance when creating text before it's considered `autoResize: false`
// we're using higher threshold so that clicks that end up being drags
// don't unintentionally create text elements that are wrapped to a few chars
// (happens a lot with fast clicks with the text tool)
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
export const DRAGGING_THRESHOLD = 10; // px
export const MINIMUM_ARROW_SIZE = 20; // px
export const LINE_CONFIRM_THRESHOLD = 8; // px
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
export const ELEMENT_TRANSLATE_AMOUNT = 1;
export const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
export const SHIFT_LOCKING_ANGLE = Math.PI / 12;
export const DEFAULT_LASER_COLOR = "red";
export const CURSOR_TYPE = {
TEXT: "text",
CROSSHAIR: "crosshair",
GRABBING: "grabbing",
GRAB: "grab",
POINTER: "pointer",
MOVE: "move",
AUTO: "",
};
export const POINTER_BUTTON = {
MAIN: 0,
WHEEL: 1,
SECONDARY: 2,
TOUCH: -1,
ERASER: 5,
} as const;
export const POINTER_EVENTS = {
enabled: "all",
disabled: "none",
// asserted as any so it can be freely assigned to React Element
// "pointerEnvets" CSS prop
inheritFromUI: "var(--ui-pointerEvents)" as any,
} as const;
export enum EVENT {
COPY = "copy",
PASTE = "paste",
CUT = "cut",
KEYDOWN = "keydown",
KEYUP = "keyup",
MOUSE_MOVE = "mousemove",
RESIZE = "resize",
UNLOAD = "unload",
FOCUS = "focus",
BLUR = "blur",
DRAG_OVER = "dragover",
DROP = "drop",
GESTURE_END = "gestureend",
BEFORE_UNLOAD = "beforeunload",
GESTURE_START = "gesturestart",
GESTURE_CHANGE = "gesturechange",
POINTER_MOVE = "pointermove",
POINTER_DOWN = "pointerdown",
POINTER_UP = "pointerup",
STATE_CHANGE = "statechange",
WHEEL = "wheel",
TOUCH_START = "touchstart",
TOUCH_END = "touchend",
HASHCHANGE = "hashchange",
VISIBILITY_CHANGE = "visibilitychange",
SCROLL = "scroll",
// custom events
EXCALIDRAW_LINK = "excalidraw-link",
MENU_ITEM_SELECT = "menu.itemSelect",
MESSAGE = "message",
FULLSCREENCHANGE = "fullscreenchange",
}
export const YOUTUBE_STATES = {
UNSTARTED: -1,
ENDED: 0,
PLAYING: 1,
PAUSED: 2,
BUFFERING: 3,
CUED: 5,
} as const;
export const ENV = {
TEST: "test",
DEVELOPMENT: "development",
PRODUCTION: "production",
};
export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
};
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
/**
* // TODO: shouldn't be really `const`, likely neither have integers as values, due to value for the custom fonts, which should likely be some hash.
*
* Let's think this through and consider:
* - https://developer.mozilla.org/en-US/docs/Web/CSS/generic-family
* - https://drafts.csswg.org/css-fonts-4/#font-family-prop
* - https://learn.microsoft.com/en-us/typography/opentype/spec/ibmfc
*/
export const FONT_FAMILY = {
Virgil: 1,
Helvetica: 2,
Cascadia: 3,
// leave 4 unused as it was historically used for Assistant (which we don't use anymore) or custom font (Obsidian)
Excalifont: 5,
Nunito: 6,
"Lilita One": 7,
"Comic Shanns": 8,
"Liberation Sans": 9,
Assistant: 10,
};
// Segoe UI Emoji fails to properly fallback for some glyphs: ∞, ∫, ≠
// so we need to have generic font fallback before it
export const SANS_SERIF_GENERIC_FONT = "sans-serif";
export const MONOSPACE_GENERIC_FONT = "monospace";
export const FONT_FAMILY_GENERIC_FALLBACKS = {
[SANS_SERIF_GENERIC_FONT]: 998,
[MONOSPACE_GENERIC_FONT]: 999,
};
export const FONT_FAMILY_FALLBACKS = {
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
...FONT_FAMILY_GENERIC_FALLBACKS,
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
};
export function getGenericFontFamilyFallback(
fontFamily: number,
): keyof typeof FONT_FAMILY_GENERIC_FALLBACKS {
switch (fontFamily) {
case FONT_FAMILY.Cascadia:
case FONT_FAMILY["Comic Shanns"]:
return MONOSPACE_GENERIC_FONT;
default:
return SANS_SERIF_GENERIC_FONT;
}
}
export const getFontFamilyFallbacks = (
fontFamily: number,
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
const genericFallbackFont = getGenericFontFamilyFallback(fontFamily);
switch (fontFamily) {
case FONT_FAMILY.Excalifont:
return [
CJK_HAND_DRAWN_FALLBACK_FONT,
genericFallbackFont,
WINDOWS_EMOJI_FALLBACK_FONT,
];
default:
return [genericFallbackFont, WINDOWS_EMOJI_FALLBACK_FONT];
}
};
export const THEME = {
LIGHT: "light",
DARK: "dark",
} as const;
export const FRAME_STYLE = {
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
strokeWidth: 2 as ExcalidrawElement["strokeWidth"],
strokeStyle: "solid" as ExcalidrawElement["strokeStyle"],
fillStyle: "solid" as ExcalidrawElement["fillStyle"],
roughness: 0 as ExcalidrawElement["roughness"],
roundness: null as ExcalidrawElement["roundness"],
backgroundColor: "transparent" as ExcalidrawElement["backgroundColor"],
radius: 8,
nameOffsetY: 3,
nameColorLightTheme: "#999999",
nameColorDarkTheme: "#7a7a7a",
nameFontSize: 14,
nameLineHeight: 1.25,
};
export const MIN_FONT_SIZE = 1;
export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
export const DEFAULT_TEXT_ALIGN = "left";
export const DEFAULT_VERTICAL_ALIGN = "top";
export const DEFAULT_VERSION = "{version}";
export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
export const SIDE_RESIZING_THRESHOLD = 2 * DEFAULT_TRANSFORM_HANDLE_SPACING;
// a small epsilon to make side resizing always take precedence
// (avoids an increase in renders and changes to tests)
export const EPSILON = 0.00001;
export const DEFAULT_COLLISION_THRESHOLD =
2 * SIDE_RESIZING_THRESHOLD - EPSILON;
export const COLOR_WHITE = "#ffffff";
export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
// keep this in sync with CSS
export const COLOR_VOICE_CALL = "#a2f1a6";
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
export const DEFAULT_GRID_SIZE = 20;
export const DEFAULT_GRID_STEP = 5;
export const IMAGE_MIME_TYPES = {
svg: "image/svg+xml",
png: "image/png",
jpg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
ico: "image/x-icon",
avif: "image/avif",
jfif: "image/jfif",
} as const;
export const MIME_TYPES = {
text: "text/plain",
html: "text/html",
json: "application/json",
// excalidraw data
excalidraw: "application/vnd.excalidraw+json",
excalidrawlib: "application/vnd.excalidrawlib+json",
// image-encoded excalidraw data
"excalidraw.svg": "image/svg+xml",
"excalidraw.png": "image/png",
// binary
binary: "application/octet-stream",
// image
...IMAGE_MIME_TYPES,
} as const;
export const ALLOWED_PASTE_MIME_TYPES = [
MIME_TYPES.text,
MIME_TYPES.html,
...Object.values(IMAGE_MIME_TYPES),
] as const;
export const EXPORT_IMAGE_TYPES = {
png: "png",
svg: "svg",
clipboard: "clipboard",
} as const;
export const EXPORT_DATA_TYPES = {
excalidraw: "excalidraw",
excalidrawClipboard: "excalidraw/clipboard",
excalidrawLibrary: "excalidrawlib",
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
} as const;
export const getExportSource = () =>
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
// time in milliseconds
export const IMAGE_RENDER_TIMEOUT = 500;
export const TAP_TWICE_TIMEOUT = 300;
export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000;
export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 100;
export const ZOOM_STEP = 0.1;
export const MIN_ZOOM = 0.1;
export const MAX_ZOOM = 30;
export const HYPERLINK_TOOLTIP_DELAY = 300;
// Report a user inactive after IDLE_THRESHOLD milliseconds
export const IDLE_THRESHOLD = 60_000;
// Report a user active each ACTIVE_THRESHOLD milliseconds
export const ACTIVE_THRESHOLD = 3_000;
// duplicates --theme-filter, should be removed soon
export const THEME_FILTER = "invert(93%) hue-rotate(180deg)";
export const URL_QUERY_KEYS = {
addLibrary: "addLibrary",
} as const;
export const URL_HASH_KEYS = {
addLibrary: "addLibrary",
} as const;
export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
canvasActions: {
changeViewBackgroundColor: true,
clearCanvas: true,
export: { saveFileToDisk: true },
loadScene: true,
saveToActiveFile: true,
toggleTheme: null,
saveAsImage: true,
},
tools: {
image: true,
},
};
// breakpoints
// -----------------------------------------------------------------------------
// md screen
export const MQ_MAX_WIDTH_PORTRAIT = 730;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
// sidebar
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
// -----------------------------------------------------------------------------
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
export const SVG_NS = "http://www.w3.org/2000/svg";
export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
`;
export const ENCRYPTION_KEY_BITS = 128;
export const VERSIONS = {
excalidraw: 2,
excalidrawLibrary: 2,
} as const;
export const BOUND_TEXT_PADDING = 5;
export const ARROW_LABEL_WIDTH_FRACTION = 0.7;
export const ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO = 11;
export const VERTICAL_ALIGN = {
TOP: "top",
MIDDLE: "middle",
BOTTOM: "bottom",
};
export const TEXT_ALIGN = {
LEFT: "left",
CENTER: "center",
RIGHT: "right",
};
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
// Radius represented as 25% of element's largest side (width/height).
// Used for LEGACY and PROPORTIONAL_RADIUS algorithms, or when the element is
// below the cutoff size.
export const DEFAULT_PROPORTIONAL_RADIUS = 0.25;
// Fixed radius for the ADAPTIVE_RADIUS algorithm. In pixels.
export const DEFAULT_ADAPTIVE_RADIUS = 32;
// roundness type (algorithm)
export const ROUNDNESS = {
// Used for legacy rounding (rectangles), which currently works the same
// as PROPORTIONAL_RADIUS, but we need to differentiate for UI purposes and
// forwards-compat.
LEGACY: 1,
// Used for linear elements & diamonds
PROPORTIONAL_RADIUS: 2,
// Current default algorithm for rectangles, using fixed pixel radius.
// It's working similarly to a regular border-radius, but attemps to make
// radius visually similar across differnt element sizes, especially
// very large and very small elements.
//
// NOTE right now we don't allow configuration and use a constant radius
// (see DEFAULT_ADAPTIVE_RADIUS constant)
ADAPTIVE_RADIUS: 3,
} as const;
export const ROUGHNESS = {
architect: 0,
artist: 1,
cartoonist: 2,
} as const;
export const STROKE_WIDTH = {
thin: 1,
bold: 2,
extraBold: 4,
} as const;
export const DEFAULT_ELEMENT_PROPS: {
strokeColor: ExcalidrawElement["strokeColor"];
backgroundColor: ExcalidrawElement["backgroundColor"];
fillStyle: ExcalidrawElement["fillStyle"];
strokeWidth: ExcalidrawElement["strokeWidth"];
strokeStyle: ExcalidrawElement["strokeStyle"];
roughness: ExcalidrawElement["roughness"];
opacity: ExcalidrawElement["opacity"];
locked: ExcalidrawElement["locked"];
} = {
strokeColor: COLOR_PALETTE.black,
backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: ROUGHNESS.artist,
opacity: 100,
locked: false,
};
export const LIBRARY_SIDEBAR_TAB = "library";
export const CANVAS_SEARCH_TAB = "search";
export const DEFAULT_SIDEBAR = {
name: "default",
defaultTab: LIBRARY_SIDEBAR_TAB,
} as const;
export const LIBRARY_DISABLED_TYPES = new Set([
"iframe",
"embeddable",
"image",
] as const);
// use these constants to easily identify reference sites
export const TOOL_TYPE = {
selection: "selection",
lasso: "lasso",
rectangle: "rectangle",
diamond: "diamond",
ellipse: "ellipse",
arrow: "arrow",
line: "line",
freedraw: "freedraw",
text: "text",
image: "image",
eraser: "eraser",
hand: "hand",
frame: "frame",
magicframe: "magicframe",
embeddable: "embeddable",
laser: "laser",
} as const;
export const EDITOR_LS_KEYS = {
OAI_API_KEY: "excalidraw-oai-api-key",
// legacy naming (non)scheme
MERMAID_TO_EXCALIDRAW: "mermaid-to-excalidraw",
PUBLISH_LIBRARY: "publish-library-data",
} as const;
/**
* not translated as this is used only in public, stateless API as default value
* where filename is optional and we can't retrieve name from app state
*/
export const DEFAULT_FILENAME = "Untitled";
export const STATS_PANELS = { generalStats: 1, elementProperties: 2 } as const;
export const MIN_WIDTH_OR_HEIGHT = 1;
export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
sharp: "sharp",
round: "round",
elbow: "elbow",
};
export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3;
export const ELEMENT_LINK_KEY = "element";
/** used in tests */
export const ORIG_ID = Symbol.for("__test__originalId__");
export enum UserIdleState {
ACTIVE = "active",
AWAY = "away",
IDLE = "idle",
}
/**
* distance at which we merge points instead of adding a new merge-point
* when converting a line to a polygon (merge currently means overlaping
* the start and end points)
*/
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
export const BIND_MODE_TIMEOUT = 800; // ms