mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-20 00:16:54 +00:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
386e6d60cf | ||
|
|
1f55e4417e | ||
|
|
3809fe730c | ||
|
|
2da68e7822 | ||
|
|
75706d02b4 | ||
|
|
55204381fe | ||
|
|
5b11fc2985 | ||
|
|
35e854ecf7 | ||
|
|
5a406e5187 | ||
|
|
e283f724c8 | ||
|
|
3947883063 | ||
|
|
3d056dbc8a | ||
|
|
2d3707b816 | ||
|
|
834e5af631 | ||
|
|
438d89861f | ||
|
|
02fc9f3505 | ||
|
|
74423812ec | ||
|
|
84e3f5a40c | ||
|
|
974f054baf | ||
|
|
afed9e6e27 | ||
|
|
d29d864884 | ||
|
|
f3d99c42e9 | ||
|
|
c10499eebb | ||
|
|
c12a8cd037 | ||
|
|
5feb2172d5 | ||
|
|
5db42c3ddb | ||
|
|
e40d1cba95 | ||
|
|
1118751f3e | ||
|
|
dc2c16d9e2 | ||
|
|
4850bf336f | ||
|
|
2b9da96100 | ||
|
|
31df3e6ef2 | ||
|
|
97c68dd371 | ||
|
|
14e1c614d4 | ||
|
|
c0ad61c674 | ||
|
|
a918648012 | ||
|
|
4e758db17e | ||
|
|
afa3a653fc | ||
|
|
6574de60b3 | ||
|
|
854d00c31b | ||
|
|
0361bdd9cb | ||
|
|
2b1c2a9fbf | ||
|
|
f1a79b73df | ||
|
|
214cd6e6e8 | ||
|
|
e1bb9ff8f8 | ||
|
|
4a6f4e7a32 | ||
|
|
e160ff7ba0 | ||
|
|
884225b1c7 | ||
|
|
abeeaeba21 | ||
|
|
c5a50d223f | ||
|
|
cf212b2f33 | ||
|
|
4872083c04 | ||
|
|
ea74787447 | ||
|
|
219571a718 |
@@ -0,0 +1,4 @@
|
||||
# Guidelines
|
||||
|
||||
- For new DOM/browser API usage, use `app.ownerDocument` and `app.ownerWindow` instead of globals; without `app`, derive them from the mounted node's `ownerDocument` and its `defaultView`.
|
||||
- When overriding properties of an existing type, prefer `Merge<Base, Overrides>` from `@excalidraw/common/utility-types` over `Omit<Base, keyof Overrides> & Overrides`.
|
||||
@@ -274,6 +274,35 @@ convertToExcalidrawElements([
|
||||
|
||||

|
||||
|
||||
### Sticky Notes
|
||||
|
||||
A sticky note is an always-filled note whose label auto-fits: the font shrinks from the label's `fontSize` (the note's font ceiling) down to a minimum, and only then does the note grow in height. Only `type`, `x` and `y` are required; `width`/`height` default to a 250×250 square and `label` is optional. The note's `strokeColor` is its ink — its text and creation-date footer are always one color; a label that sets its own `strokeColor` gives it to the note. A footer band below the label shows the note's creation date (`created`); pass `created: null` for a note without one (the band stays reserved, but empty).
|
||||
|
||||
```js
|
||||
convertToExcalidrawElements([
|
||||
{
|
||||
type: "stickynote",
|
||||
x: 100,
|
||||
y: 100,
|
||||
label: {
|
||||
text: "Remember to ship it",
|
||||
fontSize: 28,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "stickynote",
|
||||
x: 400,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 200,
|
||||
backgroundColor: "#fcc2d7",
|
||||
strokeColor: "#c2255c",
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
Notes keep their invariants regardless of the properties you pass: the background is never transparent (it falls back to the default yellow), the fill is solid, and the size is at least 75×75. The height you pass is kept as the note's `baseHeight`; if a label needs more room at the minimum font size, the created note is taller.
|
||||
|
||||
### Labelled Arrows
|
||||
|
||||
Similar to Text Containers, you can create labelled arrows as well.
|
||||
|
||||
@@ -62,6 +62,18 @@ When saving an Excalidraw scene locally to a file, the JSON file (`.excalidraw`)
|
||||
}
|
||||
```
|
||||
|
||||
### Element creation time
|
||||
|
||||
Every normalized element has a `created: number | null` field. A number is the client's wall-clock time in epoch milliseconds when this element instance was created. `null` means the creation time is unknown; restoring an older file does not substitute its load time or last-update time.
|
||||
|
||||
| Operation | `created` |
|
||||
| --- | --- |
|
||||
| Construct a new element | Current time, unless explicitly supplied (including `null`) |
|
||||
| Duplicate, paste, or insert from a library | New time for the new instance |
|
||||
| Convert element skeletons with ID regeneration | New time for each resulting instance |
|
||||
| Restore/open/import an existing scene | Preserve the carried value; missing values become `null` |
|
||||
| Edit, change type while retaining the ID, capture, undo, or redo | Preserve the value |
|
||||
|
||||
## Excalidraw clipboard format
|
||||
|
||||
When copying selected excalidraw elements to clipboard, the JSON schema is similar to `.excalidraw` format, except it differs in attributes.
|
||||
|
||||
@@ -9,8 +9,6 @@ In case you want to pick up something from the roadmap, comment on that issue an
|
||||
|
||||
## Setup
|
||||
|
||||
### Option 1 - Manual
|
||||
|
||||
1. Fork and clone the repo
|
||||
1. Run `yarn` to install dependencies
|
||||
1. Create a branch for your PR with `git checkout -b your-branch-name`
|
||||
@@ -23,15 +21,6 @@ In case you want to pick up something from the roadmap, comment on that issue an
|
||||
> git branch --set-upstream-to=upstream/master master
|
||||
> ```
|
||||
|
||||
### Option 2 - CodeSandbox
|
||||
|
||||
1. Go to https://codesandbox.io/p/github/excalidraw/excalidraw
|
||||
1. Connect your GitHub account
|
||||
1. Go to Git tab on left side
|
||||
1. Tap on `Fork Sandbox`
|
||||
1. Write your code
|
||||
1. Commit and PR automatically
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
Don't worry if you get any of the below wrong, or if you don't know how. We'll gladly help out.
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Development
|
||||
|
||||
## Code Sandbox
|
||||
|
||||
- Go to https://codesandbox.io/p/github/excalidraw/excalidraw
|
||||
- You may need to sign in with GitHub and reload the page
|
||||
- You can start coding instantly, and even send PRs from there!
|
||||
|
||||
## Local Installation
|
||||
|
||||
These instructions will get you a copy of the project up and running on your local machine for development and testing purposes.
|
||||
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
getNonDeletedElements,
|
||||
getTextFromElements,
|
||||
MIME_TYPES,
|
||||
parseSSEStream,
|
||||
TTDDialog,
|
||||
TTDStreamFetch,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { getDataURL } from "@excalidraw/excalidraw/data/blob";
|
||||
import { safelyParseJSON } from "@excalidraw/common";
|
||||
|
||||
import type { StreamChunk } from "@excalidraw/excalidraw";
|
||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { TTDIndexedDBAdapter } from "../data/TTDStorage";
|
||||
@@ -22,7 +24,7 @@ export const AIComponents = ({
|
||||
return (
|
||||
<>
|
||||
<DiagramToCodePlugin
|
||||
generate={async ({ frame, children }) => {
|
||||
generate={async ({ frame, children, onPartial }) => {
|
||||
const appState = excalidrawAPI.getAppState();
|
||||
|
||||
// SAFETY: This should never happen, but log it just in case
|
||||
@@ -51,11 +53,11 @@ export const AIComponents = ({
|
||||
const response = await fetch(
|
||||
`${
|
||||
import.meta.env.VITE_APP_AI_BACKEND
|
||||
}/v1/ai/diagram-to-code/generate`,
|
||||
}/v1/ai/diagram-to-code/generate-streaming`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -94,18 +96,57 @@ export const AIComponents = ({
|
||||
throw new Error(errorJSON.message || text);
|
||||
}
|
||||
|
||||
try {
|
||||
const { html } = await response.json();
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!html) {
|
||||
throw new Error("Generation failed (invalid response)");
|
||||
}
|
||||
return {
|
||||
html,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (!reader) {
|
||||
throw new Error("Generation failed (invalid response)");
|
||||
}
|
||||
|
||||
let html = "";
|
||||
let streamError: Error | null = null;
|
||||
|
||||
for await (const data of parseSSEStream(reader)) {
|
||||
if (data === "[DONE]") {
|
||||
break;
|
||||
}
|
||||
|
||||
const chunk = safelyParseJSON(data) as StreamChunk | null;
|
||||
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (chunk.type) {
|
||||
case "content": {
|
||||
if (chunk.delta) {
|
||||
html += chunk.delta;
|
||||
onPartial?.(html);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
streamError = new Error(
|
||||
chunk.error.message || "Generation failed",
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "done": {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (streamError) {
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
if (!html.trim()) {
|
||||
throw new Error("Generation failed (invalid response)");
|
||||
}
|
||||
|
||||
return {
|
||||
html,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ExcalLogo,
|
||||
eyeIcon,
|
||||
} from "@excalidraw/excalidraw/components/icons";
|
||||
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
||||
import { MainMenu } from "@excalidraw/excalidraw/index";
|
||||
import React from "react";
|
||||
|
||||
@@ -22,6 +23,7 @@ export const AppMainMenu: React.FC<{
|
||||
theme: Theme | "system";
|
||||
refresh: () => void;
|
||||
}> = React.memo((props) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<MainMenu>
|
||||
<MainMenu.DefaultItems.LoadScene />
|
||||
@@ -56,7 +58,7 @@ export const AppMainMenu: React.FC<{
|
||||
}?utm_source=signin&utm_medium=app&utm_content=hamburger`}
|
||||
className="highlighted"
|
||||
>
|
||||
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
|
||||
{isExcalidrawPlusSignedUser ? t("labels.signIn") : t("labels.signUp")}
|
||||
</MainMenu.ItemLink>
|
||||
{isDevEnv() && (
|
||||
<MainMenu.Item
|
||||
|
||||
@@ -72,7 +72,7 @@ export const AppWelcomeScreen: React.FC<{
|
||||
shortcut={null}
|
||||
icon={loginIcon}
|
||||
>
|
||||
Sign up
|
||||
{t("labels.signUp")}
|
||||
</WelcomeScreen.Center.MenuItemLink>
|
||||
)}
|
||||
</WelcomeScreen.Center.Menu>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
||||
|
||||
export const ExcalidrawPlusPromoBanner = ({
|
||||
isSignedIn,
|
||||
}: {
|
||||
isSignedIn: boolean;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<a
|
||||
href={
|
||||
@@ -16,7 +19,7 @@ export const ExcalidrawPlusPromoBanner = ({
|
||||
rel="noopener"
|
||||
className="plus-banner"
|
||||
>
|
||||
Excalidraw+
|
||||
{isSignedIn ? t("labels.signIn") : t("labels.upgrade")}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
applyDarkModeFilter,
|
||||
COLOR_PALETTE,
|
||||
removeDarkModeFilter,
|
||||
rgbToHex,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
@@ -215,6 +216,24 @@ describe("applyDarkModeFilter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeDarkModeFilter", () => {
|
||||
it("restores black and white", () => {
|
||||
expect(removeDarkModeFilter("#ededed")).toBe("#000000");
|
||||
expect(removeDarkModeFilter("#121212")).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it.each([
|
||||
COLOR_PALETTE.black,
|
||||
COLOR_PALETTE.white,
|
||||
COLOR_PALETTE.red[4],
|
||||
COLOR_PALETTE.green[4],
|
||||
COLOR_PALETTE.blue[4],
|
||||
])("preserves the rendered dark-mode color for %s", (color) => {
|
||||
const filtered = applyDarkModeFilter(color);
|
||||
expect(applyDarkModeFilter(removeDarkModeFilter(filtered))).toBe(filtered);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rgbToHex", () => {
|
||||
describe("basic RGB conversion", () => {
|
||||
it("converts black (0,0,0)", () => {
|
||||
|
||||
@@ -13,6 +13,9 @@ import type { Degrees } from "@excalidraw/math";
|
||||
const DARK_MODE_COLORS_CACHE: Map<string, string> | null =
|
||||
typeof window !== "undefined" ? new Map() : null;
|
||||
|
||||
const DARK_MODE_FILTER_INVERT_PERCENT = 93;
|
||||
const DARK_MODE_FILTER_HUE_ROTATE_DEGREES = 180 as Degrees;
|
||||
|
||||
function cssHueRotate(
|
||||
red: number,
|
||||
green: number,
|
||||
@@ -96,12 +99,17 @@ export const applyDarkModeFilter = (color: string, enable = true): string => {
|
||||
// order of operations matters
|
||||
// (corresponds to "filter: invert(invertPercent) hue-rotate(hueDegrees)" in css)
|
||||
const rgb = tc.toRgb();
|
||||
const inverted = cssInvert(rgb.r, rgb.g, rgb.b, 93);
|
||||
const inverted = cssInvert(
|
||||
rgb.r,
|
||||
rgb.g,
|
||||
rgb.b,
|
||||
DARK_MODE_FILTER_INVERT_PERCENT,
|
||||
);
|
||||
const rotated = cssHueRotate(
|
||||
inverted.r,
|
||||
inverted.g,
|
||||
inverted.b,
|
||||
180 as Degrees,
|
||||
DARK_MODE_FILTER_HUE_ROTATE_DEGREES,
|
||||
);
|
||||
|
||||
const result = rgbToHex(rotated.r, rotated.g, rotated.b, alpha);
|
||||
@@ -113,6 +121,44 @@ export const applyDarkModeFilter = (color: string, enable = true): string => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const _reverseDarkModeInvert = (
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
): { r: number; g: number; b: number } => {
|
||||
const p = DARK_MODE_FILTER_INVERT_PERCENT / 100;
|
||||
const denominator = 1 - 2 * p;
|
||||
const restore = (color: number) =>
|
||||
Math.round(clamp((color - 255 * p) / denominator, 0, 255));
|
||||
|
||||
return {
|
||||
r: restore(r),
|
||||
g: restore(g),
|
||||
b: restore(b),
|
||||
};
|
||||
};
|
||||
|
||||
export const removeDarkModeFilter = (color: string): string => {
|
||||
const tc = tinycolor(color);
|
||||
const alpha = tc.getAlpha();
|
||||
const rgb = tc.toRgb();
|
||||
|
||||
// 180deg hue rotation is its own inverse, so undo it before the inversion.
|
||||
const rotatedBack = cssHueRotate(
|
||||
rgb.r,
|
||||
rgb.g,
|
||||
rgb.b,
|
||||
DARK_MODE_FILTER_HUE_ROTATE_DEGREES,
|
||||
);
|
||||
const restored = _reverseDarkModeInvert(
|
||||
rotatedBack.r,
|
||||
rotatedBack.g,
|
||||
rotatedBack.b,
|
||||
);
|
||||
|
||||
return rgbToHex(restored.r, restored.g, restored.b, alpha);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color palette
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -184,6 +230,11 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
|
||||
// quick picks defaults
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** number of slots in the color-picker top-picks strip — the strip layout is
|
||||
* sized for exactly this many swatches, and pick customization (replace /
|
||||
* reorder) preserves it */
|
||||
export const COLOR_TOP_PICKS_SLOTS = 5;
|
||||
|
||||
// ORDER matters for positioning in quick picker
|
||||
export const DEFAULT_ELEMENT_STROKE_PICKS = [
|
||||
COLOR_PALETTE.black,
|
||||
@@ -213,6 +264,22 @@ export const BUCKET_FILL_BACKGROUND_PICKS = [
|
||||
COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
|
||||
] as ColorTuple;
|
||||
|
||||
// sticky notes are their own color domain (own defaults, own picks)
|
||||
export const DEFAULT_STICKY_NOTE_BG = "#ffdf6b";
|
||||
|
||||
export const STICKY_NOTE_STROKE_PICKS: ColorTuple =
|
||||
DEFAULT_ELEMENT_STROKE_PICKS;
|
||||
|
||||
// ORDER matters for positioning in quick picker. Classic note colors; the
|
||||
// first one is `DEFAULT_STICKY_NOTE_BG`. Never transparent.
|
||||
export const STICKY_NOTE_BACKGROUND_PICKS = [
|
||||
DEFAULT_STICKY_NOTE_BG,
|
||||
COLOR_PALETTE.pink[1],
|
||||
COLOR_PALETTE.green[1],
|
||||
COLOR_PALETTE.blue[1],
|
||||
COLOR_PALETTE.orange[1],
|
||||
] as ColorTuple;
|
||||
|
||||
// ORDER matters for positioning in quick picker
|
||||
export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
|
||||
COLOR_PALETTE.white,
|
||||
@@ -251,17 +318,6 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
|
||||
...COMMON_ELEMENT_SHADES,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The color the bucket fill tool actually fills with: the shared
|
||||
* `currentItemBackgroundColor`, falling back to green when that is
|
||||
* transparent (the tool's picker doesn't offer transparent, but the shared
|
||||
* state can hold it from the generic shape picker).
|
||||
*/
|
||||
export const getBucketFillBackgroundColor = (backgroundColor: string) =>
|
||||
isTransparent(backgroundColor)
|
||||
? COLOR_PALETTE.green[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX]
|
||||
: backgroundColor;
|
||||
|
||||
// color palette helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -304,6 +360,19 @@ export const rgbToHex = (r: number, g: number, b: number, a?: number) => {
|
||||
return hex6;
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns the color with its alpha replaced by `alpha` (as #RRGGBBAA), or the
|
||||
* input unchanged if it's not a valid color
|
||||
*/
|
||||
export const setColorAlpha = (color: string, alpha: number): string => {
|
||||
const tc = tinycolor(color);
|
||||
if (!tc.isValid()) {
|
||||
return color;
|
||||
}
|
||||
const { r, g, b } = tc.toRgb();
|
||||
return rgbToHex(r, g, b, alpha);
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns #RRGGBB or #RRGGBBAA based on color containing non-opaque alpha,
|
||||
* null if not valid color
|
||||
|
||||
@@ -2,10 +2,16 @@ import type {
|
||||
ExcalidrawElement,
|
||||
FontFamilyValues,
|
||||
} from "@excalidraw/element/types";
|
||||
import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
|
||||
import type {
|
||||
AppProps,
|
||||
AppState,
|
||||
NormalizedZoomValue,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { COLOR_PALETTE } from "./colors";
|
||||
|
||||
export { DEFAULT_STICKY_NOTE_BG } from "./colors";
|
||||
|
||||
export const supportsResizeObserver =
|
||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||
|
||||
@@ -16,6 +22,12 @@ export const APP_NAME = "Excalidraw";
|
||||
// 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
|
||||
// room left at each side of the visible canvas when a text stops growing at
|
||||
// its width, or is brought into view once it wraps
|
||||
export const TEXT_VIEWPORT_PADDING = 20; // px
|
||||
// the widest a typed or pasted text gets before it wraps (in scene units, so
|
||||
// at any zoom), however wide the view
|
||||
export const TEXT_MAX_WRAP_WIDTH = 800; // px
|
||||
export const DRAGGING_THRESHOLD = 10; // px
|
||||
export const MINIMUM_ARROW_SIZE = 20; // px
|
||||
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
||||
@@ -69,6 +81,7 @@ export enum EVENT {
|
||||
POINTER_MOVE = "pointermove",
|
||||
POINTER_DOWN = "pointerdown",
|
||||
POINTER_UP = "pointerup",
|
||||
POINTER_CANCEL = "pointercancel",
|
||||
STATE_CHANGE = "statechange",
|
||||
WHEEL = "wheel",
|
||||
TOUCH_START = "touchstart",
|
||||
@@ -211,7 +224,56 @@ export const FRAME_STYLE = {
|
||||
|
||||
export const MIN_FONT_SIZE = 1;
|
||||
export const DEFAULT_FONT_SIZE = 20;
|
||||
export const STICKY_NOTE_MIN_FONT_SIZE = 16;
|
||||
export const STICKY_NOTE_MAX_FONT_SIZE = 512;
|
||||
export const STICKY_NOTE_FALLBACK_FONT_SIZE = 28;
|
||||
export const STICKY_NOTE_FONT_STEP = 2;
|
||||
export const STICKY_NOTE_PADDING = 16;
|
||||
/**
|
||||
* The creation-date footer: a 20px text row under the label body plus a 12px
|
||||
* gap above it, inside the note's bottom padding. Reserved for every note —
|
||||
* also when `created` is unknown — so geometry never depends on data
|
||||
* availability. The label is chosen by width bucket, never measured, so
|
||||
* painting stays measurement-free (the server has no text measurer):
|
||||
* `minBodyWidthForYear` is the worst case ("30 May 2026") in the system sans
|
||||
* stack at 12px, with margin for wider fallbacks such as DejaVu Sans.
|
||||
*/
|
||||
/**
|
||||
* The creation-date footer of a sticky note. The label body ends `height`
|
||||
* above the note's bottom padding, and the date's baseline sits
|
||||
* `baselineFromBottom` above the note's bottom edge — so the 12px glyphs
|
||||
* (~9px above the baseline, ~3px below) end up roughly 11px from the edge
|
||||
* with a ~13px gap to the label body above them.
|
||||
*/
|
||||
export const STICKY_NOTE_FOOTER = {
|
||||
height: 20,
|
||||
fontSize: 12,
|
||||
fontFamily: "Helvetica, Arial, sans-serif",
|
||||
baselineFromBottom: 14,
|
||||
opacity: 1,
|
||||
minBodyWidthForYear: 80,
|
||||
} as const;
|
||||
/**
|
||||
* outer height → label body height: top + bottom padding + footer. The body
|
||||
* is what the label is fitted into; a middle-aligned label is still centered
|
||||
* in the whole padded note when it fits (see `computeBoundTextPosition`).
|
||||
*/
|
||||
export const STICKY_NOTE_BODY_INSET_Y =
|
||||
STICKY_NOTE_PADDING * 2 + STICKY_NOTE_FOOTER.height;
|
||||
export const DEFAULT_STICKY_NOTE_SIZE = 250;
|
||||
// floor for a finalized note's width and base height; the UI floor is font-aware
|
||||
// on top of it (see `getStickyNoteMinSize`)
|
||||
export const STICKY_NOTE_MIN_SIZE = 75;
|
||||
export const STICKY_NOTE_SHADOW_OFFSET = 3;
|
||||
export const STICKY_NOTE_SHADOW_OPACITY = 0.16;
|
||||
export const STICKY_NOTE_EDGE_SHADOW_WIDTH = 0.5;
|
||||
export const STICKY_NOTE_EDGE_SHADOW_OPACITY = 0.08;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
|
||||
/** number of slots in the font-picker top-picks strip — pick customization
|
||||
* (replace / reorder) preserves it. Must equal `DEFAULT_FONTS.length` in
|
||||
* `packages/excalidraw/components/FontPicker/FontPicker.tsx` (enforced by
|
||||
* `fontTopPicks.test.ts`) */
|
||||
export const FONT_TOP_PICKS_SLOTS = 3;
|
||||
export const DEFAULT_TEXT_ALIGN = "left";
|
||||
export const DEFAULT_VERTICAL_ALIGN = "top";
|
||||
export const DEFAULT_VERSION = "{version}";
|
||||
@@ -299,9 +361,14 @@ export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
||||
export const TITLE_TIMEOUT = 10000;
|
||||
export const VERSION_TIMEOUT = 30000;
|
||||
export const SCROLL_TIMEOUT = 100;
|
||||
export const ZEN_MODE_TRANSITION_DURATION = 250;
|
||||
export const ZOOM_STEP = 0.1;
|
||||
export const MIN_ZOOM = 0.1;
|
||||
export const MAX_ZOOM = 30;
|
||||
/** 100% zoom, for computations that have no editor zoom to go by */
|
||||
export const DEFAULT_ZOOM: AppState["zoom"] = Object.freeze({
|
||||
value: 1 as NormalizedZoomValue,
|
||||
});
|
||||
export const HYPERLINK_TOOLTIP_DELAY = 300;
|
||||
|
||||
// Report a user inactive after IDLE_THRESHOLD milliseconds
|
||||
@@ -497,6 +564,7 @@ export const TOOL_TYPE = {
|
||||
hand: "hand",
|
||||
frame: "frame",
|
||||
magicframe: "magicframe",
|
||||
stickynote: "stickynote",
|
||||
embeddable: "embeddable",
|
||||
laser: "laser",
|
||||
autoshape: "autoshape",
|
||||
|
||||
@@ -60,6 +60,7 @@ export const KEYS = {
|
||||
H: "h",
|
||||
I: "i",
|
||||
L: "l",
|
||||
N: "n",
|
||||
O: "o",
|
||||
P: "p",
|
||||
Q: "q",
|
||||
|
||||
@@ -14,3 +14,20 @@ export const reseed = (seed: number) => {
|
||||
};
|
||||
|
||||
export const randomId = () => (isTestEnv() ? `id${testIdBase++}` : nanoid());
|
||||
|
||||
/**
|
||||
* Deterministic PRNG (mulberry32) for a given seed: same seed, same
|
||||
* sequence — used where a render must be stable across frames and clients
|
||||
* (e.g. the sticky note's corner jitter) and roughjs's `Random` is
|
||||
* unsuitable (it degenerates for seed 0). Returns numbers in [0, 1).
|
||||
*/
|
||||
export const seededRandom = (seed: number) => {
|
||||
let value = seed >>> 0;
|
||||
return () => {
|
||||
value += 0x6d2b79f5;
|
||||
let next = value;
|
||||
next = Math.imul(next ^ (next >>> 15), next | 1);
|
||||
next ^= next + Math.imul(next ^ (next >>> 7), next | 61);
|
||||
return ((next ^ (next >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import {
|
||||
getNearestScrollableContainer,
|
||||
isInputLike,
|
||||
isInteractive,
|
||||
isToolIcon,
|
||||
isTransparent,
|
||||
isWritableElement,
|
||||
mapFind,
|
||||
reduceToCommonValue,
|
||||
} from "@excalidraw/common";
|
||||
@@ -11,6 +16,34 @@ import { throttleRAF } from "./utils";
|
||||
type RafCallback = FrameRequestCallback;
|
||||
|
||||
describe("@excalidraw/common/utils", () => {
|
||||
describe("cross-document element guards", () => {
|
||||
it("uses constructors from the target element's window", () => {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
|
||||
const iframeDocument = iframe.contentDocument!;
|
||||
const iframeWindow = iframe.contentWindow!;
|
||||
const textarea = iframeDocument.createElement("textarea");
|
||||
const button = iframeDocument.createElement("button");
|
||||
const toolIcon = iframeDocument.createElement("div");
|
||||
toolIcon.className = "ToolIcon__icon";
|
||||
|
||||
expect(textarea).not.toBeInstanceOf(HTMLTextAreaElement);
|
||||
expect(textarea).toBeInstanceOf(
|
||||
(iframeWindow as Window & typeof globalThis).HTMLTextAreaElement,
|
||||
);
|
||||
expect(isInputLike(textarea)).toBe(true);
|
||||
expect(isWritableElement(textarea)).toBe(true);
|
||||
expect(isInteractive(button)).toBe(true);
|
||||
expect(isToolIcon(toolIcon)).toBe(true);
|
||||
|
||||
iframeDocument.body.append(textarea);
|
||||
expect(getNearestScrollableContainer(textarea)).toBe(iframeDocument);
|
||||
|
||||
iframe.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTransparent()", () => {
|
||||
it("should return true when color is rgb transparent", () => {
|
||||
expect(isTransparent("#ff00")).toEqual(true);
|
||||
|
||||
@@ -48,10 +48,23 @@ export const getDateTime = () => {
|
||||
export const capitalizeString = (str: string) =>
|
||||
str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
const getTargetWindow = (
|
||||
target: Element | EventTarget | null,
|
||||
): (Window & typeof globalThis) | null =>
|
||||
(target as (EventTarget & { ownerDocument?: Document | null }) | null)
|
||||
?.ownerDocument?.defaultView ??
|
||||
(typeof window === "undefined" ? null : window);
|
||||
|
||||
export const isToolIcon = (
|
||||
target: Element | EventTarget | null,
|
||||
): target is HTMLElement =>
|
||||
target instanceof HTMLElement && target.className.includes("ToolIcon");
|
||||
): target is HTMLElement => {
|
||||
const targetWindow = getTargetWindow(target);
|
||||
return (
|
||||
!!targetWindow &&
|
||||
target instanceof targetWindow.HTMLElement &&
|
||||
target.className.includes("ToolIcon")
|
||||
);
|
||||
};
|
||||
|
||||
export const isInputLike = (
|
||||
target: Element | EventTarget | null,
|
||||
@@ -60,17 +73,26 @@ export const isInputLike = (
|
||||
| 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;
|
||||
| HTMLDivElement => {
|
||||
const targetWindow = getTargetWindow(target);
|
||||
return (
|
||||
!!targetWindow &&
|
||||
((target instanceof targetWindow.HTMLElement &&
|
||||
target.dataset.type === "wysiwyg") ||
|
||||
target instanceof targetWindow.HTMLBRElement || // newline in wysiwyg
|
||||
target instanceof targetWindow.HTMLInputElement ||
|
||||
target instanceof targetWindow.HTMLTextAreaElement ||
|
||||
target instanceof targetWindow.HTMLSelectElement)
|
||||
);
|
||||
};
|
||||
|
||||
export const isInteractive = (target: Element | EventTarget | null) => {
|
||||
const targetWindow = getTargetWindow(target);
|
||||
return (
|
||||
isInputLike(target) ||
|
||||
(target instanceof Element && !!target.closest("label, button"))
|
||||
(!!targetWindow &&
|
||||
target instanceof targetWindow.Element &&
|
||||
!!target.closest("label, button"))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -80,16 +102,23 @@ export const isWritableElement = (
|
||||
| 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" ||
|
||||
target.type === "password" ||
|
||||
target.type === "search")) ||
|
||||
(target instanceof HTMLElement && target.closest(".cm-editor") !== null);
|
||||
| HTMLDivElement => {
|
||||
const targetWindow = getTargetWindow(target);
|
||||
return (
|
||||
!!targetWindow &&
|
||||
((target instanceof targetWindow.HTMLElement &&
|
||||
target.dataset.type === "wysiwyg") ||
|
||||
target instanceof targetWindow.HTMLBRElement || // newline in wysiwyg
|
||||
target instanceof targetWindow.HTMLTextAreaElement ||
|
||||
(target instanceof targetWindow.HTMLInputElement &&
|
||||
(target.type === "text" ||
|
||||
target.type === "number" ||
|
||||
target.type === "password" ||
|
||||
target.type === "search")) ||
|
||||
(target instanceof targetWindow.HTMLElement &&
|
||||
target.closest(".cm-editor") !== null))
|
||||
);
|
||||
};
|
||||
|
||||
export const getFontFamilyString = ({
|
||||
fontFamily,
|
||||
@@ -344,6 +373,13 @@ const RE_RTL_CHECK = new RegExp(`^[^${RS_LTR_CHARS}]*[${RS_RTL_CHARS}]`);
|
||||
*/
|
||||
export const isRTL = (text: string) => RE_RTL_CHECK.test(text);
|
||||
|
||||
const RE_RTL_CHARS = new RegExp(`[${RS_RTL_CHARS}]`);
|
||||
/**
|
||||
* Checks whether the text has any RTL character, i.e. whether it may be laid
|
||||
* out bidirectionally.
|
||||
*/
|
||||
export const hasRTLChars = (text: string) => RE_RTL_CHARS.test(text);
|
||||
|
||||
export const tupleToCoors = (
|
||||
xyTuple: readonly [number, number],
|
||||
): { x: number; y: number } => {
|
||||
@@ -475,12 +511,14 @@ export const supportsEmoji = () => {
|
||||
export const getNearestScrollableContainer = (
|
||||
element: HTMLElement,
|
||||
): HTMLElement | Document => {
|
||||
const ownerDocument = element.ownerDocument;
|
||||
const ownerWindow = ownerDocument.defaultView ?? window;
|
||||
let parent = element.parentElement;
|
||||
while (parent) {
|
||||
if (parent === document.body) {
|
||||
return document;
|
||||
if (parent === ownerDocument.body) {
|
||||
return ownerDocument;
|
||||
}
|
||||
const { overflowY } = window.getComputedStyle(parent);
|
||||
const { overflowY } = ownerWindow.getComputedStyle(parent);
|
||||
const hasScrollableContent = parent.scrollHeight > parent.clientHeight;
|
||||
if (
|
||||
hasScrollableContent &&
|
||||
@@ -492,7 +530,7 @@ export const getNearestScrollableContainer = (
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return document;
|
||||
return ownerDocument;
|
||||
};
|
||||
|
||||
export const focusNearestParent = (element: HTMLInputElement) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -51,6 +52,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -83,6 +85,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -144,6 +147,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -210,6 +214,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -242,6 +247,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id50",
|
||||
@@ -249,6 +255,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
},
|
||||
],
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -259,6 +266,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"id": Any<String>,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -288,6 +296,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id50",
|
||||
@@ -295,6 +304,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
},
|
||||
],
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -305,6 +315,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"id": Any<String>,
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -339,6 +350,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -400,8 +412,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id50",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -412,6 +426,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"id": Any<String>,
|
||||
"index": "a3",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -446,6 +461,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -507,8 +523,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id39",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -519,6 +537,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"id": Any<String>,
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -553,6 +572,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -590,6 +610,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -627,6 +648,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -688,8 +710,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id43",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -700,6 +724,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"id": Any<String>,
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -729,6 +754,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id43",
|
||||
@@ -736,6 +762,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
},
|
||||
],
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -746,6 +773,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"id": Any<String>,
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -775,6 +803,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id43",
|
||||
@@ -782,6 +811,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
},
|
||||
],
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -792,6 +822,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"id": Any<String>,
|
||||
"index": "a3",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -821,6 +852,7 @@ exports[`Test Transform > should not allow duplicate ids 1`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -853,6 +885,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -900,6 +933,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "triangle",
|
||||
@@ -947,6 +981,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
@@ -994,6 +1029,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
@@ -1041,6 +1077,7 @@ exports[`Test Transform > should transform regular shapes 1`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1073,6 +1110,7 @@ exports[`Test Transform > should transform regular shapes 2`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1105,6 +1143,7 @@ exports[`Test Transform > should transform regular shapes 3`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1137,6 +1176,7 @@ exports[`Test Transform > should transform regular shapes 4`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "#c0eb75",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1169,6 +1209,7 @@ exports[`Test Transform > should transform regular shapes 5`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "#ffc9c9",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1201,6 +1242,7 @@ exports[`Test Transform > should transform regular shapes 6`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "#a5d8ff",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "cross-hatch",
|
||||
"frameId": null,
|
||||
@@ -1233,8 +1275,10 @@ exports[`Test Transform > should transform text element 1`] = `
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1245,6 +1289,7 @@ exports[`Test Transform > should transform text element 1`] = `
|
||||
"id": Any<String>,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1274,8 +1319,10 @@ exports[`Test Transform > should transform text element 2`] = `
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1286,6 +1333,7 @@ exports[`Test Transform > should transform text element 2`] = `
|
||||
"id": Any<String>,
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1324,6 +1372,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1363,6 +1412,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1406,6 +1456,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1453,6 +1504,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1492,6 +1544,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -1560,6 +1613,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -1619,8 +1673,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "B",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1633,6 +1689,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "a6",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1662,8 +1719,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "A",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1676,6 +1735,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "a7",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1705,8 +1765,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "Alice",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1719,6 +1781,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "a8",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1748,8 +1811,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "Bob",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1762,6 +1827,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "a9",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1791,8 +1857,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "Bob_Alice",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1803,6 +1871,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "aA",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1832,8 +1901,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "Bob_B",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1844,6 +1915,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "aB",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1859,12 +1931,12 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"textAlign": "center",
|
||||
"type": "text",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"verticalAlign": "middle",
|
||||
"width": 100,
|
||||
"x": 27.016999999999996,
|
||||
"y": 66.5,
|
||||
"x": -50,
|
||||
"y": -12.5,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1878,6 +1950,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -1930,6 +2003,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -1982,6 +2056,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -2034,6 +2109,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -2081,8 +2157,10 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id25",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2093,6 +2171,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a4",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2122,8 +2201,10 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id26",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2134,6 +2215,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a5",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2163,8 +2245,10 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id27",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2175,6 +2259,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a6",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2205,8 +2290,10 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id28",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2217,6 +2304,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a7",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2252,6 +2340,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -2289,6 +2378,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -2326,6 +2416,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -2363,6 +2454,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -2400,6 +2492,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -2437,6 +2530,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -2469,8 +2563,10 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id13",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2481,6 +2577,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a6",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2510,8 +2607,10 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id14",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2522,6 +2621,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a7",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2552,8 +2652,10 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id15",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2564,6 +2666,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a8",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2596,8 +2699,10 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id16",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2608,6 +2713,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a9",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2638,8 +2744,10 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id17",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2650,6 +2758,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"id": Any<String>,
|
||||
"index": "aA",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2681,8 +2790,10 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id18",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2693,6 +2804,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"id": Any<String>,
|
||||
"index": "aB",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ROUNDNESS } from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { Scene } from "../Scene";
|
||||
import { addNewNodes } from "../flowchart";
|
||||
import { newStickyNoteElement } from "../newElement";
|
||||
import { isFlowchartNodeElement, isStickyNoteElement } from "../typeChecks";
|
||||
|
||||
describe("flowchart", () => {
|
||||
it("creates connected sticky notes", () => {
|
||||
const sticky = newStickyNoteElement({
|
||||
type: "stickynote",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 240,
|
||||
height: 260,
|
||||
baseHeight: 220,
|
||||
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
|
||||
roughness: 2,
|
||||
backgroundColor: "#ffec99",
|
||||
strokeColor: "#1e1e1e",
|
||||
strokeWidth: 2,
|
||||
});
|
||||
const scene = new Scene([sticky], { skipValidation: true });
|
||||
const {
|
||||
nodes: [nextNode, bindingArrow],
|
||||
} = addNewNodes(
|
||||
sticky,
|
||||
{
|
||||
currentItemEndArrowhead: "arrow",
|
||||
} as AppState,
|
||||
"right",
|
||||
scene,
|
||||
1,
|
||||
);
|
||||
|
||||
expect(isFlowchartNodeElement(sticky)).toBe(true);
|
||||
expect(isFlowchartNodeElement(nextNode)).toBe(true);
|
||||
expect(isStickyNoteElement(nextNode)).toBe(true);
|
||||
expect(nextNode).toMatchObject({
|
||||
type: "stickynote",
|
||||
x: sticky.x + sticky.width + 100,
|
||||
y: sticky.y,
|
||||
width: sticky.width,
|
||||
height: sticky.height,
|
||||
baseHeight: sticky.baseHeight,
|
||||
roundness: sticky.roundness,
|
||||
roughness: sticky.roughness,
|
||||
backgroundColor: sticky.backgroundColor,
|
||||
strokeColor: sticky.strokeColor,
|
||||
strokeWidth: sticky.strokeWidth,
|
||||
});
|
||||
expect(bindingArrow).toMatchObject({
|
||||
type: "arrow",
|
||||
startBinding: { elementId: sticky.id },
|
||||
endBinding: { elementId: nextNode.id },
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,30 @@
|
||||
import {
|
||||
DEFAULT_STICKY_NOTE_BG,
|
||||
DEFAULT_STICKY_NOTE_SIZE,
|
||||
STICKY_NOTE_MIN_FONT_SIZE,
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
getUpdatedTimestamp,
|
||||
} from "@excalidraw/common";
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import {
|
||||
newElement,
|
||||
newEmbeddableElement,
|
||||
newFreeDrawElement,
|
||||
newIframeElement,
|
||||
} from "../newElement";
|
||||
|
||||
import {
|
||||
convertToExcalidrawElements,
|
||||
type ExcalidrawElementSkeleton,
|
||||
} from "../transform";
|
||||
|
||||
import type { ExcalidrawArrowElement } from "../types";
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawStickyNoteElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "../types";
|
||||
|
||||
const opts = { regenerateIds: false };
|
||||
|
||||
@@ -965,4 +983,208 @@ describe("Test Transform", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("creation timestamps", () => {
|
||||
const createElements = () => [
|
||||
{
|
||||
...newElement({ type: "rectangle", x: 0, y: 0 }),
|
||||
type: "rectangle" as const,
|
||||
},
|
||||
newFreeDrawElement({
|
||||
type: "freedraw",
|
||||
x: 0,
|
||||
y: 0,
|
||||
simulatePressure: false,
|
||||
}),
|
||||
newIframeElement({ type: "iframe", x: 0, y: 0 }),
|
||||
newEmbeddableElement({ type: "embeddable", x: 0, y: 0 }),
|
||||
];
|
||||
|
||||
it("assigns fresh timestamps when regenerating IDs without mutating the input", () => {
|
||||
const elements = createElements().map((element) => ({
|
||||
...element,
|
||||
created: 123,
|
||||
}));
|
||||
const converted = convertToExcalidrawElements(elements);
|
||||
|
||||
expect(converted).toHaveLength(elements.length);
|
||||
converted.forEach((element, index) => {
|
||||
expect(element.id).not.toBe(elements[index].id);
|
||||
expect(element.created).toBe(getUpdatedTimestamp());
|
||||
expect(elements[index].created).toBe(123);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([123, 0, null])(
|
||||
"preserves an explicit timestamp of %s when retaining IDs",
|
||||
(created) => {
|
||||
const elements = createElements().map((element) => ({
|
||||
...element,
|
||||
created,
|
||||
}));
|
||||
const converted = convertToExcalidrawElements(elements, opts);
|
||||
|
||||
converted.forEach((element, index) => {
|
||||
expect(element.id).toBe(elements[index].id);
|
||||
expect(element.created).toBe(created);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("defaults missing timestamps when retaining IDs, as every type goes through a constructor", () => {
|
||||
// models skeletons persisted before `created` existed; the declared
|
||||
// skeleton type is a complete element
|
||||
const elements = createElements().map(
|
||||
({ created, ...element }) => element,
|
||||
) as unknown as ExcalidrawElementSkeleton[];
|
||||
const converted = convertToExcalidrawElements(elements, opts);
|
||||
|
||||
converted.forEach((element, index) => {
|
||||
expect(element.id).toBe(elements[index].id);
|
||||
expect(element.created).toBe(getUpdatedTimestamp());
|
||||
expect(elements[index]).not.toHaveProperty("created");
|
||||
});
|
||||
});
|
||||
|
||||
it("assigns fresh timestamps to generated labels and binding endpoints", () => {
|
||||
const elements: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 100,
|
||||
created: 123,
|
||||
// fragments cannot carry a creation time; they are always new
|
||||
label: { text: "label" },
|
||||
start: { type: "rectangle" },
|
||||
end: { type: "text", text: "end" },
|
||||
},
|
||||
];
|
||||
const converted = convertToExcalidrawElements(elements);
|
||||
|
||||
expect(converted).toHaveLength(4);
|
||||
expect(converted.map((element) => element.created)).toEqual(
|
||||
Array(4).fill(getUpdatedTimestamp()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sticky notes", () => {
|
||||
const find = <
|
||||
T extends ExcalidrawStickyNoteElement | ExcalidrawTextElement,
|
||||
>(
|
||||
elements: readonly { type: string }[],
|
||||
type: T["type"],
|
||||
) => elements.find((element) => element.type === type) as T;
|
||||
|
||||
it("creates a finalized note with the sticky defaults", () => {
|
||||
const elements = convertToExcalidrawElements(
|
||||
[{ type: "stickynote", x: 100, y: 100 }],
|
||||
opts,
|
||||
);
|
||||
const note = find<ExcalidrawStickyNoteElement>(elements, "stickynote");
|
||||
|
||||
expect(elements).toHaveLength(1);
|
||||
expect(note.width).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
expect(note.height).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
expect(note.baseHeight).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
expect(note.backgroundColor).toBe(DEFAULT_STICKY_NOTE_BG);
|
||||
expect(note.fillStyle).toBe("solid");
|
||||
});
|
||||
|
||||
it("enforces the note invariants on the given properties", () => {
|
||||
const note = find<ExcalidrawStickyNoteElement>(
|
||||
convertToExcalidrawElements(
|
||||
[
|
||||
{
|
||||
type: "stickynote",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 300,
|
||||
backgroundColor: "transparent",
|
||||
strokeColor: "transparent",
|
||||
},
|
||||
],
|
||||
opts,
|
||||
),
|
||||
"stickynote",
|
||||
);
|
||||
|
||||
expect(note.width).toBe(STICKY_NOTE_MIN_SIZE);
|
||||
expect(note.height).toBe(300);
|
||||
expect(note.baseHeight).toBe(300);
|
||||
expect(note.backgroundColor).toBe(DEFAULT_STICKY_NOTE_BG);
|
||||
expect(note.strokeColor).not.toBe("transparent");
|
||||
});
|
||||
|
||||
it("gives the note the color of a label that sets its own", () => {
|
||||
const elements = convertToExcalidrawElements(
|
||||
[
|
||||
{
|
||||
type: "stickynote",
|
||||
x: 0,
|
||||
y: 0,
|
||||
strokeColor: "#1971c2",
|
||||
label: { text: "hello", strokeColor: "#e03131" },
|
||||
},
|
||||
],
|
||||
opts,
|
||||
);
|
||||
const note = find<ExcalidrawStickyNoteElement>(elements, "stickynote");
|
||||
const label = find<ExcalidrawTextElement>(elements, "text");
|
||||
|
||||
expect(label.strokeColor).toBe("#e03131");
|
||||
expect(note.strokeColor).toBe("#e03131");
|
||||
});
|
||||
|
||||
it("binds a label whose font size becomes the note's ceiling", () => {
|
||||
const elements = convertToExcalidrawElements(
|
||||
[
|
||||
{
|
||||
type: "stickynote",
|
||||
x: 100,
|
||||
y: 100,
|
||||
strokeColor: "#1971c2",
|
||||
label: { text: "hello", fontSize: 28 },
|
||||
},
|
||||
],
|
||||
opts,
|
||||
);
|
||||
const note = find<ExcalidrawStickyNoteElement>(elements, "stickynote");
|
||||
const label = find<ExcalidrawTextElement>(elements, "text");
|
||||
|
||||
expect(elements).toHaveLength(2);
|
||||
expect(label.containerId).toBe(note.id);
|
||||
expect(note.boundElements).toEqual([{ type: "text", id: label.id }]);
|
||||
expect(label.baseFontSize).toBe(28);
|
||||
expect(label.fontSize).toBe(28);
|
||||
// the label is the note's visible text: it takes the note's stroke
|
||||
expect(label.strokeColor).toBe("#1971c2");
|
||||
expect(note.height).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
});
|
||||
|
||||
it("grows the note for a label that overflows at the minimum font size", () => {
|
||||
const elements = convertToExcalidrawElements(
|
||||
[
|
||||
{
|
||||
type: "stickynote",
|
||||
x: 0,
|
||||
y: 0,
|
||||
label: {
|
||||
text: Array(40).fill("abcdefghijklmnopqrstuvwx").join("\n"),
|
||||
fontSize: 28,
|
||||
},
|
||||
},
|
||||
],
|
||||
opts,
|
||||
);
|
||||
const note = find<ExcalidrawStickyNoteElement>(elements, "stickynote");
|
||||
const label = find<ExcalidrawTextElement>(elements, "text");
|
||||
|
||||
expect(label.fontSize).toBe(STICKY_NOTE_MIN_FONT_SIZE);
|
||||
expect(label.baseFontSize).toBe(28);
|
||||
expect(note.baseHeight).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
expect(note.height).toBeGreaterThan(DEFAULT_STICKY_NOTE_SIZE);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,7 +188,7 @@ export const getTextBindingForArrowEndpoint = (
|
||||
// than gap-outside the outline, so offsetting there would just shift them.
|
||||
const gap = isElbowArrow(arrow)
|
||||
? 0
|
||||
: getBindingGap({ strokeWidth: targetStrokeWidth }, arrow);
|
||||
: getBindingGap({ strokeWidth: targetStrokeWidth });
|
||||
|
||||
// How far back along the arrow the bound side has to sit for the tip to
|
||||
// stay exactly where it is.
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
getBindingGap,
|
||||
getGlobalFixedPointForBindableElement,
|
||||
isBindingEnabled,
|
||||
maxBindingDistance_simple,
|
||||
unbindBindingElement,
|
||||
updateBoundPoint,
|
||||
} from "../binding";
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
isElbowArrow,
|
||||
} from "../typeChecks";
|
||||
import { LinearElementEditor } from "../linearElementEditor";
|
||||
import { getHoveredElementForFocusPoint, hitElementItself } from "../collision";
|
||||
import { getHoveredElementForBinding, hitElementItself } from "../collision";
|
||||
import { moveArrowAboveBindable } from "../zindex";
|
||||
|
||||
import type {
|
||||
@@ -94,7 +93,7 @@ export const isFocusPointVisible = (
|
||||
element: bindableElement,
|
||||
elementsMap,
|
||||
point: focusPoint,
|
||||
threshold: getBindingGap(bindableElement, arrow),
|
||||
threshold: getBindingGap(bindableElement),
|
||||
overrideShouldTestInside: true,
|
||||
})
|
||||
);
|
||||
@@ -243,12 +242,11 @@ export const handleFocusPointDrag = (
|
||||
pointerCoords.y - offsetY,
|
||||
);
|
||||
const bindingField = isStartBinding ? "startBinding" : "endBinding";
|
||||
const hit = getHoveredElementForFocusPoint(
|
||||
const hit = getHoveredElementForBinding(
|
||||
point,
|
||||
arrow,
|
||||
scene.getNonDeletedElements(),
|
||||
elementsMap,
|
||||
maxBindingDistance_simple(appState.zoom),
|
||||
appState.zoom,
|
||||
);
|
||||
|
||||
// Hovering a bindable element
|
||||
@@ -279,6 +277,7 @@ export const handleFocusPointDrag = (
|
||||
newMode || "orbit",
|
||||
linearElementEditor.draggedFocusPointBinding,
|
||||
scene,
|
||||
appState.zoom,
|
||||
point,
|
||||
);
|
||||
}
|
||||
|
||||
+351
-180
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
arrayToMap,
|
||||
DEFAULT_ZOOM,
|
||||
getFeatureFlag,
|
||||
getGridPoint,
|
||||
invariant,
|
||||
@@ -62,12 +63,13 @@ import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructRectanguloidElement,
|
||||
getElbowArrowSnapMidPoint,
|
||||
projectFixedPointOntoDiagonal,
|
||||
} from "./utils";
|
||||
|
||||
import { isNonDeletedElement } from ".";
|
||||
import { Scene } from "./Scene";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
import { isNonDeletedElement } from ".";
|
||||
|
||||
import type { ElementUpdate } from "./mutateElement";
|
||||
import type {
|
||||
@@ -85,6 +87,7 @@ import type {
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
Ordered,
|
||||
OrderedExcalidrawElement,
|
||||
PointsPositionUpdates,
|
||||
} from "./types";
|
||||
|
||||
@@ -114,7 +117,6 @@ export type BindingStrategy =
|
||||
* IMPORTANT: currently must be > 0 (this also applies to the computed gap)
|
||||
*/
|
||||
export const BASE_BINDING_GAP = 5;
|
||||
export const BASE_BINDING_GAP_ELBOW = 5;
|
||||
export const BASE_ARROW_MIN_LENGTH = 10;
|
||||
export const FOCUS_POINT_SIZE = 10 / 1.5;
|
||||
|
||||
@@ -126,12 +128,8 @@ export const getBindingGap = (
|
||||
// only the stroke width is needed, so the gap can also be computed for a
|
||||
// bind target that doesn't exist yet (see `getTextBindingForArrowEndpoint`)
|
||||
bindTarget: Pick<ExcalidrawBindableElement, "strokeWidth">,
|
||||
opts: Pick<ExcalidrawArrowElement, "elbowed">,
|
||||
): number => {
|
||||
return (
|
||||
(opts.elbowed ? BASE_BINDING_GAP_ELBOW : BASE_BINDING_GAP) +
|
||||
bindTarget.strokeWidth / 2
|
||||
);
|
||||
return BASE_BINDING_GAP + bindTarget.strokeWidth / 2;
|
||||
};
|
||||
|
||||
export const maxBindingDistance_simple = (zoom?: AppState["zoom"]): number => {
|
||||
@@ -191,6 +189,7 @@ export const bindOrUnbindBindingElement = (
|
||||
start,
|
||||
"start",
|
||||
scene,
|
||||
appState.zoom,
|
||||
appState.isBindingEnabled,
|
||||
isMidpointSnappingEnabled,
|
||||
);
|
||||
@@ -199,6 +198,7 @@ export const bindOrUnbindBindingElement = (
|
||||
end,
|
||||
"end",
|
||||
scene,
|
||||
appState.zoom,
|
||||
appState.isBindingEnabled,
|
||||
isMidpointSnappingEnabled,
|
||||
);
|
||||
@@ -244,6 +244,7 @@ const bindOrUnbindBindingElementEdge = (
|
||||
{ mode, element, focusPoint }: BindingStrategy,
|
||||
startOrEnd: "start" | "end",
|
||||
scene: Scene,
|
||||
zoom: AppState["zoom"],
|
||||
shouldSnapToOutline = true,
|
||||
isMidpointSnappingEnabled = true,
|
||||
): void => {
|
||||
@@ -257,6 +258,7 @@ const bindOrUnbindBindingElementEdge = (
|
||||
mode,
|
||||
startOrEnd,
|
||||
scene,
|
||||
zoom,
|
||||
focusPoint,
|
||||
shouldSnapToOutline,
|
||||
isMidpointSnappingEnabled,
|
||||
@@ -269,7 +271,7 @@ const bindingStrategyForElbowArrowEndpointDragging = (
|
||||
draggingPoints: PointsPositionUpdates,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
zoom?: AppState["zoom"],
|
||||
zoom: AppState["zoom"],
|
||||
): {
|
||||
start: BindingStrategy;
|
||||
end: BindingStrategy;
|
||||
@@ -293,7 +295,7 @@ const bindingStrategyForElbowArrowEndpointDragging = (
|
||||
globalPoint,
|
||||
elements,
|
||||
elementsMap,
|
||||
maxBindingDistance_simple(zoom),
|
||||
zoom,
|
||||
);
|
||||
|
||||
const current = hit
|
||||
@@ -341,7 +343,12 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
|
||||
draggingPoints.get(startDragged ? startIdx : endIdx)!.point,
|
||||
elementsMap,
|
||||
);
|
||||
const hit = getHoveredElementForBinding(point, elements, elementsMap);
|
||||
const hit = getHoveredElementForBinding(
|
||||
point,
|
||||
elements,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
);
|
||||
|
||||
// With new arrows this handles the binding at arrow creation
|
||||
if (startDragged) {
|
||||
@@ -386,7 +393,12 @@ const bindingStrategyForNewSimpleArrowEndpointDragging = (
|
||||
// Check and handle nested shapes
|
||||
if (hit && arrow.startBinding) {
|
||||
const startBinding = arrow.startBinding;
|
||||
const allHits = getAllHoveredElementAtPoint(point, elements, elementsMap);
|
||||
const allHits = getAllHoveredElementAtPoint(
|
||||
point,
|
||||
elements,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
);
|
||||
|
||||
if (allHits.find((el) => el.id === startBinding.elementId)) {
|
||||
const otherElement = elementsMap.get(
|
||||
@@ -482,15 +494,16 @@ const bindingStrategyForSimpleArrowEndpointDragging_complex = (
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
globalBindMode: AppState["bindMode"],
|
||||
arrow: NonDeleted<ExcalidrawArrowElement>,
|
||||
zoom: AppState["zoom"],
|
||||
finalize?: boolean,
|
||||
): { current: BindingStrategy; other: BindingStrategy } => {
|
||||
let current: BindingStrategy = { mode: undefined };
|
||||
let other: BindingStrategy = { mode: undefined };
|
||||
|
||||
const isMultiPoint = arrow.points.length > 2;
|
||||
const hit = getHoveredElementForBinding(point, elements, elementsMap);
|
||||
const hit = getHoveredElementForBinding(point, elements, elementsMap, zoom);
|
||||
const isOverlapping = oppositeBinding
|
||||
? getAllHoveredElementAtPoint(point, elements, elementsMap).some(
|
||||
? getAllHoveredElementAtPoint(point, elements, elementsMap, zoom).some(
|
||||
(el) => el.id === oppositeBinding.elementId,
|
||||
)
|
||||
: false;
|
||||
@@ -614,7 +627,6 @@ export const getBindingStrategyForDraggingBindingElementEndpoints = (
|
||||
altKey?: boolean;
|
||||
finalize?: boolean;
|
||||
initialBinding?: boolean;
|
||||
zoom?: AppState["zoom"];
|
||||
gridSize?: NullableGridSize;
|
||||
},
|
||||
): { start: BindingStrategy; end: BindingStrategy } => {
|
||||
@@ -655,7 +667,6 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
altKey?: boolean;
|
||||
finalize?: boolean;
|
||||
initialBinding?: boolean;
|
||||
zoom?: AppState["zoom"];
|
||||
gridSize?: NullableGridSize;
|
||||
},
|
||||
): { start: BindingStrategy; end: BindingStrategy } => {
|
||||
@@ -702,7 +713,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
draggingPoints,
|
||||
elementsMap,
|
||||
elements,
|
||||
opts?.zoom,
|
||||
appState.zoom,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -727,7 +738,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
: globalPoint,
|
||||
elements,
|
||||
elementsMap,
|
||||
maxBindingDistance_simple(appState.zoom),
|
||||
appState.zoom,
|
||||
);
|
||||
const pointInElement =
|
||||
hit &&
|
||||
@@ -843,6 +854,21 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
}
|
||||
|
||||
// Handle normal cases
|
||||
const orbitPoint =
|
||||
hit && !pointInElement && !opts?.angleLocked && appState.gridModeEnabled
|
||||
? snapBoundPointToGrid(
|
||||
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
|
||||
hit,
|
||||
elementsMap,
|
||||
appState.gridSize as NullableGridSize,
|
||||
arrow,
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
startDragged ? 1 : -2,
|
||||
elementsMap,
|
||||
),
|
||||
)
|
||||
: globalPoint;
|
||||
const current: BindingStrategy = hit
|
||||
? pointInElement
|
||||
? {
|
||||
@@ -856,22 +882,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
focusPoint:
|
||||
projectFixedPointOntoDiagonal(
|
||||
arrow,
|
||||
opts?.angleLocked
|
||||
? globalPoint
|
||||
: appState.gridModeEnabled
|
||||
? snapBoundPointToGrid(
|
||||
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
|
||||
hit,
|
||||
elementsMap,
|
||||
appState.gridSize as NullableGridSize,
|
||||
arrow,
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
startDragged ? 1 : -2,
|
||||
elementsMap,
|
||||
),
|
||||
)
|
||||
: globalPoint,
|
||||
orbitPoint,
|
||||
hit,
|
||||
startDragged ? "start" : "end",
|
||||
elementsMap,
|
||||
@@ -879,7 +890,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
appState.isMidpointSnappingEnabled &&
|
||||
!opts?.angleLocked &&
|
||||
!appState.gridModeEnabled,
|
||||
) || globalPoint,
|
||||
) || orbitPoint,
|
||||
}
|
||||
: { mode: null };
|
||||
|
||||
@@ -1009,6 +1020,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_complex = (
|
||||
draggingPoints,
|
||||
elementsMap,
|
||||
elements,
|
||||
appState.zoom,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1050,6 +1062,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_complex = (
|
||||
elements,
|
||||
globalBindMode,
|
||||
arrow,
|
||||
appState.zoom,
|
||||
opts?.finalize,
|
||||
);
|
||||
|
||||
@@ -1074,6 +1087,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_complex = (
|
||||
elements,
|
||||
globalBindMode,
|
||||
arrow,
|
||||
appState.zoom,
|
||||
opts?.finalize,
|
||||
);
|
||||
|
||||
@@ -1132,6 +1146,7 @@ export const bindBindingElement = (
|
||||
mode: BindMode,
|
||||
startOrEnd: "start" | "end",
|
||||
scene: Scene,
|
||||
zoom: AppState["zoom"],
|
||||
focusPoint?: GlobalPoint,
|
||||
shouldSnapToOutline = true,
|
||||
isMidpointSnappingEnabled = true,
|
||||
@@ -1149,6 +1164,7 @@ export const bindBindingElement = (
|
||||
hoveredElement,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
zoom,
|
||||
shouldSnapToOutline,
|
||||
isMidpointSnappingEnabled,
|
||||
),
|
||||
@@ -1186,15 +1202,17 @@ export const unbindBindingElement = (
|
||||
arrow[startOrEnd === "start" ? "endBinding" : "startBinding"];
|
||||
if (!oppositeBinding || oppositeBinding.elementId !== binding.elementId) {
|
||||
// Only remove the record on the bound element if the other
|
||||
// end is not bound to the same element
|
||||
// end is not bound to the same element.
|
||||
const boundElement = scene
|
||||
.getNonDeletedElementsMap()
|
||||
.get(binding.elementId) as NonDeleted<ExcalidrawBindableElement>;
|
||||
scene.mutateElement(boundElement, {
|
||||
boundElements: boundElement.boundElements?.filter(
|
||||
(element) => element.id !== arrow.id,
|
||||
),
|
||||
});
|
||||
.getElementsMapIncludingDeleted()
|
||||
.get(binding.elementId);
|
||||
if (boundElement && !boundElement.isDeleted) {
|
||||
scene.mutateElement(boundElement, {
|
||||
boundElements: boundElement.boundElements?.filter(
|
||||
(element) => element.id !== arrow.id,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
scene.mutateElement(arrow, { [field]: null });
|
||||
@@ -1202,12 +1220,113 @@ export const unbindBindingElement = (
|
||||
return binding.elementId;
|
||||
};
|
||||
|
||||
export const reanchorBindingsToOutline = (
|
||||
changedElement: NonDeleted<ExcalidrawBindableElement>,
|
||||
scene: Scene,
|
||||
zoom: AppState["zoom"],
|
||||
) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const center = elementCenterPoint(changedElement, elementsMap);
|
||||
const reach = Math.max(changedElement.width, changedElement.height) * 2;
|
||||
|
||||
boundElementsVisitor(elementsMap, changedElement, (element) => {
|
||||
if (!isArrowElement(element) || !isNonDeletedElement(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: {
|
||||
startBinding?: FixedPointBinding;
|
||||
endBinding?: FixedPointBinding;
|
||||
} = {};
|
||||
|
||||
for (const startOrEnd of ["startBinding", "endBinding"] as const) {
|
||||
const binding = element[startOrEnd];
|
||||
if (binding?.elementId !== changedElement.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const startOrEndName = startOrEnd === "startBinding" ? "start" : "end";
|
||||
|
||||
if (isElbowArrow(element)) {
|
||||
updates[startOrEnd] = {
|
||||
...binding,
|
||||
...calculateFixedPointForElbowArrowBinding(
|
||||
element,
|
||||
changedElement,
|
||||
startOrEndName,
|
||||
elementsMap,
|
||||
zoom,
|
||||
),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
const focusPoint = getGlobalFixedPointForBindableElement(
|
||||
binding.fixedPoint,
|
||||
changedElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (
|
||||
hitElementItself({
|
||||
element: changedElement,
|
||||
point: focusPoint,
|
||||
elementsMap,
|
||||
threshold: getBindingGap(changedElement),
|
||||
overrideShouldTestInside: true,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const direction = vectorFromPoint(focusPoint, center);
|
||||
if (direction[0] === 0 && direction[1] === 0) {
|
||||
continue;
|
||||
}
|
||||
const outlinePoint = intersectElementWithLineSegment(
|
||||
changedElement,
|
||||
elementsMap,
|
||||
lineSegment(
|
||||
center,
|
||||
pointFromVector(
|
||||
vectorScale(vectorNormalize(direction), reach),
|
||||
center,
|
||||
),
|
||||
),
|
||||
).sort(
|
||||
(a, b) =>
|
||||
pointDistanceSq(a, focusPoint) - pointDistanceSq(b, focusPoint),
|
||||
)[0];
|
||||
|
||||
if (!outlinePoint) {
|
||||
continue;
|
||||
}
|
||||
|
||||
updates[startOrEnd] = {
|
||||
...binding,
|
||||
...calculateFixedPointForNonElbowArrowBinding(
|
||||
element,
|
||||
changedElement,
|
||||
startOrEndName,
|
||||
elementsMap,
|
||||
outlinePoint,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (updates.startBinding || updates.endBinding) {
|
||||
mutateElement(element, elementsMap, updates);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Supports translating, rotating and scaling `changedElement` with bound
|
||||
// linear elements.
|
||||
export const updateBoundElements = (
|
||||
changedElement: NonDeletedExcalidrawElement,
|
||||
scene: Scene,
|
||||
options?: {
|
||||
/** see `getSimultaneouslyUpdatedElementIds` for how to pass it */
|
||||
simultaneouslyUpdated?: readonly NonDeletedExcalidrawElement[];
|
||||
changedElements?: Map<string, ExcalidrawElement>;
|
||||
},
|
||||
@@ -1371,6 +1490,7 @@ const updateArrowBindings = (
|
||||
strategy[strategyName].mode,
|
||||
strategyName,
|
||||
scene,
|
||||
appState.zoom,
|
||||
strategy[strategyName].focusPoint,
|
||||
);
|
||||
}
|
||||
@@ -1426,10 +1546,33 @@ const doesNeedUpdate = (
|
||||
);
|
||||
};
|
||||
|
||||
const getSimultaneouslyUpdatedElementIds = (
|
||||
// PERF: callers updating many elements invoke `updateBoundElements()` for each
|
||||
// of them with the same `simultaneouslyUpdated` array, so cache the id set
|
||||
// per array to avoid rebuilding it on every call (O(n^2) on multi-element
|
||||
// transforms)
|
||||
const simultaneouslyUpdatedElementIdsCache = new WeakMap<
|
||||
readonly ExcalidrawElement[],
|
||||
ReadonlySet<ExcalidrawElement["id"]>
|
||||
>();
|
||||
|
||||
/**
|
||||
* The id set of `simultaneouslyUpdated`, shared by every `updateBoundElements()`
|
||||
* call given the same array. Pass one array instance for all elements of an
|
||||
* operation, and don't change its membership after the first lookup, or the
|
||||
* cached ids go stale.
|
||||
*/
|
||||
export const getSimultaneouslyUpdatedElementIds = (
|
||||
simultaneouslyUpdated: readonly ExcalidrawElement[] | undefined,
|
||||
): Set<ExcalidrawElement["id"]> => {
|
||||
return new Set((simultaneouslyUpdated || []).map((element) => element.id));
|
||||
): ReadonlySet<ExcalidrawElement["id"]> => {
|
||||
if (!simultaneouslyUpdated) {
|
||||
return new Set();
|
||||
}
|
||||
let ids = simultaneouslyUpdatedElementIdsCache.get(simultaneouslyUpdated);
|
||||
if (!ids) {
|
||||
ids = new Set(simultaneouslyUpdated.map((element) => element.id));
|
||||
simultaneouslyUpdatedElementIdsCache.set(simultaneouslyUpdated, ids);
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
|
||||
export const getHeadingForElbowArrowSnap = (
|
||||
@@ -1480,6 +1623,7 @@ export const bindPointToSnapToElementOutline = (
|
||||
bindableElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
customIntersector?: LineSegment<GlobalPoint>,
|
||||
isMidpointSnappingEnabled = true,
|
||||
): GlobalPoint => {
|
||||
@@ -1512,19 +1656,24 @@ export const bindPointToSnapToElementOutline = (
|
||||
startOrEnd === "start" ? 1 : -2,
|
||||
elementsMap,
|
||||
);
|
||||
const bindingGap = getBindingGap(bindableElement, arrowElement);
|
||||
const bindingGap = getBindingGap(bindableElement);
|
||||
const aabb = aabbForElement(bindableElement, elementsMap);
|
||||
const bindableCenter = getCenterForBounds(aabb);
|
||||
|
||||
let intersection: GlobalPoint | null = null;
|
||||
if (elbowed) {
|
||||
const isHorizontal = headingIsHorizontal(
|
||||
headingForPointFromElement(bindableElement, aabb, point),
|
||||
);
|
||||
const snapPoint = isMidpointSnappingEnabled
|
||||
? snapToMid(bindableElement, elementsMap, edgePoint, 0.05, arrowElement)
|
||||
const snap = isMidpointSnappingEnabled
|
||||
? getElbowArrowSnapMidPoint(edgePoint, bindableElement, elementsMap, zoom)
|
||||
: undefined;
|
||||
const resolved = snapPoint || point;
|
||||
const resolved = snap?.point ?? point;
|
||||
// A midpoint on the element's axes fixes the side to bind to. The pointer
|
||||
// can report another side, e.g. horizontal when slightly inside a
|
||||
// diamond's top vertex, which would bind next to the vertex.
|
||||
const isHorizontal = headingIsHorizontal(
|
||||
snap?.onAxis
|
||||
? vectorToHeading(vectorFromPoint(snap.point, bindableCenter))
|
||||
: headingForPointFromElement(bindableElement, aabb, point),
|
||||
);
|
||||
const otherPoint = pointFrom<GlobalPoint>(
|
||||
isHorizontal ? bindableCenter[0] : resolved[0],
|
||||
!isHorizontal ? bindableCenter[1] : resolved[1],
|
||||
@@ -1541,12 +1690,16 @@ export const bindPointToSnapToElementOutline = (
|
||||
otherPoint,
|
||||
),
|
||||
);
|
||||
// The ray can cross the outline twice (e.g. a thin rotated diamond), so
|
||||
// take the crossing nearest to the point we aim at
|
||||
const byDistanceToResolved = (a: GlobalPoint, b: GlobalPoint) =>
|
||||
pointDistanceSq(a, resolved) - pointDistanceSq(b, resolved);
|
||||
intersection = intersectElementWithLineSegment(
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
intersector,
|
||||
bindingGap,
|
||||
).sort(pointDistanceSq)[0];
|
||||
).sort(byDistanceToResolved)[0];
|
||||
|
||||
if (!intersection) {
|
||||
const anotherPoint = pointFrom<GlobalPoint>(
|
||||
@@ -1567,8 +1720,8 @@ export const bindPointToSnapToElementOutline = (
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
anotherIntersector,
|
||||
BASE_BINDING_GAP_ELBOW,
|
||||
).sort(pointDistanceSq)[0];
|
||||
BASE_BINDING_GAP,
|
||||
).sort(byDistanceToResolved)[0];
|
||||
}
|
||||
} else {
|
||||
let intersector = customIntersector;
|
||||
@@ -1626,7 +1779,7 @@ export const avoidRectangularCorner = (
|
||||
-bindTarget.angle as Radians,
|
||||
);
|
||||
|
||||
const bindingGap = getBindingGap(bindTarget, arrowElement);
|
||||
const bindingGap = getBindingGap(bindTarget);
|
||||
|
||||
if (nonRotatedPoint[0] < bindTarget.x && nonRotatedPoint[1] < bindTarget.y) {
|
||||
// Top left
|
||||
@@ -1704,121 +1857,6 @@ export const avoidRectangularCorner = (
|
||||
return p;
|
||||
};
|
||||
|
||||
export const snapToMid = (
|
||||
bindTarget: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
tolerance: number = 0.05,
|
||||
arrowElement?: ExcalidrawArrowElement,
|
||||
): GlobalPoint | undefined => {
|
||||
const { x, y, width, height, angle } = bindTarget;
|
||||
const center = elementCenterPoint(bindTarget, elementsMap, -0.1, -0.1);
|
||||
const nonRotated = pointRotateRads(p, center, -angle as Radians);
|
||||
|
||||
const bindingGap = arrowElement ? getBindingGap(bindTarget, arrowElement) : 0;
|
||||
|
||||
// snap-to-center point is adaptive to element size, but we don't want to go
|
||||
// above and below certain px distance
|
||||
const verticalThreshold = clamp(tolerance * height, 5, 80);
|
||||
const horizontalThreshold = clamp(tolerance * width, 5, 80);
|
||||
|
||||
// Too close to the center makes it hard to resolve direction precisely
|
||||
if (pointDistance(center, nonRotated) < bindingGap) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
nonRotated[0] <= x + width / 2 &&
|
||||
nonRotated[1] > center[1] - verticalThreshold &&
|
||||
nonRotated[1] < center[1] + verticalThreshold
|
||||
) {
|
||||
// LEFT
|
||||
return pointRotateRads(
|
||||
pointFrom<GlobalPoint>(x - bindingGap, center[1]),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
} else if (
|
||||
nonRotated[1] <= y + height / 2 &&
|
||||
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||
nonRotated[0] < center[0] + horizontalThreshold
|
||||
) {
|
||||
// TOP
|
||||
return pointRotateRads(
|
||||
pointFrom<GlobalPoint>(center[0], y - bindingGap),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
} else if (
|
||||
nonRotated[0] >= x + width / 2 &&
|
||||
nonRotated[1] > center[1] - verticalThreshold &&
|
||||
nonRotated[1] < center[1] + verticalThreshold
|
||||
) {
|
||||
// RIGHT
|
||||
return pointRotateRads(
|
||||
pointFrom<GlobalPoint>(x + width + bindingGap, center[1]),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
} else if (
|
||||
nonRotated[1] >= y + height / 2 &&
|
||||
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||
nonRotated[0] < center[0] + horizontalThreshold
|
||||
) {
|
||||
// DOWN
|
||||
return pointRotateRads(
|
||||
pointFrom<GlobalPoint>(center[0], y + height + bindingGap),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
} else if (bindTarget.type === "diamond") {
|
||||
const distance = bindingGap;
|
||||
const topLeft = pointFrom<GlobalPoint>(
|
||||
x + width / 4 - distance,
|
||||
y + height / 4 - distance,
|
||||
);
|
||||
const topRight = pointFrom<GlobalPoint>(
|
||||
x + (3 * width) / 4 + distance,
|
||||
y + height / 4 - distance,
|
||||
);
|
||||
const bottomLeft = pointFrom<GlobalPoint>(
|
||||
x + width / 4 - distance,
|
||||
y + (3 * height) / 4 + distance,
|
||||
);
|
||||
const bottomRight = pointFrom<GlobalPoint>(
|
||||
x + (3 * width) / 4 + distance,
|
||||
y + (3 * height) / 4 + distance,
|
||||
);
|
||||
|
||||
if (
|
||||
pointDistance(topLeft, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return pointRotateRads(topLeft, center, angle);
|
||||
}
|
||||
if (
|
||||
pointDistance(topRight, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return pointRotateRads(topRight, center, angle);
|
||||
}
|
||||
if (
|
||||
pointDistance(bottomLeft, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return pointRotateRads(bottomLeft, center, angle);
|
||||
}
|
||||
if (
|
||||
pointDistance(bottomRight, nonRotated) <
|
||||
Math.max(horizontalThreshold, verticalThreshold)
|
||||
) {
|
||||
return pointRotateRads(bottomRight, center, angle);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const extractBinding = (
|
||||
arrow: ExcalidrawArrowElement,
|
||||
startOrEnd: "startBinding" | "endBinding",
|
||||
@@ -1881,7 +1919,7 @@ const snapBoundPointToGrid = (
|
||||
? vectorToHeading(vectorFromPoint(adjacentPoint, outlinePoint))
|
||||
: headingForPointFromElement(bindableElement, aabb, outlinePoint);
|
||||
|
||||
const bindingGap = getBindingGap(bindableElement, arrowElement);
|
||||
const bindingGap = getBindingGap(bindableElement);
|
||||
const extent =
|
||||
Math.max(bindableElement.width, bindableElement.height) + bindingGap * 2;
|
||||
const center = getCenterForBounds(aabb);
|
||||
@@ -1999,7 +2037,7 @@ export const updateBoundPoint = (
|
||||
otherBindable,
|
||||
elementsMap,
|
||||
intersector,
|
||||
getBindingGap(otherBindable, arrow),
|
||||
getBindingGap(otherBindable),
|
||||
).sort(
|
||||
(a, b) => pointDistanceSq(a, focusPoint) - pointDistanceSq(b, focusPoint),
|
||||
)[0];
|
||||
@@ -2009,7 +2047,7 @@ export const updateBoundPoint = (
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
intersector,
|
||||
getBindingGap(bindableElement, arrow),
|
||||
getBindingGap(bindableElement),
|
||||
).sort(
|
||||
(a, b) =>
|
||||
pointDistanceSq(a, otherFocusPointOrArrowPoint) -
|
||||
@@ -2037,7 +2075,7 @@ export const updateBoundPoint = (
|
||||
element: otherBindable,
|
||||
point: outlinePoint,
|
||||
elementsMap,
|
||||
threshold: getBindingGap(otherBindable, arrow),
|
||||
threshold: getBindingGap(otherBindable),
|
||||
overrideShouldTestInside: true,
|
||||
})
|
||||
) {
|
||||
@@ -2098,6 +2136,7 @@ export const calculateFixedPointForElbowArrowBinding = (
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
shouldSnapToOutline = true,
|
||||
isMidpointSnappingEnabled = true,
|
||||
): { fixedPoint: FixedPoint } => {
|
||||
@@ -2113,6 +2152,7 @@ export const calculateFixedPointForElbowArrowBinding = (
|
||||
hoveredElement,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
zoom,
|
||||
undefined,
|
||||
isMidpointSnappingEnabled,
|
||||
)
|
||||
@@ -2145,7 +2185,7 @@ export const calculateFixedPointForElbowArrowBinding = (
|
||||
// elements smaller than the gap don't blow up the ratio: the snapped
|
||||
// point sits at most one gap-width outside the outline, so this keeps
|
||||
// the resulting fixedPoint bounded even for zero/near-zero-size elements.
|
||||
const sizeFloor = getBindingGap(hoveredElement, linearElement);
|
||||
const sizeFloor = getBindingGap(hoveredElement);
|
||||
|
||||
return {
|
||||
fixedPoint: normalizeFixedPoint([
|
||||
@@ -2195,7 +2235,7 @@ export const calculateFixedPointForNonElbowArrowBinding = (
|
||||
// elements smaller than the gap don't blow up the ratio: the bound
|
||||
// point sits at most one gap-width outside the outline, so this keeps
|
||||
// the resulting fixedPoint bounded even for zero/near-zero-size elements.
|
||||
const sizeFloor = getBindingGap(hoveredElement, linearElement);
|
||||
const sizeFloor = getBindingGap(hoveredElement);
|
||||
|
||||
// Calculate the ratio relative to the element's bounds
|
||||
const fixedPointX =
|
||||
@@ -2282,6 +2322,137 @@ export const fixDuplicatedBindingsAfterDuplication = (
|
||||
}
|
||||
};
|
||||
|
||||
export const repairBindings = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): OrderedExcalidrawElement[] => {
|
||||
const scene = new Scene(
|
||||
elements.map((element) => ({ ...element })),
|
||||
{ skipValidation: true },
|
||||
);
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const liveElements = scene.getNonDeletedElements();
|
||||
const arrows = liveElements.filter(isArrowElement);
|
||||
const update = (
|
||||
element: ExcalidrawElement,
|
||||
updates: ElementUpdate<ExcalidrawElement>,
|
||||
) => scene.mutateElement(element, updates);
|
||||
|
||||
// arrow side: drop bindings to non-bindable or missing targets, record the
|
||||
// arrow on the remaining ones
|
||||
for (const arrow of arrows) {
|
||||
for (const bindingProp of ["startBinding", "endBinding"] as const) {
|
||||
const binding = arrow[bindingProp];
|
||||
const target = binding && elementsMap.get(binding.elementId);
|
||||
if (target && !isBindableElement(target)) {
|
||||
scene.mutateElement(arrow, { [bindingProp]: null });
|
||||
}
|
||||
}
|
||||
BoundElement.rebindAffected(elementsMap, arrow, update);
|
||||
}
|
||||
|
||||
// target side: drop duplicate records and those of elements that do not
|
||||
// bind back
|
||||
for (const element of liveElements) {
|
||||
if (!isBindableElement(element) || !element.boundElements) {
|
||||
continue;
|
||||
}
|
||||
const seen = new Set<ExcalidrawElement["id"]>();
|
||||
const boundElements = element.boundElements.filter(({ id }) => {
|
||||
if (seen.has(id)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(id);
|
||||
const bound = elementsMap.get(id) ?? null;
|
||||
return isArrowElement(bound)
|
||||
? bound.startBinding?.elementId === element.id ||
|
||||
bound.endBinding?.elementId === element.id
|
||||
: isTextElement(bound) && bound.containerId === element.id;
|
||||
});
|
||||
if (boundElements.length !== element.boundElements.length) {
|
||||
update(element, { boundElements });
|
||||
}
|
||||
}
|
||||
|
||||
for (const arrow of arrows) {
|
||||
for (const startOrEnd of ["start", "end"] as const) {
|
||||
const binding = arrow[`${startOrEnd}Binding`];
|
||||
const target = binding
|
||||
? elementsMap.get(binding.elementId) ?? null
|
||||
: null;
|
||||
if (!binding || !isBindableElement(target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// complete bindings are kept (normalized), incomplete ones are
|
||||
// re-derived from the current endpoint (elbow arrows only ever orbit)
|
||||
const isValidMode = ["inside", "orbit", "skip"].includes(binding.mode);
|
||||
if (
|
||||
isFixedPoint(binding.fixedPoint) &&
|
||||
(isElbowArrow(arrow) ? binding.mode === "orbit" : isValidMode)
|
||||
) {
|
||||
const fixedPoint = normalizeFixedPoint(binding.fixedPoint);
|
||||
if (
|
||||
fixedPoint[0] !== binding.fixedPoint[0] ||
|
||||
fixedPoint[1] !== binding.fixedPoint[1]
|
||||
) {
|
||||
scene.mutateElement(arrow, {
|
||||
[`${startOrEnd}Binding`]: { ...binding, fixedPoint },
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
startOrEnd === "start" ? 0 : -1,
|
||||
elementsMap,
|
||||
);
|
||||
const mode = isValidMode
|
||||
? binding.mode
|
||||
: !isElbowArrow(arrow) && isPointInElement(point, target, elementsMap)
|
||||
? "inside"
|
||||
: "orbit";
|
||||
|
||||
bindBindingElement(
|
||||
arrow,
|
||||
target,
|
||||
mode,
|
||||
startOrEnd,
|
||||
scene,
|
||||
DEFAULT_ZOOM,
|
||||
point,
|
||||
);
|
||||
}
|
||||
|
||||
if (isElbowArrow(arrow)) {
|
||||
scene.mutateElement(arrow, {});
|
||||
}
|
||||
|
||||
// snap bound endpoints onto the target outline
|
||||
const pointUpdates: PointsPositionUpdates = new Map();
|
||||
for (const bindingProp of ["startBinding", "endBinding"] as const) {
|
||||
const binding = arrow[bindingProp];
|
||||
const target = binding
|
||||
? elementsMap.get(binding.elementId) ?? null
|
||||
: null;
|
||||
const point =
|
||||
isBindableElement(target) &&
|
||||
updateBoundPoint(arrow, bindingProp, binding, target, elementsMap);
|
||||
if (point) {
|
||||
pointUpdates.set(
|
||||
bindingProp === "startBinding" ? 0 : arrow.points.length - 1,
|
||||
{ point },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (pointUpdates.size) {
|
||||
LinearElementEditor.movePoints(arrow, scene, pointUpdates);
|
||||
}
|
||||
}
|
||||
|
||||
return scene.getElementsIncludingDeleted() as OrderedExcalidrawElement[];
|
||||
};
|
||||
|
||||
export const fixBindingsAfterDeletion = (
|
||||
sceneElements: readonly ExcalidrawElement[],
|
||||
deletedElements: readonly ExcalidrawElement[],
|
||||
@@ -2303,11 +2474,11 @@ const newBoundElements = (
|
||||
idsToRemove: Set<ExcalidrawElement["id"]>,
|
||||
elementsToAdd: Array<ExcalidrawElement> = [],
|
||||
) => {
|
||||
if (!boundElements) {
|
||||
if (!boundElements && !elementsToAdd.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextBoundElements = boundElements.filter(
|
||||
const nextBoundElements = (boundElements ?? []).filter(
|
||||
(boundElement) => !idsToRemove.has(boundElement.id),
|
||||
);
|
||||
|
||||
|
||||
@@ -420,6 +420,7 @@ const _isRectanguloidElement = (
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "stickynote" ||
|
||||
element.type === "image" ||
|
||||
element.type === "iframe" ||
|
||||
element.type === "embeddable" ||
|
||||
|
||||
+216
-141
@@ -8,6 +8,7 @@ import {
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
pointsEqual,
|
||||
polygonIncludesPoint,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
@@ -22,10 +23,11 @@ import type {
|
||||
Curve,
|
||||
GlobalPoint,
|
||||
LineSegment,
|
||||
LocalPoint,
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||
import type { AppState, FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { isPathALoop } from "./utils";
|
||||
import {
|
||||
@@ -40,6 +42,7 @@ import {
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isBindableElement,
|
||||
isBoundToContainer,
|
||||
isFrameLikeElement,
|
||||
isFreeDrawElement,
|
||||
isIframeLikeElement,
|
||||
@@ -53,19 +56,21 @@ import {
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getTextElementWithAccuratePosition,
|
||||
} from "./textElement";
|
||||
|
||||
import { distanceToElement } from "./distance";
|
||||
|
||||
import { getBindingGap } from "./binding";
|
||||
import { maxBindingDistance_simple } from "./binding";
|
||||
|
||||
import { hasBackground } from "./comparisons";
|
||||
|
||||
import { getFreedrawFillPolygon, getFreedrawMaxStrokeRadius } from "./shape";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
@@ -141,7 +146,10 @@ export const hitElementItself = ({
|
||||
// A cached hit stays valid for any larger threshold, while a cached miss
|
||||
// stays valid only for a threshold no larger than the cached one (a larger
|
||||
// threshold could turn a miss into a hit).
|
||||
// Skipped for container-bound labels — their position can change without
|
||||
// their version being bumped (see ElementBounds.getBounds).
|
||||
if (
|
||||
!isBoundToContainer(element) &&
|
||||
cachedPoint &&
|
||||
pointsEqual(point, cachedPoint) &&
|
||||
(cachedHit ? cachedThreshold <= threshold : cachedThreshold >= threshold) &&
|
||||
@@ -177,7 +185,10 @@ export const hitElementItself = ({
|
||||
point,
|
||||
bounds,
|
||||
element.angle,
|
||||
threshold,
|
||||
// Freedraw bounds follow the centerline points, but the ink extends past them
|
||||
isFreeDrawElement(element)
|
||||
? threshold + getFreedrawMaxStrokeRadius(element)
|
||||
: threshold,
|
||||
);
|
||||
|
||||
// PERF: Bail out early if the point is not even in the
|
||||
@@ -186,6 +197,15 @@ export const hitElementItself = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
// an arrow label's stored coords can be stale. The bounds test above
|
||||
// already accounts for it (getElementBounds derives the position
|
||||
// internally), but the precise test below reads the element coords
|
||||
// directly, so substitute them. Done after the early bail so the common
|
||||
// miss path stays allocation-free.
|
||||
if (isTextElement(element)) {
|
||||
element = getTextElementWithAccuratePosition(element, elementsMap);
|
||||
}
|
||||
|
||||
// Do the precise (and relatively costly) hit test
|
||||
const hitElement = (
|
||||
overrideShouldTestInside ? true : shouldTestInside(element)
|
||||
@@ -256,78 +276,101 @@ export const hitElementBoundText = (
|
||||
if (!boundTextElementCandidate) {
|
||||
return false;
|
||||
}
|
||||
const boundTextElement = isLinearElement(element)
|
||||
? {
|
||||
...boundTextElementCandidate,
|
||||
// arrow's bound text accurate position is not stored in the element's property
|
||||
// but rather calculated and returned from the following static method
|
||||
...LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElementCandidate,
|
||||
elementsMap,
|
||||
),
|
||||
}
|
||||
: boundTextElementCandidate;
|
||||
const boundTextElement = getTextElementWithAccuratePosition(
|
||||
boundTextElementCandidate,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
return isPointInElement(point, boundTextElement, elementsMap);
|
||||
};
|
||||
|
||||
const bindingBorderTest = (
|
||||
element: NonDeleted<ExcalidrawBindableElement>,
|
||||
[x, y]: Readonly<GlobalPoint>,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance: number = 0,
|
||||
): boolean => {
|
||||
const p = pointFrom<GlobalPoint>(x, y);
|
||||
const shouldTestInside =
|
||||
// disable fullshape snapping for frame elements so we
|
||||
// can bind to frame children
|
||||
!isFrameLikeElement(element);
|
||||
// Frame children are clipped to their enclosing frame, so a point outside
|
||||
// the frame cannot hit them
|
||||
const isPointClippedByEnclosingFrame = (
|
||||
element: ExcalidrawElement,
|
||||
point: Readonly<GlobalPoint>,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (!element.frameId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const enclosingFrame = elementsMap.get(element.frameId);
|
||||
|
||||
return (
|
||||
!!enclosingFrame &&
|
||||
isFrameLikeElement(enclosingFrame) &&
|
||||
!pointInsideBounds(point, getElementBounds(enclosingFrame, elementsMap))
|
||||
);
|
||||
};
|
||||
|
||||
const bindableElementBorderDistanceIfClose = (
|
||||
element: NonDeleted<ExcalidrawBindableElement>,
|
||||
point: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
tolerance: number = 0,
|
||||
) => {
|
||||
// PERF: Run a cheap test to see if the binding element
|
||||
// is even close to the element
|
||||
const [x, y] = point;
|
||||
const t = Math.max(1, tolerance);
|
||||
const bounds = [x - t, y - t, x + t, y + t] as Bounds;
|
||||
const elementBounds = getElementBounds(element, elementsMap);
|
||||
if (!doBoundsIntersect(bounds, elementBounds)) {
|
||||
return false;
|
||||
return -Infinity;
|
||||
}
|
||||
|
||||
// If the element is inside a frame, we should clip the element
|
||||
if (element.frameId) {
|
||||
const enclosingFrame = elementsMap.get(element.frameId);
|
||||
if (enclosingFrame && isFrameLikeElement(enclosingFrame)) {
|
||||
const enclosingFrameBounds = getElementBounds(
|
||||
enclosingFrame,
|
||||
elementsMap,
|
||||
);
|
||||
if (!pointInsideBounds(p, enclosingFrameBounds)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (isPointClippedByEnclosingFrame(element, point, elementsMap)) {
|
||||
return -Infinity;
|
||||
}
|
||||
|
||||
// Do the intersection test against the element since it's close enough
|
||||
const intersections = intersectElementWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
lineSegment(elementCenterPoint(element, elementsMap), p),
|
||||
);
|
||||
const distance = distanceToElement(element, elementsMap, p);
|
||||
const isInside = isPointInElement(point, element, elementsMap);
|
||||
// frames are only bindable from the outside, so arrows ending inside
|
||||
// a frame can bind to its children (or stay unbound)
|
||||
if (isInside && isFrameLikeElement(element)) {
|
||||
return -Infinity;
|
||||
}
|
||||
|
||||
return shouldTestInside
|
||||
? intersections.length === 0 || distance <= tolerance
|
||||
: intersections.length > 0 && distance <= t;
|
||||
const distance = distanceToElement(element, elementsMap, point);
|
||||
if (isInside) {
|
||||
return distance;
|
||||
}
|
||||
|
||||
return distance > tolerance ? -Infinity : -distance;
|
||||
};
|
||||
|
||||
export const getAllHoveredElementAtPoint = (
|
||||
type BindingCandidate = {
|
||||
element: NonDeleted<ExcalidrawBindableElement>;
|
||||
/** distance to the outline: positive inside, negative outside */
|
||||
distance: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the element hides what is behind it from binding. Images count as
|
||||
* opaque, and frames once they support a background.
|
||||
*/
|
||||
const isOpaqueForBinding = (element: ExcalidrawElement) =>
|
||||
isImageElement(element) ||
|
||||
(hasBackground(element.type) && !isTransparent(element.backgroundColor));
|
||||
|
||||
/**
|
||||
* Bindable elements within binding distance of the point, front to back.
|
||||
* Stops at the first opaque element containing the point, since it hides
|
||||
* everything behind it. Locked elements can't be bound to, but still hide
|
||||
* what is behind them.
|
||||
*/
|
||||
const getBindingCandidates = (
|
||||
point: Readonly<GlobalPoint>,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance?: number,
|
||||
): NonDeleted<ExcalidrawBindableElement>[] => {
|
||||
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
|
||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||
zoom: AppState["zoom"],
|
||||
): BindingCandidate[] => {
|
||||
const maxDistance = maxBindingDistance_simple(zoom);
|
||||
const candidates: BindingCandidate[] = [];
|
||||
// A frame's children sit just below it in z-order, so a frame's background
|
||||
// can't end the search: it only hides the non-children behind it
|
||||
let occludingFrameId: ExcalidrawElement["id"] | null = null;
|
||||
// We need to do hit testing from front (end of the array) to back (beginning of the array)
|
||||
// because array is ordered from lower z-index to highest and we want element z-index
|
||||
// with higher z-index
|
||||
for (let index = elements.length - 1; index >= 0; --index) {
|
||||
@@ -335,104 +378,109 @@ export const getAllHoveredElementAtPoint = (
|
||||
|
||||
invariant(
|
||||
!element.isDeleted,
|
||||
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
|
||||
"Elements passed to binding hit tests should not contain deleted elements",
|
||||
);
|
||||
|
||||
if (
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, point, elementsMap, tolerance)
|
||||
) {
|
||||
candidateElements.push(element);
|
||||
if (occludingFrameId && element.frameId !== occludingFrameId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isBindableElement(element)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFrameLikeElement(element)) {
|
||||
if (
|
||||
hasBackground(element.type) &&
|
||||
!isTransparent(element.backgroundColor)
|
||||
isOpaqueForBinding(element) &&
|
||||
isPointInElement(point, element, elementsMap)
|
||||
) {
|
||||
occludingFrameId = element.id;
|
||||
}
|
||||
}
|
||||
|
||||
const distance = bindableElementBorderDistanceIfClose(
|
||||
element,
|
||||
point,
|
||||
elementsMap,
|
||||
maxDistance,
|
||||
);
|
||||
|
||||
if (distance > -maxDistance) {
|
||||
if (!element.locked) {
|
||||
candidates.push({ element, distance });
|
||||
}
|
||||
|
||||
if (distance >= 0 && isOpaqueForBinding(element)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidateElements;
|
||||
return candidates;
|
||||
};
|
||||
|
||||
/**
|
||||
* All elements an arrow endpoint at the point could bind to. Always includes
|
||||
* the result of `getHoveredElementForBinding` for the same arguments.
|
||||
*/
|
||||
export const getAllHoveredElementAtPoint = (
|
||||
point: Readonly<GlobalPoint>,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
): NonDeleted<ExcalidrawBindableElement>[] =>
|
||||
getBindingCandidates(point, elements, elementsMap, zoom).map(
|
||||
({ element }) => element,
|
||||
);
|
||||
|
||||
export const getHoveredElementForBinding = (
|
||||
point: Readonly<GlobalPoint>,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance?: number,
|
||||
zoom: AppState["zoom"],
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
const candidateElements = getAllHoveredElementAtPoint(
|
||||
point,
|
||||
elements,
|
||||
elementsMap,
|
||||
tolerance,
|
||||
const candidates = getBindingCandidates(point, elements, elementsMap, zoom);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidates.length === 1) {
|
||||
return candidates[0].element;
|
||||
}
|
||||
|
||||
const closestElements = candidates.sort(
|
||||
(a, b) => Math.abs(a.distance) - Math.abs(b.distance),
|
||||
);
|
||||
|
||||
if (!candidateElements || candidateElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const candidate = closestElements[0];
|
||||
const [cx1, cy1, cx2, cy2] = getElementBounds(candidate.element, elementsMap);
|
||||
const candidateArea = Math.max(
|
||||
0.00001,
|
||||
Math.abs(cx2 - cx1) * Math.abs(cy2 - cy1),
|
||||
);
|
||||
// A smaller element overlapping the closest one takes precedence, but only
|
||||
// when the point is inside it: otherwise the closest outline wins, e.g. an
|
||||
// arrow ending just inside a container's edge next to a nested element
|
||||
const overlaps = closestElements
|
||||
.filter((c) => c.element !== candidate.element && c.distance >= 0)
|
||||
.map((c) => {
|
||||
const [x1, y1, x2, y2] = getElementBounds(c.element, elementsMap);
|
||||
const overlapWidth = Math.max(0, Math.min(x2, cx2) - Math.max(x1, cx1));
|
||||
const overlapHeight = Math.max(0, Math.min(y2, cy2) - Math.max(y1, cy1));
|
||||
const area = Math.max(0.00001, Math.abs(x2 - x1) * Math.abs(y2 - y1));
|
||||
|
||||
if (candidateElements.length === 1) {
|
||||
return candidateElements[0];
|
||||
}
|
||||
return {
|
||||
...c,
|
||||
overlapPercent: (overlapHeight * overlapWidth) / area,
|
||||
relativeArea: area / candidateArea,
|
||||
};
|
||||
})
|
||||
.filter((c) => c.overlapPercent > 0.25 && c.relativeArea < 0.75);
|
||||
|
||||
// Prefer smaller shapes
|
||||
return candidateElements
|
||||
.sort(
|
||||
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
|
||||
)
|
||||
.pop() as NonDeleted<ExcalidrawBindableElement>;
|
||||
};
|
||||
|
||||
export const getHoveredElementForFocusPoint = (
|
||||
point: GlobalPoint,
|
||||
arrow: ExcalidrawArrowElement,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance?: number,
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
|
||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||
// because array is ordered from lower z-index to highest and we want element z-index
|
||||
// with higher z-index
|
||||
for (let index = elements.length - 1; index >= 0; --index) {
|
||||
const element = elements[index];
|
||||
|
||||
invariant(
|
||||
!element.isDeleted,
|
||||
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
|
||||
);
|
||||
|
||||
if (
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, point, elementsMap, tolerance)
|
||||
) {
|
||||
candidateElements.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidateElements || candidateElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidateElements.length === 1) {
|
||||
return candidateElements[0];
|
||||
}
|
||||
|
||||
const distanceFilteredCandidateElements = candidateElements
|
||||
// Resolve by distance
|
||||
.filter(
|
||||
(el) =>
|
||||
distanceToElement(el, elementsMap, point) <= getBindingGap(el, arrow) ||
|
||||
isPointInElement(point, el, elementsMap),
|
||||
);
|
||||
|
||||
if (distanceFilteredCandidateElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return distanceFilteredCandidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
|
||||
return candidate.distance >= 0 && overlaps.length > 0
|
||||
? overlaps[0].element
|
||||
: candidate.element;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -467,6 +515,7 @@ export const intersectElementWithLineSegment = (
|
||||
// Do the actual intersection test against the element's shape
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "stickynote":
|
||||
case "image":
|
||||
case "text":
|
||||
case "iframe":
|
||||
@@ -508,6 +557,8 @@ export const intersectElementWithLineSegment = (
|
||||
}
|
||||
};
|
||||
|
||||
const CURVE_BOUNDS_EPSILON = 1e-6;
|
||||
|
||||
const curveIntersections = (
|
||||
curves: Curve<GlobalPoint>[],
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
@@ -516,15 +567,19 @@ const curveIntersections = (
|
||||
angle: Radians,
|
||||
onlyFirst = false,
|
||||
) => {
|
||||
// Pad the segment bounds so an axis-aligned segment passing exactly through
|
||||
// the joint of two curves still overlaps their bounds, as
|
||||
// `doBoundsIntersect` treats bounds that only touch as not intersecting
|
||||
const b2 = [
|
||||
Math.min(segment[0][0], segment[1][0]) - CURVE_BOUNDS_EPSILON,
|
||||
Math.min(segment[0][1], segment[1][1]) - CURVE_BOUNDS_EPSILON,
|
||||
Math.max(segment[0][0], segment[1][0]) + CURVE_BOUNDS_EPSILON,
|
||||
Math.max(segment[0][1], segment[1][1]) + CURVE_BOUNDS_EPSILON,
|
||||
] as Bounds;
|
||||
|
||||
for (const c of curves) {
|
||||
// Optimize by doing a cheap bounding box check first
|
||||
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
||||
const b2 = [
|
||||
Math.min(segment[0][0], segment[1][0]),
|
||||
Math.min(segment[0][1], segment[1][1]),
|
||||
Math.max(segment[0][0], segment[1][0]),
|
||||
Math.max(segment[0][1], segment[1][1]),
|
||||
] as Bounds;
|
||||
|
||||
if (!doBoundsIntersect(b1, b2)) {
|
||||
continue;
|
||||
@@ -784,6 +839,26 @@ export const isPointInElement = (
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isFreeDrawElement(element)) {
|
||||
// Test before the rotated-bounds check below: the smoothed fill can bulge
|
||||
// past the recorded points, and how much of it those bounds would clip
|
||||
// depends on the angle. An asymmetric loop's rotated bounds center is also
|
||||
// not its rotation pivot.
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap, true);
|
||||
const [px, py] = pointRotateRads(
|
||||
point,
|
||||
pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
// The fill follows the centerline rather than the stroke outline, and uses
|
||||
// the same even-odd rule as RoughJS's filled curves.
|
||||
return polygonIncludesPoint(
|
||||
pointFrom<LocalPoint>(px - element.x, py - element.y),
|
||||
getFreedrawFillPolygon(element),
|
||||
);
|
||||
}
|
||||
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||
|
||||
if (!isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2))) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
|
||||
|
||||
export const hasBackground = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
type === "stickynote" ||
|
||||
type === "iframe" ||
|
||||
type === "embeddable" ||
|
||||
type === "ellipse" ||
|
||||
@@ -12,8 +13,12 @@ export const hasBackground = (type: ElementOrToolType) =>
|
||||
// tool-only type; makes the `G` background shortcut work for bucket fill
|
||||
type === "bucketfill";
|
||||
|
||||
export const hasFillStyle = (type: ElementOrToolType) =>
|
||||
hasBackground(type) && type !== "stickynote";
|
||||
|
||||
export const hasStrokeColor = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
type === "stickynote" ||
|
||||
type === "ellipse" ||
|
||||
type === "diamond" ||
|
||||
type === "freedraw" ||
|
||||
@@ -44,6 +49,9 @@ export const hasStrokeStyle = (type: ElementOrToolType) =>
|
||||
type === "line" ||
|
||||
type === "autoshape";
|
||||
|
||||
export const hasRoughness = (type: ElementOrToolType) =>
|
||||
hasStrokeStyle(type) || type === "stickynote";
|
||||
|
||||
export const hasFreedrawMode = (type: ElementOrToolType) => type === "freedraw";
|
||||
|
||||
export const canChangeRoundness = (type: ElementOrToolType) =>
|
||||
@@ -52,6 +60,7 @@ export const canChangeRoundness = (type: ElementOrToolType) =>
|
||||
type === "embeddable" ||
|
||||
type === "line" ||
|
||||
type === "diamond" ||
|
||||
type === "stickynote" ||
|
||||
type === "image";
|
||||
|
||||
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
|
||||
|
||||
@@ -797,6 +797,10 @@ export const convertToShapeHandlePointerMoveFromPointerDown = (
|
||||
prevPreview?.type === shapePreview.type
|
||||
? prevPreview.id
|
||||
: shapePreview.id,
|
||||
created:
|
||||
prevPreview?.type === shapePreview.type
|
||||
? prevPreview.created
|
||||
: shapePreview.created,
|
||||
seed: 1,
|
||||
opacity: ELEMENT_PENDING_DRAW_SHAPE_OPACITY,
|
||||
isDeleted: false as const,
|
||||
|
||||
+177
-35
@@ -47,6 +47,7 @@ import { mutateElement, newElementWith } from "./mutateElement";
|
||||
import { getBoundTextElementId, redrawTextBoundingBox } from "./textElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isArrowElement,
|
||||
isBindableElement,
|
||||
isBoundToContainer,
|
||||
isTextElement,
|
||||
@@ -1442,6 +1443,48 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Whether this delta results in a visible change is decided from the
|
||||
// *applied* result, not from the property patches: binding repair and
|
||||
// layout (text bounding boxes, bound arrows) run below and can move
|
||||
// elements the delta never named, and an `isDeleted` flip or a
|
||||
// `boundElements` edit is not visible by itself — deleting a note's
|
||||
// empty label is the canonical case. History relies on this answer to
|
||||
// skip such entries on undo/redo (see `History`'s apply loop).
|
||||
//
|
||||
// `idsToCheck` is every element whose "before" and "after" get
|
||||
// compared: the changed elements plus what layout can move for them —
|
||||
// a text's container, and everything in `boundElements` (a container's
|
||||
// label and arrows). The set is walked as it grows, so an arrow added
|
||||
// for its container brings in its own label in turn. The dev/test
|
||||
// assertion after the redraw verifies the set is complete.
|
||||
const idsToCheck = new Set(changedElements.keys());
|
||||
// Unchanged elements are shared between `elements` and `nextElements`,
|
||||
// and layout mutates them in place — their "before" must be a shallow
|
||||
// copy taken now. Changed elements already have a fresh instance in
|
||||
// `nextElements`, so the instance in `elements` is their "before" as
|
||||
// is. Only `idsToCheck` is saved; the whole scene is never copied.
|
||||
const previousElements = new Map<string, OrderedExcalidrawElement>();
|
||||
for (const id of idsToCheck) {
|
||||
const previous = elements.get(id);
|
||||
const next = nextElements.get(id);
|
||||
for (const element of [previous, next]) {
|
||||
if (element) {
|
||||
if (isTextElement(element) && element.containerId) {
|
||||
idsToCheck.add(element.containerId);
|
||||
}
|
||||
for (const binding of element.boundElements ?? []) {
|
||||
idsToCheck.add(binding.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previous) {
|
||||
previousElements.set(
|
||||
id,
|
||||
previous === next ? { ...previous } : previous,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// the following reorder performs mutations, but only on new instances of changed elements,
|
||||
// unless something goes really bad and it fallbacks to fixing all invalid indices
|
||||
nextElements = ElementsDelta.reorderElements(
|
||||
@@ -1450,8 +1493,53 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
flags,
|
||||
);
|
||||
|
||||
// Guard for `idsToCheck`: layout bumps the version of everything it
|
||||
// mutates, so an element whose version moved during the redraw but was
|
||||
// never collected is a layout dependency the expansion above does not
|
||||
// know about — and a visible change that undo would silently skip.
|
||||
// Fail loudly instead (dev and tests only; this is a scene-wide pass).
|
||||
const versionsBeforeRedraw =
|
||||
isTestEnv() || isDevEnv()
|
||||
? new Map(
|
||||
Array.from(nextElements, ([id, element]) => [
|
||||
id,
|
||||
element.version,
|
||||
]),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
ElementsDelta.redrawElements(nextElements, changedElements);
|
||||
|
||||
if (versionsBeforeRedraw) {
|
||||
for (const [id, element] of nextElements) {
|
||||
if (
|
||||
versionsBeforeRedraw.get(id) !== element.version &&
|
||||
!idsToCheck.has(id)
|
||||
) {
|
||||
throw new Error(
|
||||
`Redrawn element "${id}" is missing from idsToCheck`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!flags.containsVisibleDifference) {
|
||||
for (const id of idsToCheck) {
|
||||
if (
|
||||
ElementsDelta.checkForVisibleDifference(
|
||||
previousElements.get(id),
|
||||
nextElements.get(id),
|
||||
previousElements,
|
||||
nextElements,
|
||||
)
|
||||
) {
|
||||
flags.containsVisibleDifference = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
flags.containsVisibleDifference = true;
|
||||
console.error(
|
||||
`Couldn't mutate elements after applying elements change`,
|
||||
e,
|
||||
@@ -1460,9 +1548,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
return [nextElements, flags.containsVisibleDifference];
|
||||
}
|
||||
|
||||
return [nextElements, flags.containsVisibleDifference];
|
||||
}
|
||||
|
||||
public squash(delta: ElementsDelta): this {
|
||||
@@ -1625,12 +1713,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
|
||||
if (element) {
|
||||
// as the element was brought from the snapshot, it automatically results in a possible zindex difference
|
||||
// (whether it is *visible* is decided in `applyTo`, from the applied result)
|
||||
flags.containsZindexDifference = true;
|
||||
|
||||
// as the element was force deleted, we need to check if adding it back results in a visible change
|
||||
if (!partial.isDeleted || (partial.isDeleted && !element.isDeleted)) {
|
||||
flags.containsVisibleDifference = true;
|
||||
}
|
||||
} else {
|
||||
// not in elements, not in snapshot? element might have been added remotely!
|
||||
element = newElementWith(
|
||||
@@ -1687,17 +1771,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
});
|
||||
}
|
||||
|
||||
if (!flags.containsVisibleDifference) {
|
||||
// strip away fractional index, as even if it would be different, it doesn't have to result in visible change
|
||||
const { index, ...rest } = directlyApplicablePartial;
|
||||
const containsVisibleDifference = ElementsDelta.checkForVisibleDifference(
|
||||
element,
|
||||
rest,
|
||||
);
|
||||
|
||||
flags.containsVisibleDifference = containsVisibleDifference;
|
||||
}
|
||||
|
||||
// visibility is not decided here: `applyTo` compares the applied result
|
||||
// once layout has run, so that its effects count too
|
||||
if (!flags.containsZindexDifference) {
|
||||
flags.containsZindexDifference =
|
||||
delta.deleted.index !== delta.inserted.index;
|
||||
@@ -1707,29 +1782,96 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for visible changes regardless of whether they were removed, added or updated.
|
||||
* Whether an element looks different after the delta — and the layout that
|
||||
* followed it — was applied. The input to history's "skip entries with no
|
||||
* visible change" loop.
|
||||
*
|
||||
* Both sides are first reduced to what actually paints:
|
||||
* - a missing or deleted element paints nothing;
|
||||
* - an empty text element paints nothing either — unless it carries arrow
|
||||
* bindings (an arrow may end at it) or sits on an arrow (an empty label
|
||||
* still cuts the arrow's gap);
|
||||
* so an empty label's deletion or restoration compares as nothing against
|
||||
* nothing. Two painting elements are then compared property by property:
|
||||
* - `version`, `versionNonce`, `updated`, `created` are metadata, and
|
||||
* `index` is covered by the separate z-order check (`reorderElements`);
|
||||
* - `boundElements` is compared as `type:id` references with references to
|
||||
* empty labels dropped, so adding or removing an empty label's reference
|
||||
* is not visible on its own, and binding repair that rebuilt equal
|
||||
* objects is not either;
|
||||
* - everything else counts. Nested arrays (`points`, binding fixed points)
|
||||
* compare by reference, which can only over-report a change — the safe
|
||||
* direction, where undo stops one entry early.
|
||||
*
|
||||
* `previousElements` holds only `idsToCheck` (see `applyTo`); the label
|
||||
* lookups it serves are for elements whose labels are in that set.
|
||||
*/
|
||||
private static checkForVisibleDifference(
|
||||
element: OrderedExcalidrawElement,
|
||||
partial: ElementPartial,
|
||||
previous: OrderedExcalidrawElement | undefined,
|
||||
next: OrderedExcalidrawElement | undefined,
|
||||
previousElements: ReadonlyMap<string, OrderedExcalidrawElement>,
|
||||
nextElements: ReadonlyMap<string, OrderedExcalidrawElement>,
|
||||
) {
|
||||
if (element.isDeleted && partial.isDeleted !== false) {
|
||||
// when it's deleted and partial is not false, it cannot end up with a visible change
|
||||
return false;
|
||||
const visibleElement = (
|
||||
element: OrderedExcalidrawElement | undefined,
|
||||
elements: ReadonlyMap<string, OrderedExcalidrawElement>,
|
||||
) => {
|
||||
if (!element || element.isDeleted) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
isTextElement(element) &&
|
||||
!element.text.trim() &&
|
||||
!element.boundElements?.length &&
|
||||
!isArrowElement(elements.get(element.containerId ?? ""))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
const before = visibleElement(previous, previousElements);
|
||||
const after = visibleElement(next, nextElements);
|
||||
if (!before || !after) {
|
||||
return before !== after;
|
||||
}
|
||||
|
||||
if (element.isDeleted && partial.isDeleted === false) {
|
||||
// when we add an element, it results in a visible change
|
||||
return true;
|
||||
}
|
||||
const visibleBindings = (
|
||||
element: OrderedExcalidrawElement,
|
||||
elements: ReadonlyMap<string, OrderedExcalidrawElement>,
|
||||
) =>
|
||||
(element.boundElements ?? [])
|
||||
.filter((binding) => {
|
||||
const label = elements.get(binding.id);
|
||||
return (
|
||||
binding.type !== "text" ||
|
||||
isArrowElement(element) ||
|
||||
!label ||
|
||||
!isTextElement(label) ||
|
||||
!!label.text.trim()
|
||||
);
|
||||
})
|
||||
.map(({ id, type }) => `${type}:${id}`);
|
||||
|
||||
if (element.isDeleted === false && partial.isDeleted) {
|
||||
// when we remove an element, it results in a visible change
|
||||
return true;
|
||||
}
|
||||
|
||||
// check for any difference on a visible element
|
||||
return Delta.isRightDifferent(element, partial);
|
||||
return Delta.getDifferences(before, after).some((key) => {
|
||||
switch (key) {
|
||||
// Ordering is checked separately; the remaining fields are metadata.
|
||||
case "index":
|
||||
case "version":
|
||||
case "versionNonce":
|
||||
case "updated":
|
||||
case "created":
|
||||
return false;
|
||||
case "boundElements":
|
||||
return !isShallowEqual(
|
||||
visibleBindings(before, previousElements),
|
||||
visibleBindings(after, nextElements),
|
||||
);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
curvePointDistance,
|
||||
distanceToLineSegment,
|
||||
pointRotateRads,
|
||||
polygonIncludesPointNonZero,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
||||
@@ -34,6 +35,7 @@ export const distanceToElement = (
|
||||
switch (element.type) {
|
||||
case "selection":
|
||||
case "rectangle":
|
||||
case "stickynote":
|
||||
case "image":
|
||||
case "text":
|
||||
case "iframe":
|
||||
@@ -47,8 +49,9 @@ export const distanceToElement = (
|
||||
return distanceToEllipseElement(element, elementsMap, p);
|
||||
case "line":
|
||||
case "arrow":
|
||||
case "freedraw":
|
||||
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
|
||||
case "freedraw":
|
||||
return distanceToFreeDrawElement(element, elementsMap, p);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -141,3 +144,30 @@ const distanceToLinearOrFreeDraElement = (
|
||||
...curves.map((a) => curvePointDistance(a, p)),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the distance of a point to a freedraw element.
|
||||
*
|
||||
* @param element The freedraw element
|
||||
* @param p The point to consider
|
||||
* @returns 0 if the point is within the inked area, otherwise the euclidean
|
||||
* distance to the stroke outline
|
||||
*/
|
||||
const distanceToFreeDrawElement = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
) => {
|
||||
const [lines] = deconstructLinearOrFreeDrawElement(element, elementsMap);
|
||||
|
||||
if (lines.length === 0) {
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
const polygon = lines.map((line) => line[0]);
|
||||
if (polygonIncludesPointNonZero(p, polygon)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(...lines.map((s) => distanceToLineSegment(p, s)));
|
||||
};
|
||||
|
||||
@@ -15,7 +15,11 @@ import type {
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { unbindBindingElement, updateBoundElements } from "./binding";
|
||||
import {
|
||||
getSimultaneouslyUpdatedElementIds,
|
||||
unbindBindingElement,
|
||||
updateBoundElements,
|
||||
} from "./binding";
|
||||
import { getCommonBounds } from "./bounds";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
@@ -103,23 +107,14 @@ export const dragSelectedElements = (
|
||||
gridSize,
|
||||
);
|
||||
|
||||
const elementsToUpdateIds = new Set(
|
||||
Array.from(elementsToUpdate, (el) => el.id),
|
||||
// one array for the whole update, so that every element's bound-arrow
|
||||
// update and the unbinding checks share its cached id set
|
||||
const elementsToUpdateArray = Array.from(elementsToUpdate);
|
||||
const elementsToUpdateIds = getSimultaneouslyUpdatedElementIds(
|
||||
elementsToUpdateArray,
|
||||
);
|
||||
|
||||
elementsToUpdate.forEach((element) => {
|
||||
const isArrow = !isArrowElement(element);
|
||||
const isStartBoundElementSelected =
|
||||
isArrow ||
|
||||
(element.startBinding
|
||||
? elementsToUpdateIds.has(element.startBinding.elementId)
|
||||
: false);
|
||||
const isEndBoundElementSelected =
|
||||
isArrow ||
|
||||
(element.endBinding
|
||||
? elementsToUpdateIds.has(element.endBinding.elementId)
|
||||
: false);
|
||||
|
||||
if (!isArrowElement(element)) {
|
||||
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||
|
||||
@@ -137,13 +132,12 @@ export const dragSelectedElements = (
|
||||
);
|
||||
}
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||
simultaneouslyUpdated: elementsToUpdateArray,
|
||||
});
|
||||
} else if (
|
||||
// NOTE: Add a little initial drag to the arrow dragging when the arrow
|
||||
// is the single element being dragged to avoid accidentally unbinding
|
||||
// the arrow when the user just wants to select it.
|
||||
|
||||
elementsToUpdate.size > 1 ||
|
||||
Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) >
|
||||
DRAGGING_THRESHOLD ||
|
||||
@@ -151,9 +145,12 @@ export const dragSelectedElements = (
|
||||
) {
|
||||
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||
|
||||
const shouldUnbindStart =
|
||||
element.startBinding && !isStartBoundElementSelected;
|
||||
const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected;
|
||||
const shouldUnbindStart = element.startBinding
|
||||
? !elementsToUpdateIds.has(element.startBinding.elementId)
|
||||
: true;
|
||||
const shouldUnbindEnd = element.endBinding
|
||||
? !elementsToUpdateIds.has(element.endBinding.elementId)
|
||||
: true;
|
||||
if (shouldUnbindStart || shouldUnbindEnd) {
|
||||
// NOTE: Moving the bound arrow should unbind it, otherwise we would
|
||||
// have weird situations, like 0 lenght arrow when the user moves
|
||||
|
||||
@@ -30,6 +30,7 @@ import { bumpVersion } from "./mutateElement";
|
||||
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isFrameLikeElement,
|
||||
} from "./typeChecks";
|
||||
@@ -38,16 +39,54 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
|
||||
import { fixDuplicatedBindingsAfterDuplication } from "./binding";
|
||||
|
||||
import { ShapeCache } from "./shape";
|
||||
|
||||
import { isNonDeletedElement } from ".";
|
||||
|
||||
import type { ElementUpdate } from "./mutateElement";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElement,
|
||||
GroupId,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Lookups supplied to the host's `props.onDuplicate`, covering just the
|
||||
* elements taking part in the duplication.
|
||||
*/
|
||||
export type OnDuplicateData = {
|
||||
/** the duplicates, by their id */
|
||||
duplicateElements: ReadonlyMap<ExcalidrawElement["id"], ExcalidrawElement>;
|
||||
/**
|
||||
* The elements the duplicates were made from, by their id.
|
||||
*
|
||||
* On paste and library insert these are the inserted elements, which aren't
|
||||
* part of the scene (though they may share ids with the scene elements they
|
||||
* were copied from).
|
||||
*/
|
||||
originalElements: ReadonlyMap<ExcalidrawElement["id"], ExcalidrawElement>;
|
||||
/**
|
||||
* id of an original -> id of its duplicate (e.g. to remap element ids you
|
||||
* keep in `customData`, which the duplicate copied from its original)
|
||||
*/
|
||||
origIdToDuplicateId: ReadonlyMap<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement["id"]
|
||||
>;
|
||||
/**
|
||||
* id of a duplicate -> id of its original (e.g. to look up the original in
|
||||
* `originalElements` while modifying the duplicate)
|
||||
*/
|
||||
duplicateIdToOrigId: ReadonlyMap<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement["id"]
|
||||
>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Duplicate an element, often used in the alt-drag operation.
|
||||
* Note that this method has gotten a bit complicated since the
|
||||
@@ -75,6 +114,7 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
|
||||
|
||||
copy.id = randomId();
|
||||
copy.updated = getUpdatedTimestamp();
|
||||
copy.created = copy.updated;
|
||||
if (randomizeSeed) {
|
||||
copy.seed = randomInteger();
|
||||
bumpVersion(copy);
|
||||
@@ -166,11 +206,12 @@ export const duplicateElements = (
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement["id"]
|
||||
>();
|
||||
const duplicateIdToOrigElement = new Map<
|
||||
const duplicateIdToOrigId = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement
|
||||
ExcalidrawElement["id"]
|
||||
>();
|
||||
const duplicateElementsMap = new Map<string, NonDeletedExcalidrawElement>();
|
||||
const origElementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
|
||||
const elementsMap = arrayToMap(elements) as ElementsMap;
|
||||
const _idsOfElementsToDuplicate =
|
||||
opts.type === "in-place"
|
||||
@@ -230,8 +271,9 @@ export const duplicateElements = (
|
||||
processedIds.set(newElement.id, true);
|
||||
|
||||
duplicateElementsMap.set(newElement.id, newElement);
|
||||
origElementsMap.set(element.id, element);
|
||||
origIdToDuplicateId.set(element.id, newElement.id);
|
||||
duplicateIdToOrigElement.set(newElement.id, element);
|
||||
duplicateIdToOrigId.set(newElement.id, element.id);
|
||||
|
||||
origElements.push(element);
|
||||
duplicatedElements.push(newElement);
|
||||
@@ -410,7 +452,9 @@ export const duplicateElements = (
|
||||
|
||||
if (opts.overrides) {
|
||||
for (const duplicateElement of duplicatedElements) {
|
||||
const origElement = duplicateIdToOrigElement.get(duplicateElement.id);
|
||||
const origElement = origElementsMap.get(
|
||||
duplicateIdToOrigId.get(duplicateElement.id)!,
|
||||
);
|
||||
if (origElement) {
|
||||
Object.assign(
|
||||
duplicateElement,
|
||||
@@ -427,11 +471,150 @@ export const duplicateElements = (
|
||||
return {
|
||||
duplicatedElements,
|
||||
duplicateElementsMap,
|
||||
origElementsMap,
|
||||
elementsWithDuplicates,
|
||||
origIdToDuplicateId,
|
||||
duplicateIdToOrigId,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Folds the elements returned by the host (`props.onDuplicate`) back into the
|
||||
* duplicates the editor created, so that everything that follows (frame
|
||||
* assignment, bound text redraw, selection, alt-drag handover) can keep
|
||||
* working with the editor's own objects, whether the host mutated the
|
||||
* duplicates or returned new objects for them.
|
||||
*
|
||||
* - A returned element with a duplicate's id is shallow-merged into that
|
||||
* duplicate, which takes its place in the returned array. Properties the
|
||||
* host omits are kept, so that a partial element can't invalidate the
|
||||
* duplicate. Safe only because the duplicates are fresh (not in the scene or
|
||||
* the store snapshot yet), which is why nothing but the passed duplicates is
|
||||
* ever merged into. Since the merge goes around `mutateElement`, what may
|
||||
* have been cached for the duplicate by then is invalidated here.
|
||||
* - A duplicate missing from the returned array (or returned as deleted) is
|
||||
* vetoed. So is the bound text of a vetoed container. What the remaining
|
||||
* duplicates reference of the vetoed ones is cleared, as if those were never
|
||||
* part of the duplication (see `fixDuplicatedBindingsAfterDuplication`).
|
||||
* - Any other returned element is used as is (existing elements must not be
|
||||
* mutated, so the host replaces them).
|
||||
* - `false` vetoes all the duplicates.
|
||||
*
|
||||
* @returns next elements, and the duplicates that weren't vetoed
|
||||
*/
|
||||
export const reconcileDuplicatedElements = <
|
||||
TDuplicate extends ExcalidrawElement,
|
||||
>(
|
||||
/** what the host returned from `props.onDuplicate`, if anything */
|
||||
hostElements: readonly ExcalidrawElement[] | void | false,
|
||||
/** elements that were passed to the host */
|
||||
nextElements: ExcalidrawElement[],
|
||||
duplicatedElements: TDuplicate[],
|
||||
): {
|
||||
elements: ExcalidrawElement[];
|
||||
duplicatedElements: TDuplicate[];
|
||||
} => {
|
||||
if (hostElements === false) {
|
||||
return { elements: nextElements, duplicatedElements: [] };
|
||||
}
|
||||
|
||||
if (!hostElements) {
|
||||
return { elements: nextElements, duplicatedElements };
|
||||
}
|
||||
|
||||
const duplicatesMap = arrayToMap(duplicatedElements);
|
||||
// (if a duplicate is returned more than once, the last one wins)
|
||||
const hostDuplicates = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
|
||||
|
||||
for (const element of hostElements) {
|
||||
if (duplicatesMap.has(element.id)) {
|
||||
hostDuplicates.set(element.id, element);
|
||||
}
|
||||
}
|
||||
|
||||
// merge first, so that everything below sees the duplicates as the host
|
||||
// wants them (what the host returned may be partial)
|
||||
const survivedIds = new Set<ExcalidrawElement["id"]>();
|
||||
|
||||
for (const [id, element] of hostDuplicates) {
|
||||
const duplicate = duplicatesMap.get(id)!;
|
||||
|
||||
if (element !== duplicate) {
|
||||
Object.assign(duplicate, element);
|
||||
// The duplicate may have been measured already (e.g. to resolve the
|
||||
// frame it's pasted into), and we're going around `mutateElement`.
|
||||
// The shape is cached by identity, the bounds by version.
|
||||
ShapeCache.delete(duplicate);
|
||||
bumpVersion(duplicate);
|
||||
}
|
||||
|
||||
if (!duplicate.isDeleted) {
|
||||
survivedIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const isVetoed = (id: ExcalidrawElement["id"]) =>
|
||||
duplicatesMap.has(id) && !survivedIds.has(id);
|
||||
|
||||
for (const id of survivedIds) {
|
||||
const duplicate = duplicatesMap.get(id)!;
|
||||
if (isBoundToContainer(duplicate) && isVetoed(duplicate.containerId)) {
|
||||
survivedIds.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const elements: ExcalidrawElement[] = [];
|
||||
|
||||
for (const element of hostElements) {
|
||||
const duplicate = duplicatesMap.get(element.id);
|
||||
|
||||
if (!duplicate) {
|
||||
elements.push(element);
|
||||
} else if (
|
||||
survivedIds.has(element.id) &&
|
||||
hostDuplicates.get(element.id) === element
|
||||
) {
|
||||
elements.push(duplicate);
|
||||
}
|
||||
}
|
||||
|
||||
if (survivedIds.size === duplicatedElements.length) {
|
||||
return { elements, duplicatedElements };
|
||||
}
|
||||
|
||||
const survivedDuplicates = duplicatedElements.filter((duplicate) =>
|
||||
survivedIds.has(duplicate.id),
|
||||
);
|
||||
|
||||
for (const duplicate of survivedDuplicates) {
|
||||
const updates: Mutable<ElementUpdate<ExcalidrawArrowElement>> = {};
|
||||
|
||||
if (duplicate.boundElements?.some((binding) => isVetoed(binding.id))) {
|
||||
updates.boundElements = duplicate.boundElements.filter(
|
||||
(binding) => !isVetoed(binding.id),
|
||||
);
|
||||
}
|
||||
if (duplicate.frameId && isVetoed(duplicate.frameId)) {
|
||||
updates.frameId = null;
|
||||
}
|
||||
if (isArrowElement(duplicate)) {
|
||||
if (
|
||||
duplicate.startBinding &&
|
||||
isVetoed(duplicate.startBinding.elementId)
|
||||
) {
|
||||
updates.startBinding = null;
|
||||
}
|
||||
if (duplicate.endBinding && isVetoed(duplicate.endBinding.elementId)) {
|
||||
updates.endBinding = null;
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(duplicate, updates);
|
||||
}
|
||||
|
||||
return { elements, duplicatedElements: survivedDuplicates };
|
||||
};
|
||||
|
||||
// Simplified deep clone for the purpose of cloning ExcalidrawElement.
|
||||
//
|
||||
// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getSizeFromPoints,
|
||||
isDevEnv,
|
||||
arrayToMap,
|
||||
DEFAULT_ZOOM,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
@@ -30,8 +31,7 @@ import {
|
||||
getHeadingForElbowArrowSnap,
|
||||
getGlobalFixedPointForBindableElement,
|
||||
getBindingGap,
|
||||
maxBindingDistance_simple,
|
||||
BASE_BINDING_GAP_ELBOW,
|
||||
BASE_BINDING_GAP,
|
||||
} from "./binding";
|
||||
import { distanceToElement } from "./distance";
|
||||
import {
|
||||
@@ -115,7 +115,7 @@ const handleSegmentRenormalization = (
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
) => {
|
||||
const nextFixedSegments: FixedSegment[] | null = arrow.fixedSegments
|
||||
? arrow.fixedSegments.slice()
|
||||
? arrow.fixedSegments.map((segment) => ({ ...segment }))
|
||||
: null;
|
||||
|
||||
if (nextFixedSegments) {
|
||||
@@ -1217,24 +1217,20 @@ const getElbowArrowData = (
|
||||
GlobalPoint
|
||||
>(nextPoints[nextPoints.length - 1], vector(arrow.x, arrow.y));
|
||||
|
||||
// NOTE: updates coming through `mutateElement` don't carry the zoom, so
|
||||
// those fall back to the zoom=1 binding distance
|
||||
const zoom = options?.zoom ?? DEFAULT_ZOOM;
|
||||
|
||||
let hoveredStartElement = null;
|
||||
let hoveredEndElement = null;
|
||||
if (options?.isDragging && options?.isBindingEnabled !== false) {
|
||||
const elements = Array.from(elementsMap.values());
|
||||
hoveredStartElement =
|
||||
getHoveredElement(
|
||||
origStartGlobalPoint,
|
||||
elementsMap,
|
||||
elements,
|
||||
options?.zoom,
|
||||
) || null;
|
||||
getHoveredElement(origStartGlobalPoint, elementsMap, elements, zoom) ||
|
||||
null;
|
||||
hoveredEndElement =
|
||||
getHoveredElement(
|
||||
origEndGlobalPoint,
|
||||
elementsMap,
|
||||
elements,
|
||||
options?.zoom,
|
||||
) || null;
|
||||
getHoveredElement(origEndGlobalPoint, elementsMap, elements, zoom) ||
|
||||
null;
|
||||
} else {
|
||||
hoveredStartElement = arrow.startBinding
|
||||
? getBindableElementForId(arrow.startBinding.elementId, elementsMap) ||
|
||||
@@ -1256,6 +1252,7 @@ const getElbowArrowData = (
|
||||
"start",
|
||||
arrow.startBinding?.fixedPoint,
|
||||
origStartGlobalPoint,
|
||||
zoom,
|
||||
hoveredStartElement,
|
||||
elementsMap,
|
||||
options?.isDragging,
|
||||
@@ -1273,6 +1270,7 @@ const getElbowArrowData = (
|
||||
"end",
|
||||
arrow.endBinding?.fixedPoint,
|
||||
origEndGlobalPoint,
|
||||
zoom,
|
||||
hoveredEndElement,
|
||||
elementsMap,
|
||||
options?.isDragging,
|
||||
@@ -1285,7 +1283,7 @@ const getElbowArrowData = (
|
||||
hoveredStartElement,
|
||||
origStartGlobalPoint,
|
||||
elementsMap,
|
||||
options?.zoom,
|
||||
zoom,
|
||||
);
|
||||
const endHeading = getBindPointHeading(
|
||||
endGlobalPoint,
|
||||
@@ -1293,7 +1291,7 @@ const getElbowArrowData = (
|
||||
hoveredEndElement,
|
||||
origEndGlobalPoint,
|
||||
elementsMap,
|
||||
options?.zoom,
|
||||
zoom,
|
||||
);
|
||||
const startPointBounds = [
|
||||
startGlobalPoint[0] - 2,
|
||||
@@ -1314,8 +1312,8 @@ const getElbowArrowData = (
|
||||
offsetFromHeading(
|
||||
startHeading,
|
||||
arrow.startArrowhead
|
||||
? getBindingGap(hoveredStartElement, { elbowed: true }) * 6
|
||||
: getBindingGap(hoveredStartElement, { elbowed: true }) * 2,
|
||||
? getBindingGap(hoveredStartElement) * 6
|
||||
: getBindingGap(hoveredStartElement) * 2,
|
||||
1,
|
||||
),
|
||||
)
|
||||
@@ -1327,8 +1325,8 @@ const getElbowArrowData = (
|
||||
offsetFromHeading(
|
||||
endHeading,
|
||||
arrow.endArrowhead
|
||||
? getBindingGap(hoveredEndElement, { elbowed: true }) * 6
|
||||
: getBindingGap(hoveredEndElement, { elbowed: true }) * 2,
|
||||
? getBindingGap(hoveredEndElement) * 6
|
||||
: getBindingGap(hoveredEndElement) * 2,
|
||||
1,
|
||||
),
|
||||
)
|
||||
@@ -1375,8 +1373,8 @@ const getElbowArrowData = (
|
||||
? 0
|
||||
: BASE_PADDING -
|
||||
(arrow.startArrowhead
|
||||
? BASE_BINDING_GAP_ELBOW * 6
|
||||
: BASE_BINDING_GAP_ELBOW * 2),
|
||||
? BASE_BINDING_GAP * 6
|
||||
: BASE_BINDING_GAP * 2),
|
||||
BASE_PADDING,
|
||||
),
|
||||
boundsOverlap
|
||||
@@ -1391,8 +1389,8 @@ const getElbowArrowData = (
|
||||
? 0
|
||||
: BASE_PADDING -
|
||||
(arrow.endArrowhead
|
||||
? BASE_BINDING_GAP_ELBOW * 6
|
||||
: BASE_BINDING_GAP_ELBOW * 2),
|
||||
? BASE_BINDING_GAP * 6
|
||||
: BASE_BINDING_GAP * 2),
|
||||
BASE_PADDING,
|
||||
),
|
||||
boundsOverlap,
|
||||
@@ -2218,6 +2216,7 @@ const getGlobalPoint = (
|
||||
startOrEnd: "start" | "end",
|
||||
fixedPointRatio: [number, number] | undefined | null,
|
||||
initialPoint: GlobalPoint,
|
||||
zoom: AppState["zoom"],
|
||||
element?: ExcalidrawBindableElement | null,
|
||||
elementsMap?: ElementsMap,
|
||||
isDragging?: boolean,
|
||||
@@ -2231,6 +2230,7 @@ const getGlobalPoint = (
|
||||
element,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
zoom,
|
||||
undefined,
|
||||
isMidpointSnappingEnabled,
|
||||
);
|
||||
@@ -2282,14 +2282,9 @@ const getHoveredElement = (
|
||||
origPoint: GlobalPoint,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
zoom?: AppState["zoom"],
|
||||
zoom: AppState["zoom"],
|
||||
) => {
|
||||
return getHoveredElementForBinding(
|
||||
origPoint,
|
||||
elements,
|
||||
elementsMap,
|
||||
maxBindingDistance_simple(zoom),
|
||||
);
|
||||
return getHoveredElementForBinding(origPoint, elements, elementsMap, zoom);
|
||||
};
|
||||
|
||||
const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
|
||||
|
||||
@@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
||||
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
||||
|
||||
const RE_YOUTUBE =
|
||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
|
||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|live\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
|
||||
|
||||
const RE_VIMEO =
|
||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||
|
||||
@@ -25,7 +25,11 @@ import {
|
||||
} from "./heading";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { newArrowElement, newElement } from "./newElement";
|
||||
import {
|
||||
newArrowElement,
|
||||
newElement,
|
||||
newStickyNoteElement,
|
||||
} from "./newElement";
|
||||
import { aabbForElement } from "./bounds";
|
||||
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
|
||||
import {
|
||||
@@ -230,8 +234,7 @@ const cloneFlowchartNode = (
|
||||
x: number,
|
||||
y: number,
|
||||
) => {
|
||||
const node = newElement({
|
||||
type: template.type,
|
||||
const commonNodeProps = {
|
||||
x,
|
||||
y,
|
||||
width: template.width,
|
||||
@@ -244,7 +247,19 @@ const cloneFlowchartNode = (
|
||||
opacity: template.opacity,
|
||||
fillStyle: template.fillStyle,
|
||||
strokeStyle: template.strokeStyle,
|
||||
});
|
||||
};
|
||||
|
||||
const node =
|
||||
template.type === "stickynote"
|
||||
? newStickyNoteElement({
|
||||
type: "stickynote",
|
||||
...commonNodeProps,
|
||||
baseHeight: template.baseHeight,
|
||||
})
|
||||
: newElement({
|
||||
type: template.type,
|
||||
...commonNodeProps,
|
||||
});
|
||||
|
||||
invariant(
|
||||
isFlowchartNodeElement(node),
|
||||
@@ -254,7 +269,7 @@ const cloneFlowchartNode = (
|
||||
return node;
|
||||
};
|
||||
|
||||
const addNewNodes = (
|
||||
export const addNewNodes = (
|
||||
startNode: NonDeleted<ExcalidrawFlowchartNodeElement>,
|
||||
appState: AppState,
|
||||
direction: LinkDirection,
|
||||
@@ -376,8 +391,16 @@ const createBindingArrow = (
|
||||
"orbit",
|
||||
"start",
|
||||
scene,
|
||||
appState.zoom,
|
||||
);
|
||||
bindBindingElement(
|
||||
bindingArrow,
|
||||
endBindingElement,
|
||||
"orbit",
|
||||
"end",
|
||||
scene,
|
||||
appState.zoom,
|
||||
);
|
||||
bindBindingElement(bindingArrow, endBindingElement, "orbit", "end", scene);
|
||||
|
||||
const changedElements = new Map<string, OrderedExcalidrawElement>();
|
||||
changedElements.set(
|
||||
|
||||
@@ -94,6 +94,7 @@ export * from "./showSelectedShapeActions";
|
||||
export * from "./sizeHelpers";
|
||||
export * from "./sortElements";
|
||||
export * from "./store";
|
||||
export * from "./stickyNote";
|
||||
export * from "./textElement";
|
||||
export * from "./textMeasurements";
|
||||
export * from "./textWrapping";
|
||||
|
||||
@@ -8,7 +8,14 @@ import {
|
||||
pointDistance,
|
||||
vectorFromPoint,
|
||||
curveLength,
|
||||
curveLengthAtParameter,
|
||||
curvePointAtLength,
|
||||
lineSegmentClosestParameter,
|
||||
lineSegmentPointAt,
|
||||
curveClosestParameter,
|
||||
clamp,
|
||||
bezierEquation,
|
||||
isCurve,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||
@@ -30,8 +37,11 @@ import {
|
||||
moveArrowAboveBindable,
|
||||
projectFixedPointOntoDiagonal,
|
||||
type Store,
|
||||
getLinearElementPathSegments,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { LinearPathSegment } from "@excalidraw/element";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
@@ -48,7 +58,6 @@ import {
|
||||
calculateFixedPointForNonElbowArrowBinding,
|
||||
getBindingStrategyForDraggingBindingElementEndpoints,
|
||||
isBindingEnabled,
|
||||
snapToMid,
|
||||
updateBoundPoint,
|
||||
} from "./binding";
|
||||
import {
|
||||
@@ -122,6 +131,15 @@ type PointMoveOtherUpdates = {
|
||||
suggestedBinding?: AppState["suggestedBinding"] | null;
|
||||
};
|
||||
|
||||
const BoundTextPositionCache = new WeakMap<
|
||||
ExcalidrawTextElementWithContainer,
|
||||
{
|
||||
containerVersion: ExcalidrawElement["version"];
|
||||
textVersion: ExcalidrawElement["version"];
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
>();
|
||||
|
||||
export class LinearElementEditor {
|
||||
public readonly elementId: ExcalidrawElement["id"] & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
@@ -346,7 +364,7 @@ export class LinearElementEditor {
|
||||
|
||||
// Apply the point movement if needed
|
||||
let suggestedBinding: AppState["suggestedBinding"] = null;
|
||||
const { positions, updates } = pointDraggingUpdates(
|
||||
const { positions, updates, hit } = pointDraggingUpdates(
|
||||
[idx],
|
||||
deltaX,
|
||||
deltaY,
|
||||
@@ -388,17 +406,20 @@ export class LinearElementEditor {
|
||||
|
||||
// Move the arrow over the bindable object in terms of z-index
|
||||
if (isBindingElement(element)) {
|
||||
moveArrowAboveBindable(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
if (hit) {
|
||||
moveArrowAboveBindable(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[element.points.length - 1],
|
||||
elementsMap,
|
||||
),
|
||||
element,
|
||||
element.points[element.points.length - 1],
|
||||
elements,
|
||||
elementsMap,
|
||||
),
|
||||
element,
|
||||
elements,
|
||||
elementsMap,
|
||||
app.scene,
|
||||
);
|
||||
app.scene,
|
||||
hit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PERF: Avoid state updates if not absolutely necessary
|
||||
@@ -547,7 +568,7 @@ export class LinearElementEditor {
|
||||
|
||||
// Apply the point movement if needed
|
||||
let suggestedBinding: AppState["suggestedBinding"] = null;
|
||||
const { positions, updates } = pointDraggingUpdates(
|
||||
const { positions, updates, hit } = pointDraggingUpdates(
|
||||
selectedPointsIndices,
|
||||
deltaX,
|
||||
deltaY,
|
||||
@@ -591,19 +612,22 @@ export class LinearElementEditor {
|
||||
|
||||
// Move the arrow over the bindable object in terms of z-index
|
||||
if (isBindingElement(element) && startIsSelected !== endIsSelected) {
|
||||
moveArrowAboveBindable(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
if (hit) {
|
||||
moveArrowAboveBindable(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
startIsSelected
|
||||
? element.points[0]
|
||||
: element.points[element.points.length - 1],
|
||||
elementsMap,
|
||||
),
|
||||
element,
|
||||
startIsSelected
|
||||
? element.points[0]
|
||||
: element.points[element.points.length - 1],
|
||||
elements,
|
||||
elementsMap,
|
||||
),
|
||||
element,
|
||||
elements,
|
||||
elementsMap,
|
||||
app.scene,
|
||||
);
|
||||
app.scene,
|
||||
hit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Attached text might need to update if arrow dimensions change
|
||||
@@ -1031,6 +1055,8 @@ export class LinearElementEditor {
|
||||
): {
|
||||
didAddPoint: boolean;
|
||||
hitElement: NonDeleted<ExcalidrawElement> | null;
|
||||
/** the pointer went down on the arrow's label, grabbing it for a drag */
|
||||
hitBoundText: boolean;
|
||||
linearElementEditor: LinearElementEditor | null;
|
||||
} {
|
||||
const appState = app.state;
|
||||
@@ -1039,6 +1065,7 @@ export class LinearElementEditor {
|
||||
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
|
||||
didAddPoint: false,
|
||||
hitElement: null,
|
||||
hitBoundText: false,
|
||||
linearElementEditor: null,
|
||||
};
|
||||
|
||||
@@ -1112,16 +1139,51 @@ export class LinearElementEditor {
|
||||
return ret;
|
||||
}
|
||||
|
||||
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
||||
const pointIndexUnderCursor = LinearElementEditor.getPointIndexUnderCursor(
|
||||
element,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
);
|
||||
// if we clicked on a point, set the element as hitElement otherwise
|
||||
// it would get deselected if the point is outside the hitbox area
|
||||
if (clickedPointIndex >= 0 || segmentMidpoint) {
|
||||
const clickedPointIsHandle = LinearElementEditor.isPointHandle(
|
||||
element,
|
||||
pointIndexUnderCursor,
|
||||
);
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
let boundTextGrabOffset: { x: number; y: number } | null = null;
|
||||
if (
|
||||
!clickedPointIsHandle &&
|
||||
// the segment midpoint knob keeps precedence over the label sitting
|
||||
// on top of it, so a labeled arrow can still be bent at its middle
|
||||
!segmentMidpoint &&
|
||||
boundTextElement &&
|
||||
isArrowElement(element) &&
|
||||
app.arrowText.isBoundTextGrabbable(
|
||||
element,
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
)
|
||||
) {
|
||||
const { x: textX, y: textY } =
|
||||
LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
boundTextGrabOffset = {
|
||||
x: scenePointer.x - (textX + boundTextElement.width / 2),
|
||||
y: scenePointer.y - (textY + boundTextElement.height / 2),
|
||||
};
|
||||
}
|
||||
|
||||
// when the label grab wins (possible over a non-interactive elbow route
|
||||
// point), the click must not double as a point click
|
||||
const clickedPointIndex = boundTextGrabOffset ? -1 : pointIndexUnderCursor;
|
||||
ret.hitBoundText = !!boundTextGrabOffset;
|
||||
|
||||
if (clickedPointIndex >= 0 || segmentMidpoint || boundTextGrabOffset) {
|
||||
ret.hitElement = element;
|
||||
}
|
||||
|
||||
@@ -1174,7 +1236,7 @@ export class LinearElementEditor {
|
||||
x: scenePointer.x - targetPoint[0],
|
||||
y: scenePointer.y - targetPoint[1],
|
||||
}
|
||||
: { x: 0, y: 0 },
|
||||
: boundTextGrabOffset ?? { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
return ret;
|
||||
@@ -1355,6 +1417,19 @@ export class LinearElementEditor {
|
||||
return pointFrom(x - element.x, y - element.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the point at `index` is an interactive handle. Elbow arrows only
|
||||
* expose their endpoints; their intermediate route points are not draggable.
|
||||
*/
|
||||
static isPointHandle(element: ExcalidrawLinearElement, index: number) {
|
||||
return (
|
||||
index >= 0 &&
|
||||
(!isElbowArrow(element) ||
|
||||
index === 0 ||
|
||||
index === element.points.length - 1)
|
||||
);
|
||||
}
|
||||
|
||||
static getPointIndexUnderCursor(
|
||||
element: ExcalidrawLinearElement,
|
||||
elementsMap: ElementsMap,
|
||||
@@ -1885,17 +1960,163 @@ export class LinearElementEditor {
|
||||
);
|
||||
};
|
||||
|
||||
static handleBoundTextDragging(
|
||||
linearElementEditor: LinearElementEditor,
|
||||
scene: Scene,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
): LinearElementEditor | null {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(
|
||||
linearElementEditor.elementId,
|
||||
elementsMap,
|
||||
);
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (!element || !isArrowElement(element) || !boundTextElement) {
|
||||
return null;
|
||||
}
|
||||
const pointerGlobalPoint = pointFrom<GlobalPoint>(
|
||||
pointerX - linearElementEditor.pointerOffset.x,
|
||||
pointerY - linearElementEditor.pointerOffset.y,
|
||||
);
|
||||
const { segments, lengths, prefixSums, totalLength } =
|
||||
getLinearElementPathMetrics(element, elementsMap);
|
||||
if (segments.length === 0 || totalLength === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bestDistance = Infinity;
|
||||
let bestSegmentIndex = 0;
|
||||
let bestParameter = 0;
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const { t, distance } = pathSegmentClosestParameter(
|
||||
segments[i],
|
||||
pointerGlobalPoint,
|
||||
);
|
||||
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestSegmentIndex = i;
|
||||
bestParameter = t;
|
||||
}
|
||||
}
|
||||
|
||||
const lengthWithinSegment = pathSegmentLengthAtParameter(
|
||||
segments[bestSegmentIndex],
|
||||
bestParameter,
|
||||
lengths[bestSegmentIndex],
|
||||
);
|
||||
|
||||
const labelPosition = clamp(
|
||||
(prefixSums[bestSegmentIndex] + lengthWithinSegment) / totalLength,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const pathPoint = LinearElementEditor.getPointAtPathParameter(
|
||||
element,
|
||||
labelPosition,
|
||||
elementsMap,
|
||||
);
|
||||
if (!pathPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
scene.mutateElement(boundTextElement, {
|
||||
labelPosition,
|
||||
x: pathPoint[0] - boundTextElement.width / 2,
|
||||
y: pathPoint[1] - boundTextElement.height / 2,
|
||||
});
|
||||
|
||||
return {
|
||||
...linearElementEditor,
|
||||
isDragging: true,
|
||||
};
|
||||
}
|
||||
|
||||
static getPointAtPathParameter(
|
||||
container: ExcalidrawLinearElement,
|
||||
pathParameter: number,
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint | null {
|
||||
if (!Number.isFinite(pathParameter)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { segments, lengths, prefixSums, totalLength } =
|
||||
getLinearElementPathMetrics(container, elementsMap);
|
||||
if (segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetLength = clamp(pathParameter, 0, 1) * totalLength;
|
||||
|
||||
// the first segment whose end lies at or beyond the target; the last one
|
||||
// catches a target that floating point pushed past the total length
|
||||
const found = segments.findIndex(
|
||||
(_, i) => targetLength <= prefixSums[i + 1],
|
||||
);
|
||||
const i = found === -1 ? segments.length - 1 : found;
|
||||
const lengthFraction =
|
||||
lengths[i] === 0
|
||||
? 0
|
||||
: clamp((targetLength - prefixSums[i]) / lengths[i], 0, 1);
|
||||
|
||||
return pathSegmentPointAtLength(segments[i], lengthFraction, lengths[i]);
|
||||
}
|
||||
|
||||
static getBoundTextElementPosition = (
|
||||
element: ExcalidrawLinearElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
elementsMap: ElementsMap,
|
||||
): { x: number; y: number } => {
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
// derived on every hit test, bounds query and render of the label, so
|
||||
// memoize on the pair of versions the result depends on
|
||||
const cached = BoundTextPositionCache.get(boundTextElement);
|
||||
if (
|
||||
cached &&
|
||||
cached.containerVersion === element.version &&
|
||||
cached.textVersion === boundTextElement.version
|
||||
) {
|
||||
return cached.position;
|
||||
}
|
||||
|
||||
const position = LinearElementEditor.computeBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
if (points.length < 2) {
|
||||
mutateElement(boundTextElement, elementsMap, { isDeleted: true });
|
||||
|
||||
BoundTextPositionCache.set(boundTextElement, {
|
||||
containerVersion: element.version,
|
||||
textVersion: boundTextElement.version,
|
||||
position,
|
||||
});
|
||||
|
||||
return position;
|
||||
};
|
||||
|
||||
private static computeBoundTextElementPosition = (
|
||||
element: ExcalidrawLinearElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
elementsMap: ElementsMap,
|
||||
): { x: number; y: number } => {
|
||||
if (element.points.length < 2) {
|
||||
return { x: boundTextElement.x, y: boundTextElement.y };
|
||||
}
|
||||
if (isArrowElement(element) && boundTextElement.labelPosition != null) {
|
||||
const pathPoint = LinearElementEditor.getPointAtPathParameter(
|
||||
element,
|
||||
boundTextElement.labelPosition,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (pathPoint) {
|
||||
return {
|
||||
x: pathPoint[0] - boundTextElement.width / 2,
|
||||
y: pathPoint[1] - boundTextElement.height / 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const center = LinearElementEditor.getBoundTextElementCenter(
|
||||
@@ -2169,6 +2390,7 @@ const pointDraggingUpdates = (
|
||||
): {
|
||||
positions: PointsPositionUpdates;
|
||||
updates?: PointMoveOtherUpdates;
|
||||
hit?: NonDeleted<ExcalidrawBindableElement> | null;
|
||||
} => {
|
||||
const naiveDraggingPoints = new Map(
|
||||
selectedPointsIndices.map((pointIndex) => {
|
||||
@@ -2227,18 +2449,21 @@ const pointDraggingUpdates = (
|
||||
? {
|
||||
element: suggestedBindingElement,
|
||||
midPoint: app.state.isMidpointSnappingEnabled
|
||||
? snapToMid(
|
||||
suggestedBindingElement,
|
||||
elementsMap,
|
||||
? getSnapOutlineMidPoint(
|
||||
pointFrom<GlobalPoint>(
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
),
|
||||
suggestedBindingElement,
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
element,
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
hit: suggestedBindingElement,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2340,6 +2565,7 @@ const pointDraggingUpdates = (
|
||||
start.element,
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
element,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
@@ -2379,6 +2605,7 @@ const pointDraggingUpdates = (
|
||||
end.element,
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
element,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
@@ -2528,6 +2755,7 @@ const pointDraggingUpdates = (
|
||||
];
|
||||
}),
|
||||
),
|
||||
hit: startIsDragged ? start.element : end.element,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2536,3 +2764,93 @@ const determineCustomLinearAngle = (
|
||||
draggedPoint: LocalPoint,
|
||||
) =>
|
||||
Math.atan2(draggedPoint[1] - pivotPoint[1], draggedPoint[0] - pivotPoint[0]);
|
||||
|
||||
type LinearElementPathMetrics = {
|
||||
segments: LinearPathSegment[];
|
||||
lengths: number[];
|
||||
prefixSums: number[];
|
||||
totalLength: number;
|
||||
};
|
||||
|
||||
const LinearElementPathMetricsCache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
{ version: ExcalidrawElement["version"]; metrics: LinearElementPathMetrics }
|
||||
>();
|
||||
|
||||
function getLinearElementPathMetrics(
|
||||
element: ExcalidrawLinearElement,
|
||||
elementsMap: ElementsMap,
|
||||
): LinearElementPathMetrics {
|
||||
const cached = LinearElementPathMetricsCache.get(element);
|
||||
|
||||
if (cached) {
|
||||
if (cached.version === element.version) {
|
||||
return cached.metrics;
|
||||
}
|
||||
|
||||
LinearElementPathMetricsCache.delete(element);
|
||||
}
|
||||
|
||||
const segments = getLinearElementPathSegments(element, elementsMap);
|
||||
const lengths = segments.map(pathSegmentLength);
|
||||
const prefixSums = new Array<number>(lengths.length + 1);
|
||||
|
||||
prefixSums[0] = 0;
|
||||
for (let i = 0; i < lengths.length; i++) {
|
||||
prefixSums[i + 1] = prefixSums[i] + lengths[i];
|
||||
}
|
||||
|
||||
const metrics: LinearElementPathMetrics = {
|
||||
segments,
|
||||
lengths,
|
||||
prefixSums,
|
||||
totalLength: prefixSums[lengths.length],
|
||||
};
|
||||
|
||||
LinearElementPathMetricsCache.set(element, {
|
||||
version: element.version,
|
||||
metrics,
|
||||
});
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
const pathSegmentLength = (segment: LinearPathSegment): number =>
|
||||
isCurve(segment)
|
||||
? curveLength(segment)
|
||||
: pointDistance(segment[0], segment[1]);
|
||||
|
||||
/**
|
||||
* The segment's parameter `t` (`0..1`) at the point closest to `point`, and
|
||||
* how far that point is from `point`.
|
||||
*/
|
||||
const pathSegmentClosestParameter = (
|
||||
segment: LinearPathSegment,
|
||||
point: GlobalPoint,
|
||||
): { t: number; distance: number } => {
|
||||
if (isCurve(segment)) {
|
||||
const t = curveClosestParameter(segment, point);
|
||||
return { t, distance: pointDistance(point, bezierEquation(segment, t)) };
|
||||
}
|
||||
|
||||
const t = lineSegmentClosestParameter(point, segment);
|
||||
return { t, distance: pointDistance(point, lineSegmentPointAt(segment, t)) };
|
||||
};
|
||||
|
||||
/** arc length from the segment's start to its parameter `t` */
|
||||
const pathSegmentLengthAtParameter = (
|
||||
segment: LinearPathSegment,
|
||||
t: number,
|
||||
segmentLength: number,
|
||||
): number =>
|
||||
isCurve(segment) ? curveLengthAtParameter(segment, t) : t * segmentLength;
|
||||
|
||||
/** point at `fraction` (`0..1`) of the segment's arc length */
|
||||
const pathSegmentPointAtLength = (
|
||||
segment: LinearPathSegment,
|
||||
fraction: number,
|
||||
segmentLength: number,
|
||||
): GlobalPoint =>
|
||||
isCurve(segment)
|
||||
? curvePointAtLength(segment, fraction, segmentLength)
|
||||
: lineSegmentPointAt(segment, fraction);
|
||||
|
||||
@@ -21,9 +21,12 @@ import type {
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
// `created` is lifetime metadata: it is set once when an element instance is
|
||||
// constructed (or duplicated) and never updated, so it is not part of an
|
||||
// element update. An explicit repair must construct a new element object.
|
||||
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
Partial<TElement>,
|
||||
"id" | "updated"
|
||||
"id" | "updated" | "created"
|
||||
>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
DEFAULT_STICKY_NOTE_SIZE,
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
DEFAULT_STROKE_STREAMLINE,
|
||||
VERTICAL_ALIGN,
|
||||
@@ -22,6 +24,10 @@ import {
|
||||
getResizedElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
import { newElementWith } from "./mutateElement";
|
||||
import {
|
||||
normalizeStickyNoteBackgroundColor,
|
||||
normalizeStickyNoteStrokeColor,
|
||||
} from "./stickyNote";
|
||||
import { getBoundTextMaxWidth } from "./textElement";
|
||||
import { normalizeText, measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
@@ -49,6 +55,7 @@ import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawLineElement,
|
||||
ExcalidrawStickyNoteElement,
|
||||
} from "./types";
|
||||
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
@@ -74,6 +81,7 @@ export type ElementConstructorOpts = MarkOptional<
|
||||
| "locked"
|
||||
| "opacity"
|
||||
| "customData"
|
||||
| "created"
|
||||
>;
|
||||
|
||||
const _newElementBase = <T extends ExcalidrawElement>(
|
||||
@@ -123,6 +131,8 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
});
|
||||
}
|
||||
|
||||
const timestamp = getUpdatedTimestamp();
|
||||
|
||||
// assign type to guard against excess properties
|
||||
const element: Merge<
|
||||
ExcalidrawGenericElement,
|
||||
@@ -151,7 +161,9 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
versionNonce: rest.versionNonce ?? 0,
|
||||
isDeleted: false as false,
|
||||
boundElements,
|
||||
updated: getUpdatedTimestamp(),
|
||||
updated: timestamp,
|
||||
// Preserve explicit null when reconstructing a legacy element with its id.
|
||||
created: rest.created === undefined ? timestamp : rest.created,
|
||||
link,
|
||||
locked,
|
||||
customData: rest.customData,
|
||||
@@ -166,6 +178,70 @@ export const newElement = (
|
||||
): NonDeleted<ExcalidrawGenericElement> =>
|
||||
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
||||
|
||||
/**
|
||||
* Style invariants of a sticky note: never-transparent colors, solid fill.
|
||||
* Applied by the constructor and by every normalization pass. Returns the
|
||||
* same object when nothing needs fixing.
|
||||
*/
|
||||
export const normalizeStickyNoteStyle = <T extends ExcalidrawStickyNoteElement>(
|
||||
element: T,
|
||||
): T => {
|
||||
return newElementWith(element as ExcalidrawStickyNoteElement, {
|
||||
backgroundColor: normalizeStickyNoteBackgroundColor(
|
||||
element.backgroundColor,
|
||||
),
|
||||
strokeColor: normalizeStickyNoteStrokeColor(element.strokeColor),
|
||||
fillStyle: "solid",
|
||||
}) as T;
|
||||
};
|
||||
|
||||
/**
|
||||
* Geometry invariants of a *finalized* sticky note: minimum size and
|
||||
* `baseHeight ≤ height`. Deliberately not part of the constructor — a
|
||||
* pointer-down draft starts at 0×0 like every other tool and is previewed at
|
||||
* its true dragged size; pointer-up, restore, the skeleton path and the
|
||||
* action post-passes enforce this.
|
||||
*/
|
||||
export const normalizeStickyNoteGeometry = <
|
||||
T extends ExcalidrawStickyNoteElement,
|
||||
>(
|
||||
element: T,
|
||||
): T => {
|
||||
const width = Math.max(element.width, STICKY_NOTE_MIN_SIZE);
|
||||
const baseHeight = Math.max(
|
||||
element.baseHeight || element.height || DEFAULT_STICKY_NOTE_SIZE,
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
);
|
||||
|
||||
return newElementWith(element as ExcalidrawStickyNoteElement, {
|
||||
width,
|
||||
height: Math.max(element.height, baseHeight),
|
||||
baseHeight,
|
||||
}) as T;
|
||||
};
|
||||
|
||||
/** all sticky note invariants (style + finalized geometry) */
|
||||
export const normalizeStickyNote = <T extends ExcalidrawStickyNoteElement>(
|
||||
element: T,
|
||||
): T => {
|
||||
return normalizeStickyNoteGeometry(normalizeStickyNoteStyle(element));
|
||||
};
|
||||
|
||||
export const newStickyNoteElement = (
|
||||
opts: {
|
||||
type: "stickynote";
|
||||
baseHeight?: number;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawStickyNoteElement> => {
|
||||
const base = _newElementBase<ExcalidrawStickyNoteElement>("stickynote", opts);
|
||||
|
||||
// no size inflation here (see `normalizeStickyNoteGeometry`)
|
||||
return normalizeStickyNoteStyle({
|
||||
...base,
|
||||
baseHeight: opts.baseHeight ?? base.height,
|
||||
});
|
||||
};
|
||||
|
||||
export const newEmbeddableElement = (
|
||||
opts: {
|
||||
type: "embeddable";
|
||||
@@ -267,6 +343,8 @@ export const newTextElement = (
|
||||
containerId?: ExcalidrawTextContainer["id"] | null;
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
autoResize?: ExcalidrawTextElement["autoResize"];
|
||||
labelPosition?: ExcalidrawTextElement["labelPosition"];
|
||||
baseFontSize?: ExcalidrawTextElement["baseFontSize"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
@@ -289,6 +367,7 @@ export const newTextElement = (
|
||||
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
||||
text,
|
||||
fontSize,
|
||||
baseFontSize: opts.baseFontSize ?? null,
|
||||
fontFamily,
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
@@ -300,6 +379,7 @@ export const newTextElement = (
|
||||
originalText: opts.originalText ?? text,
|
||||
autoResize: opts.autoResize ?? true,
|
||||
lineHeight,
|
||||
labelPosition: opts.labelPosition ?? null,
|
||||
};
|
||||
|
||||
const textElement: NonDeleted<ExcalidrawTextElement> = newElementWith(
|
||||
@@ -314,6 +394,8 @@ const getAdjustedDimensions = (
|
||||
element: ExcalidrawTextElement,
|
||||
elementsMap: ElementsMap,
|
||||
nextText: string,
|
||||
/** the width the text is to take regardless of its content */
|
||||
nextFixedWidth?: number,
|
||||
): {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -327,7 +409,9 @@ const getAdjustedDimensions = (
|
||||
);
|
||||
|
||||
// wrapped text
|
||||
if (!element.autoResize) {
|
||||
if (nextFixedWidth !== undefined) {
|
||||
nextWidth = nextFixedWidth;
|
||||
} else if (!element.autoResize) {
|
||||
nextWidth = element.width;
|
||||
}
|
||||
|
||||
@@ -451,10 +535,38 @@ export const refreshTextDimensions = (
|
||||
container: ExcalidrawTextContainer | null,
|
||||
elementsMap: ElementsMap,
|
||||
text = textElement.text,
|
||||
/**
|
||||
* For a free text that grows with its content (`autoResize`): the widest
|
||||
* it may grow to. Crossing it, the text stops growing and wraps at this
|
||||
* width from then on (`autoResize: false`), anchored as its growth was. A
|
||||
* text that is already wider keeps growing.
|
||||
*/
|
||||
maxWidth?: number,
|
||||
) => {
|
||||
if (textElement.isDeleted) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
maxWidth !== undefined &&
|
||||
!container &&
|
||||
textElement.autoResize &&
|
||||
textElement.width <= maxWidth
|
||||
) {
|
||||
const font = getFontString(textElement);
|
||||
if (measureText(text, font, textElement.lineHeight).width > maxWidth) {
|
||||
const wrappedText = wrapText(text, font, maxWidth);
|
||||
return {
|
||||
text: wrappedText,
|
||||
autoResize: false,
|
||||
...getAdjustedDimensions(
|
||||
textElement,
|
||||
elementsMap,
|
||||
wrappedText,
|
||||
maxWidth,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (container || !textElement.autoResize) {
|
||||
text = wrapText(
|
||||
text,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import rough from "roughjs/bin/rough";
|
||||
|
||||
import {
|
||||
clamp,
|
||||
type GlobalPoint,
|
||||
isRightAngleRads,
|
||||
lineSegment,
|
||||
@@ -24,6 +25,11 @@ import {
|
||||
invariant,
|
||||
applyDarkModeFilter,
|
||||
isSafari,
|
||||
STICKY_NOTE_EDGE_SHADOW_OPACITY,
|
||||
STICKY_NOTE_EDGE_SHADOW_WIDTH,
|
||||
STICKY_NOTE_FOOTER,
|
||||
STICKY_NOTE_SHADOW_OPACITY,
|
||||
DEFAULT_ZOOM,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
@@ -31,9 +37,7 @@ import type {
|
||||
StaticCanvasAppState,
|
||||
Zoom,
|
||||
InteractiveCanvasAppState,
|
||||
ElementsPendingErasure,
|
||||
PendingExcalidrawElements,
|
||||
NormalizedZoomValue,
|
||||
ElementRenderOverrides,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type {
|
||||
@@ -61,12 +65,18 @@ import {
|
||||
isArrowElement,
|
||||
hasBoundTextElement,
|
||||
isMagicFrameElement,
|
||||
isFrameLikeElement,
|
||||
isImageElement,
|
||||
} from "./typeChecks";
|
||||
import { getContainingFrame } from "./frame";
|
||||
import { getCornerRadius } from "./utils";
|
||||
|
||||
import { ShapeCache } from "./shape";
|
||||
import {
|
||||
getStickyNoteFooter,
|
||||
getStickyNotePathCommands,
|
||||
type StickyNotePathCommand,
|
||||
} from "./stickyNote";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@@ -76,7 +86,6 @@ import type {
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
ElementsMap,
|
||||
} from "./types";
|
||||
@@ -106,30 +115,90 @@ const getCanvasPadding = (element: ExcalidrawElement) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getRenderOpacity = (
|
||||
element: ExcalidrawElement,
|
||||
containingFrame: ExcalidrawFrameLikeElement | null,
|
||||
elementsPendingErasure: ElementsPendingErasure,
|
||||
pendingNodes: Readonly<PendingExcalidrawElements> | null,
|
||||
globalAlpha: number = 1,
|
||||
) => {
|
||||
// multiplying frame opacity with element opacity to combine them
|
||||
// (e.g. frame 50% and element 50% opacity should result in 25% opacity)
|
||||
let opacity =
|
||||
(((containingFrame?.opacity ?? 100) * element.opacity) / 10000) *
|
||||
globalAlpha;
|
||||
export type RenderPositionOffset = Readonly<{ x: number; y: number }>;
|
||||
|
||||
// if pending erasure, multiply again to combine further
|
||||
// (so that erasing always results in lower opacity than original)
|
||||
const ZERO_RENDER_OFFSET: RenderPositionOffset = { x: 0, y: 0 };
|
||||
|
||||
/** Bound labels follow their container; a label's own offset is ignored. */
|
||||
export const getElementRenderOffset = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
overrides: ElementRenderOverrides | undefined,
|
||||
): RenderPositionOffset | undefined => {
|
||||
const container = isTextElement(element)
|
||||
? getContainerElement(element, elementsMap)
|
||||
: null;
|
||||
return overrides?.get((container ?? element).id)?.offset;
|
||||
};
|
||||
|
||||
export const getRenderElementWithPositionOverride = <
|
||||
TElement extends ExcalidrawElement,
|
||||
>(
|
||||
element: TElement,
|
||||
positionOffset: RenderPositionOffset,
|
||||
): TElement => {
|
||||
if (positionOffset.x === 0 && positionOffset.y === 0) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
x: element.x + positionOffset.x,
|
||||
y: element.y + positionOffset.y,
|
||||
} as TElement;
|
||||
};
|
||||
|
||||
export type ElementRenderState = Readonly<{
|
||||
/** Alpha including frame opacity and pending erasure; selection dimming is separate. */
|
||||
opacity: number;
|
||||
offset: RenderPositionOffset;
|
||||
}>;
|
||||
|
||||
/** Resolve visual state at the drawing boundary, preserving document cache keys. */
|
||||
export const resolveElementRenderState = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
renderConfig: Pick<
|
||||
StaticCanvasRenderConfig,
|
||||
| "elementRenderOverrides"
|
||||
| "elementsPendingErasure"
|
||||
| "pendingFlowchartNodes"
|
||||
>,
|
||||
allElementsMap: ElementsMap = elementsMap,
|
||||
): ElementRenderState => {
|
||||
const {
|
||||
elementRenderOverrides,
|
||||
elementsPendingErasure,
|
||||
pendingFlowchartNodes,
|
||||
} = renderConfig;
|
||||
const override = elementRenderOverrides?.get(element.id);
|
||||
const containingFrame = getContainingFrame(element, elementsMap);
|
||||
const frameOpacity = containingFrame
|
||||
? clamp(
|
||||
elementRenderOverrides?.get(containingFrame.id)?.opacity ??
|
||||
containingFrame.opacity,
|
||||
0,
|
||||
100,
|
||||
)
|
||||
: 100;
|
||||
// Frame and element alpha multiply (50% each produces 25%).
|
||||
let opacity =
|
||||
(frameOpacity * clamp(override?.opacity ?? element.opacity, 0, 100)) /
|
||||
10000;
|
||||
if (
|
||||
elementsPendingErasure.has(element.id) ||
|
||||
(pendingNodes && pendingNodes.some((node) => node.id === element.id)) ||
|
||||
pendingFlowchartNodes?.some((node) => node.id === element.id) ||
|
||||
(containingFrame && elementsPendingErasure.has(containingFrame.id))
|
||||
) {
|
||||
opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100;
|
||||
}
|
||||
const offset = getElementRenderOffset(
|
||||
element,
|
||||
allElementsMap,
|
||||
elementRenderOverrides,
|
||||
);
|
||||
|
||||
return opacity;
|
||||
return { opacity, offset: offset ?? ZERO_RENDER_OFFSET };
|
||||
};
|
||||
|
||||
export interface ExcalidrawElementWithCanvas {
|
||||
@@ -315,6 +384,50 @@ const drawImagePlaceholder = (
|
||||
);
|
||||
};
|
||||
|
||||
const drawStickyNotePath = (
|
||||
context: CanvasRenderingContext2D,
|
||||
commands: StickyNotePathCommand[],
|
||||
) => {
|
||||
context.beginPath();
|
||||
for (const command of commands) {
|
||||
if (command.type === "move") {
|
||||
context.moveTo(command.point.x, command.point.y);
|
||||
} else if (command.type === "line") {
|
||||
context.lineTo(command.point.x, command.point.y);
|
||||
} else {
|
||||
context.quadraticCurveTo(
|
||||
command.control.x,
|
||||
command.control.y,
|
||||
command.point.x,
|
||||
command.point.y,
|
||||
);
|
||||
}
|
||||
}
|
||||
context.closePath();
|
||||
};
|
||||
|
||||
const fillStickyNoteShape = (
|
||||
context: CanvasRenderingContext2D,
|
||||
commands: StickyNotePathCommand[],
|
||||
) => {
|
||||
drawStickyNotePath(context, commands);
|
||||
context.fill();
|
||||
};
|
||||
|
||||
const strokeStickyNoteEdge = (
|
||||
context: CanvasRenderingContext2D,
|
||||
commands: StickyNotePathCommand[],
|
||||
) => {
|
||||
context.save();
|
||||
drawStickyNotePath(context, commands);
|
||||
context.clip();
|
||||
context.lineWidth = STICKY_NOTE_EDGE_SHADOW_WIDTH * 2;
|
||||
context.strokeStyle = `rgba(0, 0, 0, ${STICKY_NOTE_EDGE_SHADOW_OPACITY})`;
|
||||
drawStickyNotePath(context, commands);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const drawElementOnCanvas = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
rc: RoughCanvas,
|
||||
@@ -322,6 +435,41 @@ const drawElementOnCanvas = (
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
) => {
|
||||
switch (element.type) {
|
||||
case "stickynote": {
|
||||
context.save();
|
||||
context.fillStyle = `rgba(0, 0, 0, ${STICKY_NOTE_SHADOW_OPACITY})`;
|
||||
fillStickyNoteShape(
|
||||
context,
|
||||
getStickyNotePathCommands(element, { shadow: true }),
|
||||
);
|
||||
|
||||
const commands = getStickyNotePathCommands(element);
|
||||
context.fillStyle = applyDarkModeFilter(
|
||||
element.backgroundColor,
|
||||
renderConfig.theme === THEME.DARK,
|
||||
);
|
||||
fillStickyNoteShape(context, commands);
|
||||
strokeStickyNoteEdge(context, commands);
|
||||
|
||||
// the label is absolute, so this cached canvas only goes stale at a
|
||||
// year boundary — and is regenerated on the next zoom, theme or
|
||||
// element change anyway
|
||||
const footer = getStickyNoteFooter(element);
|
||||
if (footer) {
|
||||
context.font = `${STICKY_NOTE_FOOTER.fontSize}px ${STICKY_NOTE_FOOTER.fontFamily}`;
|
||||
context.textAlign = "right";
|
||||
context.textBaseline = "alphabetic";
|
||||
context.fillStyle = applyDarkModeFilter(
|
||||
element.strokeColor,
|
||||
renderConfig.theme === THEME.DARK,
|
||||
);
|
||||
context.globalAlpha *= STICKY_NOTE_FOOTER.opacity;
|
||||
context.fillText(footer.text, footer.x, footer.y);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
break;
|
||||
}
|
||||
case "rectangle":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
@@ -542,11 +690,7 @@ const generateElementWithCanvas = (
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
) => {
|
||||
const zoom: Zoom = renderConfig
|
||||
? appState.zoom
|
||||
: {
|
||||
value: 1 as NormalizedZoomValue,
|
||||
};
|
||||
const zoom: Zoom = renderConfig ? appState.zoom : DEFAULT_ZOOM;
|
||||
const prevElementWithCanvas = elementWithCanvasCache.get(element);
|
||||
const shouldRegenerateBecauseZoom =
|
||||
prevElementWithCanvas &&
|
||||
@@ -583,21 +727,59 @@ const generateElementWithCanvas = (
|
||||
return prevElementWithCanvas;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether an element's cached bitmap may be laid on the device-pixel grid:
|
||||
* unrotated or at a right angle, and not during a zoom gesture. The cached
|
||||
* path disables smoothing for exactly these — a nearest-neighbor 1:1 blit is
|
||||
* a pixel-exact copy, and rounding its origin keeps it off the half-pixel
|
||||
* boundary where it tears — and every eligible blit is snapped. It is
|
||||
* eligibility, not a guarantee of a 1:1 copy: freedraw is blitted with
|
||||
* smoothing on, and a size-capped cache is rescaled; snapping is harmless
|
||||
* for both.
|
||||
*
|
||||
* Not during a zoom gesture: the bitmaps stay at the old scale and are
|
||||
* resampled, and blurry shapes look better on low resolution (while still
|
||||
* zooming in) than sharp ones; snapping would only make elements twitch.
|
||||
* Right angles qualify because smoothing can be off there without aliasing
|
||||
* (for other angles it is terrible on Chromium); the right-angle test
|
||||
* tolerates float arithmetic.
|
||||
*/
|
||||
const canSnapElement = (
|
||||
element: ExcalidrawElement,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
) =>
|
||||
!appState?.shouldCacheIgnoreZoom &&
|
||||
(!element.angle || isRightAngleRads(element.angle));
|
||||
|
||||
/**
|
||||
* Breaks a `Math.round` tie at exactly half a device pixel the same way
|
||||
* every frame. A label's offset from its container lands on one routinely
|
||||
* (an odd scene offset at 150%), and the float noise of a fractional drag
|
||||
* would otherwise flip it between the two neighbors.
|
||||
*/
|
||||
const SNAP_TIE_BIAS = 1e-6;
|
||||
|
||||
const drawElementFromCanvas = (
|
||||
elementWithCanvas: ExcalidrawElementWithCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
allElementsMap: NonDeletedSceneElementsMap,
|
||||
positionOffset: RenderPositionOffset,
|
||||
) => {
|
||||
const element = elementWithCanvas.element;
|
||||
// the ratio the cached bitmap was generated with (`generateElementCanvas`);
|
||||
// the blit's size math needs the same one, so it is not the owner window's
|
||||
const devicePixelRatio = window.devicePixelRatio;
|
||||
const padding = getCanvasPadding(element);
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
|
||||
const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio;
|
||||
const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio;
|
||||
const cx =
|
||||
((x1 + x2) / 2 + positionOffset.x + appState.scrollX) * devicePixelRatio;
|
||||
const cy =
|
||||
((y1 + y2) / 2 + positionOffset.y + appState.scrollY) * devicePixelRatio;
|
||||
|
||||
context.save();
|
||||
context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
|
||||
context.scale(1 / devicePixelRatio, 1 / devicePixelRatio);
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, allElementsMap);
|
||||
|
||||
@@ -612,7 +794,7 @@ const drawElementFromCanvas = (
|
||||
);
|
||||
// generously covers the arrow's blit at any rotation
|
||||
const outerHalf =
|
||||
Math.max(distance(x1, x2), distance(y1, y2)) * window.devicePixelRatio +
|
||||
Math.max(distance(x1, x2), distance(y1, y2)) * devicePixelRatio +
|
||||
padding * 10;
|
||||
context.beginPath();
|
||||
context.rect(cx - outerHalf, cy - outerHalf, outerHalf * 2, outerHalf * 2);
|
||||
@@ -620,17 +802,17 @@ const drawElementFromCanvas = (
|
||||
(boundTextCx -
|
||||
boundTextElement.width / 2 -
|
||||
BOUND_TEXT_PADDING +
|
||||
positionOffset.x +
|
||||
appState.scrollX) *
|
||||
window.devicePixelRatio,
|
||||
devicePixelRatio,
|
||||
(boundTextCy -
|
||||
boundTextElement.height / 2 -
|
||||
BOUND_TEXT_PADDING +
|
||||
positionOffset.y +
|
||||
appState.scrollY) *
|
||||
window.devicePixelRatio,
|
||||
(boundTextElement.width + BOUND_TEXT_PADDING * 2) *
|
||||
window.devicePixelRatio,
|
||||
(boundTextElement.height + BOUND_TEXT_PADDING * 2) *
|
||||
window.devicePixelRatio,
|
||||
devicePixelRatio,
|
||||
(boundTextElement.width + BOUND_TEXT_PADDING * 2) * devicePixelRatio,
|
||||
(boundTextElement.height + BOUND_TEXT_PADDING * 2) * devicePixelRatio,
|
||||
);
|
||||
context.clip("evenodd");
|
||||
}
|
||||
@@ -654,16 +836,79 @@ const drawElementFromCanvas = (
|
||||
// revert afterwards we don't have account for it during drawing
|
||||
context.translate(-cx, -cy);
|
||||
|
||||
// the blit origin, in the space the context is in here (scaled by
|
||||
// canvas scale × zoom ÷ devicePixelRatio)
|
||||
let drawX =
|
||||
(x1 + positionOffset.x + appState.scrollX) * devicePixelRatio - padding;
|
||||
let drawY =
|
||||
(y1 + positionOffset.y + appState.scrollY) * devicePixelRatio - padding;
|
||||
|
||||
const transform = context.getTransform();
|
||||
|
||||
if (canSnapElement(element, appState)) {
|
||||
// blit the cached bitmap on whole device pixels. Nearest-neighbor at a
|
||||
// fractional offset is a pixel-exact copy shifted to the nearest pixel
|
||||
// — except at an exact half pixel, where a GPU-accelerated canvas
|
||||
// decides the rounding per scanline by float precision and a few rows
|
||||
// sample the neighboring row: doubled or broken strokes, varying with
|
||||
// scroll and position. A centered label lands on a half pixel
|
||||
// routinely.
|
||||
//
|
||||
// Done by moving the transform's origin onto the rounded device
|
||||
// position of the blit and drawing at (0, 0): the rotation, mirroring
|
||||
// and scale stay in `a`–`d` (a right angle keeps its scale in `b`/`c`),
|
||||
// and the origin is an exact integer even in the canvas's float32
|
||||
// matrix. Rounding `drawX` itself only lands on device pixels at 100%
|
||||
// zoom.
|
||||
const { a, b, c, d, e, f } = transform;
|
||||
const container = isTextElement(element)
|
||||
? getContainerElement(element, allElementsMap)
|
||||
: null;
|
||||
// A bound label shares its unrotated container's offset and anchor.
|
||||
// Other bitmaps anchor to themselves.
|
||||
const anchor = container && !container.angle ? container : element;
|
||||
const anchorCoords =
|
||||
anchor === element
|
||||
? null
|
||||
: getElementAbsoluteCoords(anchor, allElementsMap);
|
||||
const anchorSceneX = anchorCoords?.[0] ?? x1;
|
||||
const anchorSceneY = anchorCoords?.[1] ?? y1;
|
||||
const anchorPadding =
|
||||
anchor === element ? padding : getCanvasPadding(anchor);
|
||||
const anchorX =
|
||||
(anchorSceneX + positionOffset.x + appState.scrollX) * devicePixelRatio -
|
||||
anchorPadding;
|
||||
const anchorY =
|
||||
(anchorSceneY + positionOffset.y + appState.scrollY) * devicePixelRatio -
|
||||
anchorPadding;
|
||||
|
||||
// Form the relative vector before introducing the scroll translation.
|
||||
const dx = (x1 - anchorSceneX) * devicePixelRatio + anchorPadding - padding;
|
||||
const dy = (y1 - anchorSceneY) * devicePixelRatio + anchorPadding - padding;
|
||||
context.setTransform(
|
||||
a,
|
||||
b,
|
||||
c,
|
||||
d,
|
||||
Math.round(a * anchorX + c * anchorY + e) +
|
||||
Math.round(a * dx + c * dy + SNAP_TIE_BIAS),
|
||||
Math.round(b * anchorX + d * anchorY + f) +
|
||||
Math.round(b * dx + d * dy + SNAP_TIE_BIAS),
|
||||
);
|
||||
drawX = 0;
|
||||
drawY = 0;
|
||||
}
|
||||
|
||||
context.drawImage(
|
||||
elementWithCanvas.canvas!,
|
||||
(x1 + appState.scrollX) * window.devicePixelRatio -
|
||||
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
|
||||
(y1 + appState.scrollY) * window.devicePixelRatio -
|
||||
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
|
||||
drawX,
|
||||
drawY,
|
||||
elementWithCanvas.canvas!.width / elementWithCanvas.scale,
|
||||
elementWithCanvas.canvas!.height / elementWithCanvas.scale,
|
||||
);
|
||||
|
||||
context.setTransform(transform);
|
||||
|
||||
if (
|
||||
import.meta.env.VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX ===
|
||||
"true" &&
|
||||
@@ -677,10 +922,10 @@ const drawElementFromCanvas = (
|
||||
context.strokeStyle = "#c92a2a";
|
||||
context.lineWidth = 3;
|
||||
context.strokeRect(
|
||||
(coords.x + appState.scrollX) * window.devicePixelRatio,
|
||||
(coords.y + appState.scrollY) * window.devicePixelRatio,
|
||||
getBoundTextMaxWidth(element, textElement) * window.devicePixelRatio,
|
||||
getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio,
|
||||
(coords.x + positionOffset.x + appState.scrollX) * devicePixelRatio,
|
||||
(coords.y + positionOffset.y + appState.scrollY) * devicePixelRatio,
|
||||
getBoundTextMaxWidth(element, textElement) * devicePixelRatio,
|
||||
getBoundTextMaxHeight(element, textElement) * devicePixelRatio,
|
||||
);
|
||||
}
|
||||
context.restore();
|
||||
@@ -696,7 +941,10 @@ export const renderSelectionElement = (
|
||||
) => {
|
||||
context.save();
|
||||
context.translate(element.x + appState.scrollX, element.y + appState.scrollY);
|
||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||
context.fillStyle = applyDarkModeFilter(
|
||||
"rgba(0, 0, 200, 0.04)",
|
||||
appState.theme === THEME.DARK,
|
||||
);
|
||||
|
||||
// render from 0.5px offset to get 1px wide line
|
||||
// https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
|
||||
@@ -720,20 +968,56 @@ export const renderElement = (
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
renderState = resolveElementRenderState(
|
||||
element,
|
||||
elementsMap,
|
||||
renderConfig,
|
||||
allElementsMap,
|
||||
),
|
||||
) => {
|
||||
const reduceAlphaForSelection =
|
||||
appState.openDialog?.name === "elementLinkSelector" &&
|
||||
!appState.selectedElementIds[element.id] &&
|
||||
!appState.hoveredElementIds[element.id];
|
||||
|
||||
context.globalAlpha = getRenderOpacity(
|
||||
element,
|
||||
getContainingFrame(element, elementsMap),
|
||||
renderConfig.elementsPendingErasure,
|
||||
renderConfig.pendingFlowchartNodes,
|
||||
reduceAlphaForSelection ? DEFAULT_REDUCED_GLOBAL_ALPHA : 1,
|
||||
);
|
||||
context.save();
|
||||
context.globalAlpha =
|
||||
renderState.opacity *
|
||||
(reduceAlphaForSelection ? DEFAULT_REDUCED_GLOBAL_ALPHA : 1);
|
||||
// Cached bitmaps apply the offset before pixel snapping. Moving it into
|
||||
// the canvas transform first loses precision at half-device-pixel ties.
|
||||
if (
|
||||
(renderConfig.isExporting || isFrameLikeElement(element)) &&
|
||||
(renderState.offset.x || renderState.offset.y)
|
||||
) {
|
||||
context.translate(renderState.offset.x, renderState.offset.y);
|
||||
}
|
||||
try {
|
||||
drawElement(
|
||||
element,
|
||||
elementsMap,
|
||||
allElementsMap,
|
||||
rc,
|
||||
context,
|
||||
renderConfig,
|
||||
appState,
|
||||
renderState,
|
||||
);
|
||||
} finally {
|
||||
context.restore();
|
||||
}
|
||||
};
|
||||
|
||||
const drawElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
elementsMap: RenderableElementsMap,
|
||||
allElementsMap: NonDeletedSceneElementsMap,
|
||||
rc: RoughCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
renderState: ElementRenderState,
|
||||
) => {
|
||||
switch (element.type) {
|
||||
case "magicframe":
|
||||
case "frame": {
|
||||
@@ -808,12 +1092,14 @@ export const renderElement = (
|
||||
renderConfig,
|
||||
appState,
|
||||
allElementsMap,
|
||||
renderState.offset,
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "rectangle":
|
||||
case "stickynote":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
case "line":
|
||||
@@ -918,17 +1204,9 @@ export const renderElement = (
|
||||
|
||||
const currentImageSmoothingStatus = context.imageSmoothingEnabled;
|
||||
|
||||
if (
|
||||
// do not disable smoothing during zoom as blurry shapes look better
|
||||
// on low resolution (while still zooming in) than sharp ones
|
||||
!appState?.shouldCacheIgnoreZoom &&
|
||||
// angle is 0 -> always disable smoothing
|
||||
(!element.angle ||
|
||||
// or check if angle is a right angle in which case we can still
|
||||
// disable smoothing without adversely affecting the result
|
||||
// We need less-than comparison because of FP artihmetic
|
||||
isRightAngleRads(element.angle))
|
||||
) {
|
||||
// see `canSnapElement` for why not during zoom gestures or at
|
||||
// other angles
|
||||
if (canSnapElement(element, appState)) {
|
||||
// Disabling smoothing makes output much sharper, especially for
|
||||
// text. Unless for non-right angles, where the aliasing is really
|
||||
// terrible on Chromium.
|
||||
@@ -965,6 +1243,8 @@ export const renderElement = (
|
||||
renderConfig,
|
||||
appState,
|
||||
allElementsMap,
|
||||
// The crop editor's uncropped preview stays at document coordinates.
|
||||
ZERO_RENDER_OFFSET,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -977,6 +1257,7 @@ export const renderElement = (
|
||||
renderConfig,
|
||||
appState,
|
||||
allElementsMap,
|
||||
renderState.offset,
|
||||
);
|
||||
|
||||
// reset
|
||||
@@ -989,8 +1270,6 @@ export const renderElement = (
|
||||
throw new Error(`Unimplemented type ${element.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
context.globalAlpha = 1;
|
||||
};
|
||||
|
||||
export function getFreedrawOutlineAsSegments(
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import {
|
||||
MIN_FONT_SIZE,
|
||||
SHIFT_LOCKING_ANGLE,
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
rescalePoints,
|
||||
getFontString,
|
||||
} from "@excalidraw/common";
|
||||
@@ -57,10 +58,16 @@ import {
|
||||
isFreeDrawElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isStickyNoteElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { isInGroup } from "./groups";
|
||||
import {
|
||||
getStickyNoteMinSize,
|
||||
getStickyNoteResizeIntent,
|
||||
updateStickyNoteLayout,
|
||||
} from "./stickyNote";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
@@ -749,11 +756,54 @@ export const resizeSingleElement = (
|
||||
);
|
||||
}
|
||||
|
||||
let boundTextFont: { fontSize?: number } = {};
|
||||
// Constraints and font scaling use magnitudes. Keep the signs for the
|
||||
// geometry below, where crossing the opposite edge flips the element.
|
||||
const flipFactorX = nextWidth < 0 ? -1 : 1;
|
||||
const flipFactorY = nextHeight < 0 ? -1 : 1;
|
||||
nextWidth = Math.abs(nextWidth);
|
||||
nextHeight = Math.abs(nextHeight);
|
||||
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
const isResizingStickyNote = isStickyNoteElement(latestElement);
|
||||
let minSize: { width: number; height: number } | undefined;
|
||||
if (isResizingStickyNote) {
|
||||
// A note must fit one line at its label's font ceiling.
|
||||
minSize = boundTextElement
|
||||
? getStickyNoteMinSize({
|
||||
fontSize: boundTextElement.baseFontSize ?? boundTextElement.fontSize,
|
||||
fontFamily: boundTextElement.fontFamily,
|
||||
})
|
||||
: { width: STICKY_NOTE_MIN_SIZE, height: STICKY_NOTE_MIN_SIZE };
|
||||
} else if (boundTextElement && !shouldMaintainAspectRatio) {
|
||||
minSize = {
|
||||
width: getApproxMinLineWidth(
|
||||
getFontString(boundTextElement),
|
||||
boundTextElement.lineHeight,
|
||||
),
|
||||
height: getApproxMinLineHeight(
|
||||
boundTextElement.fontSize,
|
||||
boundTextElement.lineHeight,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (boundTextElement) {
|
||||
if (minSize) {
|
||||
nextWidth = Math.max(nextWidth, minSize.width);
|
||||
nextHeight = Math.max(nextHeight, minSize.height);
|
||||
if (shouldMaintainAspectRatio) {
|
||||
// Both dimensions must use the same scale even at the minimum size.
|
||||
const scale = Math.max(
|
||||
nextWidth / origElement.width,
|
||||
nextHeight / origElement.height,
|
||||
);
|
||||
nextWidth = origElement.width * scale;
|
||||
nextHeight = origElement.height * scale;
|
||||
}
|
||||
}
|
||||
|
||||
let boundTextFont: { fontSize?: number } = {};
|
||||
if (boundTextElement && !isResizingStickyNote) {
|
||||
const stateOfBoundTextElementAtResize = originalElementsMap.get(
|
||||
boundTextElement.id,
|
||||
) as typeof boundTextElement | undefined;
|
||||
@@ -780,20 +830,12 @@ export const resizeSingleElement = (
|
||||
boundTextFont = {
|
||||
fontSize: nextFont.size,
|
||||
};
|
||||
} else {
|
||||
const minWidth = getApproxMinLineWidth(
|
||||
getFontString(boundTextElement),
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
const minHeight = getApproxMinLineHeight(
|
||||
boundTextElement.fontSize,
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
nextWidth = Math.max(nextWidth, minWidth);
|
||||
nextHeight = Math.max(nextHeight, minHeight);
|
||||
}
|
||||
}
|
||||
|
||||
nextWidth *= flipFactorX;
|
||||
nextHeight *= flipFactorY;
|
||||
|
||||
const rescaledPoints = rescalePointsInElement(
|
||||
origElement,
|
||||
nextWidth,
|
||||
@@ -865,7 +907,7 @@ export const resizeSingleElement = (
|
||||
shouldMaintainAspectRatio
|
||||
) {
|
||||
const fontSize =
|
||||
(nextWidth / latestElement.width) * boundTextElement.fontSize;
|
||||
(Math.abs(nextWidth) / latestElement.width) * boundTextElement.fontSize;
|
||||
if (fontSize < MIN_FONT_SIZE) {
|
||||
return;
|
||||
}
|
||||
@@ -909,18 +951,35 @@ export const resizeSingleElement = (
|
||||
isDragging: false,
|
||||
});
|
||||
|
||||
if (boundTextElement && boundTextFont != null) {
|
||||
scene.mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
if (isStickyNoteElement(latestElement)) {
|
||||
updateStickyNoteLayout(latestElement, scene, {
|
||||
...getStickyNoteResizeIntent(
|
||||
latestElement,
|
||||
originalElementsMap,
|
||||
handleDirection,
|
||||
{
|
||||
proportional: shouldMaintainAspectRatio,
|
||||
fromCenter: shouldResizeFromCenter,
|
||||
},
|
||||
),
|
||||
// the arrow pass below is this function's — keep it single
|
||||
bindings: false,
|
||||
});
|
||||
} else {
|
||||
if (boundTextElement && boundTextFont != null) {
|
||||
scene.mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
});
|
||||
}
|
||||
handleBindTextResize(
|
||||
latestElement,
|
||||
scene,
|
||||
handleDirection,
|
||||
shouldMaintainAspectRatio,
|
||||
shouldResizeFromCenter,
|
||||
flipFactorY < 0,
|
||||
);
|
||||
}
|
||||
handleBindTextResize(
|
||||
latestElement,
|
||||
scene,
|
||||
handleDirection,
|
||||
shouldMaintainAspectRatio,
|
||||
shouldResizeFromCenter,
|
||||
);
|
||||
|
||||
updateBoundElements(latestElement, scene);
|
||||
}
|
||||
@@ -1441,7 +1500,8 @@ export const resizeMultipleElements = (
|
||||
getBoundTextElementId(orig) ?? "",
|
||||
) as ExcalidrawTextElementWithContainer | undefined;
|
||||
|
||||
if (boundTextElement) {
|
||||
// sticky notes derive their label's size from the layout below
|
||||
if (boundTextElement && !isStickyNoteElement(orig)) {
|
||||
if (keepAspectRatio) {
|
||||
const newFontSize = boundTextElement.fontSize * scale;
|
||||
if (newFontSize < MIN_FONT_SIZE) {
|
||||
@@ -1473,9 +1533,27 @@ export const resizeMultipleElements = (
|
||||
|
||||
scene.mutateElement(element, update);
|
||||
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
});
|
||||
if (isStickyNoteElement(element)) {
|
||||
// the content correction runs before the (single) arrow pass, which
|
||||
// must still skip arrows resized in the same gesture
|
||||
updateStickyNoteLayout(element, scene, {
|
||||
...getStickyNoteResizeIntent(
|
||||
element,
|
||||
originalElementsMap,
|
||||
handleDirection,
|
||||
{
|
||||
proportional: keepAspectRatio,
|
||||
fromCenter: shouldResizeFromCenter,
|
||||
flip: flipByX || flipByY,
|
||||
},
|
||||
),
|
||||
bindings: { simultaneouslyUpdated: elementsToUpdate },
|
||||
});
|
||||
} else {
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
if (isBindingElement(element)) {
|
||||
if (element.startBinding) {
|
||||
@@ -1491,7 +1569,11 @@ export const resizeMultipleElements = (
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement && boundTextFontSize) {
|
||||
if (
|
||||
boundTextElement &&
|
||||
boundTextFontSize &&
|
||||
!isStickyNoteElement(element)
|
||||
) {
|
||||
scene.mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFontSize,
|
||||
angle: isLinearElement(element) ? undefined : angle,
|
||||
@@ -1502,6 +1584,7 @@ export const resizeMultipleElements = (
|
||||
handleDirection,
|
||||
true,
|
||||
shouldResizeFromCenter,
|
||||
flipByY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+102
-64
@@ -1,4 +1,4 @@
|
||||
import { simplify } from "points-on-curve";
|
||||
import { pointsOnBezierCurves, simplify } from "points-on-curve";
|
||||
import { getStroke, getStrokePoints } from "perfect-freehand";
|
||||
import { LaserPointer } from "@excalidraw/laser-pointer";
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
pointDistance,
|
||||
type LocalPoint,
|
||||
pointRotateRads,
|
||||
polygonFromPoints,
|
||||
} from "@excalidraw/math";
|
||||
import {
|
||||
ROUGHNESS,
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
|
||||
import type { GlobalPoint } from "@excalidraw/math";
|
||||
import type { GlobalPoint, Polygon } from "@excalidraw/math";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
@@ -575,6 +576,48 @@ const getArrowheadShapes = (
|
||||
}
|
||||
};
|
||||
|
||||
/** The simplified centerline the freedraw fill is drawn along. */
|
||||
const getFreedrawFillCurvePoints = (element: ExcalidrawFreeDrawElement) =>
|
||||
simplify(element.points as Mutable<LocalPoint[]>, 0.75) as [number, number][];
|
||||
|
||||
const freedrawFillPolygonCache = new WeakMap<
|
||||
ExcalidrawFreeDrawElement,
|
||||
{ version: number; polygon: Polygon<LocalPoint> }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Returns the flattened fill contour of a freedraw loop in local, unrotated
|
||||
* coordinates, following the rendered fill rather than the stroke outline.
|
||||
*/
|
||||
export const getFreedrawFillPolygon = (element: ExcalidrawFreeDrawElement) => {
|
||||
const cached = freedrawFillPolygonCache.get(element);
|
||||
if (cached?.version === element.version) {
|
||||
return cached.polygon;
|
||||
}
|
||||
|
||||
// Same curve as the rendered fill, without roughness jitter. RoughJS also
|
||||
// handles inputs simplified to two points.
|
||||
const ops = new RoughGenerator().curve(getFreedrawFillCurvePoints(element), {
|
||||
roughness: 0,
|
||||
disableMultiStroke: true,
|
||||
}).sets[0].ops;
|
||||
const bezierPoints: LocalPoint[] = [];
|
||||
// A single curve pass consists of a move followed by cubic control points.
|
||||
for (const { data } of ops) {
|
||||
for (let i = 0; i < data.length; i += 2) {
|
||||
bezierPoints.push(pointFrom<LocalPoint>(data[i], data[i + 1]));
|
||||
}
|
||||
}
|
||||
|
||||
// Omit the optional distance argument to avoid simplifying the boundary again.
|
||||
const polygon = polygonFromPoints(
|
||||
pointsOnBezierCurves(bezierPoints, 0.5) as LocalPoint[],
|
||||
);
|
||||
freedrawFillPolygonCache.set(element, { version: element.version, polygon });
|
||||
|
||||
return polygon;
|
||||
};
|
||||
|
||||
export const generateLinearCollisionShape = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
@@ -675,69 +718,44 @@ export const generateLinearCollisionShape = (
|
||||
});
|
||||
}
|
||||
case "freedraw": {
|
||||
if (element.points.length < 2) {
|
||||
const outlinePoints = getFreedrawOutlinePoints(element);
|
||||
|
||||
if (outlinePoints.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const simplifiedPoints = simplify(
|
||||
element.points as Mutable<LocalPoint[]>,
|
||||
0.75,
|
||||
);
|
||||
const collisionOutline =
|
||||
element.strokeOptions?.variability === "constant"
|
||||
? (simplify(
|
||||
outlinePoints as Mutable<LocalPoint[]>,
|
||||
CONSTANT_WIDTH_FREEDRAW.COLLISION_SIMPLIFY_TOLERANCE,
|
||||
) as [number, number][])
|
||||
: outlinePoints;
|
||||
|
||||
return generator
|
||||
.curve(simplifiedPoints as [number, number][], options)
|
||||
.sets[0].ops.slice(0, element.points.length)
|
||||
.map((op, i) => {
|
||||
if (i === 0) {
|
||||
const p = pointRotateRads<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[0],
|
||||
element.y + op.data[1],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
if (collisionOutline.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return {
|
||||
op: "move",
|
||||
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||
};
|
||||
}
|
||||
// Close the outline polygon so its boundary never has a gap at the seam.
|
||||
const [firstX, firstY] = collisionOutline[0];
|
||||
const [lastX, lastY] = collisionOutline[collisionOutline.length - 1];
|
||||
const closedOutline =
|
||||
firstX === lastX && firstY === lastY
|
||||
? collisionOutline
|
||||
: [...collisionOutline, collisionOutline[0]];
|
||||
|
||||
return {
|
||||
op: "bcurveTo",
|
||||
data: [
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[0],
|
||||
element.y + op.data[1],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[2],
|
||||
element.y + op.data[3],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[4],
|
||||
element.y + op.data[5],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
]
|
||||
.map((p) =>
|
||||
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||
)
|
||||
.flat(),
|
||||
};
|
||||
});
|
||||
return closedOutline.map((point, idx) => {
|
||||
const p = pointRotateRads<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
return {
|
||||
op: idx === 0 ? "move" : "lineTo",
|
||||
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -962,12 +980,8 @@ const _generateElementShape = (
|
||||
// (1) background fill (rc shape), optional
|
||||
if (isPathALoop(element.points)) {
|
||||
// generate rough polygon to fill freedraw shape
|
||||
const simplifiedPoints = simplify(
|
||||
element.points as Mutable<LocalPoint[]>,
|
||||
0.75,
|
||||
);
|
||||
shapes.push(
|
||||
generator.curve(simplifiedPoints as [number, number][], {
|
||||
generator.curve(getFreedrawFillCurvePoints(element), {
|
||||
...generateRoughOptions(element, false, isDarkMode),
|
||||
stroke: "none",
|
||||
}),
|
||||
@@ -979,6 +993,9 @@ const _generateElementShape = (
|
||||
|
||||
return shapes;
|
||||
}
|
||||
// sticky notes are painted directly (canvas + SVG) from
|
||||
// `getStickyNoteRenderPoints`, never through roughjs
|
||||
case "stickynote":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
case "text":
|
||||
@@ -1073,6 +1090,7 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
): GeometricShape<Point> => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "stickynote":
|
||||
case "diamond":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
@@ -1189,6 +1207,11 @@ const VARIABLE_WIDTH_FREEDRAW = {
|
||||
const CONSTANT_WIDTH_FREEDRAW = {
|
||||
/** Stroke size relative to `strokeWidth` for uniform (laser) strokes. */
|
||||
SIZE_FACTOR: 1.4,
|
||||
/**
|
||||
* Max deviation (px) when dropping vertices of the dense laser outline for
|
||||
* collision. Perfect-freehand outlines are already thinned by `smoothing`.
|
||||
*/
|
||||
COLLISION_SIMPLIFY_TOLERANCE: 0.2,
|
||||
} as const;
|
||||
|
||||
const getFreedrawStreamline = (element: ExcalidrawFreeDrawElement) =>
|
||||
@@ -1253,6 +1276,21 @@ export const getFreedrawOutlinePoints = (
|
||||
: getVariableWidthFreedrawOutline(element);
|
||||
};
|
||||
|
||||
/**
|
||||
* Upper bound on how far a freedraw stroke's ink reaches past its centerline
|
||||
* points. Both stroke generators scale the stroke size by at most 1 (easing
|
||||
* or pressure), so the size itself bounds the radius, except for
|
||||
* perfect-freehand's short strokes: under 3px long, the start cap is drawn
|
||||
* around the first point from an outline point of the last one (reaching
|
||||
* sqrt(size² + 3²)), and a single point gets a synthetic neighbor 1px away.
|
||||
*/
|
||||
export const getFreedrawMaxStrokeRadius = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
) =>
|
||||
element.strokeOptions?.variability === "constant"
|
||||
? element.strokeWidth * CONSTANT_WIDTH_FREEDRAW.SIZE_FACTOR
|
||||
: element.strokeWidth * VARIABLE_WIDTH_FREEDRAW.SIZE_FACTOR + 3;
|
||||
|
||||
/**
|
||||
* The streamline-smoothed centerline the freedraw stroke is rendered
|
||||
* around, in element-local coordinates. Boundary-sensitive consumers (e.g.
|
||||
|
||||
@@ -22,6 +22,37 @@ import {
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
|
||||
export type VerticalResizeAnchor = "top" | "bottom" | "center";
|
||||
|
||||
/** Keeps the chosen edge or center fixed when an element's height changes. */
|
||||
export const getPositionAfterHeightChange = (
|
||||
element: Pick<ExcalidrawElement, "x" | "y" | "height" | "angle">,
|
||||
nextHeight: number,
|
||||
anchor: VerticalResizeAnchor = "top",
|
||||
) => {
|
||||
const delta = (element.height - nextHeight) / 2;
|
||||
|
||||
if (anchor === "center") {
|
||||
// Rotation is about the center, so holding it is angle-independent.
|
||||
return { x: element.x, y: element.y + delta };
|
||||
}
|
||||
|
||||
const sin = Math.sin(element.angle);
|
||||
const cos = Math.cos(element.angle);
|
||||
|
||||
if (anchor === "bottom") {
|
||||
return {
|
||||
x: element.x - delta * sin,
|
||||
y: element.y + delta * (1 + cos),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
x: element.x + delta * sin,
|
||||
y: element.y + delta * (1 - cos),
|
||||
};
|
||||
};
|
||||
|
||||
export const INVISIBLY_SMALL_ELEMENT_SIZE = 0.1;
|
||||
|
||||
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
|
||||
|
||||
@@ -0,0 +1,971 @@
|
||||
import {
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
DEFAULT_STICKY_NOTE_BG,
|
||||
MIN_FONT_SIZE,
|
||||
arrayToMap,
|
||||
STICKY_NOTE_BODY_INSET_Y,
|
||||
STICKY_NOTE_FALLBACK_FONT_SIZE,
|
||||
STICKY_NOTE_FOOTER,
|
||||
STICKY_NOTE_FONT_STEP,
|
||||
STICKY_NOTE_MAX_FONT_SIZE,
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
STICKY_NOTE_MIN_FONT_SIZE,
|
||||
STICKY_NOTE_PADDING,
|
||||
STICKY_NOTE_SHADOW_OFFSET,
|
||||
getFontString,
|
||||
getLineHeight,
|
||||
isTransparent,
|
||||
seededRandom,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { clamp } from "@excalidraw/math";
|
||||
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { newElementWith } from "./mutateElement";
|
||||
import { getPositionAfterHeightChange } from "./sizeHelpers";
|
||||
import { computeBoundTextPosition, getBoundTextElement } from "./textElement";
|
||||
import { measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import { isStickyNoteElement, isTextElement } from "./typeChecks";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
import type { VerticalResizeAnchor } from "./sizeHelpers";
|
||||
import type { TransformHandleDirection } from "./transformHandles";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawStickyNoteElement,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
export type StickyNoteRenderPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type StickyNotePathCommand =
|
||||
| {
|
||||
type: "move";
|
||||
point: StickyNoteRenderPoint;
|
||||
}
|
||||
| {
|
||||
type: "line";
|
||||
point: StickyNoteRenderPoint;
|
||||
}
|
||||
| {
|
||||
type: "quadratic";
|
||||
control: StickyNoteRenderPoint;
|
||||
point: StickyNoteRenderPoint;
|
||||
};
|
||||
|
||||
const STICKY_NOTE_RENDER_ROUGHNESS = [0, 1.5, 8] as const;
|
||||
const STICKY_NOTE_CORNER_RADIUS_RATIO = 0.04;
|
||||
const STICKY_NOTE_MAX_CORNER_RADIUS = 16;
|
||||
|
||||
export const normalizeStickyNoteStrokeColor = (
|
||||
strokeColor: string | null | undefined,
|
||||
) => {
|
||||
return !strokeColor || isTransparent(strokeColor)
|
||||
? DEFAULT_ELEMENT_PROPS.strokeColor
|
||||
: strokeColor;
|
||||
};
|
||||
|
||||
export const normalizeStickyNoteBackgroundColor = (
|
||||
backgroundColor: string | null | undefined,
|
||||
) => {
|
||||
return !backgroundColor || isTransparent(backgroundColor)
|
||||
? DEFAULT_STICKY_NOTE_BG
|
||||
: backgroundColor;
|
||||
};
|
||||
|
||||
/**
|
||||
* The update that applies a picked color to an element under the sticky
|
||||
* note policy: a note is always filled and its label — the visible text,
|
||||
* which the note's own `strokeColor` seeds — never goes transparent. Every
|
||||
* color surface (actions, both eyedroppers, paste styles, bind) routes
|
||||
* through this instead of re-deriving the rule.
|
||||
*/
|
||||
/**
|
||||
* The element a color pick on `element` lands on. A note's label has no fill
|
||||
* of its own, so a background pick on the label — the styles panel while
|
||||
* editing it — goes to the note; everything else colors itself.
|
||||
*/
|
||||
export const getColorTargetElement = (
|
||||
element: ExcalidrawElement,
|
||||
property: "strokeColor" | "backgroundColor",
|
||||
elementsMap: ElementsMap,
|
||||
): ExcalidrawElement => {
|
||||
if (
|
||||
property === "backgroundColor" &&
|
||||
isTextElement(element) &&
|
||||
isStickyNoteBoundText(element, elementsMap)
|
||||
) {
|
||||
return elementsMap.get(element.containerId!) ?? element;
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
export const getColorUpdate = (
|
||||
element: ExcalidrawElement,
|
||||
property: "strokeColor" | "backgroundColor",
|
||||
color: string,
|
||||
elementsMap: ElementsMap,
|
||||
): { strokeColor: string } | { backgroundColor: string } => {
|
||||
if (isStickyNoteElement(element)) {
|
||||
return property === "backgroundColor"
|
||||
? { backgroundColor: normalizeStickyNoteBackgroundColor(color) }
|
||||
: { strokeColor: normalizeStickyNoteStrokeColor(color) };
|
||||
}
|
||||
if (
|
||||
property === "strokeColor" &&
|
||||
isTextElement(element) &&
|
||||
isStickyNoteBoundText(element, elementsMap)
|
||||
) {
|
||||
const container = elementsMap.get(element.containerId!);
|
||||
return {
|
||||
strokeColor: isTransparent(color)
|
||||
? normalizeStickyNoteStrokeColor(container?.strokeColor)
|
||||
: color,
|
||||
};
|
||||
}
|
||||
return property === "backgroundColor"
|
||||
? { backgroundColor: color }
|
||||
: { strokeColor: color };
|
||||
};
|
||||
|
||||
/**
|
||||
* A note's ink is one color: the container's `strokeColor` — the seed for a
|
||||
* new label and what the creation-date footer paints with — and its label's.
|
||||
* Run after a property write to copy the side that changed onto the other;
|
||||
* when both changed, or neither (data that drifted), the label wins: it is
|
||||
* the text the user styled. A transparent label always takes the note's
|
||||
* color. Returns the same array when nothing needs to change.
|
||||
*/
|
||||
export const syncStickyNoteInk = <T extends ExcalidrawElement>(
|
||||
elements: readonly T[],
|
||||
prevElementsMap: ElementsMap,
|
||||
): readonly T[] => {
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const inkById = new Map<string, string>();
|
||||
for (const container of elements) {
|
||||
if (!isStickyNoteElement(container) || container.isDeleted) {
|
||||
continue;
|
||||
}
|
||||
const label = getBoundTextElement(container, elementsMap);
|
||||
if (!label || label.strokeColor === container.strokeColor) {
|
||||
continue;
|
||||
}
|
||||
const containerChanged =
|
||||
prevElementsMap.get(container.id)?.strokeColor !== container.strokeColor;
|
||||
const labelChanged =
|
||||
prevElementsMap.get(label.id)?.strokeColor !== label.strokeColor;
|
||||
if (
|
||||
isTransparent(label.strokeColor) ||
|
||||
(containerChanged && !labelChanged)
|
||||
) {
|
||||
inkById.set(
|
||||
label.id,
|
||||
normalizeStickyNoteStrokeColor(container.strokeColor),
|
||||
);
|
||||
} else {
|
||||
inkById.set(
|
||||
container.id,
|
||||
normalizeStickyNoteStrokeColor(label.strokeColor),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!inkById.size) {
|
||||
return elements;
|
||||
}
|
||||
return elements.map((element) => {
|
||||
const strokeColor = inkById.get(element.id);
|
||||
return strokeColor === undefined
|
||||
? element
|
||||
: (newElementWith(element as ExcalidrawElement, { strokeColor }) as T);
|
||||
});
|
||||
};
|
||||
|
||||
const jitter = (random: () => number, amount: number) =>
|
||||
(random() * 2 - 1) * amount;
|
||||
|
||||
const pointAtDistance = (
|
||||
from: StickyNoteRenderPoint,
|
||||
to: StickyNoteRenderPoint,
|
||||
distance: number,
|
||||
) => {
|
||||
const dx = to.x - from.x;
|
||||
const dy = to.y - from.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
|
||||
if (!length) {
|
||||
return from;
|
||||
}
|
||||
|
||||
const ratio = Math.min(distance / length, 1);
|
||||
return {
|
||||
x: from.x + dx * ratio,
|
||||
y: from.y + dy * ratio,
|
||||
};
|
||||
};
|
||||
|
||||
export const getStickyNoteCornerRadius = (
|
||||
element: ExcalidrawStickyNoteElement,
|
||||
) => {
|
||||
if (!element.roundness) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
Math.min(element.width, element.height) * STICKY_NOTE_CORNER_RADIUS_RATIO,
|
||||
STICKY_NOTE_MAX_CORNER_RADIUS,
|
||||
);
|
||||
};
|
||||
|
||||
export const getStickyNoteRenderPoints = (
|
||||
element: ExcalidrawStickyNoteElement,
|
||||
{
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
seedOffset = 0,
|
||||
}: {
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
seedOffset?: number;
|
||||
} = {},
|
||||
): StickyNoteRenderPoint[] => {
|
||||
const roughness = Math.max(0, Math.min(2, Math.round(element.roughness)));
|
||||
const amount = Math.min(
|
||||
STICKY_NOTE_RENDER_ROUGHNESS[roughness],
|
||||
Math.min(element.width, element.height) * 0.012,
|
||||
);
|
||||
|
||||
if (!amount) {
|
||||
return [
|
||||
{ x: offsetX, y: offsetY },
|
||||
{ x: offsetX + element.width, y: offsetY },
|
||||
{ x: offsetX + element.width, y: offsetY + element.height },
|
||||
{ x: offsetX, y: offsetY + element.height },
|
||||
];
|
||||
}
|
||||
|
||||
const random = seededRandom(element.seed + seedOffset);
|
||||
|
||||
return [
|
||||
{
|
||||
x: offsetX + jitter(random, amount),
|
||||
y: offsetY + jitter(random, amount),
|
||||
},
|
||||
{
|
||||
x: offsetX + element.width + jitter(random, amount),
|
||||
y: offsetY + jitter(random, amount),
|
||||
},
|
||||
{
|
||||
x: offsetX + element.width + jitter(random, amount),
|
||||
y: offsetY + element.height + jitter(random, amount),
|
||||
},
|
||||
{
|
||||
x: offsetX + jitter(random, amount),
|
||||
y: offsetY + element.height + jitter(random, amount),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const getStickyNotePathCommands = (
|
||||
element: ExcalidrawStickyNoteElement,
|
||||
{ shadow = false }: { shadow?: boolean } = {},
|
||||
): StickyNotePathCommand[] => {
|
||||
const points = getStickyNoteRenderPoints(
|
||||
element,
|
||||
shadow
|
||||
? {
|
||||
offsetX: STICKY_NOTE_SHADOW_OFFSET,
|
||||
offsetY: STICKY_NOTE_SHADOW_OFFSET,
|
||||
seedOffset: 1,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const radius = getStickyNoteCornerRadius(element);
|
||||
// The note's seed keeps the same corner for the paper and shadow through
|
||||
// redraws and resizes.
|
||||
const liftedCorner =
|
||||
element.roughness === 2
|
||||
? Math.floor(seededRandom(element.seed)() * points.length)
|
||||
: -1;
|
||||
|
||||
if (!radius && liftedCorner === -1) {
|
||||
return [
|
||||
{ type: "move", point: points[0] },
|
||||
...points.slice(1).map(
|
||||
(point): StickyNotePathCommand => ({
|
||||
type: "line",
|
||||
point,
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const corners = points.map((point, index): StickyNotePathCommand[] => {
|
||||
const prev = points[(index + points.length - 1) % points.length];
|
||||
const next = points[(index + 1) % points.length];
|
||||
const cornerRadius = Math.min(
|
||||
radius,
|
||||
Math.hypot(point.x - prev.x, point.y - prev.y) / 2,
|
||||
Math.hypot(point.x - next.x, point.y - next.y) / 2,
|
||||
);
|
||||
|
||||
if (index === liftedCorner) {
|
||||
const size = Math.min(element.width, element.height);
|
||||
const reach = Math.min(size * 0.18, 40);
|
||||
// The shadow follows the paper inward at half the bend, keeping its
|
||||
// usual down-right offset from the light at the top left.
|
||||
const lift = Math.min(size * 0.02, 5) * (shadow ? 0.5 : 1);
|
||||
// The bend joins the straight edges before the corner, keeping it local.
|
||||
const tip = {
|
||||
x: point.x + (index === 1 || index === 2 ? -lift : lift),
|
||||
y: point.y + (index >= 2 ? -lift : lift),
|
||||
};
|
||||
const start = pointAtDistance(point, prev, reach);
|
||||
const end = pointAtDistance(point, next, reach);
|
||||
return [
|
||||
{ type: "line", point: start },
|
||||
{
|
||||
type: "quadratic",
|
||||
control: pointAtDistance(point, prev, reach / 2),
|
||||
point: pointAtDistance(tip, start, cornerRadius),
|
||||
},
|
||||
{
|
||||
type: "quadratic",
|
||||
control: tip,
|
||||
point: pointAtDistance(tip, end, cornerRadius),
|
||||
},
|
||||
{
|
||||
type: "quadratic",
|
||||
control: pointAtDistance(point, next, reach / 2),
|
||||
point: end,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ type: "line", point: pointAtDistance(point, prev, cornerRadius) },
|
||||
{
|
||||
type: "quadratic",
|
||||
control: point,
|
||||
point: pointAtDistance(point, next, cornerRadius),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const commands: StickyNotePathCommand[] = [
|
||||
{ type: "move", point: corners[0][corners[0].length - 1].point },
|
||||
];
|
||||
|
||||
for (let index = 1; index <= corners.length; index++) {
|
||||
commands.push(...corners[index % corners.length]);
|
||||
}
|
||||
|
||||
return commands;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// font size — `baseFontSize` is the size the user picked, `fontSize` the fitted one
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// clamping the ceiling: the fit steps down from it in `STICKY_NOTE_FONT_STEP`
|
||||
// increments, and for values >= ~2^56 (1e20 - 2 === 1e20) that would never
|
||||
// progress and hang the editor
|
||||
export const normalizeStickyNoteFontSize = (fontSize: number) => {
|
||||
if (!Number.isFinite(fontSize)) {
|
||||
return STICKY_NOTE_FALLBACK_FONT_SIZE;
|
||||
}
|
||||
return Math.min(STICKY_NOTE_MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, fontSize));
|
||||
};
|
||||
|
||||
/** whether the text element is the label of a sticky note */
|
||||
export const isStickyNoteBoundText = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return (
|
||||
!!textElement.containerId &&
|
||||
isStickyNoteElement(elementsMap.get(textElement.containerId))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The font size the user picked: the ceiling the auto-fit shrinks from for a
|
||||
* sticky note label, plain `fontSize` for any other text.
|
||||
*
|
||||
* Container-aware on purpose — generic binding repair (duplication, history)
|
||||
* rewrites `containerId` without touching the ceiling, so a numeric
|
||||
* `baseFontSize` alone does not prove the text is still sticky-bound.
|
||||
*/
|
||||
export const getBaseFontSize = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return isStickyNoteBoundText(textElement, elementsMap)
|
||||
? textElement.baseFontSize ?? textElement.fontSize
|
||||
: textElement.fontSize;
|
||||
};
|
||||
|
||||
/** the update that applies a user-picked font size (see `getBaseFontSize`) */
|
||||
export const getBaseFontSizeUpdate = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
fontSize: number,
|
||||
elementsMap: ElementsMap,
|
||||
): { fontSize: number } | { baseFontSize: number } => {
|
||||
return isStickyNoteBoundText(textElement, elementsMap)
|
||||
? { baseFontSize: normalizeStickyNoteFontSize(fontSize) }
|
||||
: { fontSize };
|
||||
};
|
||||
|
||||
const MONTHS = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
|
||||
/**
|
||||
* The creation-date label: absolute, so painting needs no clock and exports
|
||||
* don't go stale, and short ("7 Sep") while the year is the current one.
|
||||
* `null` when the note has no usable timestamp (files restored without one).
|
||||
* Fixed English in the viewer's local time zone — the element package has no
|
||||
* locale, and the footer is chosen by width bucket rather than measured.
|
||||
*/
|
||||
export const getStickyNoteDateLabel = (
|
||||
created: ExcalidrawStickyNoteElement["created"],
|
||||
{ short = false, now = Date.now() }: { short?: boolean; now?: number } = {},
|
||||
): string | null => {
|
||||
if (created === null || !Number.isFinite(created)) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(created);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const label = `${date.getDate()} ${MONTHS[date.getMonth()]}`;
|
||||
const year = date.getFullYear();
|
||||
return short || year === new Date(now).getFullYear()
|
||||
? label
|
||||
: `${label} ${year}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the footer paints and where, in note-local coordinates — shared by the
|
||||
* canvas and SVG renderers, which take the font and opacity from
|
||||
* `STICKY_NOTE_FOOTER`. `null` for the 0×0 creation draft and anything under
|
||||
* the data floor, where the band would overlap the top padding.
|
||||
*/
|
||||
export const getStickyNoteFooter = (
|
||||
element: Pick<ExcalidrawStickyNoteElement, "created" | "width" | "height">,
|
||||
now = Date.now(),
|
||||
) => {
|
||||
if (
|
||||
element.width < STICKY_NOTE_MIN_SIZE ||
|
||||
element.height < STICKY_NOTE_MIN_SIZE
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const text = getStickyNoteDateLabel(element.created, {
|
||||
short:
|
||||
element.width - STICKY_NOTE_PADDING * 2 <
|
||||
STICKY_NOTE_FOOTER.minBodyWidthForYear,
|
||||
now,
|
||||
});
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
text,
|
||||
x: element.width - STICKY_NOTE_PADDING,
|
||||
y: element.height - STICKY_NOTE_FOOTER.baselineFromBottom,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The smallest note the UI lets a user create or resize to: one line at the
|
||||
* label's font ceiling plus padding (and, vertically, the footer), never below
|
||||
* `STICKY_NOTE_MIN_SIZE`. Without the font term a fresh note would grow on
|
||||
* the very first keystroke. Data-level passes (restore, action post-passes)
|
||||
* only enforce the constant floor — the layout grows a note as needed.
|
||||
*/
|
||||
export const getStickyNoteMinSize = ({
|
||||
fontSize,
|
||||
fontFamily,
|
||||
}: Pick<ExcalidrawTextElement, "fontSize" | "fontFamily">) => {
|
||||
const lineHeightPx = Math.ceil(
|
||||
normalizeStickyNoteFontSize(fontSize) * getLineHeight(fontFamily),
|
||||
);
|
||||
return {
|
||||
width: Math.max(
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
lineHeightPx + STICKY_NOTE_PADDING * 2,
|
||||
),
|
||||
height: Math.max(
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
lineHeightPx + STICKY_NOTE_BODY_INSET_Y,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// layout — one pure calculation, two ways of applying it
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type StickyNoteLayoutAnchor = VerticalResizeAnchor;
|
||||
|
||||
export type StickyNoteLayoutOpts = {
|
||||
/** unwrapped text to lay out; defaults to the label's `originalText` */
|
||||
originalText?: string;
|
||||
/**
|
||||
* absolute base-height intent — the gesture-start value to preserve, or the
|
||||
* height a resize asked for. Omitted = keep the note's live `baseHeight`
|
||||
* (typing, style changes, restore).
|
||||
*/
|
||||
baseHeight?: number;
|
||||
/**
|
||||
* absolute font-ceiling intent (a proportional resize passes
|
||||
* `gesture-start ceiling × scale`). Omitted = keep the label's live ceiling.
|
||||
* Never a multiplier on the live value — that compounds across pointer-moves.
|
||||
*/
|
||||
baseFontSize?: number;
|
||||
/** the edge that stays put when the content correction changes the height */
|
||||
anchor?: StickyNoteLayoutAnchor;
|
||||
};
|
||||
|
||||
export type StickyNoteLayout = {
|
||||
container: Pick<
|
||||
ExcalidrawStickyNoteElement,
|
||||
"x" | "y" | "width" | "height" | "baseHeight"
|
||||
>;
|
||||
text: Pick<
|
||||
ExcalidrawTextElement,
|
||||
| "text"
|
||||
| "fontSize"
|
||||
| "baseFontSize"
|
||||
| "width"
|
||||
| "height"
|
||||
| "x"
|
||||
| "y"
|
||||
| "angle"
|
||||
> | null;
|
||||
};
|
||||
|
||||
type FontFit = {
|
||||
text: string;
|
||||
fontSize: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// `computeBoundTextPosition` only consults the map for arrow containers
|
||||
const NO_ELEMENTS: ElementsMap = new Map();
|
||||
|
||||
const getStickyNoteBaseWidth = (container: ExcalidrawStickyNoteElement) => {
|
||||
return Math.max(container.width, STICKY_NOTE_MIN_SIZE);
|
||||
};
|
||||
|
||||
/**
|
||||
* Largest font size on the grid `{ceiling − k·STEP} ∪ {min}` whose wrapped
|
||||
* text fits the note; `min` when nothing fits (the note then grows). The grid
|
||||
* is anchored at the ceiling so the result never depends on earlier edits.
|
||||
* Warm-started from the previously fitted size: the common keystroke costs one
|
||||
* or two wrap+measure passes, a cold search at most log2(steps) + 2.
|
||||
*/
|
||||
const fitStickyNoteFont = (
|
||||
fit: (fontSize: number) => FontFit,
|
||||
{
|
||||
baseFontSize,
|
||||
fontSizeMin,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
warmStart,
|
||||
}: {
|
||||
baseFontSize: number;
|
||||
fontSizeMin: number;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
warmStart: number;
|
||||
},
|
||||
): FontFit => {
|
||||
const steps = Math.max(
|
||||
0,
|
||||
Math.ceil((baseFontSize - fontSizeMin) / STICKY_NOTE_FONT_STEP),
|
||||
);
|
||||
const sizeAt = (index: number) =>
|
||||
index >= steps ? fontSizeMin : baseFontSize - index * STICKY_NOTE_FONT_STEP;
|
||||
|
||||
const fits = new Map<number, FontFit>();
|
||||
const at = (index: number) => {
|
||||
let result = fits.get(index);
|
||||
if (!result) {
|
||||
result = fit(sizeAt(index));
|
||||
fits.set(index, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const doesFit = (index: number) => {
|
||||
const result = at(index);
|
||||
return result.width <= maxWidth && result.height <= maxHeight;
|
||||
};
|
||||
|
||||
if (steps === 0) {
|
||||
return at(0);
|
||||
}
|
||||
|
||||
// the previous fitted size, snapped onto the grid and clamped into the
|
||||
// current interval (a lowered ceiling must not keep the old larger size)
|
||||
const warm = clamp(
|
||||
Math.round((baseFontSize - warmStart) / STICKY_NOTE_FONT_STEP),
|
||||
0,
|
||||
steps,
|
||||
);
|
||||
|
||||
// binary search for the smallest index (largest font) that fits
|
||||
let lo: number;
|
||||
let hi: number;
|
||||
if (doesFit(warm)) {
|
||||
if (warm === 0 || !doesFit(warm - 1)) {
|
||||
return at(warm);
|
||||
}
|
||||
lo = 0;
|
||||
hi = warm - 1;
|
||||
} else {
|
||||
if (warm === steps) {
|
||||
return at(steps);
|
||||
}
|
||||
lo = warm + 1;
|
||||
hi = steps;
|
||||
}
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (doesFit(mid)) {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
return at(lo);
|
||||
};
|
||||
|
||||
/**
|
||||
* The single source of truth for a sticky note's geometry: wraps the label at
|
||||
* the note's width, fits the font under the ceiling, grows the note past
|
||||
* `baseHeight` only when the text still overflows at the minimum size, and
|
||||
* positions the label inside. Pure — returns the updates for both elements.
|
||||
*/
|
||||
export const getStickyNoteLayout = (
|
||||
container: ExcalidrawStickyNoteElement,
|
||||
textElement: ExcalidrawTextElement | null,
|
||||
opts: StickyNoteLayoutOpts = {},
|
||||
): StickyNoteLayout => {
|
||||
const baseWidth = getStickyNoteBaseWidth(container);
|
||||
const baseHeight = Math.max(
|
||||
opts.baseHeight ?? (container.baseHeight || container.height),
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
);
|
||||
const anchor = opts.anchor ?? "top";
|
||||
|
||||
if (!textElement) {
|
||||
// an empty note sits at its base height
|
||||
return {
|
||||
container: {
|
||||
...getPositionAfterHeightChange(container, baseHeight, anchor),
|
||||
width: baseWidth,
|
||||
height: baseHeight,
|
||||
baseHeight,
|
||||
},
|
||||
text: null,
|
||||
};
|
||||
}
|
||||
|
||||
const originalText = opts.originalText ?? textElement.originalText;
|
||||
const baseFontSize = normalizeStickyNoteFontSize(
|
||||
opts.baseFontSize ?? textElement.baseFontSize ?? textElement.fontSize,
|
||||
);
|
||||
const fontSizeMin = Math.min(STICKY_NOTE_MIN_FONT_SIZE, baseFontSize);
|
||||
const maxWidth = Math.max(baseWidth - STICKY_NOTE_PADDING * 2, 1);
|
||||
const maxHeight = Math.max(baseHeight - STICKY_NOTE_BODY_INSET_Y, 0);
|
||||
const { fontFamily, lineHeight } = textElement;
|
||||
|
||||
const fit = (fontSize: number): FontFit => {
|
||||
const font = getFontString({ fontFamily, fontSize });
|
||||
const text = wrapText(originalText, font, maxWidth);
|
||||
return { text, fontSize, ...measureText(text, font, lineHeight) };
|
||||
};
|
||||
|
||||
const isBlank = !originalText.trim();
|
||||
const fitted: FontFit = isBlank
|
||||
? {
|
||||
text: "",
|
||||
fontSize: baseFontSize,
|
||||
...measureText(
|
||||
"",
|
||||
getFontString({ fontFamily, fontSize: baseFontSize }),
|
||||
lineHeight,
|
||||
),
|
||||
}
|
||||
: fitStickyNoteFont(fit, {
|
||||
baseFontSize,
|
||||
fontSizeMin,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
warmStart: textElement.fontSize,
|
||||
});
|
||||
|
||||
const height = isBlank
|
||||
? baseHeight
|
||||
: Math.max(baseHeight, fitted.height + STICKY_NOTE_BODY_INSET_Y);
|
||||
const nextContainer = {
|
||||
...getPositionAfterHeightChange(container, height, anchor),
|
||||
width: baseWidth,
|
||||
height,
|
||||
baseHeight,
|
||||
};
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
{ ...container, ...nextContainer },
|
||||
{
|
||||
...textElement,
|
||||
text: fitted.text,
|
||||
fontSize: fitted.fontSize,
|
||||
width: fitted.width,
|
||||
height: fitted.height,
|
||||
} as ExcalidrawTextElementWithContainer,
|
||||
NO_ELEMENTS,
|
||||
);
|
||||
|
||||
return {
|
||||
container: nextContainer,
|
||||
text: {
|
||||
text: fitted.text,
|
||||
fontSize: fitted.fontSize,
|
||||
baseFontSize,
|
||||
width: fitted.width,
|
||||
height: fitted.height,
|
||||
x,
|
||||
y,
|
||||
angle: container.angle,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Resize intents for a sticky note — the §3.3 matrix of the plan. Every value
|
||||
* is absolute and derived from the gesture-start snapshot on each pointer-move,
|
||||
* so releasing Shift (or Alt) mid-gesture restores the original base height
|
||||
* and ceiling instead of keeping values a previous move scaled.
|
||||
*
|
||||
* - width-only gestures (free E/W, Stats W) preserve the base height
|
||||
* - height-changing gestures use the requested height as the new base
|
||||
* - proportional gestures (Shift on any handle, aspect-locked multi-select,
|
||||
* Stats group) additionally scale the font ceiling with the note
|
||||
* - flips preserve everything
|
||||
* - the content correction anchors at the edge the gesture holds still
|
||||
*/
|
||||
export const getStickyNoteResizeIntent = (
|
||||
/** the note with the requested geometry already applied */
|
||||
container: ExcalidrawStickyNoteElement,
|
||||
originalElementsMap: ElementsMap,
|
||||
handleDirection: TransformHandleDirection,
|
||||
{
|
||||
proportional,
|
||||
fromCenter,
|
||||
flip = false,
|
||||
}: { proportional: boolean; fromCenter: boolean; flip?: boolean },
|
||||
): Pick<StickyNoteLayoutOpts, "baseHeight" | "baseFontSize" | "anchor"> => {
|
||||
const origContainer = originalElementsMap.get(container.id);
|
||||
const origSticky = isStickyNoteElement(origContainer)
|
||||
? origContainer
|
||||
: container;
|
||||
const origText = getBoundTextElement(origSticky, originalElementsMap);
|
||||
const ceiling = origText
|
||||
? origText.baseFontSize ?? origText.fontSize
|
||||
: undefined;
|
||||
|
||||
if (flip) {
|
||||
return { baseHeight: origSticky.baseHeight, baseFontSize: ceiling };
|
||||
}
|
||||
|
||||
const changesHeight =
|
||||
proportional ||
|
||||
handleDirection.includes("n") ||
|
||||
handleDirection.includes("s");
|
||||
const scale =
|
||||
proportional && origSticky.width ? container.width / origSticky.width : 1;
|
||||
const isSideHandle = handleDirection.length === 1;
|
||||
|
||||
return {
|
||||
baseHeight: changesHeight ? container.height : origSticky.baseHeight,
|
||||
baseFontSize: ceiling === undefined ? undefined : ceiling * scale,
|
||||
anchor: fromCenter
|
||||
? "center"
|
||||
: handleDirection.includes("n")
|
||||
? "bottom"
|
||||
: proportional &&
|
||||
isSideHandle &&
|
||||
(handleDirection === "e" || handleDirection === "w")
|
||||
? // Shift+E/W holds the opposite side's midpoint, i.e. the vertical center
|
||||
"center"
|
||||
: "top",
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies `getStickyNoteLayout` to a live scene, then runs the bound-arrow
|
||||
* pass unless the caller already owns one (`bindings: false`) or needs to
|
||||
* forward its context (`bindings: { simultaneouslyUpdated }` — arrows resized
|
||||
* in the same gesture must not be moved by the pass, `updateBoundElements`
|
||||
* skips them only when told).
|
||||
*/
|
||||
export const updateStickyNoteLayout = (
|
||||
container: ExcalidrawStickyNoteElement,
|
||||
scene: Scene,
|
||||
{
|
||||
text,
|
||||
bindings,
|
||||
...layoutOpts
|
||||
}: StickyNoteLayoutOpts & {
|
||||
/** the label to lay out when it is an uncommitted clone (font actions clone before install) */
|
||||
text?: ExcalidrawTextElement | null;
|
||||
bindings?:
|
||||
| { simultaneouslyUpdated?: readonly NonDeletedExcalidrawElement[] }
|
||||
| false;
|
||||
} = {},
|
||||
): StickyNoteLayout => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const textElement =
|
||||
text === undefined ? getBoundTextElement(container, elementsMap) : text;
|
||||
const layout = getStickyNoteLayout(container, textElement, layoutOpts);
|
||||
|
||||
scene.mutateElement(container, layout.container);
|
||||
if (textElement && layout.text) {
|
||||
scene.mutateElement(textElement, layout.text);
|
||||
}
|
||||
if (bindings !== false && !container.isDeleted) {
|
||||
updateBoundElements(
|
||||
container as NonDeletedExcalidrawElement,
|
||||
scene,
|
||||
bindings || undefined,
|
||||
);
|
||||
}
|
||||
|
||||
return layout;
|
||||
};
|
||||
|
||||
const STICKY_NOTE_LAYOUT_INPUTS = {
|
||||
container: ["x", "y", "width", "baseHeight", "angle"],
|
||||
text: [
|
||||
"originalText",
|
||||
"baseFontSize",
|
||||
"fontFamily",
|
||||
"lineHeight",
|
||||
"textAlign",
|
||||
"verticalAlign",
|
||||
],
|
||||
} as const;
|
||||
|
||||
const hasStickyNoteLayoutInputChanged = (
|
||||
container: ExcalidrawStickyNoteElement,
|
||||
textElement: ExcalidrawTextElement | null,
|
||||
prevElementsMap: ElementsMap,
|
||||
) => {
|
||||
const prevContainer = prevElementsMap.get(container.id);
|
||||
const prevText = textElement ? prevElementsMap.get(textElement.id) : null;
|
||||
if (
|
||||
!prevContainer ||
|
||||
(textElement && !prevText) ||
|
||||
(!textElement &&
|
||||
prevContainer.boundElements?.some(({ type }) => type === "text"))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
STICKY_NOTE_LAYOUT_INPUTS.container.some(
|
||||
(key) => (prevContainer as any)[key] !== container[key],
|
||||
) ||
|
||||
(!!textElement &&
|
||||
STICKY_NOTE_LAYOUT_INPUTS.text.some(
|
||||
(key) => (prevText as any)[key] !== textElement[key],
|
||||
))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies `getStickyNoteLayout` inside an immutable elements array (property
|
||||
* actions, paste styles, restore): the notes among `affectedIds` — or the
|
||||
* notes whose labels are — get both halves merged into their clones. Elements
|
||||
* that need no change keep their identity and version; when `prevElementsMap`
|
||||
* is given, notes whose layout inputs did not change skip the fit entirely.
|
||||
* Bound arrows are the caller's job once the result is installed.
|
||||
*/
|
||||
export const relayoutStickyNotes = <T extends ExcalidrawElement>(
|
||||
elements: readonly T[],
|
||||
affectedIds: ReadonlySet<ExcalidrawElement["id"]>,
|
||||
opts?: { prevElementsMap?: ElementsMap },
|
||||
): readonly T[] => {
|
||||
if (!affectedIds.size) {
|
||||
return elements;
|
||||
}
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const containerIds = new Set<ExcalidrawElement["id"]>();
|
||||
for (const id of affectedIds) {
|
||||
const element = elementsMap.get(id);
|
||||
if (!element || element.isDeleted) {
|
||||
continue;
|
||||
}
|
||||
if (isStickyNoteElement(element)) {
|
||||
containerIds.add(element.id);
|
||||
} else if (isTextElement(element) && element.containerId) {
|
||||
const container = elementsMap.get(element.containerId);
|
||||
if (container && !container.isDeleted && isStickyNoteElement(container)) {
|
||||
containerIds.add(container.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!containerIds.size) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
const replacements = new Map<ExcalidrawElement["id"], T>();
|
||||
for (const id of containerIds) {
|
||||
const container = elementsMap.get(id) as ExcalidrawStickyNoteElement;
|
||||
const textElement = getBoundTextElement(container, elementsMap);
|
||||
if (
|
||||
opts?.prevElementsMap &&
|
||||
!hasStickyNoteLayoutInputChanged(
|
||||
container,
|
||||
textElement,
|
||||
opts.prevElementsMap,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const layout = getStickyNoteLayout(container, textElement);
|
||||
const nextContainer = newElementWith(container, layout.container);
|
||||
if (nextContainer !== container) {
|
||||
replacements.set(id, nextContainer as unknown as T);
|
||||
}
|
||||
if (textElement && layout.text) {
|
||||
const nextText = newElementWith(textElement, layout.text);
|
||||
if (nextText !== textElement) {
|
||||
replacements.set(textElement.id, nextText as unknown as T);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!replacements.size) {
|
||||
return elements;
|
||||
}
|
||||
return elements.map((element) => replacements.get(element.id) ?? element);
|
||||
};
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
ARROW_LABEL_WIDTH_FRACTION,
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_SIZE,
|
||||
STICKY_NOTE_BODY_INSET_Y,
|
||||
STICKY_NOTE_PADDING,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
getFontString,
|
||||
@@ -19,12 +21,15 @@ import {
|
||||
updateOriginalContainerCache,
|
||||
} from "./containerCache";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { getPositionAfterHeightChange } from "./sizeHelpers";
|
||||
|
||||
import { updateStickyNoteLayout } from "./stickyNote";
|
||||
import { measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isArrowElement,
|
||||
isStickyNoteElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
@@ -50,6 +55,14 @@ export const redrawTextBoundingBox = (
|
||||
) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
if (container && isStickyNoteElement(container)) {
|
||||
// the sticky fit owns both halves (label + note geometry). `textElement`
|
||||
// may be an uncommitted clone (font actions clone before install), so it
|
||||
// is passed explicitly instead of looked up in the scene
|
||||
updateStickyNoteLayout(container, scene, { text: textElement });
|
||||
return;
|
||||
}
|
||||
|
||||
let maxWidth = undefined;
|
||||
|
||||
if (!isProdEnv()) {
|
||||
@@ -145,7 +158,14 @@ export const handleBindTextResize = (
|
||||
transformHandleType: MaybeTransformHandleType,
|
||||
shouldMaintainAspectRatio = false,
|
||||
shouldResizeFromCenter = false,
|
||||
flipByY = false,
|
||||
) => {
|
||||
if (isStickyNoteElement(container)) {
|
||||
// resize callers pass their intents to `updateStickyNoteLayout` directly
|
||||
// and own the bound-arrow pass; this is the fallback for generic callers
|
||||
updateStickyNoteLayout(container, scene, { bindings: false });
|
||||
return;
|
||||
}
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const boundTextElementId = getBoundTextElementId(container);
|
||||
if (!boundTextElementId) {
|
||||
@@ -190,24 +210,24 @@ export const handleBindTextResize = (
|
||||
container.type,
|
||||
);
|
||||
|
||||
const diff = containerHeight - container.height;
|
||||
// fix the y coord when resizing from ne/nw/n
|
||||
// Crossing the opposite edge swaps the anchor for text-driven growth.
|
||||
const shouldResizeFromTop =
|
||||
transformHandleType === "n" ||
|
||||
transformHandleType === "ne" ||
|
||||
transformHandleType === "nw";
|
||||
(transformHandleType === "n" ||
|
||||
transformHandleType === "ne" ||
|
||||
transformHandleType === "nw") !== flipByY;
|
||||
|
||||
let offsetY = 0;
|
||||
if (!isArrowElement(container)) {
|
||||
if (shouldResizeFromCenter) {
|
||||
offsetY = diff / 2;
|
||||
} else if (shouldResizeFromTop) {
|
||||
offsetY = diff;
|
||||
}
|
||||
}
|
||||
scene.mutateElement(container, {
|
||||
height: containerHeight,
|
||||
y: container.y - offsetY,
|
||||
...(!isArrowElement(container) &&
|
||||
getPositionAfterHeightChange(
|
||||
container,
|
||||
containerHeight,
|
||||
shouldResizeFromCenter
|
||||
? "center"
|
||||
: shouldResizeFromTop
|
||||
? "bottom"
|
||||
: "top",
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -248,6 +268,18 @@ export const computeBoundTextPosition = (
|
||||
y = containerCoords.y;
|
||||
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
y = containerCoords.y + (maxContainerHeight - boundTextElement.height);
|
||||
} else if (isStickyNoteElement(container)) {
|
||||
// a note's label body ends above the creation-date footer, but a label
|
||||
// centered in that body sits visibly high — center it in the whole
|
||||
// padded note while it stays clear of the footer, and only push it up
|
||||
// against the body's bottom once it would overlap
|
||||
const paddedHeight = container.height - STICKY_NOTE_PADDING * 2;
|
||||
y =
|
||||
containerCoords.y +
|
||||
Math.min(
|
||||
(paddedHeight - boundTextElement.height) / 2,
|
||||
maxContainerHeight - boundTextElement.height,
|
||||
);
|
||||
} else {
|
||||
y =
|
||||
containerCoords.y +
|
||||
@@ -264,10 +296,17 @@ export const computeBoundTextPosition = (
|
||||
const angle = (container.angle ?? 0) as Radians;
|
||||
|
||||
if (angle !== 0) {
|
||||
const contentCenter = pointFrom(
|
||||
containerCoords.x + maxContainerWidth / 2,
|
||||
containerCoords.y + maxContainerHeight / 2,
|
||||
);
|
||||
// A sticky's footer makes its body asymmetric. The body still rotates
|
||||
// about the note's center, rather than about its own (higher) center.
|
||||
const contentCenter = isStickyNoteElement(container)
|
||||
? pointFrom(
|
||||
container.x + container.width / 2,
|
||||
container.y + container.height / 2,
|
||||
)
|
||||
: pointFrom(
|
||||
containerCoords.x + maxContainerWidth / 2,
|
||||
containerCoords.y + maxContainerHeight / 2,
|
||||
);
|
||||
const textCenter = pointFrom(
|
||||
x + boundTextElement.width / 2,
|
||||
y + boundTextElement.height / 2,
|
||||
@@ -355,8 +394,11 @@ export const getContainerCenter = (
|
||||
};
|
||||
|
||||
export const getContainerCoords = (container: ExcalidrawElement) => {
|
||||
let offsetX = BOUND_TEXT_PADDING;
|
||||
let offsetY = BOUND_TEXT_PADDING;
|
||||
const padding = isStickyNoteElement(container)
|
||||
? STICKY_NOTE_PADDING
|
||||
: BOUND_TEXT_PADDING;
|
||||
let offsetX = padding;
|
||||
let offsetY = padding;
|
||||
|
||||
if (container.type === "ellipse") {
|
||||
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
|
||||
@@ -401,6 +443,35 @@ export const getBoundTextElementPosition = (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the text element with its accurate coords. An arrow label's
|
||||
* position is derived from the arrow at render time rather than kept in
|
||||
* sync on the element itself (see getBoundTextElementPosition), so
|
||||
* consumers reading a label's coords directly must go through this helper.
|
||||
*/
|
||||
export const getTextElementWithAccuratePosition = <
|
||||
T extends ExcalidrawTextElement,
|
||||
>(
|
||||
textElement: T,
|
||||
elementsMap: ElementsMap,
|
||||
): T => {
|
||||
if (!textElement.containerId) {
|
||||
return textElement;
|
||||
}
|
||||
const container = elementsMap.get(textElement.containerId);
|
||||
if (!container || !isArrowElement(container)) {
|
||||
return textElement;
|
||||
}
|
||||
return {
|
||||
...textElement,
|
||||
...LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
elementsMap,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldAllowVerticalAlign = (
|
||||
selectedElements: readonly ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
@@ -436,6 +507,7 @@ export const suppportsHorizontalAlign = (
|
||||
|
||||
const VALID_CONTAINER_TYPES = new Set([
|
||||
"rectangle",
|
||||
"stickynote",
|
||||
"ellipse",
|
||||
"diamond",
|
||||
"arrow",
|
||||
@@ -487,7 +559,13 @@ export const getBoundTextMaxWidth = (
|
||||
// Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
|
||||
return Math.round(width / 2) - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
return width - BOUND_TEXT_PADDING * 2;
|
||||
return (
|
||||
width -
|
||||
(isStickyNoteElement(container)
|
||||
? STICKY_NOTE_PADDING
|
||||
: BOUND_TEXT_PADDING) *
|
||||
2
|
||||
);
|
||||
};
|
||||
|
||||
export const getBoundTextMaxHeight = (
|
||||
@@ -495,6 +573,10 @@ export const getBoundTextMaxHeight = (
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
const { height } = container;
|
||||
if (isStickyNoteElement(container)) {
|
||||
// the label body ends above the creation-date footer
|
||||
return Math.max(0, height - STICKY_NOTE_BODY_INSET_Y);
|
||||
}
|
||||
if (isArrowElement(container)) {
|
||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerHeight <= 0) {
|
||||
@@ -531,3 +613,5 @@ export const getTextFromElements = (
|
||||
.join(separator);
|
||||
return text;
|
||||
};
|
||||
|
||||
export const DEFAULT_BOUND_TEXT_LABEL_POSITION = 0.5;
|
||||
|
||||
@@ -11,30 +11,44 @@ import {
|
||||
assertNever,
|
||||
cloneJSON,
|
||||
getFontString,
|
||||
getUpdatedTimestamp,
|
||||
isDevEnv,
|
||||
toBrandedType,
|
||||
getLineHeight,
|
||||
DEFAULT_STICKY_NOTE_SIZE,
|
||||
isTransparent,
|
||||
DEFAULT_ZOOM,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { MarkOptional } from "@excalidraw/common/utility-types";
|
||||
import type { Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { bindBindingElement } from "./binding";
|
||||
import {
|
||||
newArrowElement,
|
||||
newElement,
|
||||
newEmbeddableElement,
|
||||
newFrameElement,
|
||||
newFreeDrawElement,
|
||||
newIframeElement,
|
||||
newImageElement,
|
||||
newLinearElement,
|
||||
newMagicFrameElement,
|
||||
newTextElement,
|
||||
type ElementConstructorOpts,
|
||||
newStickyNoteElement,
|
||||
normalizeStickyNoteGeometry,
|
||||
} from "./newElement";
|
||||
import { normalizeStickyNoteStrokeColor } from "./stickyNote";
|
||||
import { measureText, normalizeText } from "./textMeasurements";
|
||||
import { isArrowElement } from "./typeChecks";
|
||||
import { isArrowElement, isStickyNoteElement } from "./typeChecks";
|
||||
|
||||
import { syncInvalidIndices } from "./fractionalIndex";
|
||||
|
||||
import { redrawTextBoundingBox } from "./textElement";
|
||||
import {
|
||||
DEFAULT_BOUND_TEXT_LABEL_POSITION,
|
||||
redrawTextBoundingBox,
|
||||
} from "./textElement";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
@@ -63,8 +77,19 @@ import type {
|
||||
Ordered,
|
||||
TextAlign,
|
||||
VerticalAlign,
|
||||
ExcalidrawStickyNoteElement,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Options for elements generated from a skeleton fragment (bound labels and
|
||||
* binding endpoints). Such elements are always constructed fresh, hence they
|
||||
* never carry a creation time of their own.
|
||||
*/
|
||||
type FragmentConstructorOpts = MarkOptional<
|
||||
Omit<ElementConstructorOpts, "created">,
|
||||
"x" | "y"
|
||||
>;
|
||||
|
||||
export type ValidLinearElement = {
|
||||
type: "arrow" | "line";
|
||||
x: number;
|
||||
@@ -75,7 +100,7 @@ export type ValidLinearElement = {
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
} & FragmentConstructorOpts;
|
||||
end?:
|
||||
| (
|
||||
| (
|
||||
@@ -115,9 +140,9 @@ export type ValidLinearElement = {
|
||||
text: string;
|
||||
}
|
||||
) &
|
||||
Partial<ExcalidrawTextElement>)
|
||||
Partial<Omit<ExcalidrawTextElement, "created">>)
|
||||
) &
|
||||
MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
FragmentConstructorOpts;
|
||||
start?:
|
||||
| (
|
||||
| (
|
||||
@@ -157,9 +182,9 @@ export type ValidLinearElement = {
|
||||
text: string;
|
||||
}
|
||||
) &
|
||||
Partial<ExcalidrawTextElement>)
|
||||
Partial<Omit<ExcalidrawTextElement, "created">>)
|
||||
) &
|
||||
MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
FragmentConstructorOpts;
|
||||
} & Partial<ExcalidrawLinearElement>;
|
||||
|
||||
export type ValidContainer =
|
||||
@@ -172,9 +197,21 @@ export type ValidContainer =
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
} & FragmentConstructorOpts;
|
||||
} & ElementConstructorOpts;
|
||||
|
||||
/**
|
||||
* A sticky note: an always-filled note whose label auto-fits. `label.fontSize`
|
||||
* is the font ceiling the fit shrinks from; the note grows past its height
|
||||
* (kept as `baseHeight`) only once the label hits the minimum font size.
|
||||
*/
|
||||
export type ValidStickyNote = {
|
||||
type: "stickynote";
|
||||
id?: ExcalidrawStickyNoteElement["id"];
|
||||
label?: Extract<ValidContainer, { label?: unknown }>["label"];
|
||||
} & ElementConstructorOpts &
|
||||
Partial<Pick<ExcalidrawStickyNoteElement, "baseHeight">>;
|
||||
|
||||
export type ExcalidrawElementSkeleton =
|
||||
| Extract<
|
||||
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
@@ -186,6 +223,7 @@ export type ExcalidrawElementSkeleton =
|
||||
y: number;
|
||||
} & Partial<ExcalidrawLinearElement>)
|
||||
| ValidContainer
|
||||
| ValidStickyNote
|
||||
| ValidLinearElement
|
||||
| ({
|
||||
type: "text";
|
||||
@@ -220,9 +258,19 @@ const DEFAULT_DIMENSION = 100;
|
||||
|
||||
const bindTextToContainer = (
|
||||
container: ExcalidrawElement,
|
||||
textProps: { text: string } & MarkOptional<ElementConstructorOpts, "x" | "y">,
|
||||
textProps: { text: string } & FragmentConstructorOpts,
|
||||
scene: Scene,
|
||||
) => {
|
||||
// a note and its label share one ink: a label that sets its own color
|
||||
// gives it to the note (the footer paints with it); transparent falls
|
||||
// back to the note's
|
||||
const stickyInk = isStickyNoteElement(container)
|
||||
? normalizeStickyNoteStrokeColor(
|
||||
textProps.strokeColor && !isTransparent(textProps.strokeColor)
|
||||
? textProps.strokeColor
|
||||
: container.strokeColor,
|
||||
)
|
||||
: null;
|
||||
const textElement: ExcalidrawTextElement = newTextElement({
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -230,7 +278,10 @@ const bindTextToContainer = (
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
...textProps,
|
||||
containerId: container.id,
|
||||
strokeColor: textProps.strokeColor || container.strokeColor,
|
||||
strokeColor: stickyInk ?? (textProps.strokeColor || container.strokeColor),
|
||||
labelPosition: isArrowElement(container)
|
||||
? DEFAULT_BOUND_TEXT_LABEL_POSITION
|
||||
: null,
|
||||
});
|
||||
|
||||
Object.assign(container, {
|
||||
@@ -238,6 +289,9 @@ const bindTextToContainer = (
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
...(stickyInk && stickyInk !== container.strokeColor
|
||||
? { strokeColor: stickyInk }
|
||||
: null),
|
||||
});
|
||||
|
||||
redrawTextBoundingBox(textElement, container, scene);
|
||||
@@ -251,6 +305,7 @@ const bindLinearElementToElement = (
|
||||
end: ValidLinearElement["end"],
|
||||
elementStore: ElementStore,
|
||||
scene: Scene,
|
||||
zoom: Zoom,
|
||||
): {
|
||||
linearElement: ExcalidrawLinearElement;
|
||||
startBoundElement?: ExcalidrawElement;
|
||||
@@ -338,6 +393,7 @@ const bindLinearElementToElement = (
|
||||
"orbit",
|
||||
"start",
|
||||
scene,
|
||||
zoom,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -414,6 +470,7 @@ const bindLinearElementToElement = (
|
||||
"orbit",
|
||||
"end",
|
||||
scene,
|
||||
zoom,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -525,11 +582,14 @@ export const convertToExcalidrawElements = (
|
||||
const oldToNewElementIdMap = new Map<string, string>();
|
||||
|
||||
// Create individual elements
|
||||
// regenerated ids mean new instances, hence a fresh creation time as well
|
||||
const created = getUpdatedTimestamp();
|
||||
|
||||
for (const element of elements) {
|
||||
let excalidrawElement: ExcalidrawElement;
|
||||
const originalId = element.id;
|
||||
if (opts?.regenerateIds !== false) {
|
||||
Object.assign(element, { id: randomId() });
|
||||
Object.assign(element, { id: randomId(), created });
|
||||
}
|
||||
|
||||
switch (element.type) {
|
||||
@@ -628,10 +688,32 @@ export const convertToExcalidrawElements = (
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "freedraw":
|
||||
case "iframe":
|
||||
case "freedraw": {
|
||||
excalidrawElement = newFreeDrawElement({ ...element });
|
||||
break;
|
||||
}
|
||||
case "iframe": {
|
||||
excalidrawElement = newIframeElement({ ...element });
|
||||
break;
|
||||
}
|
||||
case "stickynote": {
|
||||
const width = element.width || DEFAULT_STICKY_NOTE_SIZE;
|
||||
const height = element.height || DEFAULT_STICKY_NOTE_SIZE;
|
||||
// finalized geometry (min size, baseHeight ≤ height) — only a
|
||||
// pointer-down draft is exempt from it
|
||||
excalidrawElement = normalizeStickyNoteGeometry(
|
||||
newStickyNoteElement({
|
||||
...element,
|
||||
type: "stickynote",
|
||||
width,
|
||||
height,
|
||||
baseHeight: element.baseHeight ?? height,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "embeddable": {
|
||||
excalidrawElement = element;
|
||||
excalidrawElement = newEmbeddableElement({ ...element });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -668,8 +750,11 @@ export const convertToExcalidrawElements = (
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond":
|
||||
case "stickynote":
|
||||
case "arrow": {
|
||||
if (element.label?.text) {
|
||||
// for a sticky note this runs the sticky fit (via
|
||||
// `redrawTextBoundingBox`): the label's font size becomes its ceiling
|
||||
let [container, text] = bindTextToContainer(
|
||||
excalidrawElement,
|
||||
element?.label,
|
||||
@@ -702,6 +787,7 @@ export const convertToExcalidrawElements = (
|
||||
originalEnd,
|
||||
elementStore,
|
||||
scene,
|
||||
DEFAULT_ZOOM,
|
||||
);
|
||||
container = linearElement;
|
||||
elementStore.add(linearElement);
|
||||
@@ -727,6 +813,7 @@ export const convertToExcalidrawElements = (
|
||||
end,
|
||||
elementStore,
|
||||
scene,
|
||||
DEFAULT_ZOOM,
|
||||
);
|
||||
|
||||
elementStore.add(linearElement);
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
ExcalidrawLineElement,
|
||||
ExcalidrawFlowchartNodeElement,
|
||||
ExcalidrawLinearElementSubType,
|
||||
ExcalidrawStickyNoteElement,
|
||||
} from "./types";
|
||||
|
||||
export const isInitializedImageElement = <T extends ExcalidrawElement>(
|
||||
@@ -69,6 +70,12 @@ export const isTextElement = <T extends ExcalidrawElement>(
|
||||
return element != null && element.type === "text";
|
||||
};
|
||||
|
||||
export const isStickyNoteElement = <T extends ExcalidrawElement>(
|
||||
element: T | null | undefined,
|
||||
): element is T & ExcalidrawStickyNoteElement => {
|
||||
return element != null && element.type === "stickynote";
|
||||
};
|
||||
|
||||
export const isFrameElement = <T extends ExcalidrawElement>(
|
||||
element: T | null,
|
||||
): element is T & ExcalidrawFrameElement => {
|
||||
@@ -182,6 +189,7 @@ export const isBindableElement = <T extends ExcalidrawElement>(
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "stickynote" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "image" ||
|
||||
@@ -199,6 +207,7 @@ export const isRectanguloidElement = <T extends ExcalidrawElement>(
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "stickynote" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "image" ||
|
||||
element.type === "iframe" ||
|
||||
@@ -217,6 +226,7 @@ export const isRectangularElement = <T extends ExcalidrawElement>(
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "stickynote" ||
|
||||
element.type === "image" ||
|
||||
element.type === "text" ||
|
||||
element.type === "iframe" ||
|
||||
@@ -235,6 +245,7 @@ export const isTextBindableContainer = <T extends ExcalidrawElement>(
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "stickynote" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
isArrowElement(element))
|
||||
@@ -252,6 +263,7 @@ export const isExcalidrawElement = (
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "rectangle":
|
||||
case "stickynote":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "ellipse":
|
||||
@@ -276,6 +288,7 @@ export const isFlowchartNodeElement = <T extends ExcalidrawElement>(
|
||||
): element is T & ExcalidrawFlowchartNodeElement => {
|
||||
return (
|
||||
element.type === "rectangle" ||
|
||||
element.type === "stickynote" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "diamond"
|
||||
);
|
||||
@@ -313,7 +326,10 @@ export const isUsingAdaptiveRadius = (type: string) =>
|
||||
type === "image";
|
||||
|
||||
export const isUsingProportionalRadius = (type: string) =>
|
||||
type === "line" || type === "arrow" || type === "diamond";
|
||||
type === "line" ||
|
||||
type === "arrow" ||
|
||||
type === "diamond" ||
|
||||
type === "stickynote";
|
||||
|
||||
export const canApplyRoundnessTypeToElement = (
|
||||
roundnessType: RoundnessType,
|
||||
@@ -397,6 +413,7 @@ export const canBecomePolygon = (
|
||||
export const isEligibleFrameChildType = (type: ElementOrToolType) => {
|
||||
switch (type) {
|
||||
case "rectangle":
|
||||
case "stickynote":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
case "arrow":
|
||||
|
||||
@@ -76,6 +76,11 @@ type _ExcalidrawElementBase = Readonly<{
|
||||
boundElements: readonly BoundElement[] | null;
|
||||
/** epoch (ms) timestamp of last element update */
|
||||
updated: number;
|
||||
/** Client wall-clock creation time in epoch milliseconds; null if unknown.
|
||||
Preserved for this element's lifetime, including edits and undo/redo,
|
||||
and excluded from `ElementUpdate` (mutateElement / newElementWith).
|
||||
Duplicating an element starts a new lifetime. Not an ordering clock. */
|
||||
created: number | null;
|
||||
link: string | null;
|
||||
locked: boolean;
|
||||
customData?: Record<string, any>;
|
||||
@@ -89,6 +94,16 @@ export type ExcalidrawRectangleElement = _ExcalidrawElementBase & {
|
||||
type: "rectangle";
|
||||
};
|
||||
|
||||
export type ExcalidrawStickyNoteElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "stickynote";
|
||||
/**
|
||||
* The height the user set, from which the layout derives `height`: the
|
||||
* note grows above it to fit its label and never shrinks below it
|
||||
*/
|
||||
baseHeight: number;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawDiamondElement = _ExcalidrawElementBase & {
|
||||
type: "diamond";
|
||||
};
|
||||
@@ -185,11 +200,13 @@ export type ExcalidrawGenericElement =
|
||||
|
||||
export type ExcalidrawFlowchartNodeElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawStickyNoteElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement;
|
||||
|
||||
export type ExcalidrawRectanguloidElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawStickyNoteElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
@@ -205,6 +222,7 @@ export type ExcalidrawRectanguloidElement =
|
||||
*/
|
||||
export type ExcalidrawElement =
|
||||
| ExcalidrawGenericElement
|
||||
| ExcalidrawStickyNoteElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawLinearElement
|
||||
| ExcalidrawArrowElement
|
||||
@@ -237,10 +255,19 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
||||
type: "text";
|
||||
fontSize: number;
|
||||
fontFamily: FontFamilyValues;
|
||||
/**
|
||||
* The font size the user picked, from which the layout derives `fontSize`.
|
||||
* Today only sticky note labels have one: the auto-fit shrinks below it
|
||||
* and never above it (compare `baseHeight`, which the note grows above).
|
||||
* `null` for every other text. Read it through `getBaseFontSize` —
|
||||
* generic binding repair can detach a label without clearing this, so
|
||||
* the container decides its meaning.
|
||||
*/
|
||||
baseFontSize: number | null;
|
||||
text: string;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
containerId: ExcalidrawGenericElement["id"] | null;
|
||||
containerId: ExcalidrawTextContainer["id"] | null;
|
||||
originalText: string;
|
||||
/**
|
||||
* If `true` the width will fit the text. If `false`, the text will
|
||||
@@ -254,10 +281,18 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
||||
* with font size (using `getLineHeightInPx` helper).
|
||||
*/
|
||||
lineHeight: number & { _brand: "unitlessLineHeight" };
|
||||
/**
|
||||
* Position of text bound to a linear element (such as an arrow),
|
||||
* expressed as a normalized arc-length parameter (0–1) along the
|
||||
* container's whole path. Independent of how the path is segmented,
|
||||
* so it survives midpoint insertion and other geometry changes.
|
||||
* */
|
||||
labelPosition?: number | null;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawBindableElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawStickyNoteElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawTextElement
|
||||
@@ -269,6 +304,7 @@ export type ExcalidrawBindableElement =
|
||||
|
||||
export type ExcalidrawTextContainer =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawStickyNoteElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawArrowElement;
|
||||
|
||||
+214
-65
@@ -8,6 +8,7 @@ import {
|
||||
|
||||
import {
|
||||
bezierEquation,
|
||||
clamp,
|
||||
curve,
|
||||
curveCatmullRomCubicApproxPoints,
|
||||
curveOffsetPoints,
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
type GlobalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||
import type { Curve, LineSegment, LocalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
@@ -40,13 +41,14 @@ import { generateLinearCollisionShape } from "./shape";
|
||||
|
||||
import { hitElementItself, isPointInElement } from "./collision";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { isRectangularElement } from "./typeChecks";
|
||||
import { maxBindingDistance_simple } from "./binding";
|
||||
import { isElbowArrow, isRectangularElement } from "./typeChecks";
|
||||
import { getBindingGap, maxBindingDistance_simple } from "./binding";
|
||||
|
||||
import {
|
||||
getGlobalFixedPointForBindableElement,
|
||||
normalizeFixedPoint,
|
||||
} from "./binding";
|
||||
import { getStickyNoteCornerRadius } from "./stickyNote";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
@@ -59,6 +61,8 @@ import type {
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
export type LinearPathSegment = LineSegment<GlobalPoint> | Curve<GlobalPoint>;
|
||||
|
||||
type ElementShape = [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]];
|
||||
|
||||
const ElementShapesCache = new WeakMap<
|
||||
@@ -125,7 +129,7 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||
export function deconstructLinearOrFreeDrawElement(
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
): ElementShape {
|
||||
const cachedShape = getElementShapesCacheEntry(element, 0);
|
||||
|
||||
if (cachedShape) {
|
||||
@@ -133,8 +137,8 @@ export function deconstructLinearOrFreeDrawElement(
|
||||
}
|
||||
|
||||
const ops = generateLinearCollisionShape(element, elementsMap);
|
||||
const lines = [];
|
||||
const curves = [];
|
||||
const lines: LineSegment<GlobalPoint>[] = [];
|
||||
const curves: Curve<GlobalPoint>[] = [];
|
||||
|
||||
for (let idx = 0; idx < ops.length; idx += 1) {
|
||||
const op = ops[idx];
|
||||
@@ -193,12 +197,43 @@ export function deconstructLinearOrFreeDrawElement(
|
||||
}
|
||||
}
|
||||
|
||||
const shape = [lines, curves] as ElementShape;
|
||||
const shape: ElementShape = [lines, curves];
|
||||
setElementShapesCacheEntry(element, shape, 0);
|
||||
|
||||
return shape;
|
||||
}
|
||||
|
||||
export function getLinearElementPathSegments(
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): LinearPathSegment[] {
|
||||
// For now, model elbow arrows as their unrounded logical path. Rounded
|
||||
// joints can be incorporated once the path model supports mixed straight
|
||||
// and curved segments.
|
||||
if (isElbowArrow(element)) {
|
||||
return element.points
|
||||
.slice(1)
|
||||
.map((point, index) =>
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.points[index][0],
|
||||
element.y + element.points[index][1],
|
||||
),
|
||||
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
// Non-elbow paths currently contain only one segment type. Mixed paths
|
||||
// should consume an ordered operation stream instead of these type buckets.
|
||||
return curves.length > 0 ? curves : lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the building components of a rectanguloid element in the form of
|
||||
* line segments and curves **unrotated**.
|
||||
@@ -210,17 +245,17 @@ export function deconstructLinearOrFreeDrawElement(
|
||||
export function deconstructRectanguloidElement(
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
): ElementShape {
|
||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||
|
||||
if (cachedShape) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
let radius = getCornerRadius(
|
||||
Math.min(element.width, element.height),
|
||||
element,
|
||||
);
|
||||
let radius =
|
||||
element.type === "stickynote"
|
||||
? getStickyNoteCornerRadius(element)
|
||||
: getCornerRadius(Math.min(element.width, element.height), element);
|
||||
|
||||
if (radius === 0) {
|
||||
radius = 0.01;
|
||||
@@ -422,7 +457,7 @@ export function getDiamondBaseCorners(
|
||||
export function deconstructDiamondElement(
|
||||
element: ExcalidrawDiamondElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
): ElementShape {
|
||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||
|
||||
if (cachedShape) {
|
||||
@@ -597,67 +632,179 @@ const getDiagonalsForBindableElement = (
|
||||
return [diagonalOne, diagonalTwo];
|
||||
};
|
||||
|
||||
export const getSnapOutlineMidPoint = (
|
||||
point: GlobalPoint,
|
||||
/**
|
||||
* `onAxis` is true for the midpoints on the element's axes (the side midpoints,
|
||||
* or a diamond's vertices), whose direction from the center is unambiguous,
|
||||
* and false for a diamond's edge midpoints
|
||||
*/
|
||||
const getSnappedMidpointForElbowArrow = (
|
||||
element: ExcalidrawBindableElement,
|
||||
point: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
) => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const sideMidpoints =
|
||||
element.type === "diamond"
|
||||
? getDiamondBaseCorners(element).map((curve) => {
|
||||
const point = bezierEquation(curve, 0.5);
|
||||
const rotatedPoint = pointRotateRads(point, center, element.angle);
|
||||
center: GlobalPoint,
|
||||
horizontalThreshold: number,
|
||||
verticalThreshold: number,
|
||||
): { point: GlobalPoint; onAxis: boolean } | undefined => {
|
||||
const { x, y, width, height, angle } = element;
|
||||
const nonRotated = pointRotateRads(point, center, -angle as Radians);
|
||||
|
||||
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
|
||||
})
|
||||
: [
|
||||
// RIGHT midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width,
|
||||
element.y + element.height / 2,
|
||||
),
|
||||
const bindingGap = getBindingGap(element);
|
||||
|
||||
if (pointDistance(center, nonRotated) < bindingGap) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [right, bottom, left, top] = getAllMidpoints(element, elementsMap);
|
||||
|
||||
if (
|
||||
nonRotated[0] <= x + width / 2 &&
|
||||
nonRotated[1] > center[1] - verticalThreshold &&
|
||||
nonRotated[1] < center[1] + verticalThreshold
|
||||
) {
|
||||
return { point: left, onAxis: true };
|
||||
} else if (
|
||||
nonRotated[1] <= y + height / 2 &&
|
||||
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||
nonRotated[0] < center[0] + horizontalThreshold
|
||||
) {
|
||||
return { point: top, onAxis: true };
|
||||
} else if (
|
||||
nonRotated[0] >= x + width / 2 &&
|
||||
nonRotated[1] > center[1] - verticalThreshold &&
|
||||
nonRotated[1] < center[1] + verticalThreshold
|
||||
) {
|
||||
return { point: right, onAxis: true };
|
||||
} else if (
|
||||
nonRotated[1] >= y + height / 2 &&
|
||||
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||
nonRotated[0] < center[0] + horizontalThreshold
|
||||
) {
|
||||
return { point: bottom, onAxis: true };
|
||||
} else if (element.type === "diamond") {
|
||||
// Elbow arrows can also snap to the midpoints of a diamond's edges
|
||||
const threshold = Math.max(horizontalThreshold, verticalThreshold);
|
||||
const edgeMidpoints = [
|
||||
// top-left, top-right, bottom-left, bottom-right
|
||||
[x + width / 4, y + height / 4, -1, -1],
|
||||
[x + (3 * width) / 4, y + height / 4, 1, -1],
|
||||
[x + width / 4, y + (3 * height) / 4, -1, 1],
|
||||
[x + (3 * width) / 4, y + (3 * height) / 4, 1, 1],
|
||||
] as const;
|
||||
|
||||
for (const [edgeX, edgeY, dirX, dirY] of edgeMidpoints) {
|
||||
// the snap zone sits a binding gap outside the edge
|
||||
const zoneCenter = pointFrom<GlobalPoint>(
|
||||
edgeX + dirX * bindingGap,
|
||||
edgeY + dirY * bindingGap,
|
||||
);
|
||||
|
||||
if (pointDistance(zoneCenter, nonRotated) < threshold) {
|
||||
return {
|
||||
point: pointRotateRads(
|
||||
pointFrom<GlobalPoint>(edgeX, edgeY),
|
||||
center,
|
||||
element.angle,
|
||||
angle,
|
||||
),
|
||||
// BOTTOM midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
// LEFT midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
// TOP midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
];
|
||||
const candidate = sideMidpoints.find(
|
||||
(midpoint) =>
|
||||
pointDistance(point, midpoint) <=
|
||||
maxBindingDistance_simple(zoom) + element.strokeWidth / 2 &&
|
||||
onAxis: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getSnappedMidpointIndexForSimpleArrow = (
|
||||
element: ExcalidrawBindableElement,
|
||||
point: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
threshold: number,
|
||||
) => {
|
||||
const baseMidpoints = getAllMidpoints(element, elementsMap);
|
||||
|
||||
for (let i = 0; i < baseMidpoints.length; i++) {
|
||||
if (
|
||||
pointDistance(baseMidpoints[i], point) <= threshold &&
|
||||
!hitElementItself({
|
||||
point,
|
||||
element,
|
||||
threshold: 0,
|
||||
elementsMap,
|
||||
overrideShouldTestInside: true,
|
||||
}),
|
||||
})
|
||||
) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
};
|
||||
|
||||
export const getAllMidpoints = (
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
if (element.type === "diamond") {
|
||||
return getDiamondBaseCorners(element).map((curve) =>
|
||||
pointRotateRads(bezierEquation(curve, 0.5), center, element.angle),
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
pointFrom(element.width, element.height / 2),
|
||||
pointFrom(element.width / 2, element.height),
|
||||
pointFrom(0, element.height / 2),
|
||||
pointFrom(element.width / 2, 0),
|
||||
].map(([x, y]) =>
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + x, element.y + y),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const getElbowArrowSnapMidPoint = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
) => {
|
||||
const TOLERANCE = 0.05;
|
||||
const maxDistance = maxBindingDistance_simple(zoom) + element.strokeWidth / 2;
|
||||
|
||||
return getSnappedMidpointForElbowArrow(
|
||||
element,
|
||||
point,
|
||||
elementsMap,
|
||||
elementCenterPoint(element, elementsMap),
|
||||
clamp(TOLERANCE * element.width, 5, maxDistance),
|
||||
clamp(TOLERANCE * element.height, 5, maxDistance),
|
||||
);
|
||||
};
|
||||
|
||||
export const getSnapOutlineMidPoint = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
arrow: { elbowed: boolean },
|
||||
): GlobalPoint | undefined => {
|
||||
if (arrow.elbowed) {
|
||||
return getElbowArrowSnapMidPoint(point, element, elementsMap, zoom)?.point;
|
||||
}
|
||||
|
||||
const maxDistance = maxBindingDistance_simple(zoom) + element.strokeWidth / 2;
|
||||
const idx = getSnappedMidpointIndexForSimpleArrow(
|
||||
element,
|
||||
point,
|
||||
elementsMap,
|
||||
maxDistance,
|
||||
);
|
||||
|
||||
return candidate;
|
||||
return idx === -1 ? undefined : getAllMidpoints(element, elementsMap)[idx];
|
||||
};
|
||||
|
||||
export const projectFixedPointOntoDiagonal = (
|
||||
@@ -670,22 +817,24 @@ export const projectFixedPointOntoDiagonal = (
|
||||
isMidpointSnappingEnabled: boolean = true,
|
||||
): GlobalPoint | null => {
|
||||
invariant(arrow.points.length >= 2, "Arrow must have at least two points");
|
||||
if (arrow.width < 3 && arrow.height < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isMidpointSnappingEnabled) {
|
||||
const sideMidPoint = getSnapOutlineMidPoint(
|
||||
point,
|
||||
element,
|
||||
elementsMap,
|
||||
zoom,
|
||||
arrow,
|
||||
);
|
||||
if (sideMidPoint) {
|
||||
return sideMidPoint;
|
||||
}
|
||||
}
|
||||
|
||||
// Projection needs a meaningful arrow direction
|
||||
if (arrow.width < 3 && arrow.height < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Do the projection onto the diagonals (or center lines
|
||||
// for non-rectangular shapes)
|
||||
const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement(
|
||||
|
||||
@@ -10,9 +10,6 @@ import { getElementsInGroup } from "./groups";
|
||||
import { syncMovedIndices } from "./fractionalIndex";
|
||||
import { getSelectedElements } from "./selection";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import { getHoveredElementForBinding } from "./collision";
|
||||
|
||||
import { getNonDeletedElements } from ".";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
import type {
|
||||
@@ -159,17 +156,8 @@ export const moveArrowAboveBindable = (
|
||||
elements: readonly OrderedExcalidrawElement[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
scene: Scene,
|
||||
hit?: NonDeletedExcalidrawElement,
|
||||
hoveredElement: NonDeletedExcalidrawElement,
|
||||
): readonly OrderedExcalidrawElement[] => {
|
||||
const hoveredElement = hit
|
||||
? hit
|
||||
: getHoveredElementForBinding(
|
||||
point,
|
||||
// SAFETY: callers that omit `hit` pass non-deleted elements
|
||||
getNonDeletedElements(elements),
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (!hoveredElement) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
|
||||
class="excalidraw-wysiwyg"
|
||||
data-type="wysiwyg"
|
||||
dir="auto"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform-origin: 5px 12.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
|
||||
tabindex="0"
|
||||
wrap="off"
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,43 @@
|
||||
import { arrayToMap, reseed } from "@excalidraw/common";
|
||||
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
|
||||
import {
|
||||
ROUNDNESS,
|
||||
arrayToMap,
|
||||
getSizeFromPoints,
|
||||
reseed,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
lineSegment,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
} from "@excalidraw/math";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import "@excalidraw/utils/test-utils";
|
||||
import { render } from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import type { Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import * as distance from "../src/distance";
|
||||
import { hitElementItself } from "../src/collision";
|
||||
import { getElementBounds } from "../src/bounds";
|
||||
import {
|
||||
getAllHoveredElementAtPoint,
|
||||
getHoveredElementForBinding,
|
||||
hitElementItself,
|
||||
intersectElementWithLineSegment,
|
||||
isPointInElement,
|
||||
} from "../src/collision";
|
||||
import { mutateElement } from "../src/mutateElement";
|
||||
import { newFreeDrawElement } from "../src/newElement";
|
||||
import { getAllMidpoints } from "../src/utils";
|
||||
|
||||
import type {
|
||||
ExcalidrawDiamondElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
Ordered,
|
||||
} from "../src/types";
|
||||
|
||||
describe("check rotated elements can be hit:", () => {
|
||||
beforeEach(async () => {
|
||||
@@ -217,4 +247,699 @@ describe("hitElementItself cache", () => {
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rechecks a bound label when only its arrow changes", () => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
x: 200,
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 0,
|
||||
points: [pointFrom(0, 0), pointFrom(300, 0)],
|
||||
});
|
||||
const label = API.createElement({
|
||||
type: "text",
|
||||
text: "label",
|
||||
x: 325,
|
||||
y: 187.5,
|
||||
width: 50,
|
||||
height: 25,
|
||||
containerId: arrow.id,
|
||||
});
|
||||
API.setElements([arrow, label]);
|
||||
API.updateElement(arrow, {
|
||||
boundElements: [{ id: label.id, type: "text" }],
|
||||
});
|
||||
const elementsMap = window.h.scene.getNonDeletedElementsMap();
|
||||
const labelVersion = label.version;
|
||||
const point = pointFrom<GlobalPoint>(350, 200);
|
||||
|
||||
expect(
|
||||
hitElementItself({ point, element: label, threshold: 1, elementsMap }),
|
||||
).toBe(true);
|
||||
API.updateElement(arrow, { x: 500 });
|
||||
expect(label.version).toBe(labelVersion);
|
||||
expect(
|
||||
hitElementItself({ point, element: label, threshold: 1, elementsMap }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hitElementItself({
|
||||
point: pointFrom<GlobalPoint>(650, 200),
|
||||
element: label,
|
||||
threshold: 1,
|
||||
elementsMap,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("freedraw collision matches the rendered stroke width", () => {
|
||||
// A straight, horizontal stroke centered on y === 0.
|
||||
const points = Array.from({ length: 21 }, (_, i) =>
|
||||
pointFrom<LocalPoint>(i * 5, 0),
|
||||
);
|
||||
|
||||
const createFreeDraw = (variability: "variable" | "constant") =>
|
||||
API.createElement({
|
||||
type: "freedraw",
|
||||
x: 0,
|
||||
y: 0,
|
||||
strokeWidth: 10,
|
||||
points,
|
||||
strokeOptions: { variability, streamline: 0.5 },
|
||||
});
|
||||
|
||||
const distanceAt = (
|
||||
element: ReturnType<typeof createFreeDraw>,
|
||||
x: number,
|
||||
y: number,
|
||||
) =>
|
||||
distance.distanceToElement(
|
||||
element,
|
||||
arrayToMap([element]),
|
||||
pointFrom<GlobalPoint>(x, y),
|
||||
);
|
||||
|
||||
it("treats a point on the centerline as a direct hit for both modes", () => {
|
||||
expect(distanceAt(createFreeDraw("variable"), 50, 0)).toBe(0);
|
||||
expect(distanceAt(createFreeDraw("constant"), 50, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("hits across the body of a thick stroke, not just near the centerline", () => {
|
||||
expect(distanceAt(createFreeDraw("variable"), 50, 20)).toBe(0);
|
||||
});
|
||||
|
||||
it("uses a wider hit area for variable-width than for constant-width strokes", () => {
|
||||
const offset = 20;
|
||||
const variableDistance = distanceAt(createFreeDraw("variable"), 50, offset);
|
||||
const constantDistance = distanceAt(createFreeDraw("constant"), 50, offset);
|
||||
|
||||
expect(variableDistance).toBe(0);
|
||||
expect(constantDistance).toBeGreaterThan(0);
|
||||
expect(variableDistance).toBeLessThan(constantDistance);
|
||||
});
|
||||
|
||||
it("does not hit points clearly outside even the widest stroke", () => {
|
||||
expect(distanceAt(createFreeDraw("variable"), 50, 45)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("hits thick ink outside the centerline bounds", () => {
|
||||
const hitAt = (
|
||||
element: ReturnType<typeof createFreeDraw>,
|
||||
x: number,
|
||||
y: number,
|
||||
) =>
|
||||
hitElementItself({
|
||||
element,
|
||||
elementsMap: arrayToMap([element]),
|
||||
point: pointFrom<GlobalPoint>(x, y),
|
||||
threshold: 1,
|
||||
});
|
||||
|
||||
// The centerline bounds have zero height, far below the ink's reach.
|
||||
expect(hitAt(createFreeDraw("variable"), 50, 20)).toBe(true);
|
||||
expect(hitAt(createFreeDraw("variable"), 50, -20)).toBe(true);
|
||||
expect(hitAt(createFreeDraw("constant"), 50, 10)).toBe(true);
|
||||
expect(hitAt(createFreeDraw("constant"), 50, 20)).toBe(false);
|
||||
expect(hitAt(createFreeDraw("variable"), 50, 45)).toBe(false);
|
||||
});
|
||||
|
||||
it("hits the start cap of a short stroke reaching past its stroke size", () => {
|
||||
// Under 3px long, perfect-freehand draws the start cap around the first
|
||||
// point from the last point's outline, reaching past the stroke size.
|
||||
const element = newFreeDrawElement({
|
||||
type: "freedraw",
|
||||
x: 0,
|
||||
y: 0,
|
||||
strokeWidth: 1,
|
||||
simulatePressure: false,
|
||||
pressures: [1, 1, 1],
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(0.01, 0),
|
||||
pointFrom<LocalPoint>(2.9, 0),
|
||||
],
|
||||
strokeOptions: { variability: "variable", streamline: 0.5 },
|
||||
});
|
||||
const elementsMap = arrayToMap([element]);
|
||||
const point = pointFrom<GlobalPoint>(0.1, 4.9);
|
||||
|
||||
expect(distance.distanceToElement(element, elementsMap, point)).toBe(0);
|
||||
// The hit threshold at 30x zoom.
|
||||
expect(
|
||||
hitElementItself({ element, elementsMap, point, threshold: 0.6 }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const createLoop = ({
|
||||
angle = 0,
|
||||
backgroundColor = "#ffc9c9",
|
||||
variability = "variable",
|
||||
points = Array.from({ length: 81 }, (_, i) =>
|
||||
pointFrom<LocalPoint>(
|
||||
80 + 80 * Math.cos((i / 80) * 2 * Math.PI),
|
||||
40 + 40 * Math.sin((i / 80) * 2 * Math.PI),
|
||||
),
|
||||
),
|
||||
}: {
|
||||
angle?: number;
|
||||
backgroundColor?: string;
|
||||
variability?: "variable" | "constant";
|
||||
points?: LocalPoint[];
|
||||
} = {}) =>
|
||||
API.createElement({
|
||||
type: "freedraw",
|
||||
x: 300,
|
||||
y: 200,
|
||||
...getSizeFromPoints(points),
|
||||
angle,
|
||||
strokeWidth: 2,
|
||||
backgroundColor,
|
||||
fillStyle: "solid",
|
||||
points,
|
||||
strokeOptions: { variability, streamline: 0.5 },
|
||||
});
|
||||
|
||||
// The fill contour does not depend on the stroke; only its interplay with the
|
||||
// stroke geometry is tested per stroke variability.
|
||||
describe.each(["variable", "constant"] as const)(
|
||||
"%s freedraw loop containment",
|
||||
(variability) => {
|
||||
it.each([0, Math.PI / 4])(
|
||||
"hits the fill independently of the stroke at angle %s",
|
||||
(angle) => {
|
||||
const element = createLoop({ angle, variability });
|
||||
const elementsMap = arrayToMap([element]);
|
||||
const point = pointRotateRads(
|
||||
pointFrom<GlobalPoint>(400, 250),
|
||||
pointFrom<GlobalPoint>(380, 240),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
// Checking the fill must not replace the cached stroke geometry.
|
||||
expect(
|
||||
distance.distanceToElement(element, elementsMap, point),
|
||||
).toBeGreaterThan(10);
|
||||
expect(isPointInElement(point, element, elementsMap)).toBe(true);
|
||||
expect(
|
||||
hitElementItself({ element, elementsMap, point, threshold: 1 }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
distance.distanceToElement(element, elementsMap, point),
|
||||
).toBeGreaterThan(10);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([0, Math.PI / 4])(
|
||||
"does not hit the interior of a transparent loop at angle %s",
|
||||
(angle) => {
|
||||
const element = createLoop({
|
||||
angle,
|
||||
backgroundColor: "transparent",
|
||||
variability,
|
||||
});
|
||||
const elementsMap = arrayToMap([element]);
|
||||
const point = pointRotateRads(
|
||||
pointFrom<GlobalPoint>(400, 250),
|
||||
pointFrom<GlobalPoint>(380, 240),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
// Warm the fill cache first; it must not replace the stroke geometry.
|
||||
isPointInElement(point, element, elementsMap);
|
||||
expect(
|
||||
hitElementItself({ element, elementsMap, point, threshold: 1 }),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not treat the empty corners of the loop bounds as fill", () => {
|
||||
const element = createLoop({ variability });
|
||||
const elementsMap = arrayToMap([element]);
|
||||
const point = pointFrom<GlobalPoint>(310, 210);
|
||||
|
||||
expect(isPointInElement(point, element, elementsMap)).toBe(false);
|
||||
expect(
|
||||
hitElementItself({ element, elementsMap, point, threshold: 1 }),
|
||||
).toBe(false);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("freedraw loop fill containment", () => {
|
||||
it.each([0, Math.PI / 4])(
|
||||
"hits the center and axes of a loop with curve junctions at angle %s",
|
||||
(angle) => {
|
||||
const element = createLoop({ angle });
|
||||
const elementsMap = arrayToMap([element]);
|
||||
const center = pointFrom<GlobalPoint>(380, 240);
|
||||
|
||||
for (const point of [
|
||||
center,
|
||||
pointFrom<GlobalPoint>(400, 240),
|
||||
pointFrom<GlobalPoint>(380, 250),
|
||||
]) {
|
||||
expect(
|
||||
isPointInElement(
|
||||
pointRotateRads(point, center, element.angle),
|
||||
element,
|
||||
elementsMap,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([0, Math.PI / 4])(
|
||||
"implicitly closes the fill of an unsnapped loop at angle %s",
|
||||
(angle) => {
|
||||
const element = createLoop({ angle });
|
||||
const elementsMap = arrayToMap([element]);
|
||||
mutateElement(element, elementsMap, {
|
||||
points: [
|
||||
...element.points.slice(0, -1),
|
||||
pointFrom<LocalPoint>(160, 36),
|
||||
],
|
||||
});
|
||||
const point = pointRotateRads(
|
||||
pointFrom<GlobalPoint>(440, 239.5),
|
||||
pointFrom<GlobalPoint>(380, 240),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
expect(isPointInElement(point, element, elementsMap)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("rotates around the unrotated bounds center of an asymmetric loop", () => {
|
||||
const element = createLoop({
|
||||
angle: Math.PI / 4,
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(200, 0),
|
||||
pointFrom<LocalPoint>(0, 100),
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
],
|
||||
});
|
||||
const point = pointRotateRads(
|
||||
pointFrom<GlobalPoint>(330, 205),
|
||||
pointFrom<GlobalPoint>(400, 250),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
expect(isPointInElement(point, element, arrayToMap([element]))).toBe(true);
|
||||
});
|
||||
|
||||
it("hits smoothed fill outside the rotated bounds of the points", () => {
|
||||
const element = createLoop({
|
||||
angle: (2 * Math.PI) / 3,
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(200, 0),
|
||||
pointFrom<LocalPoint>(0, 100),
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
],
|
||||
});
|
||||
const elementsMap = arrayToMap([element]);
|
||||
// Outside the raw triangle, inside the curve bulging past its hypotenuse.
|
||||
const point = pointRotateRads(
|
||||
pointFrom<GlobalPoint>(436, 240),
|
||||
pointFrom<GlobalPoint>(400, 250),
|
||||
element.angle,
|
||||
);
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||
|
||||
expect(
|
||||
point[0] < x1 || point[0] > x2 || point[1] < y1 || point[1] > y2,
|
||||
).toBe(true);
|
||||
expect(isPointInElement(point, element, elementsMap)).toBe(true);
|
||||
expect(
|
||||
hitElementItself({ element, elementsMap, point, threshold: 1 }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "a three-point retraced path",
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(100, 100),
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
],
|
||||
point: pointFrom<GlobalPoint>(350, 250),
|
||||
},
|
||||
{
|
||||
name: "a loop that simplifies to two identical points",
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(0.3, 0),
|
||||
pointFrom<LocalPoint>(0.3, 0.3),
|
||||
pointFrom<LocalPoint>(0, 0.3),
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
],
|
||||
point: pointFrom<GlobalPoint>(300.15, 200.15),
|
||||
},
|
||||
])("contains nothing for $name", ({ points, point }) => {
|
||||
const element = createLoop({ points });
|
||||
|
||||
expect(isPointInElement(point, element, arrayToMap([element]))).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the curved fill of a nearly closed three-point path", () => {
|
||||
const element = createLoop({
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(100, 100),
|
||||
pointFrom<LocalPoint>(3, 0),
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
isPointInElement(
|
||||
pointFrom<GlobalPoint>(350, 250),
|
||||
element,
|
||||
arrayToMap([element]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("includes the smoothed fill beyond the raw-point polygon", () => {
|
||||
const element = createLoop({
|
||||
points: [
|
||||
pointFrom<LocalPoint>(200, 100),
|
||||
pointFrom<LocalPoint>(100, 200),
|
||||
pointFrom<LocalPoint>(0, 100),
|
||||
pointFrom<LocalPoint>(100, 0),
|
||||
pointFrom<LocalPoint>(200, 100),
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
isPointInElement(
|
||||
pointFrom<GlobalPoint>(305, 285),
|
||||
element,
|
||||
arrayToMap([element]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("uses even-odd fill for a self-intersecting loop", () => {
|
||||
const element = createLoop({
|
||||
points: Array.from({ length: 6 }, (_, i) => {
|
||||
const angle = -Math.PI / 2 + (((i * 2) % 5) / 5) * 2 * Math.PI;
|
||||
return pointFrom<LocalPoint>(
|
||||
100 + 95 * Math.cos(angle),
|
||||
100 + 95 * Math.sin(angle),
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
expect(
|
||||
isPointInElement(
|
||||
pointFrom<GlobalPoint>(400, 300),
|
||||
element,
|
||||
arrayToMap([element]),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("invalidates the fill contour when points change on the same element", () => {
|
||||
const element = createLoop();
|
||||
const elementsMap = arrayToMap([element]);
|
||||
const point = pointFrom<GlobalPoint>(415, 255);
|
||||
const version = element.version;
|
||||
|
||||
expect(isPointInElement(point, element, elementsMap)).toBe(true);
|
||||
|
||||
// Preserve the bounds so a stale contour cannot hide behind that pre-check.
|
||||
mutateElement(element, elementsMap, {
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(160, 0),
|
||||
pointFrom<LocalPoint>(0, 80),
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
],
|
||||
});
|
||||
|
||||
expect(element.version).toBeGreaterThan(version);
|
||||
expect(isPointInElement(point, element, elementsMap)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("binding hit tests", () => {
|
||||
type SceneElement = Ordered<NonDeletedExcalidrawElement>;
|
||||
const zoom = (value: number) => ({ value } as Zoom);
|
||||
|
||||
const hitTest = (
|
||||
elements: SceneElement[],
|
||||
point: GlobalPoint,
|
||||
zoomValue = 1,
|
||||
) => {
|
||||
const elementsMap = arrayToMap(elements) as NonDeletedSceneElementsMap;
|
||||
return {
|
||||
hovered: getHoveredElementForBinding(
|
||||
point,
|
||||
elements,
|
||||
elementsMap,
|
||||
zoom(zoomValue),
|
||||
)?.id,
|
||||
all: getAllHoveredElementAtPoint(
|
||||
point,
|
||||
elements,
|
||||
elementsMap,
|
||||
zoom(zoomValue),
|
||||
).map((element) => element.id),
|
||||
};
|
||||
};
|
||||
|
||||
it("both use the zoom-aware binding distance", () => {
|
||||
const rect = API.createElement({
|
||||
id: "rect",
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
}) as SceneElement;
|
||||
// 20px outside the right edge
|
||||
const point = pointFrom<GlobalPoint>(120, 50);
|
||||
|
||||
// 15px binding distance at zoom 1
|
||||
expect(hitTest([rect], point, 1)).toEqual({ hovered: undefined, all: [] });
|
||||
// 25px binding distance at zoom 0.4
|
||||
expect(hitTest([rect], point, 0.4)).toEqual({
|
||||
hovered: "rect",
|
||||
all: ["rect"],
|
||||
});
|
||||
});
|
||||
|
||||
it("both skip elements hidden behind an opaque element", () => {
|
||||
const hidden = API.createElement({
|
||||
id: "hidden",
|
||||
type: "rectangle",
|
||||
x: 30,
|
||||
y: 30,
|
||||
width: 40,
|
||||
height: 40,
|
||||
index: "a0" as SceneElement["index"],
|
||||
}) as SceneElement;
|
||||
const cover = (backgroundColor: string) =>
|
||||
API.createElement({
|
||||
id: "cover",
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor,
|
||||
index: "a1" as SceneElement["index"],
|
||||
}) as SceneElement;
|
||||
const point = pointFrom<GlobalPoint>(50, 50);
|
||||
|
||||
expect(hitTest([hidden, cover("#ffc9c9")], point)).toEqual({
|
||||
hovered: "cover",
|
||||
all: ["cover"],
|
||||
});
|
||||
|
||||
const transparent = hitTest([hidden, cover("transparent")], point);
|
||||
expect(transparent.hovered).toBe("hidden");
|
||||
expect(transparent.all).toEqual(["cover", "hidden"]);
|
||||
});
|
||||
|
||||
it("an image, and a locked opaque element, hide what's behind them", () => {
|
||||
const hidden = API.createElement({
|
||||
id: "hidden",
|
||||
type: "rectangle",
|
||||
x: 30,
|
||||
y: 30,
|
||||
width: 40,
|
||||
height: 40,
|
||||
index: "a0" as SceneElement["index"],
|
||||
}) as SceneElement;
|
||||
const point = pointFrom<GlobalPoint>(50, 50);
|
||||
|
||||
const image = API.createElement({
|
||||
id: "image",
|
||||
type: "image",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
index: "a1" as SceneElement["index"],
|
||||
}) as SceneElement;
|
||||
expect(hitTest([hidden, image], point)).toEqual({
|
||||
hovered: "image",
|
||||
all: ["image"],
|
||||
});
|
||||
|
||||
const locked = (backgroundColor: string) =>
|
||||
API.createElement({
|
||||
id: "locked",
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor,
|
||||
locked: true,
|
||||
index: "a1" as SceneElement["index"],
|
||||
}) as SceneElement;
|
||||
// can't bind to the locked element, nor through it
|
||||
expect(hitTest([hidden, locked("#ffc9c9")], point)).toEqual({
|
||||
hovered: undefined,
|
||||
all: [],
|
||||
});
|
||||
// a transparent locked element hides nothing
|
||||
expect(hitTest([hidden, locked("transparent")], point)).toEqual({
|
||||
hovered: "hidden",
|
||||
all: ["hidden"],
|
||||
});
|
||||
});
|
||||
|
||||
describe("overlapping elements", () => {
|
||||
const rect = (id: string, x: number, y: number, w: number, h: number) =>
|
||||
API.createElement({
|
||||
id,
|
||||
type: "rectangle",
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
}) as SceneElement;
|
||||
|
||||
it("binds to the container's edge next to a nested element", () => {
|
||||
const container = rect("container", 0, 0, 200, 200);
|
||||
// 18px inside the container's left edge
|
||||
const child = rect("child", 18, 80, 60, 40);
|
||||
|
||||
// 5px inside the container's edge, 13px outside the child
|
||||
expect(
|
||||
hitTest([container, child], pointFrom<GlobalPoint>(5, 100)).hovered,
|
||||
).toBe("container");
|
||||
// closer to the child's outline than to the container's
|
||||
expect(
|
||||
hitTest([container, child], pointFrom<GlobalPoint>(10, 100)).hovered,
|
||||
).toBe("child");
|
||||
});
|
||||
|
||||
it("binds to a smaller element straddling the container's edge when inside both", () => {
|
||||
const container = rect("container", 0, 0, 200, 200);
|
||||
const badge = rect("badge", -30, 80, 60, 40);
|
||||
|
||||
// 3px inside the container's edge, inside the badge
|
||||
expect(
|
||||
hitTest([container, badge], pointFrom<GlobalPoint>(3, 92)).hovered,
|
||||
).toBe("badge");
|
||||
// 5px outside the container's edge, inside the badge: the closer
|
||||
// container outline wins
|
||||
expect(
|
||||
hitTest([container, badge], pointFrom<GlobalPoint>(-5, 108)).hovered,
|
||||
).toBe("container");
|
||||
});
|
||||
});
|
||||
|
||||
it("binds at a circle's exact center, where an opaque circle still occludes", () => {
|
||||
const hidden = API.createElement({
|
||||
id: "hidden",
|
||||
type: "rectangle",
|
||||
x: 90,
|
||||
y: 90,
|
||||
width: 20,
|
||||
height: 20,
|
||||
index: "a0" as SceneElement["index"],
|
||||
}) as SceneElement;
|
||||
const circle = (backgroundColor: string) =>
|
||||
API.createElement({
|
||||
id: "circle",
|
||||
type: "ellipse",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 200,
|
||||
backgroundColor,
|
||||
index: "a1" as SceneElement["index"],
|
||||
}) as SceneElement;
|
||||
const center = pointFrom<GlobalPoint>(100, 100);
|
||||
|
||||
expect(hitTest([circle("transparent")], center).hovered).toBe("circle");
|
||||
expect(hitTest([hidden, circle("#ffc9c9")], center)).toEqual({
|
||||
hovered: "circle",
|
||||
all: ["circle"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ellipse outline hit test", () => {
|
||||
it("doesn't hit a transparent ellipse's outline from its center line", () => {
|
||||
const ellipse = API.createElement({
|
||||
type: "ellipse",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 400,
|
||||
height: 100,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
|
||||
// on the horizontal center line, ~50px from the outline
|
||||
expect(
|
||||
hitElementItself({
|
||||
point: pointFrom<GlobalPoint>(210, 50),
|
||||
element: ellipse,
|
||||
threshold: 10,
|
||||
elementsMap: arrayToMap([ellipse]),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("intersectElementWithLineSegment", () => {
|
||||
it("hits a rounded diamond corner along a line through its apex", () => {
|
||||
const diamond = API.createElement({
|
||||
type: "diamond",
|
||||
x: 100,
|
||||
y: -100,
|
||||
width: 200,
|
||||
height: 200,
|
||||
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
|
||||
}) as ExcalidrawDiamondElement;
|
||||
const elementsMap = arrayToMap([diamond]);
|
||||
const [, , left] = getAllMidpoints(diamond, elementsMap);
|
||||
|
||||
// the offset corner is split into several curves, and this line passes
|
||||
// exactly through the joint of two of them
|
||||
const hits = intersectElementWithLineSegment(
|
||||
diamond,
|
||||
elementsMap,
|
||||
lineSegment(
|
||||
pointFrom<GlobalPoint>(200, left[1]),
|
||||
pointFrom<GlobalPoint>(-200, left[1]),
|
||||
),
|
||||
6,
|
||||
);
|
||||
|
||||
expect(hits.length).toBeGreaterThan(0);
|
||||
expect(hits[0][0]).toBeCloseTo(left[0] - 6, 0);
|
||||
expect(hits[0][1]).toBeCloseTo(left[1]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,292 @@
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||
import type { LinearElementEditor } from "@excalidraw/element";
|
||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||
import type {
|
||||
ExcalidrawTextElement,
|
||||
FractionalIndex,
|
||||
SceneElementsMap,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
|
||||
import { mutateElement, newElementWith } from "../src/mutateElement";
|
||||
|
||||
describe("ElementsDelta", () => {
|
||||
describe("visible changes", () => {
|
||||
const apply = (before: SceneElementsMap, after: SceneElementsMap) =>
|
||||
ElementsDelta.calculate(before, after).applyTo(before, before, {
|
||||
excludedProperties: new Set(["version", "versionNonce"]),
|
||||
});
|
||||
|
||||
const boundText = (
|
||||
type: "rectangle" | "stickynote" | "arrow",
|
||||
text = "",
|
||||
) => {
|
||||
const container = API.createElement({
|
||||
type,
|
||||
id: "container",
|
||||
index: "a0" as FractionalIndex,
|
||||
width: 250,
|
||||
height: type === "arrow" ? 0 : 250,
|
||||
baseHeight: type === "stickynote" ? 250 : undefined,
|
||||
points:
|
||||
type === "arrow" ? [pointFrom(0, 0), pointFrom(250, 0)] : undefined,
|
||||
boundElements: [{ id: "label", type: "text" }],
|
||||
});
|
||||
const label: ExcalidrawTextElement = {
|
||||
...API.createElement({
|
||||
type: "text",
|
||||
id: "label",
|
||||
index: "a1" as FractionalIndex,
|
||||
containerId: container.id,
|
||||
fontSize: 20,
|
||||
textAlign: "center",
|
||||
verticalAlign: "middle",
|
||||
}),
|
||||
// API.createElement substitutes "test" for an empty string.
|
||||
text,
|
||||
originalText: text,
|
||||
};
|
||||
const before = arrayToMap([container, label]) as SceneElementsMap;
|
||||
const after = arrayToMap([
|
||||
newElementWith(container, { boundElements: [] }),
|
||||
newElementWith(label, { isDeleted: true }),
|
||||
]) as SceneElementsMap;
|
||||
return { container, label, before, after };
|
||||
};
|
||||
|
||||
it.each(["rectangle", "stickynote"] as const)(
|
||||
"ignores empty-label deletion and restoration on a %s",
|
||||
(type) => {
|
||||
const { before, after, container, label } = boundText(type);
|
||||
const [deleted, deletionIsVisible] = apply(before, after);
|
||||
expect(deletionIsVisible).toBe(false);
|
||||
expect(deleted.get(label.id)?.isDeleted).toBe(true);
|
||||
expect(deleted.get(container.id)?.boundElements).toEqual([]);
|
||||
|
||||
const [restored, restorationIsVisible] = apply(after, before);
|
||||
expect(restorationIsVisible).toBe(false);
|
||||
expect(restored.get(label.id)?.isDeleted).toBe(false);
|
||||
expect(restored.get(container.id)?.boundElements).toEqual([
|
||||
{ id: label.id, type: "text" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps deletion and restoration of nonempty labels visible", () => {
|
||||
const { before, after } = boundText("stickynote", "hello");
|
||||
expect(apply(before, after)[1]).toBe(true);
|
||||
expect(apply(after, before)[1]).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["excluded", "already applied"])(
|
||||
"ignores %s properties when deciding visibility",
|
||||
(mode) => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
index: "a0" as FractionalIndex,
|
||||
});
|
||||
const before = arrayToMap([element]) as SceneElementsMap;
|
||||
const after = arrayToMap([
|
||||
newElementWith(element, { strokeColor: "red" }),
|
||||
]) as SceneElementsMap;
|
||||
const delta = ElementsDelta.calculate(before, after);
|
||||
const current = mode === "excluded" ? before : after;
|
||||
const [elements, isVisible] = delta.applyTo(current, current, {
|
||||
excludedProperties: new Set([
|
||||
"version",
|
||||
"versionNonce",
|
||||
...(mode === "excluded" ? ["strokeColor" as const] : []),
|
||||
]),
|
||||
});
|
||||
|
||||
expect(elements.get(element.id)?.strokeColor).toBe(
|
||||
current.get(element.id)?.strokeColor,
|
||||
);
|
||||
expect(isVisible).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps snapshot restoration visible for a metadata-only update", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
index: "a0" as FractionalIndex,
|
||||
});
|
||||
const before = arrayToMap([element]) as SceneElementsMap;
|
||||
const after = arrayToMap([
|
||||
newElementWith(element, { version: element.version + 1 }),
|
||||
]) as SceneElementsMap;
|
||||
const delta = ElementsDelta.calculate(before, after);
|
||||
const [elements, isVisible] = delta.applyTo(
|
||||
new Map() as SceneElementsMap,
|
||||
before,
|
||||
);
|
||||
|
||||
expect(elements.get(element.id)?.isDeleted).toBe(false);
|
||||
expect(isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an arrow's empty-label gap visible", () => {
|
||||
const { before, after } = boundText("arrow");
|
||||
expect(apply(before, after)[1]).toBe(true);
|
||||
expect(apply(after, before)[1]).toBe(true);
|
||||
});
|
||||
|
||||
it("detects a container resized by restoring an empty label", () => {
|
||||
const { container, label } = boundText("rectangle");
|
||||
const smallContainer = newElementWith(container, {
|
||||
width: 20,
|
||||
height: 10,
|
||||
boundElements: [],
|
||||
});
|
||||
const before = arrayToMap([
|
||||
smallContainer,
|
||||
newElementWith(label, { isDeleted: true }),
|
||||
]) as SceneElementsMap;
|
||||
const after = arrayToMap([
|
||||
newElementWith(smallContainer, {
|
||||
boundElements: [{ id: label.id, type: "text" }],
|
||||
}),
|
||||
newElementWith(label, { isDeleted: false }),
|
||||
]) as SceneElementsMap;
|
||||
|
||||
const [elements, isVisible] = apply(before, after);
|
||||
expect(elements.get(container.id)?.height).toBeGreaterThan(10);
|
||||
expect(isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("detects a bound label repositioned by a container's redraw", () => {
|
||||
const { container, label } = boundText("rectangle", "hello");
|
||||
const misplacedLabel = newElementWith(label, { x: -100, y: -100 });
|
||||
const before = arrayToMap([
|
||||
container,
|
||||
misplacedLabel,
|
||||
]) as SceneElementsMap;
|
||||
const after = arrayToMap([
|
||||
newElementWith(container, { version: container.version + 1 }),
|
||||
misplacedLabel,
|
||||
]) as SceneElementsMap;
|
||||
|
||||
const [elements, isVisible] = apply(before, after);
|
||||
expect(elements.get(container.id)?.height).toBe(container.height);
|
||||
expect(elements.get(label.id)?.x).not.toBe(-100);
|
||||
expect(isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("detects a bound arrow repositioned by a container's redraw", () => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "container",
|
||||
index: "a0" as FractionalIndex,
|
||||
boundElements: [{ id: "arrow", type: "arrow" }],
|
||||
});
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow",
|
||||
index: "a1" as FractionalIndex,
|
||||
x: 200,
|
||||
y: 50,
|
||||
points: [pointFrom(0, 0), pointFrom(100, 0)],
|
||||
startBinding: {
|
||||
elementId: container.id,
|
||||
mode: "orbit",
|
||||
fixedPoint: [1, 0.5],
|
||||
},
|
||||
});
|
||||
const before = arrayToMap([container, arrow]) as SceneElementsMap;
|
||||
const after = arrayToMap([
|
||||
newElementWith(container, { version: container.version + 1 }),
|
||||
arrow,
|
||||
]) as SceneElementsMap;
|
||||
|
||||
const [elements, isVisible] = apply(before, after);
|
||||
expect(elements.get(container.id)?.width).toBe(container.width);
|
||||
expect(elements.get(arrow.id)?.x).not.toBe(200);
|
||||
expect(isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["test", "development", "production"])(
|
||||
"guards against untracked layout mutations in %s mode",
|
||||
(mode) => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "changed",
|
||||
index: "a0" as FractionalIndex,
|
||||
});
|
||||
const unrelated = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "unrelated",
|
||||
index: "a1" as FractionalIndex,
|
||||
});
|
||||
const before = arrayToMap([element, unrelated]) as SceneElementsMap;
|
||||
const after = arrayToMap([
|
||||
newElementWith(element, { strokeColor: "red" }),
|
||||
unrelated,
|
||||
]) as SceneElementsMap;
|
||||
// Simulate a future layout dependency missing from idsToCheck.
|
||||
// Mutate the shared instance so the guard must snapshot version values.
|
||||
const redraw = vi
|
||||
.spyOn(ElementsDelta, "redrawElements")
|
||||
.mockImplementationOnce((elements) => {
|
||||
mutateElement(elements.get(unrelated.id)!, elements, {
|
||||
x: unrelated.x + 1,
|
||||
});
|
||||
return elements;
|
||||
});
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
vi.stubEnv("MODE", mode);
|
||||
|
||||
try {
|
||||
if (mode === "production") {
|
||||
expect(apply(before, after)[1]).toBe(true);
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(() => apply(before, after)).toThrow(
|
||||
'Redrawn element "unrelated" is missing from idsToCheck',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
redraw.mockRestore();
|
||||
consoleError.mockRestore();
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps an empty text element's arrow bindings visible", () => {
|
||||
const label: ExcalidrawTextElement = {
|
||||
...API.createElement({
|
||||
type: "text",
|
||||
id: "label",
|
||||
index: "a0" as FractionalIndex,
|
||||
boundElements: [{ id: "arrow", type: "arrow" }],
|
||||
}),
|
||||
text: "",
|
||||
originalText: "",
|
||||
};
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow",
|
||||
index: "a1" as FractionalIndex,
|
||||
startBinding: {
|
||||
elementId: label.id,
|
||||
mode: "orbit",
|
||||
fixedPoint: [1, 0.5],
|
||||
},
|
||||
});
|
||||
const before = arrayToMap([label, arrow]) as SceneElementsMap;
|
||||
const after = arrayToMap([
|
||||
newElementWith(label, { isDeleted: true, boundElements: [] }),
|
||||
newElementWith(arrow, { startBinding: null }),
|
||||
]) as SceneElementsMap;
|
||||
expect(apply(before, after)[1]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elements delta calculation", () => {
|
||||
it("should not throw when element gets removed but was already deleted", () => {
|
||||
const element = API.createElement({
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import type { PointerDownState } from "@excalidraw/excalidraw/types";
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { Scene } from "../src/Scene";
|
||||
import { updateBoundElements } from "../src/binding";
|
||||
import { dragSelectedElements } from "../src/dragElements";
|
||||
import { syncInvalidIndices } from "../src/fractionalIndex";
|
||||
import { LinearElementEditor } from "../src/linearElementEditor";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "../src/types";
|
||||
|
||||
vi.mock("../src/binding", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/binding")>();
|
||||
return {
|
||||
...actual,
|
||||
updateBoundElements: vi.fn(actual.updateBoundElements),
|
||||
};
|
||||
});
|
||||
|
||||
const pointerDownStateFor = (scene: Scene): PointerDownState => {
|
||||
const state: Pick<PointerDownState, "originalElements"> = {
|
||||
originalElements: new Map(
|
||||
scene
|
||||
.getNonDeletedElements()
|
||||
.map((element) => [element.id, { ...element }]),
|
||||
),
|
||||
};
|
||||
return state as PointerDownState;
|
||||
};
|
||||
|
||||
const drag = (
|
||||
scene: Scene,
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
offset = { x: 20, y: 30 },
|
||||
pointerDownState = pointerDownStateFor(scene),
|
||||
) => {
|
||||
dragSelectedElements(
|
||||
pointerDownState,
|
||||
selectedElements,
|
||||
offset,
|
||||
scene,
|
||||
{ x: 0, y: 0 },
|
||||
null,
|
||||
);
|
||||
};
|
||||
|
||||
const createBoundArrow = () => {
|
||||
const rectangle = API.createElement({
|
||||
id: "rectangle",
|
||||
boundElements: [{ id: "arrow", type: "arrow" }],
|
||||
});
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow",
|
||||
x: 25,
|
||||
y: 25,
|
||||
width: 200,
|
||||
height: 0,
|
||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(200, 0)],
|
||||
startBinding: {
|
||||
elementId: rectangle.id,
|
||||
fixedPoint: [0.25, 0.25],
|
||||
mode: "inside",
|
||||
},
|
||||
});
|
||||
const scene = new Scene(syncInvalidIndices([rectangle, arrow]));
|
||||
return { scene, rectangle, arrow };
|
||||
};
|
||||
|
||||
describe("dragSelectedElements", () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(updateBoundElements).mockClear();
|
||||
});
|
||||
|
||||
it("passes every bound-arrow update the same moved-element array", () => {
|
||||
const selected = [0, 1, 2].map((i) =>
|
||||
API.createElement({ x: i * 200, y: 0 }),
|
||||
);
|
||||
const scene = new Scene(syncInvalidIndices(selected));
|
||||
|
||||
drag(scene, selected);
|
||||
|
||||
const calls = vi.mocked(updateBoundElements).mock.calls;
|
||||
expect(calls).toHaveLength(3);
|
||||
// a fresh array per call misses the per-array id-set cache and rebuilds
|
||||
// a selection-sized set for every element
|
||||
const [[, , firstOptions]] = calls;
|
||||
expect(firstOptions?.simultaneouslyUpdated).toEqual(selected);
|
||||
for (const [, , options] of calls) {
|
||||
expect(options?.simultaneouslyUpdated).toBe(
|
||||
firstOptions?.simultaneouslyUpdated,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("moves grouped elements and their bound label across z-order", () => {
|
||||
const rectangle = API.createElement({
|
||||
id: "rectangle",
|
||||
groupIds: ["group"],
|
||||
boundElements: [{ id: "label", type: "text" }],
|
||||
});
|
||||
const label = API.createElement({
|
||||
type: "text",
|
||||
id: "label",
|
||||
containerId: rectangle.id,
|
||||
x: 10,
|
||||
y: 15,
|
||||
width: 80,
|
||||
height: 20,
|
||||
groupIds: ["group"],
|
||||
});
|
||||
const unrelated = API.createElement({ x: 500, y: 600 });
|
||||
const second = API.createElement({
|
||||
x: 200,
|
||||
y: 300,
|
||||
groupIds: ["group"],
|
||||
});
|
||||
const scene = new Scene(
|
||||
syncInvalidIndices([rectangle, label, unrelated, second]),
|
||||
);
|
||||
const order = scene.getNonDeletedElements().map((element) => element.id);
|
||||
|
||||
drag(scene, [rectangle, second]);
|
||||
|
||||
expect(scene.getNonDeletedElements().map(({ x, y }) => ({ x, y }))).toEqual(
|
||||
[
|
||||
{ x: 20, y: 30 },
|
||||
{ x: 30, y: 45 },
|
||||
{ x: 500, y: 600 },
|
||||
{ x: 220, y: 330 },
|
||||
],
|
||||
);
|
||||
expect(scene.getNonDeletedElements().map((element) => element.id)).toEqual(
|
||||
order,
|
||||
);
|
||||
});
|
||||
|
||||
it("moves a frame and its children once even when a child is also selected", () => {
|
||||
const frame = API.createElement({ type: "frame", width: 400, height: 400 });
|
||||
const selectedChild = API.createElement({ x: 10, frameId: frame.id });
|
||||
const otherChild = API.createElement({ x: 200, frameId: frame.id });
|
||||
const elements = [selectedChild, otherChild, frame];
|
||||
const scene = new Scene(syncInvalidIndices(elements));
|
||||
const versions = elements.map((element) => element.version);
|
||||
|
||||
drag(scene, [frame, selectedChild]);
|
||||
|
||||
expect(elements.map(({ x, y }) => ({ x, y }))).toEqual([
|
||||
{ x: 30, y: 40 },
|
||||
{ x: 220, y: 230 },
|
||||
{ x: 20, y: 30 },
|
||||
]);
|
||||
expect(elements.map((element) => element.version)).toEqual(
|
||||
versions.map((version) => version + 1),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates an unselected bound arrow", () => {
|
||||
const { scene, rectangle, arrow } = createBoundArrow();
|
||||
|
||||
drag(scene, [rectangle]);
|
||||
|
||||
expect(rectangle).toMatchObject({ x: 20, y: 30 });
|
||||
expect(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
0,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
).toEqual([45, 55]);
|
||||
expect(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
-1,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
).toEqual([225, 25]);
|
||||
expect(arrow.startBinding?.elementId).toBe(rectangle.id);
|
||||
});
|
||||
|
||||
it("preserves bindings when translating an arrow with its attached element", () => {
|
||||
const { scene, rectangle, arrow } = createBoundArrow();
|
||||
const arrowVersion = arrow.version;
|
||||
const originalPoints = arrow.points;
|
||||
|
||||
drag(scene, [rectangle, arrow]);
|
||||
|
||||
expect(arrow).toMatchObject({
|
||||
x: 45,
|
||||
y: 55,
|
||||
startBinding: { elementId: rectangle.id },
|
||||
version: arrowVersion + 1,
|
||||
});
|
||||
expect(arrow.points).toBe(originalPoints);
|
||||
expect(rectangle.boundElements).toEqual([{ id: arrow.id, type: "arrow" }]);
|
||||
});
|
||||
|
||||
it("unbinds a translated arrow from an unselected element", () => {
|
||||
const { scene, rectangle, arrow } = createBoundArrow();
|
||||
|
||||
drag(scene, [arrow]);
|
||||
|
||||
expect(arrow).toMatchObject({ x: 45, y: 55, startBinding: null });
|
||||
expect(rectangle).toMatchObject({ x: 0, y: 0, boundElements: [] });
|
||||
});
|
||||
|
||||
it("does not mutate for unchanged coordinates", () => {
|
||||
const rectangle = API.createElement({});
|
||||
const second = API.createElement({ x: 200 });
|
||||
const scene = new Scene(syncInvalidIndices([rectangle, second]));
|
||||
const originalState = pointerDownStateFor(scene);
|
||||
const versions = () => [rectangle.version, second.version];
|
||||
const initial = versions();
|
||||
|
||||
drag(scene, [rectangle, second], { x: 0, y: 0 }, originalState);
|
||||
expect(versions()).toEqual(initial);
|
||||
|
||||
drag(scene, [rectangle, second], { x: 20, y: 30 }, originalState);
|
||||
const moved = versions();
|
||||
expect(moved).toEqual(initial.map((version) => version + 1));
|
||||
|
||||
drag(scene, [rectangle, second], { x: 20, y: 30 }, originalState);
|
||||
expect(versions()).toEqual(moved);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ORIG_ID,
|
||||
ROUNDNESS,
|
||||
isPrimitive,
|
||||
getUpdatedTimestamp,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
|
||||
@@ -24,7 +25,11 @@ import {
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { duplicateElement, duplicateElements } from "../src/duplicate";
|
||||
import {
|
||||
deepCopyElement,
|
||||
duplicateElement,
|
||||
duplicateElements,
|
||||
} from "../src/duplicate";
|
||||
|
||||
import type { ExcalidrawLinearElement } from "../src/types";
|
||||
|
||||
@@ -43,6 +48,24 @@ const assertCloneObjects = (source: any, clone: any) => {
|
||||
};
|
||||
|
||||
describe("duplicating single elements", () => {
|
||||
it.each([123, 0, null])(
|
||||
"preserves created=%s on a deep copy and resets it for a new instance",
|
||||
(created) => {
|
||||
const element = API.createElement({ type: "rectangle", created });
|
||||
const cloned = deepCopyElement(element);
|
||||
const duplicate = duplicateElement(null, new Map(), element);
|
||||
|
||||
expect(cloned).toEqual(element);
|
||||
expect(duplicate).toMatchObject({
|
||||
created: getUpdatedTimestamp(),
|
||||
version: element.version,
|
||||
versionNonce: element.versionNonce,
|
||||
});
|
||||
expect(duplicate.id).not.toBe(element.id);
|
||||
expect(element.created).toBe(created);
|
||||
},
|
||||
);
|
||||
|
||||
it("clones arrow element", () => {
|
||||
const element = API.createElement({
|
||||
type: "arrow",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ARROW_TYPE } from "@excalidraw/common";
|
||||
import { ARROW_TYPE, DEFAULT_ZOOM } from "@excalidraw/common";
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import { actionSelectAll } from "@excalidraw/excalidraw/actions";
|
||||
@@ -188,8 +188,22 @@ describe("elbow arrow routing", () => {
|
||||
}) as NonDeleted<ExcalidrawElbowArrowElement>;
|
||||
API.setElements([rectangle1, rectangle2, arrow]);
|
||||
|
||||
bindBindingElement(arrow, rectangle1, "orbit", "start", h.scene);
|
||||
bindBindingElement(arrow, rectangle2, "orbit", "end", h.scene);
|
||||
bindBindingElement(
|
||||
arrow,
|
||||
rectangle1,
|
||||
"orbit",
|
||||
"start",
|
||||
h.scene,
|
||||
DEFAULT_ZOOM,
|
||||
);
|
||||
bindBindingElement(
|
||||
arrow,
|
||||
rectangle2,
|
||||
"orbit",
|
||||
"end",
|
||||
h.scene,
|
||||
DEFAULT_ZOOM,
|
||||
);
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
@@ -95,6 +95,18 @@ describe("YouTube timestamp parsing", () => {
|
||||
expect(result?.intrinsicSize).toEqual({ w: 315, h: 560 });
|
||||
});
|
||||
|
||||
it("should handle YouTube live URLs", () => {
|
||||
const url = "https://www.youtube.com/live/dQw4w9WgXcQ?si=abc&t=30";
|
||||
const result = getEmbedLink(url);
|
||||
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toBe(
|
||||
"https://www.youtube.com/embed/dQw4w9WgXcQ?enablejsapi=1&start=30",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle playlist URLs with timestamps", () => {
|
||||
const url =
|
||||
"https://www.youtube.com/playlist?list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ&t=60";
|
||||
|
||||
@@ -3,6 +3,7 @@ import { act, queryByTestId, queryByText } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import {
|
||||
CURSOR_TYPE,
|
||||
ROUNDNESS,
|
||||
VERTICAL_ALIGN,
|
||||
KEYS,
|
||||
@@ -31,6 +32,7 @@ import * as textElementUtils from "../src/textElement";
|
||||
import { getBoundTextElementPosition, getBoundTextMaxWidth } from "../src";
|
||||
import { LinearElementEditor } from "../src";
|
||||
import { newArrowElement } from "../src";
|
||||
import { getLinearElementPathSegments } from "../src/utils";
|
||||
|
||||
import {
|
||||
getTextEditor,
|
||||
@@ -213,13 +215,15 @@ describe("Test Linear Elements", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
const line = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toEqual(2);
|
||||
|
||||
// drag line from midpoint
|
||||
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`10`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(line.points.length).toEqual(3);
|
||||
expect(line.points).toMatchInlineSnapshot(`
|
||||
@@ -502,7 +506,7 @@ describe("Test Linear Elements", () => {
|
||||
// drag line from midpoint
|
||||
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`14`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -543,7 +547,7 @@ describe("Test Linear Elements", () => {
|
||||
fireEvent.click(screen.getByTitle("Round"));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`10`,
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
@@ -604,7 +608,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(startPoint, endPoint);
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`13`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -672,7 +676,7 @@ describe("Test Linear Elements", () => {
|
||||
);
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`15`,
|
||||
`18`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
|
||||
|
||||
@@ -723,7 +727,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`14`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -764,7 +768,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`14`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -812,7 +816,7 @@ describe("Test Linear Elements", () => {
|
||||
deletePoint(points[2]);
|
||||
expect(line.points.length).toEqual(3);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`18`,
|
||||
`22`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`);
|
||||
|
||||
@@ -870,7 +874,7 @@ describe("Test Linear Elements", () => {
|
||||
),
|
||||
);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`15`,
|
||||
`18`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
|
||||
expect(line.points.length).toEqual(5);
|
||||
@@ -968,7 +972,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`14`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -1202,6 +1206,93 @@ describe("Test Linear Elements", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getPointAtPathParameter", () => {
|
||||
it("should survive midpoint insertion", () => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 0,
|
||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(100, 0)],
|
||||
roundness: null,
|
||||
});
|
||||
API.setElements([arrow]);
|
||||
|
||||
const before = LinearElementEditor.getPointAtPathParameter(
|
||||
arrow,
|
||||
0.25,
|
||||
arrayToMap(h.elements),
|
||||
)!;
|
||||
expect(before[0]).toBeCloseTo(25);
|
||||
expect(before[1]).toBeCloseTo(0);
|
||||
|
||||
// inserting a midpoint re-segments the path but must not move the
|
||||
// point at the same normalized arc-length parameter
|
||||
act(() => {
|
||||
h.app.scene.mutateElement(arrow, {
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(50, 0),
|
||||
pointFrom<LocalPoint>(100, 0),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const after = LinearElementEditor.getPointAtPathParameter(
|
||||
arrow,
|
||||
0.25,
|
||||
arrayToMap(h.elements),
|
||||
)!;
|
||||
expect(after[0]).toBeCloseTo(before[0]);
|
||||
expect(after[1]).toBeCloseTo(before[1]);
|
||||
});
|
||||
|
||||
it("should use the unrounded logical path of elbow arrows", () => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(100, 0),
|
||||
pointFrom<LocalPoint>(100, 100),
|
||||
],
|
||||
elbowed: true,
|
||||
});
|
||||
API.setElements([arrow]);
|
||||
const elementsMap = arrayToMap(h.elements);
|
||||
|
||||
// Until mixed paths are supported, elbow arrows are treated as the
|
||||
// straight segments between their logical points.
|
||||
const segments = getLinearElementPathSegments(arrow, elementsMap);
|
||||
expect(segments).toHaveLength(2);
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
const prevEnd = segments[i - 1][segments[i - 1].length - 1];
|
||||
expect(segments[i][0][0]).toBeCloseTo(prevEnd[0]);
|
||||
expect(segments[i][0][1]).toBeCloseTo(prevEnd[1]);
|
||||
}
|
||||
|
||||
const expectedPoints = [
|
||||
pointFrom<GlobalPoint>(0, 0),
|
||||
pointFrom<GlobalPoint>(50, 0),
|
||||
pointFrom<GlobalPoint>(100, 0),
|
||||
pointFrom<GlobalPoint>(100, 50),
|
||||
pointFrom<GlobalPoint>(100, 100),
|
||||
];
|
||||
for (const [index, parameter] of [0, 0.25, 0.5, 0.75, 1].entries()) {
|
||||
const point = LinearElementEditor.getPointAtPathParameter(
|
||||
arrow,
|
||||
parameter,
|
||||
elementsMap,
|
||||
);
|
||||
expect(point).toEqual(expectedPoints[index]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should match styles for text editor", async () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
@@ -1325,7 +1416,7 @@ describe("Test Linear Elements", () => {
|
||||
expect({ width: container.width, height: container.height })
|
||||
.toMatchInlineSnapshot(`
|
||||
{
|
||||
"height": 130,
|
||||
"height": 150,
|
||||
"width": "366.11716",
|
||||
}
|
||||
`);
|
||||
@@ -1354,11 +1445,11 @@ describe("Test Linear Elements", () => {
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
20,
|
||||
35,
|
||||
45,
|
||||
"501.11716",
|
||||
95,
|
||||
220,
|
||||
"205.45894",
|
||||
"52.50000",
|
||||
145,
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1469,6 +1560,7 @@ describe("Test Linear Elements", () => {
|
||||
"nw",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
wrapText(
|
||||
@@ -1577,6 +1669,270 @@ describe("Test Linear Elements", () => {
|
||||
expect(label.x).toBe(0);
|
||||
expect(label.y).toBe(0);
|
||||
});
|
||||
|
||||
describe("dragging the label along the arrow", () => {
|
||||
// a tall label, so its lower half is beyond the arrow's hit threshold:
|
||||
// there the pointer is over the label but not over the arrow's path
|
||||
const createArrowWithTallLabel = () => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
x: p1[0],
|
||||
y: p1[1],
|
||||
width: p2[0] - p1[0],
|
||||
height: 0,
|
||||
points: [pointFrom(0, 0), pointFrom(p2[0] - p1[0], 0)],
|
||||
});
|
||||
const label = {
|
||||
...API.createElement({
|
||||
type: "text",
|
||||
text: "label",
|
||||
containerId: arrow.id,
|
||||
width: 30,
|
||||
height: 60,
|
||||
}),
|
||||
labelPosition: 0.5,
|
||||
} as ExcalidrawTextElementWithContainer;
|
||||
|
||||
return {
|
||||
label,
|
||||
arrow: {
|
||||
...arrow,
|
||||
boundElements: [{ type: "text", id: label.id } as const],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// covers the label and the arrow's segment midpoint handle, but not the
|
||||
// arrow's start point, so the arrow can still be selected by clicking it
|
||||
const createOccluder = () =>
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
x: 30,
|
||||
y: 5,
|
||||
width: 30,
|
||||
height: 50,
|
||||
backgroundColor: "#ff0000",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
|
||||
// inside the label, off the arrow's path
|
||||
const labelOnlyPoint = pointFrom<GlobalPoint>(40, 45);
|
||||
|
||||
it("treats a point handle under the label as the handle, on hover and on pointer down", () => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
x: p1[0],
|
||||
y: p1[1],
|
||||
width: p2[0] - p1[0],
|
||||
height: 0,
|
||||
points: [pointFrom(0, 0), pointFrom(p2[0] - p1[0], 0)],
|
||||
});
|
||||
const label = {
|
||||
...API.createElement({
|
||||
type: "text",
|
||||
text: "label",
|
||||
containerId: arrow.id,
|
||||
width: 30,
|
||||
height: 30,
|
||||
}),
|
||||
// centered on the end point
|
||||
labelPosition: 1,
|
||||
} as ExcalidrawTextElementWithContainer;
|
||||
API.setElements([
|
||||
{
|
||||
...arrow,
|
||||
boundElements: [{ type: "text", id: label.id } as const],
|
||||
},
|
||||
label,
|
||||
]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.clickAt(p1[0], p1[1]);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
|
||||
|
||||
// inside the label and within the end point's handle radius, but
|
||||
// farther from the arrow's path than the arrow's own hit threshold
|
||||
const point = pointFrom<GlobalPoint>(p2[0], p2[1] + 9);
|
||||
|
||||
mouse.moveTo(point[0], point[1]);
|
||||
expect(h.state.selectedLinearElement?.hoverPointIndex).toBe(1);
|
||||
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
|
||||
CURSOR_TYPE.POINTER,
|
||||
);
|
||||
|
||||
drag(point, pointFrom<GlobalPoint>(point[0] + 40, point[1]));
|
||||
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).labelPosition,
|
||||
).toBe(1);
|
||||
expect(
|
||||
(h.elements[0] as ExcalidrawLinearElement).points[1][0],
|
||||
).toBeCloseTo(p2[0] - p1[0] + 40);
|
||||
});
|
||||
|
||||
it("keeps the arrow selected when a point handle is grabbed beyond the arrow's tip", () => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
x: p1[0],
|
||||
y: p1[1],
|
||||
width: p2[0] - p1[0],
|
||||
height: 0,
|
||||
points: [pointFrom(0, 0), pointFrom(p2[0] - p1[0], 0)],
|
||||
});
|
||||
API.setElements([arrow]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.clickAt(p1[0], p1[1]);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
|
||||
|
||||
// past the end point along the arrow: inside the handle radius, but
|
||||
// outside the arrow's own hit area
|
||||
const point = pointFrom<GlobalPoint>(p2[0] + 9, p2[1]);
|
||||
|
||||
mouse.moveTo(point[0], point[1]);
|
||||
expect(h.state.selectedLinearElement?.hoverPointIndex).toBe(1);
|
||||
|
||||
drag(point, pointFrom<GlobalPoint>(point[0] + 40, point[1]));
|
||||
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.state.selectedElementIds[arrow.id]).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
|
||||
expect(
|
||||
(h.elements[0] as ExcalidrawLinearElement).points[1][0],
|
||||
).toBeCloseTo(p2[0] - p1[0] + 40);
|
||||
});
|
||||
|
||||
it("grabs the label when the covering element is below the arrow", () => {
|
||||
const { arrow, label } = createArrowWithTallLabel();
|
||||
API.setElements([createOccluder(), arrow, label]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.clickAt(p1[0], p1[1]);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
|
||||
|
||||
drag(labelOnlyPoint, pointFrom<GlobalPoint>(50, 45));
|
||||
|
||||
expect(
|
||||
(h.elements[2] as ExcalidrawTextElementWithContainer).labelPosition,
|
||||
).toBeCloseTo(0.75);
|
||||
});
|
||||
|
||||
it("does not grab the label when it is covered by an element above the arrow", () => {
|
||||
const { arrow, label } = createArrowWithTallLabel();
|
||||
API.setElements([arrow, label, createOccluder()]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.clickAt(p1[0], p1[1]);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
|
||||
|
||||
drag(labelOnlyPoint, pointFrom<GlobalPoint>(50, 45));
|
||||
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).labelPosition,
|
||||
).toBe(0.5);
|
||||
});
|
||||
|
||||
it("grabs an elbow arrow's label lying over an intermediate route point", () => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(100, 0),
|
||||
pointFrom<LocalPoint>(100, 100),
|
||||
],
|
||||
elbowed: true,
|
||||
});
|
||||
const label = {
|
||||
...API.createElement({
|
||||
type: "text",
|
||||
text: "label",
|
||||
containerId: arrow.id,
|
||||
width: 30,
|
||||
height: 30,
|
||||
}),
|
||||
// the arc-length middle of this symmetric elbow sits on the
|
||||
// rounded corner, right next to the route point at (100, 0)
|
||||
labelPosition: 0.5,
|
||||
} as ExcalidrawTextElementWithContainer;
|
||||
API.setElements([
|
||||
{
|
||||
...arrow,
|
||||
boundElements: [{ type: "text", id: label.id } as const],
|
||||
},
|
||||
label,
|
||||
]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.clickAt(0, 0);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
|
||||
|
||||
// inside the label AND within the hit radius of the route point at
|
||||
// (100, 0) — pre-fix the route point swallowed this pointerdown and
|
||||
// the label never moved
|
||||
const grabPoint = pointFrom<GlobalPoint>(97, 3);
|
||||
|
||||
drag(
|
||||
grabPoint,
|
||||
pointFrom<GlobalPoint>(grabPoint[0] - 40, grabPoint[1]),
|
||||
);
|
||||
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).labelPosition,
|
||||
).not.toBe(0.5);
|
||||
});
|
||||
|
||||
it("gives the segment midpoint handle precedence over the label on top of it", () => {
|
||||
const { arrow, label } = createArrowWithTallLabel();
|
||||
API.setElements([arrow, label]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.clickAt(p1[0], p1[1]);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
|
||||
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toBe(
|
||||
2,
|
||||
);
|
||||
|
||||
// the label sits centered on the midpoint knob; dragging there must
|
||||
// bend the arrow, not slide the label
|
||||
drag(midpoint, pointFrom<GlobalPoint>(midpoint[0], midpoint[1] + 40));
|
||||
|
||||
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toBe(
|
||||
3,
|
||||
);
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).labelPosition,
|
||||
).toBe(0.5);
|
||||
});
|
||||
|
||||
it("leaves the segment midpoint handle grabbable under a covered label", () => {
|
||||
const { arrow, label } = createArrowWithTallLabel();
|
||||
API.setElements([arrow, label, createOccluder()]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.clickAt(p1[0], p1[1]);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(arrow.id);
|
||||
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toBe(
|
||||
2,
|
||||
);
|
||||
|
||||
// the midpoint handle sits under the label, which is itself covered
|
||||
drag(midpoint, pointFrom<GlobalPoint>(midpoint[0], midpoint[1] + 40));
|
||||
|
||||
expect(
|
||||
h.state.selectedLinearElement?.initialState.segmentMidpoint.value,
|
||||
).not.toBeNull();
|
||||
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toBe(
|
||||
3,
|
||||
);
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).labelPosition,
|
||||
).toBe(0.5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test moving linear element points", () => {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as common from "@excalidraw/common";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { newElement, newLinearElement } from "../src/newElement";
|
||||
import { mutateElement, newElementWith } from "../src/mutateElement";
|
||||
|
||||
describe("element creation time", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"rectangle",
|
||||
"diamond",
|
||||
"ellipse",
|
||||
"text",
|
||||
"line",
|
||||
"arrow",
|
||||
"freedraw",
|
||||
"image",
|
||||
"frame",
|
||||
"magicframe",
|
||||
"embeddable",
|
||||
"iframe",
|
||||
] as const)("initializes a new %s before any capture", (type) => {
|
||||
vi.spyOn(common, "getUpdatedTimestamp").mockReturnValue(123);
|
||||
const element = API.createElement({ type });
|
||||
expect(element.created).toBe(123);
|
||||
|
||||
vi.mocked(common.getUpdatedTimestamp).mockReturnValue(456);
|
||||
const edited = newElementWith(element, { x: 50 });
|
||||
mutateElement(edited, new Map(), { y: 50 });
|
||||
|
||||
expect(edited).toMatchObject({ created: 123, updated: 456 });
|
||||
});
|
||||
|
||||
it.each([123, 0, null])(
|
||||
"preserves explicit created=%s when reconstructing the same element",
|
||||
(created) => {
|
||||
const original = newElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
created,
|
||||
});
|
||||
const converted = newLinearElement({ ...original, type: "line" });
|
||||
|
||||
expect(converted).toMatchObject({ id: original.id, created });
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,517 @@
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { repairBindings, unbindBindingElement } from "../src/binding";
|
||||
import { newArrowElement, newElement } from "../src/newElement";
|
||||
import { Scene } from "../src/Scene";
|
||||
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawElement,
|
||||
FixedPointBinding,
|
||||
} from "../src/types";
|
||||
|
||||
const rect = (
|
||||
id: string,
|
||||
x = 0,
|
||||
extra: Partial<ExcalidrawElement> = {},
|
||||
): ExcalidrawElement =>
|
||||
({
|
||||
...newElement({ type: "rectangle", x, y: 0, width: 100, height: 100 }),
|
||||
id,
|
||||
...extra,
|
||||
} as ExcalidrawElement);
|
||||
|
||||
const arrow = (
|
||||
id: string,
|
||||
from: [number, number],
|
||||
to: [number, number],
|
||||
extra: Partial<ExcalidrawArrowElement> = {},
|
||||
): ExcalidrawArrowElement => ({
|
||||
...newArrowElement({
|
||||
type: "arrow",
|
||||
elbowed: !!extra.elbowed,
|
||||
x: from[0],
|
||||
y: from[1],
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(to[0] - from[0], to[1] - from[1]),
|
||||
],
|
||||
}),
|
||||
id,
|
||||
...extra,
|
||||
});
|
||||
|
||||
/** minimal bound text; `newTextElement` would need font measurement */
|
||||
const label = (id: string, containerId: string | null) =>
|
||||
({ ...rect(id), type: "text", containerId } as unknown as ExcalidrawElement);
|
||||
|
||||
const elbow = (
|
||||
id: string,
|
||||
from: [number, number],
|
||||
to: [number, number],
|
||||
extra: Partial<ExcalidrawArrowElement> = {},
|
||||
) => arrow(id, from, to, { ...extra, elbowed: true });
|
||||
|
||||
const P = (x: number, y: number) => pointFrom<LocalPoint>(x, y);
|
||||
|
||||
/** every segment of an elbow arrow must be horizontal or vertical */
|
||||
const isOrthogonal = (element: ExcalidrawArrowElement) =>
|
||||
element.points
|
||||
.slice(1)
|
||||
.every(
|
||||
([x, y], i) => x === element.points[i][0] || y === element.points[i][1],
|
||||
);
|
||||
|
||||
const byId = (elements: readonly ExcalidrawElement[]) =>
|
||||
Object.fromEntries(elements.map((element) => [element.id, element])) as any;
|
||||
|
||||
describe("repairBindings", () => {
|
||||
// must work headless, i.e. without any DOM
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("window", undefined);
|
||||
vi.stubGlobal("document", undefined);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("leaves unbound endpoints alone without mutating input", () => {
|
||||
const input = [rect("a"), rect("b", 300), arrow("x", [102, 50], [298, 50])];
|
||||
const snapshot = JSON.stringify(input);
|
||||
|
||||
const { a, b, x } = byId(repairBindings(input));
|
||||
|
||||
expect(x.startBinding).toBeNull();
|
||||
expect(x.endBinding).toBeNull();
|
||||
expect(a.boundElements).toBeNull();
|
||||
expect(b.boundElements).toBeNull();
|
||||
expect(JSON.stringify(input)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it("drops dangling bindings and stale records", () => {
|
||||
const binding = {
|
||||
elementId: "missing",
|
||||
mode: "orbit",
|
||||
fixedPoint: [0.5001, 0.5001],
|
||||
} as FixedPointBinding;
|
||||
|
||||
const input = [
|
||||
rect("a", 500, {
|
||||
boundElements: [
|
||||
{ id: "x", type: "arrow" },
|
||||
{ id: "gone", type: "arrow" },
|
||||
],
|
||||
}),
|
||||
arrow("x", [0, 0], [100, 0], { startBinding: binding }),
|
||||
];
|
||||
const snapshot = JSON.stringify(input);
|
||||
|
||||
const { a, x } = byId(repairBindings(input));
|
||||
|
||||
expect(x.startBinding).toBeNull();
|
||||
expect(a.boundElements).toEqual([]);
|
||||
expect(JSON.stringify(input)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it("keeps label records that bind back and drops invalid ones", () => {
|
||||
const { a } = byId(
|
||||
repairBindings([
|
||||
rect("a", 0, {
|
||||
boundElements: [
|
||||
{ id: "t", type: "text" },
|
||||
{ id: "u", type: "text" },
|
||||
{ id: "d", type: "arrow" },
|
||||
{ id: "r", type: "arrow" },
|
||||
],
|
||||
}),
|
||||
label("t", "a"),
|
||||
label("u", null),
|
||||
arrow("d", [300, 50], [102, 50], {
|
||||
isDeleted: true,
|
||||
endBinding: { elementId: "a", mode: "orbit", fixedPoint: [1, 0.4] },
|
||||
}),
|
||||
rect("r", 500),
|
||||
]),
|
||||
);
|
||||
|
||||
// only the label pointing back survives: not the unbound label, the
|
||||
// deleted arrow, or the rectangle, which cannot be bound
|
||||
expect(a.boundElements).toEqual([{ id: "t", type: "text" }]);
|
||||
});
|
||||
|
||||
it("drops bindings to deleted targets", () => {
|
||||
const { a, x } = byId(
|
||||
repairBindings([
|
||||
rect("a", 0, {
|
||||
isDeleted: true,
|
||||
boundElements: [{ id: "x", type: "arrow" }],
|
||||
}),
|
||||
arrow("x", [300, 50], [102, 50], {
|
||||
endBinding: { elementId: "a", mode: "orbit", fixedPoint: [1, 0.5] },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(x.endBinding).toBeNull();
|
||||
// deleted elements are passed through untouched
|
||||
expect(a.boundElements).toEqual([{ id: "x", type: "arrow" }]);
|
||||
});
|
||||
|
||||
it("drops bindings to non-bindable targets without recording on them", () => {
|
||||
const { x, y } = byId(
|
||||
repairBindings([
|
||||
arrow("y", [0, 0], [100, 0]),
|
||||
arrow("x", [300, 50], [100, 0], {
|
||||
endBinding: { elementId: "y", mode: "orbit", fixedPoint: [1, 0.5] },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(x.endBinding).toBeNull();
|
||||
expect(y.boundElements).toBeNull();
|
||||
});
|
||||
|
||||
it("removes duplicate records", () => {
|
||||
const { a } = byId(
|
||||
repairBindings([
|
||||
rect("a", 0, {
|
||||
boundElements: [
|
||||
{ id: "x", type: "arrow" },
|
||||
{ id: "x", type: "arrow" },
|
||||
],
|
||||
}),
|
||||
arrow("x", [300, 50], [102, 50], {
|
||||
endBinding: { elementId: "a", mode: "orbit", fixedPoint: [1, 0.4] },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(a.boundElements).toEqual([{ id: "x", type: "arrow" }]);
|
||||
});
|
||||
|
||||
it("normalizes the fixed point of complete bindings", () => {
|
||||
const normalized = {
|
||||
elementId: "a",
|
||||
mode: "orbit",
|
||||
fixedPoint: [1, 0.4],
|
||||
} as FixedPointBinding;
|
||||
|
||||
const { x, y, z } = byId(
|
||||
repairBindings([
|
||||
rect("a"),
|
||||
arrow("x", [300, 50], [102, 50], {
|
||||
endBinding: { elementId: "a", mode: "orbit", fixedPoint: [1, 0.5] },
|
||||
}),
|
||||
arrow("y", [300, 40], [102, 40], { endBinding: normalized }),
|
||||
arrow("z", [300, 30], [102, 30], {
|
||||
endBinding: { elementId: "a", mode: "orbit", fixedPoint: [50, 0.4] },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(x.endBinding.fixedPoint).toEqual([1, 0.5001]);
|
||||
expect(z.endBinding.fixedPoint).toEqual([10, 0.4]);
|
||||
// already normalized bindings are left untouched
|
||||
expect(y.endBinding).toBe(normalized);
|
||||
});
|
||||
|
||||
it("records a declared binding on its target", () => {
|
||||
const binding = {
|
||||
elementId: "a",
|
||||
mode: "orbit",
|
||||
fixedPoint: [1, 0.4],
|
||||
} as FixedPointBinding;
|
||||
|
||||
const { a, b } = byId(
|
||||
repairBindings([
|
||||
rect("a"),
|
||||
rect("b", 500, { boundElements: [{ id: "y", type: "arrow" }] }),
|
||||
arrow("x", [300, 50], [102, 50], { endBinding: binding }),
|
||||
arrow("y", [300, 40], [498, 40], {
|
||||
endBinding: { ...binding, elementId: "b", fixedPoint: [0, 0.4] },
|
||||
}),
|
||||
arrow("z", [300, 30], [498, 30], {
|
||||
endBinding: { ...binding, elementId: "b", fixedPoint: [0, 0.3] },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(a.boundElements).toEqual([{ id: "x", type: "arrow" }]);
|
||||
// appended to existing records
|
||||
expect(b.boundElements).toEqual([
|
||||
{ id: "y", type: "arrow" },
|
||||
{ id: "z", type: "arrow" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("records an arrow bound at both ends to the same element once", () => {
|
||||
const { a } = byId(
|
||||
repairBindings([
|
||||
rect("a"),
|
||||
arrow("x", [102, 20], [102, 80], {
|
||||
startBinding: { elementId: "a", mode: "orbit", fixedPoint: [1, 0.2] },
|
||||
endBinding: { elementId: "a", mode: "orbit", fixedPoint: [1, 0.8] },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(a.boundElements).toEqual([{ id: "x", type: "arrow" }]);
|
||||
});
|
||||
|
||||
it("repairs locked arrows and locked targets", () => {
|
||||
const { a, x } = byId(
|
||||
repairBindings([
|
||||
rect("a", 0, { locked: true }),
|
||||
arrow("x", [300, 50], [102, 50], {
|
||||
locked: true,
|
||||
endBinding: { elementId: "a", mode: "orbit", fixedPoint: [1, 0.5] },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(a.boundElements).toEqual([{ id: "x", type: "arrow" }]);
|
||||
expect(x.endBinding.fixedPoint).toEqual([1, 0.5001]);
|
||||
});
|
||||
|
||||
it("completes a binding missing mode and fixedPoint", () => {
|
||||
const { x, y } = byId(
|
||||
repairBindings([
|
||||
rect("a"),
|
||||
arrow("x", [300, 50], [102, 50], {
|
||||
endBinding: { elementId: "a" } as FixedPointBinding,
|
||||
}),
|
||||
arrow("y", [102, 40], [300, 40], {
|
||||
startBinding: { elementId: "a" } as FixedPointBinding,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(x.endBinding.mode).toBe("orbit");
|
||||
expect(x.endBinding.fixedPoint).toHaveLength(2);
|
||||
// re-derived from the first point, just outside the right side
|
||||
expect(y.startBinding.mode).toBe("orbit");
|
||||
expect(y.startBinding.fixedPoint[0]).toBeGreaterThan(1);
|
||||
expect(y.startBinding.fixedPoint[1]).toBeCloseTo(0.4, 1);
|
||||
});
|
||||
|
||||
it("re-derives bindings with an invalid mode or fixedPoint", () => {
|
||||
const { x, y, z } = byId(
|
||||
repairBindings([
|
||||
rect("a"),
|
||||
arrow("x", [300, 50], [102, 50], {
|
||||
endBinding: {
|
||||
elementId: "a",
|
||||
mode: "bogus",
|
||||
fixedPoint: [1, 0.4],
|
||||
} as unknown as FixedPointBinding,
|
||||
}),
|
||||
arrow("y", [300, 40], [102, 40], {
|
||||
endBinding: {
|
||||
elementId: "a",
|
||||
mode: "orbit",
|
||||
fixedPoint: [NaN, 0.4],
|
||||
} as FixedPointBinding,
|
||||
}),
|
||||
arrow("z", [300, 30], [102, 30], {
|
||||
endBinding: { elementId: "a", mode: "orbit" } as FixedPointBinding,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(x.endBinding.mode).toBe("orbit");
|
||||
for (const { endBinding } of [x, y, z]) {
|
||||
expect(endBinding.fixedPoint.every(Number.isFinite)).toBe(true);
|
||||
// re-derived from the endpoint, just outside the right side
|
||||
expect(endBinding.fixedPoint[0]).toBeGreaterThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("completes a binding as inside when the endpoint is inside", () => {
|
||||
const { x } = byId(
|
||||
repairBindings([
|
||||
rect("a"),
|
||||
arrow("x", [300, 50], [50, 40], {
|
||||
endBinding: { elementId: "a" } as FixedPointBinding,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(x.endBinding.mode).toBe("inside");
|
||||
});
|
||||
|
||||
it("snaps a bound endpoint onto the moved target", () => {
|
||||
const { x } = byId(
|
||||
repairBindings([
|
||||
rect("a", 1000),
|
||||
arrow("x", [300, 50], [102, 50], {
|
||||
endBinding: {
|
||||
elementId: "a",
|
||||
mode: "orbit",
|
||||
fixedPoint: [0, 0.5001],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const endX = x.x + x.points[x.points.length - 1][0];
|
||||
expect(endX).toBeGreaterThan(990);
|
||||
expect(endX).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
describe("elbow arrows", () => {
|
||||
it("completes a binding in orbit mode when the endpoint is inside", () => {
|
||||
const { b, x } = byId(
|
||||
repairBindings([
|
||||
rect("b", 300, { y: 200 }),
|
||||
elbow("x", [102, 50], [320, 230], {
|
||||
endBinding: { elementId: "b" } as FixedPointBinding,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(x.endBinding).toMatchObject({ elementId: "b", mode: "orbit" });
|
||||
// snapped outside the left side rather than kept inside
|
||||
expect(x.endBinding.fixedPoint[0]).toBeLessThan(0);
|
||||
expect(b.boundElements).toEqual([{ id: "x", type: "arrow" }]);
|
||||
expect(isOrthogonal(x)).toBe(true);
|
||||
});
|
||||
|
||||
it("completes a binding missing mode and fixedPoint as orbit", () => {
|
||||
const { x } = byId(
|
||||
repairBindings([
|
||||
rect("a"),
|
||||
elbow("x", [300, 50], [102, 50], {
|
||||
endBinding: { elementId: "a" } as FixedPointBinding,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(x.endBinding.mode).toBe("orbit");
|
||||
expect(x.endBinding.fixedPoint).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["empty", []],
|
||||
// the normalized form of no fixed segments
|
||||
["null", null],
|
||||
["pinned", [{ index: 2, start: P(100, 0), end: P(100, 200) }]],
|
||||
] as const)(
|
||||
"reroutes orthogonally to a moved target with %s fixed segments",
|
||||
(_, fixedSegments) => {
|
||||
const { x } = byId(
|
||||
repairBindings([
|
||||
rect("a", 1000, { y: 200 }),
|
||||
elbow("x", [300, 50], [500, 250], {
|
||||
points: [P(0, 0), P(100, 0), P(100, 200), P(200, 200)],
|
||||
fixedSegments,
|
||||
endBinding: {
|
||||
elementId: "a",
|
||||
mode: "orbit",
|
||||
fixedPoint: [0, 0.5001],
|
||||
},
|
||||
} as Partial<ExcalidrawElbowArrowElement>),
|
||||
]),
|
||||
);
|
||||
|
||||
// elbow arrows end exactly at their fixed point
|
||||
const [endX, endY] = x.points[x.points.length - 1];
|
||||
expect(x.x + endX).toBeCloseTo(1000, 0);
|
||||
expect(x.y + endY).toBeCloseTo(250, 0);
|
||||
expect(isOrthogonal(x)).toBe(true);
|
||||
expect(x.fixedSegments ?? []).toHaveLength(fixedSegments?.length ?? 0);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not mutate the fixed segments of the input", () => {
|
||||
// collinear with the previous segment, so normalization merges them
|
||||
const segment = Object.freeze({
|
||||
index: 2,
|
||||
start: P(40, 0),
|
||||
end: P(80, 0),
|
||||
});
|
||||
const input = elbow("x", [0, 0], [200, 100], {
|
||||
points: [P(0, 0), P(40, 0), P(80, 0), P(80, 100), P(200, 100)],
|
||||
fixedSegments: [segment],
|
||||
} as Partial<ExcalidrawElbowArrowElement>);
|
||||
const snapshot = JSON.stringify(input);
|
||||
|
||||
repairBindings([input]);
|
||||
|
||||
expect(JSON.stringify(input)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it("drops a dangling binding and keeps the route orthogonal", () => {
|
||||
const { x } = byId(
|
||||
repairBindings([
|
||||
elbow("x", [0, 0], [200, 100], {
|
||||
startBinding: {
|
||||
elementId: "missing",
|
||||
mode: "orbit",
|
||||
fixedPoint: [1, 0.5001],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(x.startBinding).toBeNull();
|
||||
expect(isOrthogonal(x)).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes the fixed point of complete bindings", () => {
|
||||
const { x } = byId(
|
||||
repairBindings([
|
||||
rect("a"),
|
||||
elbow("x", [300, 10], [102, 50], {
|
||||
endBinding: {
|
||||
elementId: "a",
|
||||
mode: "orbit",
|
||||
fixedPoint: [1.06, 0.5],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(x.endBinding.fixedPoint).toEqual([1.06, 0.5001]);
|
||||
expect(isOrthogonal(x)).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes a declared inside binding to orbit", () => {
|
||||
const { x } = byId(
|
||||
repairBindings([
|
||||
rect("a"),
|
||||
elbow("x", [300, 10], [90, 50], {
|
||||
endBinding: {
|
||||
elementId: "a",
|
||||
mode: "inside",
|
||||
fixedPoint: [0.9, 0.5001],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(x.endBinding.mode).toBe("orbit");
|
||||
// re-derived onto the outline, just outside the right side
|
||||
expect(x.endBinding.fixedPoint[0]).toBeGreaterThan(1);
|
||||
expect(isOrthogonal(x)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("unbindBindingElement", () => {
|
||||
it("unbinds an arrow whose target is missing", () => {
|
||||
const x = arrow("x", [0, 0], [100, 0], {
|
||||
endBinding: {
|
||||
elementId: "missing",
|
||||
mode: "orbit",
|
||||
fixedPoint: [0, 0.5001],
|
||||
},
|
||||
});
|
||||
const scene = new Scene([x], { skipValidation: true });
|
||||
|
||||
expect(unbindBindingElement(x, "end", scene)).toBe("missing");
|
||||
expect(x.endBinding).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,9 @@
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
import {
|
||||
bezierEquation,
|
||||
isCurve,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import {
|
||||
@@ -12,14 +17,19 @@ import {
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { UI, Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
unmountComponent,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import type { GlobalPoint } from "@excalidraw/math";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { isLinearElement } from "../src/typeChecks";
|
||||
import { resizeSingleElement } from "../src/resizeElements";
|
||||
import { distanceToElement } from "../src/distance";
|
||||
import { getLinearElementPathSegments } from "../src/utils";
|
||||
import { LinearElementEditor } from "../src/linearElementEditor";
|
||||
import { getElementPointsCoords } from "../src/bounds";
|
||||
import { computeContainerDimensionForBoundText } from "../src/textElement";
|
||||
@@ -229,6 +239,45 @@ describe("generic element", () => {
|
||||
expect(label.fontSize).toEqual(20);
|
||||
});
|
||||
|
||||
describe.each(["rectangle", "ellipse", "diamond"] as const)(
|
||||
"%s with a label",
|
||||
(type) => {
|
||||
it.each([false, true])(
|
||||
"flips horizontally while resizing (proportional: %s)",
|
||||
async (proportional) => {
|
||||
const container = UI.createElement(type, {
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
const label = await UI.editText(container, "Hello");
|
||||
|
||||
UI.resize(container, proportional ? "se" : "e", [-500, 0], {
|
||||
shift: proportional,
|
||||
});
|
||||
|
||||
expect(container.x).toBeCloseTo(-300);
|
||||
expect(container.y).toBeCloseTo(0);
|
||||
expect(container.width).toBeCloseTo(300);
|
||||
expect(container.height).toBeCloseTo(proportional ? 150 : 100);
|
||||
expect(label.x + label.width / 2).toBeCloseTo(
|
||||
container.x + container.width / 2,
|
||||
0,
|
||||
);
|
||||
expect(label.y + label.height / 2).toBeCloseTo(
|
||||
container.y + container.height / 2,
|
||||
0,
|
||||
);
|
||||
expect(label.angle).toBe(0);
|
||||
if (proportional) {
|
||||
expect(label.fontSize).toBeGreaterThan(20);
|
||||
} else {
|
||||
expect(label.fontSize).toBe(20);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each<{ handle: TransformHandleDirection; move: [number, number] }>([
|
||||
{ handle: "n", move: [0, 100] },
|
||||
{ handle: "s", move: [0, -100] },
|
||||
@@ -258,6 +307,47 @@ describe("generic element", () => {
|
||||
expect(rectangle.height).toBeCloseTo(minContainerHeight);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the flipped corner anchored while wrapped text sets the minimum height", async () => {
|
||||
const rectangle = UI.createElement("rectangle", {
|
||||
width: 200,
|
||||
height: 200,
|
||||
});
|
||||
const label = await UI.editText(rectangle, "first second third fourth");
|
||||
const originalElements = arrayToMap(
|
||||
h.app.scene.getNonDeletedElements().map((element) => ({ ...element })),
|
||||
);
|
||||
const originalRectangle = originalElements.get(rectangle.id)!;
|
||||
let minHeight = 0;
|
||||
|
||||
// One SE drag crosses the top edge, stays below the wrapped text's
|
||||
// minimum height, then grows far enough for the pointer to control it.
|
||||
for (const nextHeight of [-10, -40, -80, -200]) {
|
||||
act(() => {
|
||||
resizeSingleElement(
|
||||
80,
|
||||
nextHeight,
|
||||
h.app.scene.getNonDeletedElement(rectangle.id)!,
|
||||
originalRectangle,
|
||||
originalElements,
|
||||
h.app.scene,
|
||||
"se",
|
||||
);
|
||||
});
|
||||
|
||||
expect(rectangle.x).toBe(originalRectangle.x);
|
||||
expect(rectangle.y + rectangle.height).toBe(originalRectangle.y);
|
||||
expect(label.y).toBeGreaterThan(rectangle.y);
|
||||
expect(label.y + label.height).toBeLessThan(
|
||||
rectangle.y + rectangle.height,
|
||||
);
|
||||
if (nextHeight === -10) {
|
||||
minHeight = rectangle.height;
|
||||
expect(minHeight).toBeGreaterThan(80);
|
||||
}
|
||||
expect(rectangle.height).toBe(Math.max(minHeight, -nextHeight));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(["line", "freedraw"] as const)("%s element", (type) => {
|
||||
@@ -490,37 +580,86 @@ describe("arrow element", () => {
|
||||
});
|
||||
const label = await UI.editText(arrow, "Hello");
|
||||
const elementsMap = arrayToMap(h.elements);
|
||||
UI.resize(arrow, "se", [50, 30]);
|
||||
let labelPos = LinearElementEditor.getBoundTextElementPosition(
|
||||
arrow,
|
||||
label,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(labelPos.x + label.width / 2).toBeCloseTo(
|
||||
arrow.x + arrow.points[2][0],
|
||||
);
|
||||
expect(labelPos.y + label.height / 2).toBeCloseTo(
|
||||
arrow.y + arrow.points[2][1],
|
||||
);
|
||||
// independently locate the arc-length midpoint by densely sampling the
|
||||
// path, so a regression in the arc-length inversion the production code
|
||||
// uses cannot cancel out of the comparison
|
||||
const sampledPathMidpoint = (): GlobalPoint => {
|
||||
const samples: GlobalPoint[] = [];
|
||||
for (const segment of getLinearElementPathSegments(arrow, elementsMap)) {
|
||||
for (let i = 0; i <= 1000; i++) {
|
||||
const t = i / 1000;
|
||||
samples.push(
|
||||
isCurve(segment)
|
||||
? bezierEquation(segment, t)
|
||||
: pointFrom<GlobalPoint>(
|
||||
segment[0][0] + t * (segment[1][0] - segment[0][0]),
|
||||
segment[0][1] + t * (segment[1][1] - segment[0][1]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const cumulative = [0];
|
||||
for (let i = 1; i < samples.length; i++) {
|
||||
cumulative.push(
|
||||
cumulative[i - 1] + pointDistance(samples[i - 1], samples[i]),
|
||||
);
|
||||
}
|
||||
const half = cumulative[cumulative.length - 1] / 2;
|
||||
return samples[cumulative.findIndex((length) => length >= half)];
|
||||
};
|
||||
|
||||
// the label defaults to the arc-length midpoint of the arrow's path and
|
||||
// keeps that normalized position through resizes
|
||||
const expectLabelAtPathMidpoint = () => {
|
||||
const labelPos = LinearElementEditor.getBoundTextElementPosition(
|
||||
arrow,
|
||||
label,
|
||||
elementsMap,
|
||||
);
|
||||
const centerX = labelPos.x + label.width / 2;
|
||||
const centerY = labelPos.y + label.height / 2;
|
||||
const pathMidpoint = sampledPathMidpoint();
|
||||
|
||||
expect(label.labelPosition).toBe(0.5);
|
||||
expect(centerX).toBeCloseTo(pathMidpoint[0], 0);
|
||||
expect(centerY).toBeCloseTo(pathMidpoint[1], 0);
|
||||
expect(
|
||||
distanceToElement(arrow, elementsMap, pointFrom(centerX, centerY)),
|
||||
).toBeLessThan(0.5);
|
||||
};
|
||||
|
||||
expectLabelAtPathMidpoint();
|
||||
|
||||
UI.resize(arrow, "se", [50, 30]);
|
||||
expectLabelAtPathMidpoint();
|
||||
expect(label.angle).toBeCloseTo(0);
|
||||
expect(label.fontSize).toEqual(20);
|
||||
|
||||
UI.resize(arrow, "w", [20, 0]);
|
||||
labelPos = LinearElementEditor.getBoundTextElementPosition(
|
||||
arrow,
|
||||
label,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(labelPos.x + label.width / 2).toBeCloseTo(
|
||||
arrow.x + arrow.points[2][0],
|
||||
);
|
||||
expect(labelPos.y + label.height / 2).toBeCloseTo(
|
||||
arrow.y + arrow.points[2][1],
|
||||
);
|
||||
expectLabelAtPathMidpoint();
|
||||
expect(label.angle).toBeCloseTo(0);
|
||||
expect(label.fontSize).toEqual(20);
|
||||
|
||||
const { x, width, height } = arrow;
|
||||
const latestArrow = h.app.scene.getNonDeletedElement(arrow.id)!;
|
||||
act(() => {
|
||||
resizeSingleElement(
|
||||
-2 * width,
|
||||
2 * height,
|
||||
latestArrow,
|
||||
{ ...latestArrow },
|
||||
arrayToMap(h.elements.map((element) => ({ ...element }))),
|
||||
h.app.scene,
|
||||
"se",
|
||||
{ shouldMaintainAspectRatio: true },
|
||||
);
|
||||
});
|
||||
expect(getBoundsFromPoints(arrow)[2]).toBeCloseTo(x);
|
||||
expect(arrow.width).toBeCloseTo(2 * width);
|
||||
expect(label.fontSize).toBeCloseTo(40);
|
||||
expect(label.angle).toBeCloseTo(0);
|
||||
expectLabelAtPathMidpoint();
|
||||
});
|
||||
|
||||
it("flips the fixed point binding on negative resize for single bindable", () => {
|
||||
@@ -866,6 +1005,30 @@ describe("image element", () => {
|
||||
});
|
||||
|
||||
describe("multiple selection", () => {
|
||||
it("keeps a flipped text container anchored when its label requires extra height", async () => {
|
||||
const rectangle = UI.createElement("rectangle", {
|
||||
position: 0,
|
||||
width: 200,
|
||||
height: 35,
|
||||
});
|
||||
const label = await UI.editText(rectangle, "hello");
|
||||
const other = UI.createElement("rectangle", {
|
||||
x: 300,
|
||||
y: 0,
|
||||
size: 100,
|
||||
});
|
||||
const originalHeight = rectangle.height;
|
||||
|
||||
// Halve the selection and flip vertically. Padding does not scale with
|
||||
// the font, so even a single-line label needs extra container height.
|
||||
UI.resize([rectangle, other], "se", [-200, -150], { shift: true });
|
||||
|
||||
expect(label.fontSize).toBeCloseTo(10);
|
||||
expect(label.text).toBe("hello");
|
||||
expect(rectangle.height).toBeGreaterThan(originalHeight / 2);
|
||||
expect(rectangle.y + rectangle.height).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("resizes with generic elements", async () => {
|
||||
const rectangle = UI.createElement("rectangle", {
|
||||
position: 0,
|
||||
|
||||
@@ -13,8 +13,36 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Wheel navigation (2026-09-13) [#XXXX](https://github.com/excalidraw/excalidraw/pull/XXXX)
|
||||
|
||||
- Scrolling while holding the wheel (middle) mouse button zooms the canvas around the pointer, the same as ctrl/cmd+wheel — a wheel-button pan can be zoomed one-handed, without reaching for a modifier.
|
||||
- New `appState.inputDevice` preference (`"auto" | "mouse" | "trackpad"`, default `"auto"`, persisted in browser storage) selecting the wheel mappings. With `"trackpad"` a plain wheel pans; with `"mouse"` a plain wheel zooms. Ctrl/cmd+wheel (how a pinch is delivered) zooms with either device. Shift+wheel pans horizontally, and ctrl/cmd+shift+wheel pans vertically. The wheel-button zoom above is unaffected. `"auto"` is reserved for detecting the device from the wheel events and resolves to `"trackpad"` until that is implemented (`resolveInputDevice()`). Set it from the new "Input device" radio in the main menu's preferences submenu, also exposed as `MainMenu.DefaultItems.Preferences.InputDevice`.
|
||||
|
||||
### Sticky notes (2026-09-06) [#XXXX](https://github.com/excalidraw/excalidraw/pull/XXXX)
|
||||
|
||||
- New `stickynote` element type and toolbar tool (`N`): an always-filled, flat-rendered note whose label auto-fits — the font shrinks from the user's size down to a minimum, and only then does the note grow in height (it never shrinks below the height the user gave it). Click to place a 250×250 note, drag to size it (previewed at the dragged size, snapped to a font-aware minimum on release); text editing starts right away.
|
||||
- Element model: the note carries `baseHeight` (the user's height; `height ≥ baseHeight`), its label carries `baseFontSize` (the user's font ceiling, `null`/absent on any other text) while `fontSize` holds the fitted size. Read a text element's user-facing size through `getBaseFontSize(text, elementsMap)`.
|
||||
- Sticky notes are their own color domain: new `appState.currentItemStickynoteStrokeColor` (the note's text color) and `appState.currentItemStickynoteBackgroundColor` defaults, and new customizable top-pick slots `appState.colorTopPicks.stickyNoteStroke` / `stickyNoteBackground`. The stroke and background pickers, both eyedroppers and paste-styles resolve their target (regular / sticky / mixed) at execution time. A note and its label share one ink (the stroke picker reads "Text color" for notes): coloring the label while editing colors the note too, binding a colored text gives the note its color, and the creation-date footer always paints in that ink.
|
||||
- Skeleton API: `convertToExcalidrawElements([{ type: "stickynote", x, y, label?: { text, fontSize? } }])` creates a note (and fitted label); see the element-skeleton docs.
|
||||
- Tools can be dragged out of the toolbar: drag the sticky note button onto the canvas to drop a default-sized note where you release it, previewed translucently while dragging (nothing reaches the scene, history or collaborators until the drop; Escape cancels). The mechanism is generic — other tools can opt in.
|
||||
- Toolbar: on desktop and tablet the image tool moved into the "More tools" menu (still `9`; `UIOptions.tools.image` keeps hiding it; the mobile toolbar keeps its image slot), and tool badges now show the letter shortcuts (`R`, `T`, `N`, …) instead of the numbers, which are no longer contiguous. The number keys keep working.
|
||||
- Each note shows its creation date (`element.created`) in a footer band — "7 Sep", or "7 Sep 2025" once the year differs — on canvas and in SVG/PNG exports. The band is reserved in the note's layout, so the label never overlaps it; notes without a timestamp keep the band empty.
|
||||
- Resize semantics: a note's corners resize proportionally by default and scale the label's font ceiling with the note (Shift frees them); its edges resize freely by default (Shift constrains them). Drag-creation is proportional by default too (Shift frees it) and never touches the font ceiling. Width-only gestures keep the base height, height gestures set it, aspect-locked multi-select and Stats group edits scale the ceiling; flips preserve everything. Arrows bound to a note follow it as it grows.
|
||||
|
||||
## Excalidraw API
|
||||
|
||||
### `setViewport` stops following a collaborator (2026-09-26) [#XXXX](https://github.com/excalidraw/excalidraw/pull/XXXX)
|
||||
|
||||
- `setViewport` now asks to stop following a collaborator whenever it navigates (`onUserFollow` with `action: "UNFOLLOW"`, if `userToFollow` is set), as a user's pan or zoom does. `setViewport(null)` (clearing a lock) and a target that doesn't resolve don't navigate, and leave following alone.
|
||||
|
||||
### `onDuplicate` can replace and veto duplicates (2026-09-18) [#XXXX](https://github.com/excalidraw/excalidraw/pull/XXXX)
|
||||
|
||||
- `props.onDuplicate` may now return new objects for the duplicated elements in every duplication path. Previously the editor kept using the objects it created, so on paste (and library insert) the frame assignment and bound text redraw went to objects that were no longer in the scene, and alt-drag dragged an element that wasn't in the scene. The returned changes are now merged into the editor's duplicates, so they're part of the duplication's own undo entry and durable increment (no follow-up `updateScene` needed).
|
||||
- When pasting or inserting onto a frame, the duplicates passed to `onDuplicate` already have their `frameId` set (and are ordered under the frame).
|
||||
- Omitting a duplicate from the returned array (or returning it as deleted) now reliably prevents that element from being duplicated — previously pasting over a frame brought it back, and alt-drag broke. References to it from the remaining duplicates (bindings, `boundElements`, `frameId`) are cleared, and a bound text isn't duplicated without its container. If no duplicate remains, the duplication is cancelled and the returned elements are ignored: paste and the duplicate action do nothing, and alt-drag moves the original elements instead. When only some duplicates are vetoed during alt-drag, their originals stay where they were.
|
||||
- `onDuplicate` may return `false` to prevent the duplication as a whole (same as omitting every duplicate, without having to find them).
|
||||
- `onDuplicate` receives a third argument with lookups covering just the elements taking part in the duplication, so you don't have to derive them by scanning the scene: `{ duplicateElements, originalElements, origIdToDuplicateId, duplicateIdToOrigId }` — the duplicates by id, the elements they were made from by id, and their ids mapped in both directions (`duplicateIdToOrigId` to look up a duplicate's original while modifying it, `origIdToDuplicateId` e.g. for remapping element ids you keep in `customData`). On paste and library insert the originals are the inserted elements, which aren't part of the scene. The maps are read-only.
|
||||
|
||||
### Host-controlled active tool (2026-07-14) [#11665](https://github.com/excalidraw/excalidraw/pull/11665)
|
||||
|
||||
- Added `activeTool` prop (`{ type: ToolType } | { type: "custom"; customType: string }`) for forcing the active editor tool (controlled). While set, user- and API-driven tool switching is ignored — `setActiveTool` refuses non-matching activations with a console warning, non-forced toolbar buttons render disabled, and the tool-lock toggle (`Q`) is inert — and the editor snaps back if internal flows reset the tool (e.g. `restore()` on scene load). The forced tool behaves as if locked — it doesn't revert to selection after use and drawn elements aren't auto-selected — without mutating `appState.activeTool.locked`, so the user's persisted padlock preference stays untouched. Unset the prop to return tool control to the editor (the current tool stays active). The forced tool must be activatable to take effect — not disabled via `UIOptions.tools`, and (while non-interactive) allowed via `interaction.enabled.tools`; otherwise the editor stays on the `selection` tool and applies the forced tool once it becomes activatable. `image` cannot be forced (its activation opens the file picker). Composes with `interaction.enabled.tools` for presentation-style hosts: force `laser` for the presenter, `selection` + `interaction={false}` for viewers.
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
arrayToMap,
|
||||
getFontString,
|
||||
getStrokeWidthByKey,
|
||||
isTransparent,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
getOriginalContainerHeightFromCache,
|
||||
@@ -15,15 +16,19 @@ import {
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
DEFAULT_BOUND_TEXT_LABEL_POSITION,
|
||||
computeBoundTextPosition,
|
||||
computeContainerDimensionForBoundText,
|
||||
getBoundTextElement,
|
||||
normalizeStickyNoteFontSize,
|
||||
redrawTextBoundingBox,
|
||||
updateStickyNoteLayout,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isArrowElement,
|
||||
isStickyNoteElement,
|
||||
isTextBindableContainer,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
@@ -86,17 +91,26 @@ export const actionUnbindText = register({
|
||||
width,
|
||||
height,
|
||||
text: boundTextElement.originalText,
|
||||
baseFontSize: null,
|
||||
x,
|
||||
y,
|
||||
labelPosition: null,
|
||||
});
|
||||
app.scene.mutateElement(element, {
|
||||
boundElements: element.boundElements?.filter(
|
||||
(ele) => ele.id !== boundTextElement.id,
|
||||
),
|
||||
height: originalContainerHeight
|
||||
? originalContainerHeight
|
||||
: element.height,
|
||||
});
|
||||
if (isStickyNoteElement(element)) {
|
||||
// an empty note sits at its base height; bound arrows follow
|
||||
updateStickyNoteLayout(element, app.scene);
|
||||
} else {
|
||||
app.scene.mutateElement(element, {
|
||||
height: originalContainerHeight
|
||||
? originalContainerHeight
|
||||
: element.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return {
|
||||
@@ -154,18 +168,38 @@ export const actionBindText = register({
|
||||
textElement = selectedElements[1] as ExcalidrawTextElement;
|
||||
container = selectedElements[0] as ExcalidrawTextContainer;
|
||||
}
|
||||
// a note and its label share one ink: the text the user styled wins,
|
||||
// unless it is transparent (a note's label never is)
|
||||
const stickyInk = isStickyNoteElement(container)
|
||||
? isTransparent(textElement.strokeColor)
|
||||
? container.strokeColor
|
||||
: textElement.strokeColor
|
||||
: null;
|
||||
app.scene.mutateElement(textElement, {
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
autoResize: true,
|
||||
angle: (isArrowElement(container) ? 0 : container?.angle ?? 0) as Radians,
|
||||
labelPosition: isArrowElement(container)
|
||||
? DEFAULT_BOUND_TEXT_LABEL_POSITION
|
||||
: null,
|
||||
...(stickyInk
|
||||
? {
|
||||
baseFontSize: normalizeStickyNoteFontSize(
|
||||
textElement.baseFontSize ?? textElement.fontSize,
|
||||
),
|
||||
strokeColor: stickyInk,
|
||||
}
|
||||
: null),
|
||||
});
|
||||
app.scene.mutateElement(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
// the footer paints with the note's ink
|
||||
...(stickyInk ? { strokeColor: stickyInk } : null),
|
||||
});
|
||||
const originalContainerHeight = container.height;
|
||||
redrawTextBoundingBox(textElement, container, app.scene);
|
||||
|
||||
@@ -111,6 +111,8 @@ export const actionClearCanvas = register({
|
||||
gridStep: appState.gridStep,
|
||||
gridModeEnabled: appState.gridModeEnabled,
|
||||
stats: appState.stats,
|
||||
colorTopPicks: appState.colorTopPicks,
|
||||
fontTopPicks: appState.fontTopPicks,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
? {
|
||||
@@ -153,17 +155,24 @@ export const actionZoomIn = register({
|
||||
PanelComponent: ({ updateData }) => {
|
||||
const zoomValue = useAppStateValue((appState) => appState.zoom.value);
|
||||
return (
|
||||
<IconButton
|
||||
type="button"
|
||||
className="zoom-in-button zoom-button"
|
||||
icon={ZoomInIcon}
|
||||
title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
|
||||
aria-label={t("buttons.zoomIn")}
|
||||
disabled={zoomValue >= MAX_ZOOM}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
label={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
|
||||
className="zoom-in-button-wrapper"
|
||||
delay
|
||||
>
|
||||
<IconButton
|
||||
type="button"
|
||||
className="zoom-in-button zoom-button"
|
||||
icon={ZoomInIcon}
|
||||
aria-label={t("buttons.zoomIn")}
|
||||
// the shortcut used to reach AT via the (now removed) native title
|
||||
aria-keyshortcuts={getShortcutKey("CtrlOrCmd++")}
|
||||
disabled={zoomValue >= MAX_ZOOM}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@@ -200,17 +209,24 @@ export const actionZoomOut = register({
|
||||
PanelComponent: ({ updateData }) => {
|
||||
const zoomValue = useAppStateValue((appState) => appState.zoom.value);
|
||||
return (
|
||||
<IconButton
|
||||
type="button"
|
||||
className="zoom-out-button zoom-button"
|
||||
icon={ZoomOutIcon}
|
||||
title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
|
||||
aria-label={t("buttons.zoomOut")}
|
||||
disabled={zoomValue <= MIN_ZOOM}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
label={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
|
||||
className="zoom-out-button-wrapper"
|
||||
delay
|
||||
>
|
||||
<IconButton
|
||||
type="button"
|
||||
className="zoom-out-button zoom-button"
|
||||
icon={ZoomOutIcon}
|
||||
aria-label={t("buttons.zoomOut")}
|
||||
// the shortcut used to reach AT via the (now removed) native title
|
||||
aria-keyshortcuts={getShortcutKey("CtrlOrCmd+-")}
|
||||
disabled={zoomValue <= MIN_ZOOM}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@@ -252,11 +268,15 @@ export const actionResetZoom = register({
|
||||
PanelComponent: ({ updateData }) => {
|
||||
const zoomValue = useAppStateValue((appState) => appState.zoom.value);
|
||||
return (
|
||||
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
|
||||
<Tooltip
|
||||
label={t("buttons.resetZoom")}
|
||||
style={{ height: "100%" }}
|
||||
className="reset-zoom-button-wrapper"
|
||||
delay
|
||||
>
|
||||
<IconButton
|
||||
type="button"
|
||||
className="reset-zoom-button zoom-button"
|
||||
title={t("buttons.resetZoom")}
|
||||
aria-label={t("buttons.resetZoom")}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
|
||||
@@ -89,7 +89,7 @@ export const actionPaste = register({
|
||||
}
|
||||
|
||||
try {
|
||||
app.pasteFromClipboard(createPasteEvent({ types }));
|
||||
app.clipboard.pasteFromClipboard(createPasteEvent({ types }));
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return {
|
||||
|
||||
@@ -67,6 +67,7 @@ export const actionDeselect = register({
|
||||
trackEvent: false,
|
||||
perform: (_elements, appState, _, app) => {
|
||||
const activeTool = getNextActiveTool(appState, app);
|
||||
app.cursor.applyForTool(activeTool);
|
||||
|
||||
if (appState.editingGroupId) {
|
||||
const nonDeletedElements = app.scene.getNonDeletedElements();
|
||||
@@ -103,7 +104,6 @@ export const actionDeselect = register({
|
||||
selectionElement: null,
|
||||
showHyperlinkPopup: false,
|
||||
suggestedBinding: null,
|
||||
hoveredArrowTextAnchor: null,
|
||||
frameToHighlight: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
@@ -122,7 +122,6 @@ export const actionDeselect = register({
|
||||
selectionElement: null,
|
||||
showHyperlinkPopup: false,
|
||||
suggestedBinding: null,
|
||||
hoveredArrowTextAnchor: null,
|
||||
frameToHighlight: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
|
||||
@@ -60,7 +60,7 @@ export const actionDuplicateSelection = register({
|
||||
}
|
||||
}
|
||||
|
||||
let { duplicatedElements, elementsWithDuplicates } = duplicateElements({
|
||||
const duplication = duplicateElements({
|
||||
type: "in-place",
|
||||
elements,
|
||||
idsOfElementsToDuplicate: arrayToMap(
|
||||
@@ -82,13 +82,19 @@ export const actionDuplicateSelection = register({
|
||||
},
|
||||
});
|
||||
|
||||
if (app.props.onDuplicate && elementsWithDuplicates) {
|
||||
const mappedElements = app.props.onDuplicate(
|
||||
elementsWithDuplicates,
|
||||
elements,
|
||||
);
|
||||
if (mappedElements) {
|
||||
elementsWithDuplicates = mappedElements;
|
||||
let { duplicatedElements, elementsWithDuplicates } = duplication;
|
||||
|
||||
if (app.props.onDuplicate) {
|
||||
({ elements: elementsWithDuplicates, duplicatedElements } =
|
||||
app.duplicate.runOnDuplicate(
|
||||
duplication,
|
||||
elementsWithDuplicates,
|
||||
elements,
|
||||
));
|
||||
|
||||
// host vetoed the duplication
|
||||
if (!duplicatedElements.length) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import type { AppState } from "../types";
|
||||
type FormData = {
|
||||
event: PointerEvent;
|
||||
sceneCoords: { x: number; y: number };
|
||||
hitBoundText?: boolean;
|
||||
};
|
||||
|
||||
export const actionFinalize = register<FormData>({
|
||||
@@ -86,7 +87,10 @@ export const actionFinalize = register<FormData>({
|
||||
|
||||
if (
|
||||
isBindingElement(element) &&
|
||||
!appState.selectedLinearElement.segmentMidPointHoveredCoords
|
||||
!appState.selectedLinearElement.segmentMidPointHoveredCoords &&
|
||||
// a label drag along the arrow moves no endpoint, so it must not
|
||||
// rebind either of them
|
||||
!data.hitBoundText
|
||||
) {
|
||||
const newArrow = !!appState.newElement;
|
||||
|
||||
@@ -193,7 +197,6 @@ export const actionFinalize = register<FormData>({
|
||||
},
|
||||
selectionElement: null,
|
||||
suggestedBinding: null,
|
||||
hoveredArrowTextAnchor: null,
|
||||
newElement: null,
|
||||
multiElement: null,
|
||||
},
|
||||
@@ -202,9 +205,7 @@ export const actionFinalize = register<FormData>({
|
||||
}
|
||||
}
|
||||
|
||||
if (window.document.activeElement instanceof HTMLElement) {
|
||||
focusContainer();
|
||||
}
|
||||
focusContainer();
|
||||
|
||||
// clean up pending gesture even if active tool is already not drawShape
|
||||
const hadPendingSketch = app.drawShape.hasPendingGesture();
|
||||
@@ -399,7 +400,6 @@ export const actionFinalize = register<FormData>({
|
||||
multiElement: null,
|
||||
editingTextElement: null,
|
||||
suggestedBinding: null,
|
||||
hoveredArrowTextAnchor: null,
|
||||
frameToHighlight: null,
|
||||
selectedElementIds: isDrawShapeTool
|
||||
? {}
|
||||
|
||||
@@ -71,6 +71,18 @@ describe("element locking", () => {
|
||||
expect(hachureFillButton).toBe(null);
|
||||
});
|
||||
|
||||
it("should not show fill style for sticky note tool", () => {
|
||||
UI.clickTool("stickynote");
|
||||
|
||||
API.setAppState({
|
||||
currentItemBackgroundColor: DEFAULT_ELEMENT_BACKGROUND_PICKS[1],
|
||||
currentItemFillStyle: "hachure",
|
||||
});
|
||||
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
|
||||
|
||||
expect(hachureFillButton).toBe(null);
|
||||
});
|
||||
|
||||
it("should show horizontal text align for text tool", () => {
|
||||
UI.clickTool("text");
|
||||
|
||||
@@ -110,6 +122,19 @@ describe("element locking", () => {
|
||||
expect(crossHatchButton).toBe(null);
|
||||
});
|
||||
|
||||
it("should not show fill style for selected sticky note", () => {
|
||||
const stickyNote = API.createElement({
|
||||
type: "stickynote",
|
||||
backgroundColor: DEFAULT_ELEMENT_BACKGROUND_PICKS[1],
|
||||
fillStyle: "hachure",
|
||||
});
|
||||
API.setElements([stickyNote]);
|
||||
API.setSelectedElements([stickyNote]);
|
||||
|
||||
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
|
||||
expect(hachureFillButton).toBe(null);
|
||||
});
|
||||
|
||||
it("should highlight common stroke width of selected elements", () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
|
||||
@@ -6,10 +6,6 @@ import {
|
||||
BUCKET_FILL_BACKGROUND_PICKS,
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
getBucketFillBackgroundColor,
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_STROKE_PICKS,
|
||||
ARROW_TYPE,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
@@ -32,8 +28,10 @@ import {
|
||||
|
||||
import {
|
||||
canBecomePolygon,
|
||||
normalizeStickyNote,
|
||||
getNonDeletedElements,
|
||||
isNonDeletedElement,
|
||||
syncStickyNoteInk,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
@@ -49,6 +47,8 @@ import { getArrowheadForPicker } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getBaseFontSize,
|
||||
getBaseFontSizeUpdate,
|
||||
redrawTextBoundingBox,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
@@ -58,11 +58,17 @@ import {
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
isStickyNoteElement,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { hasStrokeColor } from "@excalidraw/element";
|
||||
import {
|
||||
getColorTargetElement,
|
||||
getColorUpdate,
|
||||
hasFillStyle,
|
||||
hasStrokeColor,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
updateElbowArrowPoints,
|
||||
@@ -165,6 +171,10 @@ import {
|
||||
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import {
|
||||
getColorTargetAppStateUpdates,
|
||||
resolveColorTarget,
|
||||
} from "./colorTargets";
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||
@@ -204,7 +214,13 @@ export const changeProperty = (
|
||||
"[NONDELETED][INVARIANT] changeProperty(): skipping deleted selected/editing element",
|
||||
);
|
||||
}
|
||||
return callback(element as NonDeletedExcalidrawElement);
|
||||
const nextElement = callback(element as NonDeletedExcalidrawElement);
|
||||
// sticky notes keep their data invariants (never-transparent colors,
|
||||
// solid fill, minimum size) whatever property was written; a no-op
|
||||
// normalization returns the same object
|
||||
return isStickyNoteElement(nextElement)
|
||||
? normalizeStickyNote(nextElement)
|
||||
: nextElement;
|
||||
}
|
||||
return element;
|
||||
});
|
||||
@@ -283,6 +299,7 @@ const changeFontSize = (
|
||||
fallbackValue?: ExcalidrawTextElement["fontSize"],
|
||||
) => {
|
||||
const newFontSizes = new Set<number>();
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
|
||||
const updatedElements = changeProperty(
|
||||
elements,
|
||||
@@ -291,15 +308,13 @@ const changeFontSize = (
|
||||
if (isTextElement(oldElement)) {
|
||||
const newFontSize = getNewFontSize(oldElement);
|
||||
newFontSizes.add(newFontSize);
|
||||
const container = app.scene.getContainerElement(oldElement);
|
||||
|
||||
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
|
||||
fontSize: newFontSize,
|
||||
});
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
app.scene.getContainerElement(oldElement),
|
||||
app.scene,
|
||||
let newElement: ExcalidrawTextElement = newElementWith(
|
||||
oldElement,
|
||||
getBaseFontSizeUpdate(oldElement, newFontSize, elementsMap),
|
||||
);
|
||||
redrawTextBoundingBox(newElement, container, app.scene);
|
||||
|
||||
newElement = offsetElementAfterFontResize(
|
||||
oldElement,
|
||||
@@ -341,58 +356,75 @@ const changeFontSize = (
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const actionChangeStrokeColor = register<
|
||||
Pick<AppState, "currentItemStrokeColor">
|
||||
Partial<AppState> & { color?: string }
|
||||
>({
|
||||
name: "changeStrokeColor",
|
||||
label: "labels.stroke",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
const { color, ...appStateUpdates } = value ?? {};
|
||||
if (color === undefined) {
|
||||
return {
|
||||
appState: { ...appState, ...appStateUpdates },
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
}
|
||||
// resolved from the state the action runs against — never from the
|
||||
// render-time closure of the memoized picker (see `resolveColorTarget`)
|
||||
const target = resolveColorTarget(appState, elements, "strokeColor");
|
||||
const elementsMap = arrayToMap(elements);
|
||||
|
||||
return {
|
||||
...(value?.currentItemStrokeColor && {
|
||||
elements: changeProperty(
|
||||
// a note and its label share one ink: coloring the label while editing
|
||||
// it (no selection) colors the note too — the footer paints with it
|
||||
elements: syncStickyNoteInk(
|
||||
changeProperty(
|
||||
elements,
|
||||
appState,
|
||||
(el) => {
|
||||
return hasStrokeColor(el.type)
|
||||
? newElementWith(el, {
|
||||
strokeColor: value.currentItemStrokeColor,
|
||||
})
|
||||
: el;
|
||||
},
|
||||
(el) =>
|
||||
hasStrokeColor(el.type)
|
||||
? newElementWith(
|
||||
el,
|
||||
getColorUpdate(el, "strokeColor", color, elementsMap),
|
||||
)
|
||||
: el,
|
||||
true,
|
||||
),
|
||||
}),
|
||||
elementsMap,
|
||||
),
|
||||
appState: {
|
||||
...appState,
|
||||
...value,
|
||||
...appStateUpdates,
|
||||
...getColorTargetAppStateUpdates(target, color),
|
||||
},
|
||||
captureUpdate: !!value?.currentItemStrokeColor
|
||||
? CaptureUpdateAction.IMMEDIATELY
|
||||
: CaptureUpdateAction.EVENTUALLY,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
const { stylesPanelMode } = getStylesPanelInfo(app);
|
||||
const target = resolveColorTarget(appState, elements, "strokeColor");
|
||||
// a note has no stroke: its "stroke" is the ink of its text and footer
|
||||
const label =
|
||||
target.kind === "sticky" ? t("labels.textColor") : t("labels.stroke");
|
||||
|
||||
return (
|
||||
<>
|
||||
{stylesPanelMode === "full" && (
|
||||
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
||||
)}
|
||||
{stylesPanelMode === "full" && <h3 aria-hidden="true">{label}</h3>}
|
||||
<ColorPicker
|
||||
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
|
||||
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
|
||||
topPicks={target.topPicks}
|
||||
palette={target.palette}
|
||||
customizableTopPicks={target.customizableTopPicks}
|
||||
excludedColors={target.excludedColors}
|
||||
type="elementStroke"
|
||||
label={t("labels.stroke")}
|
||||
label={label}
|
||||
color={getFormValue(
|
||||
elements,
|
||||
app,
|
||||
(element) => element.strokeColor,
|
||||
true,
|
||||
(hasSelection) =>
|
||||
!hasSelection ? appState.currentItemStrokeColor : null,
|
||||
(hasSelection) => (!hasSelection ? target.currentValue : null),
|
||||
)}
|
||||
onChange={(color) => updateData({ currentItemStrokeColor: color })}
|
||||
onChange={(color) => updateData({ color })}
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
updateData={updateData}
|
||||
@@ -403,27 +435,29 @@ export const actionChangeStrokeColor = register<
|
||||
});
|
||||
|
||||
export const actionChangeBackgroundColor = register<
|
||||
Pick<AppState, "currentItemBackgroundColor" | "viewBackgroundColor">
|
||||
Partial<AppState> & { color?: string }
|
||||
>({
|
||||
name: "changeBackgroundColor",
|
||||
label: "labels.changeBackground",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
if (!value?.currentItemBackgroundColor) {
|
||||
const { color, ...appStateUpdates } = value ?? {};
|
||||
if (color === undefined) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...value,
|
||||
},
|
||||
appState: { ...appState, ...appStateUpdates },
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
}
|
||||
const target = resolveColorTarget(appState, elements, "backgroundColor");
|
||||
const elementsMap = arrayToMap(elements);
|
||||
|
||||
let nextElements;
|
||||
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
const shouldEnablePolygon =
|
||||
!isTransparent(value.currentItemBackgroundColor) &&
|
||||
!isTransparent(color) &&
|
||||
// `every` is vacuously true with nothing selected (editing text)
|
||||
selectedElements.length > 0 &&
|
||||
selectedElements.every(
|
||||
(el) => isLineElement(el) && canBecomePolygon(el.points),
|
||||
);
|
||||
@@ -433,7 +467,7 @@ export const actionChangeBackgroundColor = register<
|
||||
nextElements = elements.map((el) => {
|
||||
if (selectedElementsMap.has(el.id) && isLineElement(el)) {
|
||||
return newElementWith(el, {
|
||||
backgroundColor: value.currentItemBackgroundColor,
|
||||
backgroundColor: color,
|
||||
...toggleLinePolygonState(el, true),
|
||||
});
|
||||
}
|
||||
@@ -441,23 +475,49 @@ export const actionChangeBackgroundColor = register<
|
||||
});
|
||||
} else {
|
||||
nextElements = changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
backgroundColor: value.currentItemBackgroundColor,
|
||||
}),
|
||||
// a note's label passes the pick on to the note (below)
|
||||
getColorTargetElement(el, "backgroundColor", elementsMap) === el
|
||||
? newElementWith(
|
||||
el,
|
||||
getColorUpdate(el, "backgroundColor", color, elementsMap),
|
||||
)
|
||||
: el,
|
||||
);
|
||||
// editing a note's label (no selection): the label has no fill, the
|
||||
// pick colors the note — text and background in one editing pass
|
||||
const editingText =
|
||||
appState.editingTextElement &&
|
||||
elementsMap.get(appState.editingTextElement.id);
|
||||
const editingTarget =
|
||||
editingText &&
|
||||
getColorTargetElement(editingText, "backgroundColor", elementsMap);
|
||||
if (editingTarget && editingTarget !== editingText) {
|
||||
nextElements = nextElements.map((el) =>
|
||||
el.id === editingTarget.id
|
||||
? newElementWith(
|
||||
el,
|
||||
getColorUpdate(el, "backgroundColor", color, elementsMap),
|
||||
)
|
||||
: el,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...appState,
|
||||
...value,
|
||||
...appStateUpdates,
|
||||
...getColorTargetAppStateUpdates(target, color),
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
const { stylesPanelMode } = getStylesPanelInfo(app);
|
||||
const target = resolveColorTarget(appState, elements, "backgroundColor");
|
||||
// while editing a note's label the picker shows and sets the note's fill
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -465,21 +525,22 @@ export const actionChangeBackgroundColor = register<
|
||||
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
||||
)}
|
||||
<ColorPicker
|
||||
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
|
||||
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
|
||||
topPicks={target.topPicks}
|
||||
palette={target.palette}
|
||||
customizableTopPicks={target.customizableTopPicks}
|
||||
excludedColors={target.excludedColors}
|
||||
type="elementBackground"
|
||||
label={t("labels.background")}
|
||||
color={getFormValue(
|
||||
elements,
|
||||
app,
|
||||
(element) => element.backgroundColor,
|
||||
(element) =>
|
||||
getColorTargetElement(element, "backgroundColor", elementsMap)
|
||||
.backgroundColor,
|
||||
true,
|
||||
(hasSelection) =>
|
||||
!hasSelection ? appState.currentItemBackgroundColor : null,
|
||||
(hasSelection) => (!hasSelection ? target.currentValue : null),
|
||||
)}
|
||||
onChange={(color) =>
|
||||
updateData({ currentItemBackgroundColor: color })
|
||||
}
|
||||
onChange={(color) => updateData({ color })}
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
updateData={updateData}
|
||||
@@ -521,9 +582,13 @@ export const actionChangeBucketFillBackgroundColor = register<
|
||||
// hidden rather than removed from the palette so the remaining
|
||||
// colors keep their usual hotkeys (w for white etc.)
|
||||
excludedColors={[COLOR_PALETTE.transparent]}
|
||||
// customized independently of the background picker's picks even
|
||||
// though both drive `currentItemBackgroundColor` — the bucket
|
||||
// strip's defaults and use case differ (no transparent)
|
||||
customizableTopPicks="bucketFill"
|
||||
type="elementBackground"
|
||||
label={t("labels.background")}
|
||||
color={getBucketFillBackgroundColor(
|
||||
color={app.bucketFill.getBucketFillBackgroundColor(
|
||||
appState.currentItemBackgroundColor,
|
||||
)}
|
||||
onChange={(color) =>
|
||||
@@ -552,9 +617,7 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
|
||||
);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
fillStyle: value,
|
||||
}),
|
||||
hasFillStyle(el.type) ? newElementWith(el, { fillStyle: value }) : el,
|
||||
),
|
||||
appState: { ...appState, currentItemFillStyle: value },
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
@@ -562,9 +625,12 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
const selectedFillStyleElements = selectedElements.filter((element) =>
|
||||
hasFillStyle(element.type),
|
||||
);
|
||||
const allElementsZigZag =
|
||||
selectedElements.length > 0 &&
|
||||
selectedElements.every((el) => el.fillStyle === "zigzag");
|
||||
selectedFillStyleElements.length > 0 &&
|
||||
selectedFillStyleElements.every((el) => el.fillStyle === "zigzag");
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
@@ -599,7 +665,7 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
|
||||
elements,
|
||||
app,
|
||||
(element) => element.fillStyle,
|
||||
(element) => element.hasOwnProperty("fillStyle"),
|
||||
(element) => hasFillStyle(element.type),
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemFillStyle,
|
||||
)}
|
||||
@@ -607,7 +673,9 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
|
||||
const nextValue =
|
||||
event.altKey &&
|
||||
value === "hachure" &&
|
||||
selectedElements.every((el) => el.fillStyle === "hachure")
|
||||
selectedFillStyleElements.every(
|
||||
(el) => el.fillStyle === "hachure",
|
||||
)
|
||||
? "zigzag"
|
||||
: value;
|
||||
|
||||
@@ -984,15 +1052,16 @@ export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>(
|
||||
elements,
|
||||
app,
|
||||
(element) => {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
if (isTextElement(element)) {
|
||||
return element.fontSize;
|
||||
return getBaseFontSize(element, elementsMap);
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(
|
||||
element,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
elementsMap,
|
||||
);
|
||||
if (boundTextElement) {
|
||||
return boundTextElement.fontSize;
|
||||
return getBaseFontSize(boundTextElement, elementsMap);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
@@ -1033,7 +1102,8 @@ export const actionDecreaseFontSize = register({
|
||||
Math.round(
|
||||
// get previous value before relative increase (doesn't work fully
|
||||
// due to rounding and float precision issues)
|
||||
(1 / (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)) * element.fontSize,
|
||||
(1 / (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)) *
|
||||
getBaseFontSize(element, app.scene.getNonDeletedElementsMap()),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -1054,7 +1124,10 @@ export const actionIncreaseFontSize = register({
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
return changeFontSize(elements, appState, app, (element) =>
|
||||
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
|
||||
Math.round(
|
||||
getBaseFontSize(element, app.scene.getNonDeletedElementsMap()) *
|
||||
(1 + FONT_SIZE_RELATIVE_INCREASE_STEP),
|
||||
),
|
||||
);
|
||||
},
|
||||
keyTest: (event) => {
|
||||
@@ -1070,7 +1143,10 @@ export const actionIncreaseFontSize = register({
|
||||
type ChangeFontFamilyData = Partial<
|
||||
Pick<
|
||||
AppState,
|
||||
"openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily"
|
||||
| "openPopup"
|
||||
| "currentItemFontFamily"
|
||||
| "currentHoveredFontFamily"
|
||||
| "fontTopPicks"
|
||||
>
|
||||
> & {
|
||||
/** cache of selected & editing elements populated on opened popup */
|
||||
@@ -1246,8 +1322,9 @@ export const actionChangeFontFamily = register<{
|
||||
fontFamily: nextFontFamily,
|
||||
})}`;
|
||||
const chars = Array.from(uniqueChars.values()).join();
|
||||
const ownerDocument = app.props.ownerDocument ?? document;
|
||||
|
||||
if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) {
|
||||
if (skipFontFaceCheck || ownerDocument.fonts.check(fontString, chars)) {
|
||||
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
|
||||
for (const [element, container] of elementContainerMapping) {
|
||||
// trigger synchronous redraw
|
||||
@@ -1255,7 +1332,7 @@ export const actionChangeFontFamily = register<{
|
||||
}
|
||||
} else {
|
||||
// otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
|
||||
window.document.fonts.load(fontString, chars).then((fontFaces) => {
|
||||
ownerDocument.fonts.load(fontString, chars).then((fontFaces) => {
|
||||
for (const [element, container] of elementContainerMapping) {
|
||||
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
|
||||
const latestElement = app.scene.getElement(element.id);
|
||||
@@ -1367,7 +1444,9 @@ export const actionChangeFontFamily = register<{
|
||||
isOpened={appState.openPopup === "fontFamily"}
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
hoveredFontFamily={appState.currentHoveredFontFamily}
|
||||
topPicks={appState.fontTopPicks}
|
||||
compactMode={stylesPanelMode !== "full"}
|
||||
onTopPicksChange={(fontTopPicks) => updateData({ fontTopPicks })}
|
||||
onSelect={(fontFamily) => {
|
||||
withCaretPositionPreservation(
|
||||
() => {
|
||||
@@ -1455,7 +1534,7 @@ export const actionChangeFontFamily = register<{
|
||||
|
||||
// Refocus text editor when font picker closes if we were editing text
|
||||
if (isCompact && appState.editingTextElement) {
|
||||
restoreCaretPosition(null); // Just refocus without saved position
|
||||
restoreCaretPosition(null, app.ownerDocument); // Just refocus without saved position
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -1677,7 +1756,7 @@ export const actionChangeRoundness = register<"sharp" | "round">({
|
||||
return el;
|
||||
}
|
||||
|
||||
return newElementWith(el, {
|
||||
const nextElement = newElementWith(el, {
|
||||
roundness:
|
||||
value === "round"
|
||||
? {
|
||||
@@ -1687,6 +1766,8 @@ export const actionChangeRoundness = register<"sharp" | "round">({
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
return nextElement;
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
@@ -2071,6 +2152,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
|
||||
startElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
appState.isBindingEnabled,
|
||||
),
|
||||
}
|
||||
@@ -2085,6 +2167,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
|
||||
endElement,
|
||||
"end",
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
appState.isBindingEnabled,
|
||||
),
|
||||
}
|
||||
@@ -2117,6 +2200,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
|
||||
appState.bindMode === "inside" ? "inside" : "orbit",
|
||||
"start",
|
||||
app.scene,
|
||||
appState.zoom,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2131,6 +2215,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
|
||||
appState.bindMode === "inside" ? "inside" : "orbit",
|
||||
"end",
|
||||
app.scene,
|
||||
appState.zoom,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,28 @@ import {
|
||||
CODES,
|
||||
KEYS,
|
||||
getLineHeight,
|
||||
arrayToMap,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { newElementWith, syncStickyNoteInk } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
normalizeStickyNote,
|
||||
hasBoundTextElement,
|
||||
canApplyRoundnessTypeToElement,
|
||||
getDefaultRoundnessTypeForElement,
|
||||
isFrameLikeElement,
|
||||
isArrowElement,
|
||||
isExcalidrawElement,
|
||||
getColorUpdate,
|
||||
isNonDeletedElement,
|
||||
isStickyNoteBoundText,
|
||||
isStickyNoteElement,
|
||||
isTextElement,
|
||||
getBaseFontSize,
|
||||
getBaseFontSizeUpdate,
|
||||
relayoutStickyNotes,
|
||||
updateBoundElements,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
@@ -86,84 +96,133 @@ export const actionPasteStyles = register({
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
const selectedElementIds = selectedElements.map((element) => element.id);
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (selectedElementIds.includes(element.id)) {
|
||||
let elementStylesToCopyFrom = pastedElement;
|
||||
if (isTextElement(element) && element.containerId) {
|
||||
elementStylesToCopyFrom = boundTextElement;
|
||||
}
|
||||
if (!elementStylesToCopyFrom) {
|
||||
return element;
|
||||
}
|
||||
let newElement = newElementWith(element, {
|
||||
backgroundColor: elementStylesToCopyFrom?.backgroundColor,
|
||||
strokeWidth: elementStylesToCopyFrom?.strokeWidth,
|
||||
strokeColor: elementStylesToCopyFrom?.strokeColor,
|
||||
strokeStyle: elementStylesToCopyFrom?.strokeStyle,
|
||||
fillStyle: elementStylesToCopyFrom?.fillStyle,
|
||||
opacity: elementStylesToCopyFrom?.opacity,
|
||||
roughness: elementStylesToCopyFrom?.roughness,
|
||||
roundness: elementStylesToCopyFrom.roundness
|
||||
? canApplyRoundnessTypeToElement(
|
||||
elementStylesToCopyFrom.roundness.type,
|
||||
element,
|
||||
)
|
||||
? elementStylesToCopyFrom.roundness
|
||||
: getDefaultRoundnessTypeForElement(element)
|
||||
: null,
|
||||
});
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
const fontSize =
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).fontSize ||
|
||||
DEFAULT_FONT_SIZE;
|
||||
const fontFamily =
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).fontFamily ||
|
||||
DEFAULT_FONT_FAMILY;
|
||||
const newTextElement = newElementWith(newElement, {
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign:
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).textAlign ||
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
lineHeight:
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
|
||||
getLineHeight(fontFamily),
|
||||
const elementsMap = arrayToMap(elements);
|
||||
// whether the copied text was a sticky label is decided by the copied
|
||||
// snapshot — its container may be gone from the live scene by now
|
||||
const copiedElementsMap = arrayToMap(
|
||||
(elementsCopied as unknown[]).filter(isExcalidrawElement),
|
||||
);
|
||||
const nextElements = relayoutStickyNotes(
|
||||
// a restyled note and its label end up with one ink — the label's,
|
||||
// when the copied styles carry two colors
|
||||
syncStickyNoteInk(
|
||||
elements.map((element) => {
|
||||
if (selectedElementIds.includes(element.id)) {
|
||||
let elementStylesToCopyFrom = pastedElement;
|
||||
if (isTextElement(element) && element.containerId) {
|
||||
elementStylesToCopyFrom = boundTextElement;
|
||||
}
|
||||
if (!elementStylesToCopyFrom) {
|
||||
return element;
|
||||
}
|
||||
let newElement = newElementWith(element, {
|
||||
backgroundColor: elementStylesToCopyFrom?.backgroundColor,
|
||||
strokeWidth: elementStylesToCopyFrom?.strokeWidth,
|
||||
strokeColor: elementStylesToCopyFrom?.strokeColor,
|
||||
strokeStyle: elementStylesToCopyFrom?.strokeStyle,
|
||||
fillStyle: elementStylesToCopyFrom?.fillStyle,
|
||||
opacity: elementStylesToCopyFrom?.opacity,
|
||||
roughness: elementStylesToCopyFrom?.roughness,
|
||||
roundness: elementStylesToCopyFrom.roundness
|
||||
? canApplyRoundnessTypeToElement(
|
||||
elementStylesToCopyFrom.roundness.type,
|
||||
element,
|
||||
)
|
||||
? elementStylesToCopyFrom.roundness
|
||||
: getDefaultRoundnessTypeForElement(element)
|
||||
: null,
|
||||
});
|
||||
newElement = newTextElement;
|
||||
let container = null;
|
||||
if (newTextElement.containerId) {
|
||||
container =
|
||||
selectedElements.find(
|
||||
(element) => element.id === newTextElement.containerId,
|
||||
) || null;
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
const sourceText =
|
||||
elementStylesToCopyFrom as ExcalidrawTextElement;
|
||||
const fontSize =
|
||||
(isTextElement(elementStylesToCopyFrom)
|
||||
? getBaseFontSize(elementStylesToCopyFrom, copiedElementsMap)
|
||||
: sourceText.fontSize) || DEFAULT_FONT_SIZE;
|
||||
const fontFamily = sourceText.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
let container = null;
|
||||
const containerId = newElement.containerId;
|
||||
if (containerId) {
|
||||
container =
|
||||
selectedElements.find(
|
||||
(element) => element.id === containerId,
|
||||
) || null;
|
||||
}
|
||||
const newTextElement = newElementWith(newElement, {
|
||||
...getBaseFontSizeUpdate(newElement, fontSize, elementsMap),
|
||||
fontFamily,
|
||||
textAlign: sourceText.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
lineHeight: sourceText.lineHeight || getLineHeight(fontFamily),
|
||||
});
|
||||
newElement = newTextElement;
|
||||
|
||||
if (isStickyNoteBoundText(newTextElement, elementsMap)) {
|
||||
// the copied stroke may be transparent; a note's label never is
|
||||
newElement = newElementWith(
|
||||
newTextElement,
|
||||
getColorUpdate(
|
||||
newTextElement,
|
||||
"strokeColor",
|
||||
newTextElement.strokeColor,
|
||||
elementsMap,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// sticky labels are laid out together with their (possibly
|
||||
// also restyled) note in the post-pass below
|
||||
redrawTextBoundingBox(newTextElement, container, app.scene);
|
||||
}
|
||||
}
|
||||
|
||||
redrawTextBoundingBox(newTextElement, container, app.scene);
|
||||
}
|
||||
if (
|
||||
newElement.type === "arrow" &&
|
||||
isArrowElement(elementStylesToCopyFrom)
|
||||
) {
|
||||
newElement = newElementWith(newElement, {
|
||||
startArrowhead: elementStylesToCopyFrom.startArrowhead,
|
||||
endArrowhead: elementStylesToCopyFrom.endArrowhead,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
newElement.type === "arrow" &&
|
||||
isArrowElement(elementStylesToCopyFrom)
|
||||
) {
|
||||
newElement = newElementWith(newElement, {
|
||||
startArrowhead: elementStylesToCopyFrom.startArrowhead,
|
||||
endArrowhead: elementStylesToCopyFrom.endArrowhead,
|
||||
});
|
||||
}
|
||||
if (isFrameLikeElement(element)) {
|
||||
newElement = newElementWith(newElement, {
|
||||
roundness: null,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
}
|
||||
|
||||
if (isFrameLikeElement(element)) {
|
||||
newElement = newElementWith(newElement, {
|
||||
roundness: null,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
}
|
||||
if (isStickyNoteElement(newElement)) {
|
||||
newElement = normalizeStickyNote(newElement);
|
||||
}
|
||||
|
||||
return newElement;
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
return newElement;
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
elementsMap,
|
||||
),
|
||||
new Set(selectedElementIds),
|
||||
{ prevElementsMap: elementsMap },
|
||||
);
|
||||
|
||||
// a restyled note may have grown or shrunk — arrows bound to it follow
|
||||
for (const element of nextElements) {
|
||||
const prev = elementsMap.get(element.id);
|
||||
if (
|
||||
isStickyNoteElement(element) &&
|
||||
isNonDeletedElement(element) &&
|
||||
prev &&
|
||||
(prev.height !== element.height ||
|
||||
prev.x !== element.x ||
|
||||
prev.y !== element.y)
|
||||
) {
|
||||
updateBoundElements(element, app.scene);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { messageCircleIcon } from "../components/icons";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleShowHints = register({
|
||||
name: "showHints",
|
||||
label: "labels.showHints",
|
||||
icon: messageCircleIcon,
|
||||
keywords: ["hint", "hints", "tip", "tips"],
|
||||
// hints live in the toolbar, which view mode hides entirely
|
||||
viewMode: false,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.showHints,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
showHints: !this.checked!(appState),
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.showHints,
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_STROKE_PICKS,
|
||||
STICKY_NOTE_BACKGROUND_PICKS,
|
||||
STICKY_NOTE_STROKE_PICKS,
|
||||
arrayToMap,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
getColorTargetElement,
|
||||
hasBackground,
|
||||
hasStrokeColor,
|
||||
isStickyNoteBoundText,
|
||||
isStickyNoteElement,
|
||||
isTextElement,
|
||||
normalizeStickyNoteBackgroundColor,
|
||||
normalizeStickyNoteStrokeColor,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { ColorPaletteCustom, ColorTuple } from "@excalidraw/common";
|
||||
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
export type ColorProperty = "strokeColor" | "backgroundColor";
|
||||
|
||||
/**
|
||||
* Sticky notes are their own color domain: own defaults, own top picks, no
|
||||
* transparent. A pick targets the regular domain, the sticky domain, or both
|
||||
* ("mixed" selections write both defaults and use the regular picker).
|
||||
*/
|
||||
export type ColorTargetKind = "regular" | "sticky" | "mixed";
|
||||
|
||||
export type ColorDefaultKey =
|
||||
| "currentItemStrokeColor"
|
||||
| "currentItemBackgroundColor"
|
||||
| "currentItemStickynoteStrokeColor"
|
||||
| "currentItemStickynoteBackgroundColor";
|
||||
|
||||
export type ColorTargetAppState = Pick<
|
||||
AppState,
|
||||
"selectedElementIds" | "editingTextElement" | "activeTool" | ColorDefaultKey
|
||||
>;
|
||||
|
||||
export type ColorTarget = {
|
||||
kind: ColorTargetKind;
|
||||
property: ColorProperty;
|
||||
/** the current-item defaults a pick is written to (normalized per domain) */
|
||||
appStateKeys: readonly ColorDefaultKey[];
|
||||
/** the default shown when no element is targeted */
|
||||
currentValue: string;
|
||||
palette: ColorPaletteCustom;
|
||||
topPicks: ColorTuple;
|
||||
customizableTopPicks: keyof AppState["colorTopPicks"];
|
||||
excludedColors: readonly string[] | undefined;
|
||||
};
|
||||
|
||||
const DEFAULT_KEYS: Record<
|
||||
ColorProperty,
|
||||
Record<"regular" | "sticky", ColorDefaultKey>
|
||||
> = {
|
||||
strokeColor: {
|
||||
regular: "currentItemStrokeColor",
|
||||
sticky: "currentItemStickynoteStrokeColor",
|
||||
},
|
||||
backgroundColor: {
|
||||
regular: "currentItemBackgroundColor",
|
||||
sticky: "currentItemStickynoteBackgroundColor",
|
||||
},
|
||||
};
|
||||
|
||||
// transparent is hidden rather than removed from the palette so the remaining
|
||||
// colors keep their usual hotkeys (same mechanism as the bucket fill picker);
|
||||
// a module constant so the memoized picker's identity comparison holds
|
||||
const STICKY_NOTE_EXCLUDED_COLORS: readonly string[] = [
|
||||
COLOR_PALETTE.transparent,
|
||||
];
|
||||
|
||||
const isStickyNoteColorTarget = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
) =>
|
||||
isStickyNoteElement(element) ||
|
||||
(isTextElement(element) && isStickyNoteBoundText(element, elementsMap));
|
||||
|
||||
/**
|
||||
* Who a stroke/background pick targets. Resolve it from the state an action
|
||||
* runs against, at execution time — never capture it in a render closure:
|
||||
* the memoized picker keeps a stale `onChange`, and the always-visible top
|
||||
* picks fire it without a re-render.
|
||||
*
|
||||
* Targets are the selected color-capable elements (for stroke incl. bound
|
||||
* labels, since a note's visible text is its label) plus the text being
|
||||
* edited (`handleTextWysiwyg` deselects while editing) — or, for a
|
||||
* background pick on a note's label, the note (see `getColorTargetElement`).
|
||||
* With no target, the active tool decides the domain.
|
||||
*/
|
||||
export const resolveColorTarget = (
|
||||
appState: ColorTargetAppState,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
property: ColorProperty,
|
||||
): ColorTarget => {
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const supports = (element: ExcalidrawElement) =>
|
||||
property === "strokeColor"
|
||||
? hasStrokeColor(element.type)
|
||||
: hasBackground(element.type);
|
||||
|
||||
const targets: ExcalidrawElement[] = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: property === "strokeColor",
|
||||
}).filter(supports);
|
||||
const editingText =
|
||||
appState.editingTextElement &&
|
||||
elementsMap.get(appState.editingTextElement.id);
|
||||
const editing =
|
||||
editingText && getColorTargetElement(editingText, property, elementsMap);
|
||||
if (
|
||||
editing &&
|
||||
!editing.isDeleted &&
|
||||
supports(editing) &&
|
||||
!targets.some((element) => element.id === editing.id)
|
||||
) {
|
||||
targets.push(editing);
|
||||
}
|
||||
|
||||
let kind: ColorTargetKind;
|
||||
if (!targets.length) {
|
||||
kind = appState.activeTool.type === "stickynote" ? "sticky" : "regular";
|
||||
} else {
|
||||
const stickyCount = targets.filter((element) =>
|
||||
isStickyNoteColorTarget(element, elementsMap),
|
||||
).length;
|
||||
kind =
|
||||
stickyCount === 0
|
||||
? "regular"
|
||||
: stickyCount === targets.length
|
||||
? "sticky"
|
||||
: "mixed";
|
||||
}
|
||||
|
||||
const keys = DEFAULT_KEYS[property];
|
||||
const isStroke = property === "strokeColor";
|
||||
|
||||
return {
|
||||
kind,
|
||||
property,
|
||||
appStateKeys: kind === "mixed" ? [keys.regular, keys.sticky] : [keys[kind]],
|
||||
currentValue: appState[kind === "sticky" ? keys.sticky : keys.regular],
|
||||
palette: isStroke
|
||||
? DEFAULT_ELEMENT_STROKE_COLOR_PALETTE
|
||||
: DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
topPicks:
|
||||
kind === "sticky"
|
||||
? isStroke
|
||||
? STICKY_NOTE_STROKE_PICKS
|
||||
: STICKY_NOTE_BACKGROUND_PICKS
|
||||
: isStroke
|
||||
? DEFAULT_ELEMENT_STROKE_PICKS
|
||||
: DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
customizableTopPicks:
|
||||
kind === "sticky"
|
||||
? isStroke
|
||||
? "stickyNoteStroke"
|
||||
: "stickyNoteBackground"
|
||||
: isStroke
|
||||
? "elementStroke"
|
||||
: "elementBackground",
|
||||
excludedColors: kind === "sticky" ? STICKY_NOTE_EXCLUDED_COLORS : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/** the current-item default updates for a picked color */
|
||||
export const getColorTargetAppStateUpdates = (
|
||||
target: ColorTarget,
|
||||
color: string,
|
||||
): Partial<AppState> => {
|
||||
const updates: Partial<AppState> = {};
|
||||
for (const key of target.appStateKeys) {
|
||||
updates[key] =
|
||||
key === "currentItemStickynoteStrokeColor"
|
||||
? normalizeStickyNoteStrokeColor(color)
|
||||
: key === "currentItemStickynoteBackgroundColor"
|
||||
? normalizeStickyNoteBackgroundColor(color)
|
||||
: color;
|
||||
}
|
||||
return updates;
|
||||
};
|
||||
@@ -82,6 +82,7 @@ export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||
export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
|
||||
export { actionToggleArrowBinding } from "./actionToggleArrowBinding";
|
||||
export { actionToggleMidpointSnapping } from "./actionToggleMidpointSnapping";
|
||||
export { actionToggleShowHints } from "./actionToggleShowHints";
|
||||
|
||||
export { actionToggleStats } from "./actionToggleStats";
|
||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
|
||||
@@ -192,14 +192,21 @@ export class ActionManager {
|
||||
const action = this.actions[name];
|
||||
const PanelComponent = action.PanelComponent!;
|
||||
PanelComponent.displayName = "PanelComponent";
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const updateData = (formState?: any) => {
|
||||
if (this.isActionBlockedByViewportTransition(action)) {
|
||||
return;
|
||||
}
|
||||
|
||||
trackAction(action, "ui", appState, elements, this.app, formState);
|
||||
// read fresh state at call time — memoized panel children may invoke
|
||||
// an `updateData` closure minted by an earlier render
|
||||
trackAction(
|
||||
action,
|
||||
"ui",
|
||||
this.getAppState(),
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.app,
|
||||
formState,
|
||||
);
|
||||
|
||||
this.updater(
|
||||
action.perform(
|
||||
|
||||
@@ -61,6 +61,7 @@ export type ActionName =
|
||||
| "objectsSnapMode"
|
||||
| "arrowBinding"
|
||||
| "midpointSnapping"
|
||||
| "showHints"
|
||||
| "stats"
|
||||
| "changeStrokeColor"
|
||||
| "changeBackgroundColor"
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import { isDevEnv } from "@excalidraw/common";
|
||||
|
||||
// small subset of categories at a given time.
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette", "export"]);
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = new Set([
|
||||
"command_palette",
|
||||
"export",
|
||||
"ai",
|
||||
]);
|
||||
|
||||
export const trackEvent = (
|
||||
category: string,
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
THEME,
|
||||
DEFAULT_GRID_STEP,
|
||||
isTestEnv,
|
||||
DEFAULT_STICKY_NOTE_BG,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { AppState, NormalizedZoomValue } from "./types";
|
||||
import type { AppState, InputDevice, NormalizedZoomValue } from "./types";
|
||||
|
||||
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
||||
? devicePixelRatio
|
||||
@@ -38,6 +39,8 @@ export const getDefaultAppState = (): Omit<
|
||||
currentItemStrokeVariability: "constant",
|
||||
currentItemStartArrowhead: null,
|
||||
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||
currentItemStickynoteStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||
currentItemStickynoteBackgroundColor: DEFAULT_STICKY_NOTE_BG,
|
||||
currentItemRoundness: isTestEnv() ? "sharp" : "round",
|
||||
currentItemArrowType: ARROW_TYPE.round,
|
||||
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||
@@ -74,6 +77,8 @@ export const getDefaultAppState = (): Omit<
|
||||
isBindingEnabled: true,
|
||||
bindingPreference: "enabled",
|
||||
isMidpointSnappingEnabled: true,
|
||||
showHints: true,
|
||||
inputDevice: "auto",
|
||||
defaultSidebarDockedPreference: false,
|
||||
isLoading: false,
|
||||
isResizing: false,
|
||||
@@ -103,7 +108,7 @@ export const getDefaultAppState = (): Omit<
|
||||
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
|
||||
},
|
||||
suggestedBinding: null,
|
||||
hoveredArrowTextAnchor: null,
|
||||
textToolHover: null,
|
||||
frameRendering: { enabled: true, clip: true, name: true, outline: true },
|
||||
frameToHighlight: null,
|
||||
editingFrame: null,
|
||||
@@ -130,6 +135,14 @@ export const getDefaultAppState = (): Omit<
|
||||
activeLockedId: null,
|
||||
bindMode: "orbit",
|
||||
boxSelectionMode: "contain",
|
||||
colorTopPicks: {
|
||||
elementStroke: null,
|
||||
elementBackground: null,
|
||||
bucketFill: null,
|
||||
stickyNoteStroke: null,
|
||||
stickyNoteBackground: null,
|
||||
},
|
||||
fontTopPicks: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -176,6 +189,16 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
},
|
||||
currentItemStartArrowhead: { browser: true, export: false, server: false },
|
||||
currentItemStrokeColor: { browser: true, export: false, server: false },
|
||||
currentItemStickynoteStrokeColor: {
|
||||
browser: true,
|
||||
export: false,
|
||||
server: false,
|
||||
},
|
||||
currentItemStickynoteBackgroundColor: {
|
||||
browser: true,
|
||||
export: false,
|
||||
server: false,
|
||||
},
|
||||
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||
currentItemStrokeWidthKey: { browser: true, export: false, server: false },
|
||||
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||
@@ -203,6 +226,8 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
boxSelectionMode: { browser: true, export: false, server: false },
|
||||
bindingPreference: { browser: true, export: false, server: false },
|
||||
isMidpointSnappingEnabled: { browser: true, export: false, server: false },
|
||||
showHints: { browser: true, export: false, server: false },
|
||||
inputDevice: { browser: true, export: false, server: false },
|
||||
defaultSidebarDockedPreference: {
|
||||
browser: true,
|
||||
export: false,
|
||||
@@ -239,7 +264,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||
stats: { browser: true, export: false, server: false },
|
||||
suggestedBinding: { browser: false, export: false, server: false },
|
||||
hoveredArrowTextAnchor: { browser: false, export: false, server: false },
|
||||
textToolHover: { browser: false, export: false, server: false },
|
||||
frameRendering: { browser: false, export: false, server: false },
|
||||
frameToHighlight: { browser: false, export: false, server: false },
|
||||
editingFrame: { browser: false, export: false, server: false },
|
||||
@@ -261,6 +286,8 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
lockedMultiSelections: { browser: true, export: true, server: true },
|
||||
activeLockedId: { browser: false, export: false, server: false },
|
||||
bindMode: { browser: true, export: false, server: false },
|
||||
colorTopPicks: { browser: true, export: false, server: false },
|
||||
fontTopPicks: { browser: true, export: false, server: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
@@ -312,3 +339,17 @@ export const isHandToolActive = ({
|
||||
}) => {
|
||||
return activeTool.type === "hand";
|
||||
};
|
||||
|
||||
/**
|
||||
* The device the wheel mappings follow for the given preference.
|
||||
*
|
||||
* `auto` is meant to detect the device from the wheel events themselves
|
||||
* (line vs. pixel delta modes, whole vs. fractional deltas, one vs. two axes
|
||||
* moving, event cadence and momentum tails). That is not implemented yet, so
|
||||
* it resolves to `trackpad` — the mapping the editor has always had, and the
|
||||
* default to keep until detection exists.
|
||||
*/
|
||||
export const resolveInputDevice = (
|
||||
inputDevice: InputDevice,
|
||||
): Exclude<InputDevice, "auto"> =>
|
||||
inputDevice === "auto" ? "trackpad" : inputDevice;
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
COLOR_CHARCOAL_BLACK,
|
||||
COLOR_VOICE_CALL,
|
||||
COLOR_WHITE,
|
||||
THEME,
|
||||
UserIdleState,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
@@ -122,9 +121,7 @@ export const renderRemoteCursors = ({
|
||||
context.closePath();
|
||||
}
|
||||
|
||||
// TODO remove the dark theme color after we stop inverting canvas colors
|
||||
const IS_SPEAKING_COLOR =
|
||||
appState.theme === THEME.DARK ? "#2f6330" : COLOR_VOICE_CALL;
|
||||
const IS_SPEAKING_COLOR = COLOR_VOICE_CALL;
|
||||
|
||||
const isSpeaking = collaborator?.isSpeaking;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.zoom-actions,
|
||||
.undo-redo-buttons {
|
||||
background-color: var(--island-bg-color);
|
||||
background-color: var(--color-surface-low);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
.zoom-button,
|
||||
.undo-redo-buttons button {
|
||||
border-radius: 0 !important;
|
||||
background-color: var(--color-surface-low) !important;
|
||||
font-size: 0.875rem !important;
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
@@ -31,6 +30,24 @@
|
||||
width: 3.75rem !important;
|
||||
justify-content: center;
|
||||
color: var(--text-primary-color);
|
||||
|
||||
// unlike the other zoom buttons, has no .ToolIcon__icon to show the hover
|
||||
.zoom-actions &:hover:not(:active) {
|
||||
background-color: var(--button-hover-bg);
|
||||
}
|
||||
}
|
||||
|
||||
// shifted left in zen mode (the wrapper rather than the button, so that the
|
||||
// tooltip, positioned off the wrapper, moves along)
|
||||
.reset-zoom-button-wrapper {
|
||||
transition: transform var(--zen-mode-transition-duration) ease-in-out;
|
||||
}
|
||||
|
||||
// faded out in zen mode
|
||||
.zoom-out-button-wrapper,
|
||||
.zoom-in-button-wrapper {
|
||||
transition: opacity var(--zen-mode-transition-duration) ease-in-out,
|
||||
visibility var(--zen-mode-transition-duration);
|
||||
}
|
||||
|
||||
.zoom-out-button {
|
||||
@@ -92,6 +109,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
// two separate rows (rather than a flex-wrap row break) so the panel's
|
||||
// intrinsic width is that of a single row
|
||||
.align-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.compact-shape-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -94,31 +94,24 @@ const AlignFieldset = ({
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.align")}</legend>
|
||||
<div className="buttonList">
|
||||
{isRTL ? (
|
||||
<>
|
||||
{renderAction("alignRight")}
|
||||
{renderAction("alignHorizontallyCentered")}
|
||||
{renderAction("alignLeft")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{renderAction("alignLeft")}
|
||||
{renderAction("alignHorizontallyCentered")}
|
||||
{renderAction("alignRight")}
|
||||
</>
|
||||
)}
|
||||
{showDistribute && renderAction("distributeHorizontally")}
|
||||
{/* breaks the row ˇˇ */}
|
||||
<div style={{ flexBasis: "100%", height: 0 }} />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: ".5rem",
|
||||
marginTop: "-0.5rem",
|
||||
}}
|
||||
>
|
||||
<div className="buttonList align-buttons">
|
||||
<div className="align-buttons__row">
|
||||
{isRTL ? (
|
||||
<>
|
||||
{renderAction("alignRight")}
|
||||
{renderAction("alignHorizontallyCentered")}
|
||||
{renderAction("alignLeft")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{renderAction("alignLeft")}
|
||||
{renderAction("alignHorizontallyCentered")}
|
||||
{renderAction("alignRight")}
|
||||
</>
|
||||
)}
|
||||
{showDistribute && renderAction("distributeHorizontally")}
|
||||
</div>
|
||||
<div className="align-buttons__row">
|
||||
{renderAction("alignTop")}
|
||||
{renderAction("alignVerticallyCentered")}
|
||||
{renderAction("alignBottom")}
|
||||
@@ -420,7 +413,9 @@ const CombinedTextProperties = ({
|
||||
predicates: ShapeActionPredicates;
|
||||
container: HTMLDivElement | null;
|
||||
}) => {
|
||||
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
|
||||
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus(
|
||||
container?.ownerDocument,
|
||||
);
|
||||
const isOpen = appState.openPopup === "compactTextProperties";
|
||||
|
||||
return (
|
||||
@@ -902,10 +897,14 @@ export const UndoRedoActions = ({
|
||||
}) => (
|
||||
<div className={`undo-redo-buttons ${className}`}>
|
||||
<div className="undo-button-container">
|
||||
<Tooltip label={t("buttons.undo")}>{renderAction("undo")}</Tooltip>
|
||||
<Tooltip label={t("buttons.undo")} delay>
|
||||
{renderAction("undo")}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="redo-button-container">
|
||||
<Tooltip label={t("buttons.redo")}> {renderAction("redo")}</Tooltip>
|
||||
<Tooltip label={t("buttons.redo")} delay>
|
||||
{renderAction("redo")}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { CURSOR_TYPE, DRAGGING_THRESHOLD } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
bindBindingElementToFixedPoint,
|
||||
dragNewTextElement,
|
||||
getBoundTextElement,
|
||||
getEndpointBoundTextDragAnchor,
|
||||
getTextBindingForArrowEndpoint,
|
||||
getUnboundArrowEndpointAtPoint,
|
||||
hitElementBoundText,
|
||||
isArrowElement,
|
||||
isBindingEnabled,
|
||||
isEndpointBoundText,
|
||||
isTextElement,
|
||||
LinearElementEditor,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
import { pointDistance, pointFrom } from "@excalidraw/math";
|
||||
|
||||
import type { ArrowEndpoint } from "@excalidraw/element";
|
||||
import type {
|
||||
@@ -19,64 +22,28 @@ import type {
|
||||
ExcalidrawTextElement,
|
||||
FixedPoint,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type App from "./App";
|
||||
import type { AppState } from "../types";
|
||||
import type { PointerDownState } from "../types";
|
||||
|
||||
/**
|
||||
* The text tool's interaction with arrows: the hover affordance showing where
|
||||
* a click would attach text to an arrow — a free endpoint (binds the arrow to
|
||||
* a new text element positioned against that endpoint) or the arrow's midpoint
|
||||
* (adds a label bound to the arrow) — and the endpoint-bound flavor of
|
||||
* drag-sizing a new text.
|
||||
* Text ↔ arrow interactions.
|
||||
*
|
||||
* The scene-level logic lives in `@excalidraw/element`'s `arrowEndpointText.ts`.
|
||||
* With the text tool: the free endpoint a click would bind a new text to
|
||||
* (`AppTextTool` resolves and shows it), the binding itself, and the
|
||||
* endpoint-bound flavor of drag-sizing a new text.
|
||||
*
|
||||
* With the selection tool: dragging an arrow's existing label along the arrow
|
||||
* — the grab affordance and the drag itself.
|
||||
*
|
||||
* The scene-level logic lives in `@excalidraw/element`'s
|
||||
* `arrowEndpointText.ts` and `linearElementEditor.ts`.
|
||||
*/
|
||||
export class AppArrowText {
|
||||
constructor(private app: App) {}
|
||||
|
||||
/**
|
||||
* With the text tool active, an arrow under the cursor is a target for
|
||||
* attaching text. Returns where the text would land (if anywhere), and keeps
|
||||
* `appState.hoveredArrowTextAnchor` — which drives the highlight — in sync.
|
||||
*/
|
||||
updateHoveredAnchor = (scenePointer: {
|
||||
x: number;
|
||||
y: number;
|
||||
}): AppState["hoveredArrowTextAnchor"] => {
|
||||
const hovered =
|
||||
this.app.state.activeTool.type === "text" &&
|
||||
!this.app.state.editingTextElement &&
|
||||
!this.app.state.newElement
|
||||
? this.getAnchorAtPosition(scenePointer.x, scenePointer.y)
|
||||
: null;
|
||||
|
||||
const previous = this.app.state.hoveredArrowTextAnchor;
|
||||
|
||||
if (
|
||||
previous?.elementId !== hovered?.elementId ||
|
||||
previous?.anchor !== hovered?.anchor
|
||||
) {
|
||||
this.app.setState({ hoveredArrowTextAnchor: hovered });
|
||||
}
|
||||
|
||||
return hovered;
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-evaluates the hovered anchor at the last known pointer position — for
|
||||
* events that change what a click would do without the pointer moving,
|
||||
* i.e. the ctrl/cmd binding toggle. (Events that invalidate the anchor
|
||||
* wholesale — tool switches, finalize, deselect — clear it directly
|
||||
* instead.)
|
||||
*/
|
||||
refresh = (): void => {
|
||||
if (this.app.lastPointerMoveCoords) {
|
||||
this.updateHoveredAnchor(this.app.lastPointerMoveCoords);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A free arrow endpoint a new text could be bound to. Binding an endpoint is
|
||||
* an arrow binding, so it follows the binding toggle (ctrl/cmd) like every
|
||||
@@ -98,17 +65,17 @@ export class AppArrowText {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The text tool edits before it creates: when a text element is the
|
||||
// top-most hit at this position, a click edits that text, so a nearby
|
||||
// endpoint must not be offered over it.
|
||||
if (this.app.getTextElementAtPosition(x, y)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The endpoint scan only knows about arrows, so it happily reaches through
|
||||
// whatever is drawn on top of them. An element stacked above the arrow that
|
||||
// the pointer actually hits owns the click — the text tool should label
|
||||
// that element rather than bind the endpoint hidden behind it.
|
||||
// the pointer actually hits owns the click — the text tool should edit or
|
||||
// label that element rather than bind the endpoint hidden behind it. This
|
||||
// includes text: a text stacked above the arrow keeps its edit behavior.
|
||||
//
|
||||
// The preference is z-aware on purpose. When the arrow is the top-most hit
|
||||
// (e.g. drawn over an existing text), its endpoint has full preference
|
||||
// over the whole hit circle — a z-blind "any text under the cursor wins"
|
||||
// rule would make the affordance flicker between the endpoint anchor and
|
||||
// text editing wherever a text bbox edge cuts into the circle.
|
||||
const hitElement = this.app.getElementAtPosition(x, y, {
|
||||
includeLockedElements: true,
|
||||
});
|
||||
@@ -125,49 +92,6 @@ export class AppArrowText {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors what `handleTextOnPointerDown` would do at this position, so the
|
||||
* highlight can't promise something the click won't deliver.
|
||||
*/
|
||||
private getAnchorAtPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
): AppState["hoveredArrowTextAnchor"] {
|
||||
const endpoint = this.getBindableEndpointAtPosition(x, y);
|
||||
|
||||
if (endpoint) {
|
||||
return { elementId: endpoint.arrow.id, anchor: endpoint.startOrEnd };
|
||||
}
|
||||
|
||||
const container = this.app.getTextBindableContainerAtPosition(x, y);
|
||||
|
||||
// Only arrows get a midpoint label anchor worth pointing at; other
|
||||
// containers center the text in themselves, which needs no affordance.
|
||||
// An arrow that already has a label is edited in place, not re-anchored.
|
||||
if (
|
||||
!isArrowElement(container) ||
|
||||
getBoundTextElement(container, this.app.scene.getNonDeletedElementsMap())
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `getTextBindableContainerAtPosition` resolves an arrow anywhere in its
|
||||
// bounding box, but a click only becomes a *label* when it also snaps to
|
||||
// the arrow's center — off-center clicks drop a free-floating text
|
||||
// instead. Gate on the same check so the highlight can't promise a label
|
||||
// the click won't deliver.
|
||||
const snappedToCenter = this.app.getTextWysiwygSnappedToCenterPosition(
|
||||
x,
|
||||
y,
|
||||
this.app.state,
|
||||
container,
|
||||
);
|
||||
|
||||
return snappedToCenter
|
||||
? { elementId: container.id, anchor: "label" }
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a text should be created to read as a label for this endpoint — the
|
||||
* side midpoint to bind, the alignment that pins it, and the scene position
|
||||
@@ -234,4 +158,77 @@ export class AppArrowText {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the arrow's label is what a grab at this position would pick up:
|
||||
* the pointer is over the label, and no element stacked above the arrow
|
||||
* owns the hit instead.
|
||||
*/
|
||||
isBoundTextGrabbable(
|
||||
element: NonDeletedExcalidrawElement,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean {
|
||||
if (
|
||||
!isArrowElement(element) ||
|
||||
!hitElementBoundText(
|
||||
pointFrom(x, y),
|
||||
element,
|
||||
this.app.scene.getNonDeletedElementsMap(),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hitElements = this.app.getElementsAtPosition(x, y);
|
||||
const arrowIndex = hitElements.findIndex((el) => el.id === element.id);
|
||||
|
||||
return (
|
||||
arrowIndex !== -1 &&
|
||||
!hitElements
|
||||
.slice(arrowIndex + 1)
|
||||
.some((el) => this.app.hitElement(x, y, el, false))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pointer-move half of dragging a label along its arrow. Owns the move
|
||||
* whenever the gesture started on the label (`pointerDownState.hit.arrowLabel`),
|
||||
* dragging only once past the threshold. Returns whether it owned it.
|
||||
*/
|
||||
maybeDragLabel(
|
||||
linearElementEditor: LinearElementEditor,
|
||||
pointerDownState: PointerDownState,
|
||||
pointerCoords: { x: number; y: number },
|
||||
): boolean {
|
||||
if (!pointerDownState.hit.arrowLabel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.app.cursor.set(CURSOR_TYPE.GRABBING);
|
||||
|
||||
if (
|
||||
linearElementEditor.isDragging ||
|
||||
pointDistance(
|
||||
pointFrom(pointerDownState.origin.x, pointerDownState.origin.y),
|
||||
pointFrom(pointerCoords.x, pointerCoords.y),
|
||||
) >=
|
||||
DRAGGING_THRESHOLD / this.app.state.zoom.value
|
||||
) {
|
||||
const updatedEditor = LinearElementEditor.handleBoundTextDragging(
|
||||
linearElementEditor,
|
||||
this.app.scene,
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
);
|
||||
if (updatedEditor) {
|
||||
pointerDownState.drag.hasOccurred = true;
|
||||
this.app.setState({
|
||||
selectedLinearElement: updatedEditor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import {
|
||||
getBucketFillBackgroundColor,
|
||||
applyDarkModeFilter,
|
||||
BUCKET_FILL_BACKGROUND_PICKS,
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||
getSizeFromPoints,
|
||||
isTransparent,
|
||||
THEME,
|
||||
} from "@excalidraw/common";
|
||||
import { pointFrom, type GlobalPoint, type LocalPoint } from "@excalidraw/math";
|
||||
|
||||
@@ -13,10 +18,17 @@ import {
|
||||
ShapeCache,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
import type {
|
||||
NonDeletedExcalidrawElement,
|
||||
Theme,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { actionChangeBucketFillBackgroundColor } from "../actions";
|
||||
import { editorJotaiStore } from "../editor-jotai";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { activeEyeDropperAtom, type EyeDropperProperties } from "./EyeDropper";
|
||||
|
||||
import type App from "./App";
|
||||
|
||||
type ScenePoint = {
|
||||
@@ -24,10 +36,16 @@ type ScenePoint = {
|
||||
y: number;
|
||||
};
|
||||
|
||||
const BUCKET_FILL_KEYBOARD_COLOR_PICKS = BUCKET_FILL_BACKGROUND_PICKS.filter(
|
||||
(color) => color !== COLOR_PALETTE.white,
|
||||
);
|
||||
|
||||
/** Owns bucket-fill interaction and App-bound scene mutations. */
|
||||
export class AppBucketFill {
|
||||
constructor(private app: App) {}
|
||||
|
||||
private temporaryEyeDropper: EyeDropperProperties | null = null;
|
||||
|
||||
/**
|
||||
* The click armed on pointer down, committed on pointer up. Deferring the
|
||||
* scene mutation to pointer up is what lets a pinch/pan second finger, a
|
||||
@@ -59,12 +77,60 @@ export class AppBucketFill {
|
||||
this.pending = null;
|
||||
};
|
||||
|
||||
cycleBackgroundColor = () => {
|
||||
const currentColor = this.getBucketFillBackgroundColor(
|
||||
this.app.state.currentItemBackgroundColor,
|
||||
);
|
||||
const currentIndex = BUCKET_FILL_KEYBOARD_COLOR_PICKS.indexOf(currentColor);
|
||||
const nextColor =
|
||||
BUCKET_FILL_KEYBOARD_COLOR_PICKS[
|
||||
(currentIndex + 1) % BUCKET_FILL_KEYBOARD_COLOR_PICKS.length
|
||||
];
|
||||
|
||||
this.app.actionManager.executeAction(
|
||||
actionChangeBucketFillBackgroundColor,
|
||||
"keyboard",
|
||||
{ currentItemBackgroundColor: nextColor },
|
||||
);
|
||||
};
|
||||
|
||||
openTemporaryEyeDropper = () => {
|
||||
if (editorJotaiStore.get(activeEyeDropperAtom)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eyeDropper: EyeDropperProperties = {
|
||||
colorPickerType: "elementBackground",
|
||||
keepOpenOnAlt: true,
|
||||
onSelect: (color) => {
|
||||
this.app.actionManager.executeAction(
|
||||
actionChangeBucketFillBackgroundColor,
|
||||
"ui",
|
||||
{ currentItemBackgroundColor: color },
|
||||
);
|
||||
},
|
||||
};
|
||||
this.temporaryEyeDropper = eyeDropper;
|
||||
this.app.updateEditorAtom(activeEyeDropperAtom, eyeDropper);
|
||||
};
|
||||
|
||||
closeTemporaryEyeDropper = () => {
|
||||
const eyeDropper = this.temporaryEyeDropper;
|
||||
this.temporaryEyeDropper = null;
|
||||
if (
|
||||
eyeDropper &&
|
||||
editorJotaiStore.get(activeEyeDropperAtom) === eyeDropper
|
||||
) {
|
||||
this.app.updateEditorAtom(activeEyeDropperAtom, null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply the current bucket fill settings to an existing fill-compatible
|
||||
* element (no-op when nothing would change). One undoable step.
|
||||
*/
|
||||
private restyle = (element: NonDeletedExcalidrawElement) => {
|
||||
const backgroundColor = getBucketFillBackgroundColor(
|
||||
const backgroundColor = this.getBucketFillBackgroundColor(
|
||||
this.app.state.currentItemBackgroundColor,
|
||||
);
|
||||
if (
|
||||
@@ -89,7 +155,7 @@ export class AppBucketFill {
|
||||
private fill = (scenePointer: ScenePoint) => {
|
||||
// shared with the generic shape background, but a transparent fill would
|
||||
// be invisible, so fall back to a real color (appState is not mutated)
|
||||
const backgroundColor = getBucketFillBackgroundColor(
|
||||
const backgroundColor = this.getBucketFillBackgroundColor(
|
||||
this.app.state.currentItemBackgroundColor,
|
||||
);
|
||||
|
||||
@@ -217,4 +283,19 @@ export class AppBucketFill {
|
||||
// (even when the tool isn't locked).
|
||||
this.app.scheduleCapture();
|
||||
};
|
||||
|
||||
/**
|
||||
* The color the bucket fill tool actually fills with: the shared
|
||||
* `currentItemBackgroundColor`, falling back to green when that is
|
||||
* transparent (the tool's picker doesn't offer transparent, but the shared
|
||||
* state can hold it from the generic shape picker).
|
||||
*/
|
||||
public getBucketFillBackgroundColor = (
|
||||
backgroundColor: string,
|
||||
/** supply only for display purposes such as when applying to the cursor */
|
||||
theme?: Theme,
|
||||
) =>
|
||||
isTransparent(backgroundColor)
|
||||
? COLOR_PALETTE.green[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX]
|
||||
: applyDarkModeFilter(backgroundColor, theme === THEME.DARK);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
import { flushSync } from "react-dom";
|
||||
|
||||
import {
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
getFontString,
|
||||
getLineHeight,
|
||||
isWritableElement,
|
||||
normalizeEOL,
|
||||
normalizeLink,
|
||||
viewportCoordsToSceneCoords,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
convertToExcalidrawElements,
|
||||
embeddableURLValidator,
|
||||
getCommonBounds,
|
||||
getEmbedLink,
|
||||
getLineHeightInPx,
|
||||
makeNextSelectedElementIds,
|
||||
maybeParseEmbedSrc,
|
||||
measureText,
|
||||
newElementWith,
|
||||
newTextElement,
|
||||
normalizeText,
|
||||
wrapText,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElementSkeleton } from "@excalidraw/element";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
ExcalidrawTextElement,
|
||||
NonDeleted,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { actionCopy, actionCut } from "../actions";
|
||||
import { tryParseSpreadsheet } from "../charts";
|
||||
import { parseClipboard, parseDataTransferEvent } from "../clipboard";
|
||||
import { ImageURLToFile, SVGStringToFile } from "../data/blob";
|
||||
import { t } from "../i18n";
|
||||
import { isMaybeMermaidDefinition } from "../mermaid";
|
||||
import { withBatchedUpdates } from "../reactUtils";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
import { scrollBoundsIntoView } from "../viewport";
|
||||
|
||||
import type React from "react";
|
||||
|
||||
import type {
|
||||
ClipboardData,
|
||||
ParsedDataTransferFile,
|
||||
PastedMixedContent,
|
||||
} from "../clipboard";
|
||||
import type App from "./App";
|
||||
import type { Offsets } from "../types";
|
||||
|
||||
let IS_PLAIN_PASTE = false;
|
||||
let IS_PLAIN_PASTE_TIMER = 0;
|
||||
let PLAIN_PASTE_TOAST_SHOWN = false;
|
||||
|
||||
type AppClipboardDependencies = {
|
||||
getContainer: () => HTMLDivElement | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The clipboard: cut and copy (through their actions), and paste — the
|
||||
* pasted data parsed (`pasteFromClipboard`) and inserted as whatever it is
|
||||
* (`insertClipboardContent`): images or SVG code, elements, a Mermaid
|
||||
* definition, embeddable URLs, a spreadsheet (as a chart), mixed content,
|
||||
* or text. Pasted elements go in through `app.addElementsFromPasteOrLibrary`,
|
||||
* as a library item or a drop does.
|
||||
*/
|
||||
export class AppClipboard {
|
||||
constructor(
|
||||
private app: App,
|
||||
private dependencies: AppClipboardDependencies,
|
||||
) {}
|
||||
|
||||
// Copy/paste
|
||||
|
||||
public onCut = withBatchedUpdates((event: ClipboardEvent) => {
|
||||
if (!this.app.isInteractionEnabled()) {
|
||||
return;
|
||||
}
|
||||
const isExcalidrawActive = this.dependencies
|
||||
.getContainer()
|
||||
?.contains(this.app.ownerDocument.activeElement);
|
||||
if (!isExcalidrawActive || isWritableElement(event.target)) {
|
||||
return;
|
||||
}
|
||||
this.app.actionManager.executeAction(actionCut, "keyboard", event);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
|
||||
public onCopy = withBatchedUpdates((event: ClipboardEvent) => {
|
||||
if (!this.app.isInteractionEnabled()) {
|
||||
return;
|
||||
}
|
||||
const isExcalidrawActive = this.dependencies
|
||||
.getContainer()
|
||||
?.contains(this.app.ownerDocument.activeElement);
|
||||
if (!isExcalidrawActive || isWritableElement(event.target)) {
|
||||
return;
|
||||
}
|
||||
this.app.actionManager.executeAction(actionCopy, "keyboard", event);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
|
||||
// TODO: Cover with tests
|
||||
private async insertClipboardContent(
|
||||
data: ClipboardData,
|
||||
dataTransferFiles: ParsedDataTransferFile[],
|
||||
isPlainPaste: boolean,
|
||||
) {
|
||||
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||
{
|
||||
clientX: this.app.viewport.lastPosition.x,
|
||||
clientY: this.app.viewport.lastPosition.y,
|
||||
},
|
||||
this.app.state,
|
||||
);
|
||||
|
||||
// ------------------- Error -------------------
|
||||
if (data.errorMessage) {
|
||||
this.app.setState({ errorMessage: data.errorMessage });
|
||||
return;
|
||||
}
|
||||
|
||||
// ------------------- Mixed content with no files -------------------
|
||||
if (dataTransferFiles.length === 0 && !isPlainPaste && data.mixedContent) {
|
||||
await this.addElementsFromMixedContentPaste(data.mixedContent, {
|
||||
isPlainPaste,
|
||||
sceneX,
|
||||
sceneY,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ------------------- Spreadsheet -------------------
|
||||
|
||||
if (!isPlainPaste && data.text) {
|
||||
const result = tryParseSpreadsheet(data.text);
|
||||
if (result.ok) {
|
||||
this.app.setState({
|
||||
openDialog: {
|
||||
name: "charts",
|
||||
data: result.data,
|
||||
rawText: data.text,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------- Images or SVG code -------------------
|
||||
const imageFiles = dataTransferFiles.map((data) => data.file);
|
||||
|
||||
if (imageFiles.length === 0 && data.text && !isPlainPaste) {
|
||||
const trimmedText = data.text.trim();
|
||||
if (trimmedText.startsWith("<svg") && trimmedText.endsWith("</svg>")) {
|
||||
// ignore SVG validation/normalization which will be done during image
|
||||
// initialization
|
||||
imageFiles.push(SVGStringToFile(trimmedText));
|
||||
}
|
||||
}
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
if (this.app.isToolSupported("image")) {
|
||||
await this.app.insertImages(imageFiles, sceneX, sceneY);
|
||||
} else {
|
||||
this.app.setState({ errorMessage: t("errors.imageToolNotSupported") });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ------------------- Elements -------------------
|
||||
if (data.elements) {
|
||||
const elements = (
|
||||
data.programmaticAPI
|
||||
? convertToExcalidrawElements(
|
||||
data.elements as ExcalidrawElementSkeleton[],
|
||||
)
|
||||
: data.elements
|
||||
) as readonly ExcalidrawElement[];
|
||||
// TODO: remove formatting from elements if isPlainPaste
|
||||
this.app.addElementsFromPasteOrLibrary({
|
||||
elements,
|
||||
files: data.files || null,
|
||||
position:
|
||||
this.app.editorInterface.formFactor === "desktop"
|
||||
? "cursor"
|
||||
: "center",
|
||||
retainSeed: isPlainPaste,
|
||||
preserveFrameChildrenOrder: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ------------------- Only textual stuff remaining -------------------
|
||||
if (!data.text) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ------------------- Successful Mermaid -------------------
|
||||
if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) {
|
||||
const api = await import("@excalidraw/mermaid-to-excalidraw");
|
||||
try {
|
||||
const { elements: skeletonElements, files = {} } =
|
||||
await api.parseMermaidToExcalidraw(data.text);
|
||||
|
||||
const elements = convertToExcalidrawElements(skeletonElements, {
|
||||
regenerateIds: true,
|
||||
});
|
||||
|
||||
this.app.addElementsFromPasteOrLibrary({
|
||||
elements,
|
||||
files,
|
||||
position:
|
||||
this.app.editorInterface.formFactor === "desktop"
|
||||
? "cursor"
|
||||
: "center",
|
||||
});
|
||||
|
||||
return;
|
||||
} catch (err: any) {
|
||||
console.warn(
|
||||
`parsing pasted text as mermaid definition failed: ${err.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------- Pure embeddable URLs -------------------
|
||||
const nonEmptyLines = normalizeEOL(data.text)
|
||||
.split(/\n+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const embbeddableUrls = nonEmptyLines
|
||||
.map((str) => maybeParseEmbedSrc(str))
|
||||
.filter(
|
||||
(string) =>
|
||||
embeddableURLValidator(string, this.app.props.validateEmbeddable) &&
|
||||
(/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) ||
|
||||
getEmbedLink(string)?.type === "video"),
|
||||
);
|
||||
|
||||
if (
|
||||
!isPlainPaste &&
|
||||
embbeddableUrls.length > 0 &&
|
||||
embbeddableUrls.length === nonEmptyLines.length
|
||||
) {
|
||||
const embeddables: NonDeleted<ExcalidrawEmbeddableElement>[] = [];
|
||||
for (const url of embbeddableUrls) {
|
||||
const prevEmbeddable: ExcalidrawEmbeddableElement | undefined =
|
||||
embeddables[embeddables.length - 1];
|
||||
const embeddable = this.app.insertEmbeddableElement({
|
||||
sceneX: prevEmbeddable
|
||||
? prevEmbeddable.x + prevEmbeddable.width + 20
|
||||
: sceneX,
|
||||
sceneY,
|
||||
link: normalizeLink(url),
|
||||
});
|
||||
if (embeddable) {
|
||||
embeddables.push(embeddable);
|
||||
}
|
||||
}
|
||||
if (embeddables.length) {
|
||||
this.app.store.scheduleCapture();
|
||||
this.app.setState({
|
||||
selectedElementIds: Object.fromEntries(
|
||||
embeddables.map((embeddable) => [embeddable.id, true]),
|
||||
),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ------------------- Text -------------------
|
||||
this.addTextFromPaste(data.text, isPlainPaste);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ctrl/Cmd+V: whether the paste event that follows is a plain one (with
|
||||
* Shift)
|
||||
*/
|
||||
public onPasteShortcut = (event: React.KeyboardEvent | KeyboardEvent) => {
|
||||
IS_PLAIN_PASTE = event.shiftKey;
|
||||
clearTimeout(IS_PLAIN_PASTE_TIMER);
|
||||
// reset (100ms to be safe that we it runs after the ensuing
|
||||
// paste event). Though, technically unnecessary to reset since we
|
||||
// (re)set the flag before each paste event.
|
||||
IS_PLAIN_PASTE_TIMER = this.app.ownerWindow.setTimeout(() => {
|
||||
IS_PLAIN_PASTE = false;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
public clearPlainPaste = () => {
|
||||
clearTimeout(IS_PLAIN_PASTE_TIMER);
|
||||
IS_PLAIN_PASTE_TIMER = 0;
|
||||
IS_PLAIN_PASTE = false;
|
||||
};
|
||||
|
||||
public pasteFromClipboard = withBatchedUpdates(
|
||||
async (event: ClipboardEvent) => {
|
||||
if (!this.app.isInteractionEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isPlainPaste = !!IS_PLAIN_PASTE;
|
||||
|
||||
// #686
|
||||
const target = this.app.ownerDocument.activeElement;
|
||||
const isExcalidrawActive = this.dependencies
|
||||
.getContainer()
|
||||
?.contains(target);
|
||||
if (event && !isExcalidrawActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elementUnderCursor = this.app.ownerDocument.elementFromPoint(
|
||||
this.app.viewport.lastPosition.x,
|
||||
this.app.viewport.lastPosition.y,
|
||||
);
|
||||
if (
|
||||
event &&
|
||||
(!(
|
||||
elementUnderCursor instanceof this.app.ownerWindow.HTMLCanvasElement
|
||||
) ||
|
||||
isWritableElement(target))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// must be called in the same frame (thus before any awaits) as the paste
|
||||
// event else some browsers (FF...) will clear the clipboardData
|
||||
// (something something security)
|
||||
const dataTransferList = await parseDataTransferEvent(event);
|
||||
|
||||
const filesList = dataTransferList.getFiles();
|
||||
|
||||
const data = await parseClipboard(dataTransferList, isPlainPaste);
|
||||
|
||||
if (this.app.props.onPaste) {
|
||||
try {
|
||||
if ((await this.app.props.onPaste(data, event)) === false) {
|
||||
return;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
await this.insertClipboardContent(data, filesList, isPlainPaste);
|
||||
|
||||
this.app.setActiveTool(
|
||||
{ type: this.app.state.preferredSelectionTool.type },
|
||||
{ keepSelection: true },
|
||||
);
|
||||
event?.preventDefault();
|
||||
},
|
||||
);
|
||||
|
||||
// TODO rewrite this to paste both text & images at the same time if
|
||||
// pasted data contains both
|
||||
private async addElementsFromMixedContentPaste(
|
||||
mixedContent: PastedMixedContent,
|
||||
{
|
||||
isPlainPaste,
|
||||
sceneX,
|
||||
sceneY,
|
||||
}: { isPlainPaste: boolean; sceneX: number; sceneY: number },
|
||||
) {
|
||||
if (
|
||||
!isPlainPaste &&
|
||||
mixedContent.some((node) => node.type === "imageUrl") &&
|
||||
this.app.isToolSupported("image")
|
||||
) {
|
||||
const imageURLs = mixedContent
|
||||
.filter((node) => node.type === "imageUrl")
|
||||
.map((node) => node.value);
|
||||
const responses = await Promise.all(
|
||||
imageURLs.map(async (url) => {
|
||||
try {
|
||||
return { file: await ImageURLToFile(url) };
|
||||
} catch (error: any) {
|
||||
let errorMessage = error.message;
|
||||
if (error.cause === "FETCH_ERROR") {
|
||||
errorMessage = t("errors.failedToFetchImage");
|
||||
} else if (error.cause === "UNSUPPORTED") {
|
||||
errorMessage = t("errors.unsupportedFileType");
|
||||
}
|
||||
return { errorMessage };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const imageFiles = responses
|
||||
.filter((response): response is { file: File } => !!response.file)
|
||||
.map((response) => response.file);
|
||||
await this.app.insertImages(imageFiles, sceneX, sceneY);
|
||||
const error = responses.find((response) => !!response.errorMessage);
|
||||
if (error && error.errorMessage) {
|
||||
this.app.setState({ errorMessage: error.errorMessage });
|
||||
}
|
||||
} else {
|
||||
const textNodes = mixedContent.filter((node) => node.type === "text");
|
||||
if (textNodes.length) {
|
||||
this.addTextFromPaste(
|
||||
textNodes.map((node) => node.value).join("\n\n"),
|
||||
isPlainPaste,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addTextFromPaste(text: string, isPlainPaste = false) {
|
||||
const { x, y } = viewportCoordsToSceneCoords(
|
||||
{
|
||||
clientX: this.app.viewport.lastPosition.x,
|
||||
clientY: this.app.viewport.lastPosition.y,
|
||||
},
|
||||
this.app.state,
|
||||
);
|
||||
|
||||
const textElementProps = {
|
||||
x,
|
||||
y,
|
||||
strokeColor: this.app.state.currentItemStrokeColor,
|
||||
backgroundColor: this.app.state.currentItemBackgroundColor,
|
||||
fillStyle: this.app.state.currentItemFillStyle,
|
||||
strokeWidth: this.app.getCurrentItemStrokeWidth("text"),
|
||||
strokeStyle: this.app.state.currentItemStrokeStyle,
|
||||
roundness: null,
|
||||
roughness: this.app.state.currentItemRoughness,
|
||||
opacity: this.app.state.currentItemOpacity,
|
||||
text,
|
||||
fontSize: this.app.state.currentItemFontSize,
|
||||
fontFamily: this.app.state.currentItemFontFamily,
|
||||
textAlign: DEFAULT_TEXT_ALIGN,
|
||||
verticalAlign: DEFAULT_VERTICAL_ALIGN,
|
||||
locked: false,
|
||||
};
|
||||
const fontString = getFontString({
|
||||
fontSize: textElementProps.fontSize,
|
||||
fontFamily: textElementProps.fontFamily,
|
||||
});
|
||||
const lineHeight = getLineHeight(textElementProps.fontFamily);
|
||||
const LINE_GAP = 10;
|
||||
|
||||
const lines = isPlainPaste ? [text] : text.split("\n");
|
||||
const createTextElements = (maxTextWidth: number) => {
|
||||
let currentY = y;
|
||||
return lines.reduce((acc: ExcalidrawTextElement[], line, idx) => {
|
||||
const originalText = normalizeText(line).trim();
|
||||
if (originalText.length) {
|
||||
const topLayerFrame = this.app.getTopLayerFrameAtSceneCoords({
|
||||
x,
|
||||
y: currentY,
|
||||
});
|
||||
|
||||
let metrics = measureText(originalText, fontString, lineHeight);
|
||||
const isTextUnwrapped = metrics.width > maxTextWidth;
|
||||
|
||||
const text = isTextUnwrapped
|
||||
? wrapText(originalText, fontString, maxTextWidth)
|
||||
: originalText;
|
||||
|
||||
metrics = isTextUnwrapped
|
||||
? measureText(text, fontString, lineHeight)
|
||||
: metrics;
|
||||
|
||||
const startX = x - metrics.width / 2;
|
||||
const startY = currentY - metrics.height / 2;
|
||||
|
||||
const element = newTextElement({
|
||||
...textElementProps,
|
||||
x: startX,
|
||||
y: startY,
|
||||
text,
|
||||
originalText,
|
||||
lineHeight,
|
||||
autoResize: !isTextUnwrapped,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
});
|
||||
acc.push(element);
|
||||
currentY += element.height + LINE_GAP;
|
||||
} else {
|
||||
const prevLine = lines[idx - 1]?.trim();
|
||||
// add paragraph only if previous line was not empty, IOW don't add
|
||||
// more than one empty line
|
||||
if (prevLine) {
|
||||
currentY +=
|
||||
getLineHeightInPx(textElementProps.fontSize, lineHeight) +
|
||||
LINE_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
// long texts wrap as a typed one does; wrapped to fit the view, all of
|
||||
// it is brought in view, too — by moving it there (as far as the view
|
||||
// would have to scroll), not the view
|
||||
const pasteWithin = (offsets: Required<Offsets>) => {
|
||||
const textElements = createTextElements(
|
||||
this.app.text.getMaxTextWidth(offsets),
|
||||
);
|
||||
const scroll =
|
||||
textElements.length && textElements.some((el) => !el.autoResize)
|
||||
? scrollBoundsIntoView({
|
||||
bounds: getCommonBounds(textElements),
|
||||
appState: this.app.state,
|
||||
offsets,
|
||||
})
|
||||
: null;
|
||||
if (!scroll) {
|
||||
return textElements;
|
||||
}
|
||||
const dx = scroll.scrollX - this.app.state.scrollX;
|
||||
const dy = scroll.scrollY - this.app.state.scrollY;
|
||||
return textElements.map((element) => {
|
||||
const nextX = element.x + dx;
|
||||
const nextY = element.y + dy;
|
||||
// at the point it's pasted at: its center
|
||||
const topLayerFrame = this.app.getTopLayerFrameAtSceneCoords({
|
||||
x: nextX + element.width / 2,
|
||||
y: nextY + element.height / 2,
|
||||
});
|
||||
return newElementWith(element, {
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
});
|
||||
});
|
||||
};
|
||||
// laid out as if the stats panel weren't there, to see where it lands:
|
||||
// it counts only beside it
|
||||
const offsets = this.app.text.getTextViewportOffsets();
|
||||
let textElements = pasteWithin(offsets);
|
||||
if (textElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
const getSelectedElementIds = () =>
|
||||
makeNextSelectedElementIds(
|
||||
Object.fromEntries(textElements.map((el) => [el.id, true])),
|
||||
this.app.state,
|
||||
);
|
||||
|
||||
if (this.app.viewport.getSideUIRect("stats")) {
|
||||
// selected, it's in the stats panel, which grows to show its
|
||||
// properties: rendered so (not captured yet), the panels are measured
|
||||
// as they'll be — the styles panel, too, shown then, not reserved
|
||||
flushSync(() => {
|
||||
this.app.insertNewElements(textElements);
|
||||
this.app.setState({ selectedElementIds: getSelectedElementIds() });
|
||||
});
|
||||
const besideOffsets = this.app.text.getTextViewportOffsets(
|
||||
getCommonBounds(textElements),
|
||||
);
|
||||
if (
|
||||
besideOffsets.left !== offsets.left ||
|
||||
besideOffsets.right !== offsets.right
|
||||
) {
|
||||
// beside the stats panel, or the styles panel's not as reserved:
|
||||
// laid out again, as the same elements (for anyone who's seen them
|
||||
// rendered already)
|
||||
const relaid = pasteWithin(besideOffsets);
|
||||
const pastedIds = new Set(textElements.map((element) => element.id));
|
||||
textElements = textElements.map((element, index) => {
|
||||
const { x, y, width, height, text, autoResize, frameId } =
|
||||
relaid[index];
|
||||
return newElementWith(element, {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
text,
|
||||
autoResize,
|
||||
frameId,
|
||||
});
|
||||
});
|
||||
this.app.scene.replaceAllElements(
|
||||
this.app.scene
|
||||
.getElementsIncludingDeleted()
|
||||
.filter((element) => !pastedIds.has(element.id)),
|
||||
);
|
||||
this.app.insertNewElements(textElements);
|
||||
}
|
||||
} else {
|
||||
this.app.insertNewElements(textElements);
|
||||
}
|
||||
this.app.store.scheduleCapture();
|
||||
this.app.setState({ selectedElementIds: getSelectedElementIds() });
|
||||
|
||||
if (
|
||||
!isPlainPaste &&
|
||||
textElements.length > 1 &&
|
||||
PLAIN_PASTE_TOAST_SHOWN === false &&
|
||||
this.app.editorInterface.formFactor !== "phone"
|
||||
) {
|
||||
this.app.setToast({
|
||||
message: t("toast.pasteAsSingleElement", {
|
||||
shortcut: getShortcutKey("CtrlOrCmd+Shift+V"),
|
||||
}),
|
||||
duration: 5000,
|
||||
});
|
||||
PLAIN_PASTE_TOAST_SHOWN = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import { CURSOR_TYPE, MIME_TYPES, THEME } from "@excalidraw/common";
|
||||
|
||||
import { isHandToolActive, isEraserActive } from "../appState";
|
||||
|
||||
import type App from "./App";
|
||||
import { bucketFillIconSvgPaths } from "./icons";
|
||||
|
||||
import type { AppState, DataURL } from "../types";
|
||||
import type App from "./App";
|
||||
|
||||
const laserPointerCursorSVG_tag = `<svg viewBox="0 0 24 24" stroke-width="1" width="28" height="28" xmlns="http://www.w3.org/2000/svg">`;
|
||||
const laserPointerCursorBackgroundSVG = `<path d="M6.164 11.755a5.314 5.314 0 0 1-4.932-5.298 5.314 5.314 0 0 1 5.311-5.311 5.314 5.314 0 0 1 5.307 5.113l8.773 8.773a3.322 3.322 0 0 1 0 4.696l-.895.895a3.322 3.322 0 0 1-4.696 0l-8.868-8.868Z" style="fill:#fff"/>`;
|
||||
@@ -20,6 +22,19 @@ const laserPointerCursorDataURL_darkMode = `data:${
|
||||
`${laserPointerCursorSVG_tag}${laserPointerCursorBackgroundSVG}${laserPointerCursorIconSVG}</svg>`,
|
||||
)}`;
|
||||
|
||||
const createBucketFillCursorDataURL = (color: string) => {
|
||||
const paths = bucketFillIconSvgPaths
|
||||
.map(
|
||||
(path, index) =>
|
||||
`<path d="${path}" ${index < 2 ? `fill="${color}"` : ""} />`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `data:${MIME_TYPES.svg},${encodeURIComponent(
|
||||
`<svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke-linecap="round" stroke-linejoin="round"><g stroke="#fff" fill="#fff" stroke-width="5">${paths}</g><g stroke="#1b1b1f" stroke-width="1.25">${paths}</g></svg>`,
|
||||
)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Captures all cursor management for the interactive canvas.
|
||||
*
|
||||
@@ -29,16 +44,18 @@ const laserPointerCursorDataURL_darkMode = `data:${
|
||||
* cursors set here.
|
||||
*/
|
||||
export class AppCursor {
|
||||
private toolCursorMemo: {
|
||||
canvas: HTMLCanvasElement;
|
||||
type: AppState["activeTool"]["type"];
|
||||
bucketFillColor?: string;
|
||||
theme?: AppState["theme"];
|
||||
} | null = null;
|
||||
|
||||
private eraserCanvasCache:
|
||||
| (HTMLCanvasElement & { theme?: AppState["theme"] })
|
||||
| null = null;
|
||||
private eraserPreviewDataURL: DataURL | null = null;
|
||||
|
||||
private drawShapeCanvasCache:
|
||||
| (HTMLCanvasElement & { theme?: AppState["theme"] })
|
||||
| null = null;
|
||||
private drawShapePreviewDataURL: DataURL | null = null;
|
||||
|
||||
constructor(private app: App) {}
|
||||
|
||||
private get canvas() {
|
||||
@@ -46,6 +63,9 @@ export class AppCursor {
|
||||
}
|
||||
|
||||
set = (cursor: string) => {
|
||||
// Hover and interaction cursors override the resting tool cursor. The
|
||||
// next applyForTool() must restore it even if the tool itself is unchanged.
|
||||
this.toolCursorMemo = null;
|
||||
if (this.canvas) {
|
||||
this.canvas.style.cursor = cursor;
|
||||
}
|
||||
@@ -75,9 +95,35 @@ export class AppCursor {
|
||||
applyForTool = (
|
||||
activeTool: AppState["activeTool"] = this.app.state.activeTool,
|
||||
) => {
|
||||
if (!this.canvas) {
|
||||
const canvas = this.canvas;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const theme =
|
||||
activeTool.type === "eraser" ||
|
||||
activeTool.type === "laser" ||
|
||||
activeTool.type === "bucketfill"
|
||||
? this.app.state.theme
|
||||
: undefined;
|
||||
|
||||
const bucketFillColor =
|
||||
activeTool.type === "bucketfill"
|
||||
? this.app.bucketFill.getBucketFillBackgroundColor(
|
||||
this.app.state.currentItemBackgroundColor,
|
||||
theme,
|
||||
)
|
||||
: undefined;
|
||||
const memo = this.toolCursorMemo;
|
||||
if (
|
||||
memo?.canvas === canvas &&
|
||||
memo.type === activeTool.type &&
|
||||
memo.bucketFillColor === bucketFillColor &&
|
||||
memo.theme === theme
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTool.type === "selection") {
|
||||
this.clear();
|
||||
} else if (isHandToolActive({ activeTool })) {
|
||||
@@ -86,6 +132,11 @@ export class AppCursor {
|
||||
this.applyEraser();
|
||||
} else if (activeTool.type === "autoshape") {
|
||||
this.set(CURSOR_TYPE.CROSSHAIR);
|
||||
} else if (activeTool.type === "bucketfill") {
|
||||
// The hotspot is the center of the paint droplet in the icon.
|
||||
this.set(
|
||||
`url(${createBucketFillCursorDataURL(bucketFillColor!)}) 5 18, auto`,
|
||||
);
|
||||
} else if (activeTool.type === "laser") {
|
||||
const url =
|
||||
this.app.state.theme === THEME.LIGHT
|
||||
@@ -100,13 +151,53 @@ export class AppCursor {
|
||||
} else if (activeTool.type !== "image") {
|
||||
this.set(CURSOR_TYPE.AUTO);
|
||||
}
|
||||
|
||||
this.toolCursorMemo = {
|
||||
canvas,
|
||||
type: activeTool.type,
|
||||
bucketFillColor,
|
||||
theme,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-runs the hover cursor logic at the last known pointer position by
|
||||
* re-dispatching a synthetic pointermove — for state changes that alter
|
||||
* what hovering means without the pointer moving (e.g. the tool reverting
|
||||
* to selection after an element is created). Mouse only: touch and pen
|
||||
* have no resting hover to restore.
|
||||
*/
|
||||
refreshHover = () => {
|
||||
const lastEvent =
|
||||
this.app.lastPointerMoveEvent ?? this.app.lastPointerUpEvent;
|
||||
|
||||
if (!lastEvent || !this.canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerType = (lastEvent as PointerEvent).pointerType;
|
||||
if (pointerType && pointerType !== "mouse") {
|
||||
return;
|
||||
}
|
||||
|
||||
// jsdom has no PointerEvent; a MouseEvent of type "pointermove" walks
|
||||
// the identical dispatch path
|
||||
const EventConstructor =
|
||||
typeof PointerEvent !== "undefined" ? PointerEvent : MouseEvent;
|
||||
|
||||
this.canvas.dispatchEvent(
|
||||
new EventConstructor("pointermove", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: lastEvent.clientX,
|
||||
clientY: lastEvent.clientY,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/** clears the inline cursor so the environment default (CSS) applies */
|
||||
private clear = () => {
|
||||
if (this.canvas) {
|
||||
this.canvas.style.cursor = "";
|
||||
}
|
||||
this.set("");
|
||||
};
|
||||
|
||||
private applyEraser = () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
isBindingElement,
|
||||
isLineElement,
|
||||
LinearElementEditor,
|
||||
maxBindingDistance_simple,
|
||||
newArrowElement,
|
||||
updateBoundPoint,
|
||||
} from "@excalidraw/element";
|
||||
@@ -92,7 +91,6 @@ export class AppDrawShape {
|
||||
const { app } = this;
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const elements = app.scene.getNonDeletedElements();
|
||||
const bindingDistance = maxBindingDistance_simple(app.state.zoom);
|
||||
|
||||
const endpoints = (["start", "end"] as const).map((startOrEnd) => {
|
||||
const globalPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
@@ -107,7 +105,7 @@ export class AppDrawShape {
|
||||
globalPoint,
|
||||
elements,
|
||||
elementsMap,
|
||||
bindingDistance,
|
||||
app.state.zoom,
|
||||
),
|
||||
};
|
||||
});
|
||||
@@ -125,6 +123,7 @@ export class AppDrawShape {
|
||||
sameTarget ? "inside" : "orbit",
|
||||
startOrEnd,
|
||||
app.scene,
|
||||
app.state.zoom,
|
||||
globalPoint,
|
||||
app.state.isBindingEnabled,
|
||||
isMidpointSnappingEnabled,
|
||||
@@ -175,7 +174,6 @@ export class AppDrawShape {
|
||||
const { app } = this;
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const elements = app.scene.getNonDeletedElements();
|
||||
const bindingDistance = maxBindingDistance_simple(app.state.zoom);
|
||||
|
||||
const [startTarget, endTarget] = ([0, -1] as const).map((index) =>
|
||||
getHoveredElementForBinding(
|
||||
@@ -186,7 +184,7 @@ export class AppDrawShape {
|
||||
),
|
||||
elements,
|
||||
elementsMap,
|
||||
bindingDistance,
|
||||
app.state.zoom,
|
||||
),
|
||||
);
|
||||
if ((!startTarget && !endTarget) || startTarget === endTarget) {
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import { flushSync } from "react-dom";
|
||||
|
||||
import {
|
||||
arrayToMap,
|
||||
distance,
|
||||
getGridPoint,
|
||||
randomInteger,
|
||||
viewportCoordsToSceneCoords,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
addElementsToFrame,
|
||||
deepCopyElement,
|
||||
duplicateElements,
|
||||
filterElementsEligibleAsFrameChildren,
|
||||
getCommonBounds,
|
||||
getSelectionStateForElements,
|
||||
isBindableElement,
|
||||
newElementWith,
|
||||
reconcileDuplicatedElements,
|
||||
syncMovedIndices,
|
||||
updateBoundElements,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { PointerDownState } from "../types";
|
||||
|
||||
import type App from "./App";
|
||||
|
||||
type Duplication = Pick<
|
||||
ReturnType<typeof duplicateElements>,
|
||||
| "duplicatedElements"
|
||||
| "duplicateElementsMap"
|
||||
| "origElementsMap"
|
||||
| "origIdToDuplicateId"
|
||||
| "duplicateIdToOrigId"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Owns the duplication paths which involve the host (`props.onDuplicate`):
|
||||
* duplicating elements into the scene (paste, library insert etc.), and
|
||||
* duplicating the dragged selection (alt-drag). The duplicate action shares
|
||||
* `runOnDuplicate()`.
|
||||
*/
|
||||
export class AppDuplicate {
|
||||
constructor(private app: App) {}
|
||||
|
||||
/**
|
||||
* Hands the duplication over to the host's `props.onDuplicate` (if any),
|
||||
* and reconciles what it returned with the duplicates.
|
||||
*
|
||||
* @returns next elements, and the duplicates that weren't vetoed
|
||||
*/
|
||||
runOnDuplicate = (
|
||||
duplication: Duplication,
|
||||
nextElements: ExcalidrawElement[],
|
||||
/** excludes the duplicated elements */
|
||||
prevElements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
return reconcileDuplicatedElements(
|
||||
this.app.props.onDuplicate?.(nextElements, prevElements, {
|
||||
duplicateElements: duplication.duplicateElementsMap,
|
||||
originalElements: duplication.origElementsMap,
|
||||
origIdToDuplicateId: duplication.origIdToDuplicateId,
|
||||
duplicateIdToOrigId: duplication.duplicateIdToOrigId,
|
||||
}),
|
||||
nextElements,
|
||||
duplication.duplicatedElements,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Duplicates elements so that they end up centered at the scene coords
|
||||
* (and in the frame at that point, if any). Doesn't update the scene.
|
||||
*
|
||||
* @returns `null` if the host vetoed the duplication
|
||||
*/
|
||||
duplicateAtSceneCoords = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
{ x, y }: { x: number; y: number },
|
||||
opts?: {
|
||||
retainSeed?: boolean;
|
||||
preserveFrameChildrenOrder?: boolean;
|
||||
},
|
||||
) => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
|
||||
const elementsCenterX = distance(minX, maxX) / 2;
|
||||
const elementsCenterY = distance(minY, maxY) / 2;
|
||||
|
||||
const dx = x - elementsCenterX;
|
||||
const dy = y - elementsCenterY;
|
||||
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
dx,
|
||||
dy,
|
||||
this.app.getEffectiveGridSize(),
|
||||
);
|
||||
|
||||
const duplication = duplicateElements({
|
||||
type: "everything",
|
||||
elements: elements.map((element) => {
|
||||
return newElementWith(element, {
|
||||
x: element.x + gridX - minX,
|
||||
y: element.y + gridY - minY,
|
||||
});
|
||||
}),
|
||||
randomizeSeed: !opts?.retainSeed,
|
||||
preserveFrameChildrenOrder: opts?.preserveFrameChildrenOrder,
|
||||
});
|
||||
let { duplicatedElements } = duplication;
|
||||
|
||||
const prevElements = this.app.scene.getElementsIncludingDeleted();
|
||||
let nextElements: ExcalidrawElement[] = [
|
||||
...prevElements,
|
||||
...duplicatedElements,
|
||||
];
|
||||
|
||||
syncMovedIndices(nextElements, arrayToMap(duplicatedElements));
|
||||
|
||||
// resolved ahead of `onDuplicate` so that the host sees the duplicates
|
||||
// the way they end up in the scene
|
||||
const topLayerFrame = this.app.getTopLayerFrameAtSceneCoords({ x, y });
|
||||
|
||||
if (topLayerFrame) {
|
||||
const eligibleElements = filterElementsEligibleAsFrameChildren(
|
||||
duplicatedElements,
|
||||
topLayerFrame,
|
||||
);
|
||||
nextElements = addElementsToFrame(
|
||||
nextElements,
|
||||
eligibleElements,
|
||||
topLayerFrame,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.app.props.onDuplicate) {
|
||||
({ elements: nextElements, duplicatedElements } = this.runOnDuplicate(
|
||||
duplication,
|
||||
nextElements,
|
||||
prevElements,
|
||||
));
|
||||
|
||||
// host vetoed the duplication
|
||||
if (!duplicatedElements.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// host may have reordered the elements
|
||||
syncMovedIndices(nextElements, arrayToMap(duplicatedElements));
|
||||
}
|
||||
|
||||
return { nextElements, duplicatedElements };
|
||||
};
|
||||
|
||||
/**
|
||||
* Duplicates the selection being dragged: the originals go back to where
|
||||
* the drag started, and the drag continues with the duplicates.
|
||||
*
|
||||
* If the host vetoes the duplication, the originals keep being dragged.
|
||||
*/
|
||||
duplicateDraggedSelection = (
|
||||
pointerDownState: PointerDownState,
|
||||
event: PointerEvent,
|
||||
) => {
|
||||
// Move the currently selected elements to the top of the z index stack, and
|
||||
// put the duplicates where the selected elements used to be.
|
||||
// (the origin point where the dragging started)
|
||||
|
||||
pointerDownState.hit.hasBeenDuplicated = true;
|
||||
|
||||
const elements = this.app.scene.getElementsIncludingDeleted();
|
||||
const hitElement = pointerDownState.hit.element;
|
||||
const selectedElements = this.app.scene.getSelectedElements({
|
||||
selectedElementIds: this.app.state.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
if (
|
||||
hitElement &&
|
||||
// hit element may not end up being selected
|
||||
// if we're alt-dragging a common bounding box
|
||||
// over the hit element
|
||||
pointerDownState.hit.wasAddedToSelection &&
|
||||
!selectedElements.find((el) => el.id === hitElement.id)
|
||||
) {
|
||||
selectedElements.push(hitElement);
|
||||
}
|
||||
|
||||
const idsOfElementsToDuplicate = new Map(
|
||||
selectedElements.map((el) => [el.id, el]),
|
||||
);
|
||||
|
||||
const duplication = duplicateElements({
|
||||
type: "in-place",
|
||||
elements,
|
||||
appState: this.app.state,
|
||||
randomizeSeed: true,
|
||||
idsOfElementsToDuplicate,
|
||||
overrides: ({ duplicateElement, origElement }) => {
|
||||
return {
|
||||
// reset to the original element's frameId (unless we've
|
||||
// duplicated alongside a frame in which case we need to
|
||||
// keep the duplicate frame's id) so that the element
|
||||
// frame membership is refreshed on pointerup
|
||||
// NOTE this is a hacky solution and should be done
|
||||
// differently
|
||||
frameId: duplicateElement.frameId ?? origElement.frameId,
|
||||
seed: randomInteger(),
|
||||
};
|
||||
},
|
||||
});
|
||||
const { origElementsMap, origIdToDuplicateId } = duplication;
|
||||
|
||||
const mappedClonedElements = duplication.elementsWithDuplicates.map(
|
||||
(el) => {
|
||||
if (idsOfElementsToDuplicate.has(el.id)) {
|
||||
const origEl = pointerDownState.originalElements.get(el.id);
|
||||
|
||||
if (origEl) {
|
||||
const resetElement = newElementWith(el, {
|
||||
x: origEl.x,
|
||||
y: origEl.y,
|
||||
});
|
||||
// so that the host gets the originals as they are in the
|
||||
// next elements
|
||||
if (origElementsMap.has(el.id)) {
|
||||
origElementsMap.set(el.id, resetElement);
|
||||
}
|
||||
return resetElement;
|
||||
}
|
||||
}
|
||||
return el;
|
||||
},
|
||||
);
|
||||
|
||||
const { elements: nextElements, duplicatedElements } = this.runOnDuplicate(
|
||||
duplication,
|
||||
mappedClonedElements,
|
||||
elements,
|
||||
);
|
||||
|
||||
// host vetoed the duplication, so we keep dragging the originals
|
||||
if (!duplicatedElements.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// (originals whose duplicates were vetoed are left behind)
|
||||
const duplicateElementsMap = arrayToMap(duplicatedElements);
|
||||
|
||||
duplicatedElements.forEach((element) => {
|
||||
pointerDownState.originalElements.set(
|
||||
element.id,
|
||||
deepCopyElement(element),
|
||||
);
|
||||
});
|
||||
|
||||
const elementsWithIndices = syncMovedIndices(
|
||||
nextElements,
|
||||
duplicateElementsMap,
|
||||
);
|
||||
|
||||
// we need to update synchronously so as to keep pointerDownState,
|
||||
// appState, and scene elements in sync
|
||||
flushSync(() => {
|
||||
// swap hit element with the duplicated one
|
||||
if (pointerDownState.hit.element) {
|
||||
const cloneId = origIdToDuplicateId.get(
|
||||
pointerDownState.hit.element.id,
|
||||
);
|
||||
const clonedElement = cloneId && duplicateElementsMap.get(cloneId);
|
||||
pointerDownState.hit.element = clonedElement || null;
|
||||
}
|
||||
// swap hit elements with the duplicated ones
|
||||
pointerDownState.hit.allHitElements =
|
||||
pointerDownState.hit.allHitElements.reduce(
|
||||
(acc: typeof pointerDownState.hit.allHitElements, origHitElement) => {
|
||||
const cloneId = origIdToDuplicateId.get(origHitElement.id);
|
||||
const clonedElement = cloneId && duplicateElementsMap.get(cloneId);
|
||||
if (clonedElement) {
|
||||
acc.push(clonedElement);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// update drag origin to the position at which we started
|
||||
// the duplication so that the drag offset is correct
|
||||
pointerDownState.drag.origin = viewportCoordsToSceneCoords(
|
||||
event,
|
||||
this.app.state,
|
||||
);
|
||||
|
||||
// switch selected elements to the duplicated ones
|
||||
this.app.setState((prevState) => ({
|
||||
...getSelectionStateForElements(
|
||||
duplicatedElements,
|
||||
this.app.scene.getNonDeletedElements(),
|
||||
prevState,
|
||||
),
|
||||
}));
|
||||
|
||||
this.app.scene.replaceAllElements(elementsWithIndices);
|
||||
selectedElements.forEach((element) => {
|
||||
if (
|
||||
isBindableElement(element) &&
|
||||
element.boundElements?.some((other) => other.type === "arrow")
|
||||
) {
|
||||
updateBoundElements(element, this.app.scene);
|
||||
}
|
||||
});
|
||||
|
||||
this.app.maybeCacheVisibleGaps(event, selectedElements, true);
|
||||
this.app.maybeCacheReferenceSnapPoints(event, selectedElements, true);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { CURSOR_TYPE, EVENT, POINTER_BUTTON } from "@excalidraw/common";
|
||||
|
||||
import { isHandToolActive } from "../appState";
|
||||
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
||||
|
||||
import type React from "react";
|
||||
|
||||
import type App from "./App";
|
||||
|
||||
const SECONDARY_BUTTON_PAN_THRESHOLD = 5; // px
|
||||
|
||||
/**
|
||||
* The drag-pan: a pointer drag that moves the canvas — the wheel or the
|
||||
* secondary button, the main button while space is held or the hand tool
|
||||
* is active, or any button in view mode. One session at a time, owning its
|
||||
* window listeners and teardown; wheel input is gated on it (see
|
||||
* `AppWheel`).
|
||||
*
|
||||
* A secondary-button session is a pan only once the pointer travels past
|
||||
* the drag threshold; released before that, it is a right-click. The
|
||||
* platform's `contextmenu` event opens the menu for a right-click where it
|
||||
* follows the release (Windows), as it always has; where it comes with the
|
||||
* press (macOS, Linux) it is swallowed — a drag cannot be told from a
|
||||
* click yet — and the session opens the menu on release instead.
|
||||
*/
|
||||
export class AppPan {
|
||||
/** space held down turns a main-button drag into a pan */
|
||||
private spaceHeld = false;
|
||||
private active = false;
|
||||
/** the secondary-button session, while one is active */
|
||||
private secondary: { engaged: boolean; nativeMenuSeen: boolean } | null =
|
||||
null;
|
||||
/** the platform fires `contextmenu` on mouseup (Windows) or on mousedown
|
||||
* (macOS, Linux); the one belonging to a secondary-button session is not a
|
||||
* new click, whichever side of the session it lands on */
|
||||
private suppressNextContextMenu = false;
|
||||
/** applies the pointer move the session is holding back for its next
|
||||
* frame, if any */
|
||||
private pendingMoveFlush: (() => void) | null = null;
|
||||
private teardown: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private app: App,
|
||||
private dependencies: {
|
||||
/** pointers currently down (a two-finger gesture is not a pan) */
|
||||
getPointerCount: () => number;
|
||||
/** a scrollbar being dragged (App's own drag, not a pan session) */
|
||||
isDraggingScrollBar: () => boolean;
|
||||
},
|
||||
) {}
|
||||
|
||||
isActive = () => this.active;
|
||||
|
||||
isSpaceHeld = () => this.spaceHeld;
|
||||
|
||||
setSpaceHeld = (held: boolean) => {
|
||||
this.spaceHeld = held;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the pointer is navigating the viewport rather than acting on the
|
||||
* scene — space held to pan, a pan in progress, a scrollbar drag, or the
|
||||
* hand tool. Hover and cursor then belong to the navigation.
|
||||
*/
|
||||
isNavigating = () =>
|
||||
this.spaceHeld ||
|
||||
this.active ||
|
||||
this.dependencies.isDraggingScrollBar() ||
|
||||
isHandToolActive(this.app.state);
|
||||
|
||||
/** applies the pointer move still waiting for its frame, if any */
|
||||
flushMove = () => {
|
||||
this.pendingMoveFlush?.();
|
||||
};
|
||||
|
||||
/** ends the active session, if any — pointerup may never arrive (the user
|
||||
* tabs away, a new pointerdown lands first) */
|
||||
end = () => {
|
||||
this.teardown?.();
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a `contextmenu` event belongs to a secondary-button session and
|
||||
* must not open the menu: it came with the press (the session decides on
|
||||
* release), or it follows a release that turned out to be a drag.
|
||||
*/
|
||||
consumesContextMenuEvent = () => {
|
||||
if (this.secondary) {
|
||||
this.secondary.nativeMenuSeen = true;
|
||||
return true;
|
||||
}
|
||||
if (this.suppressNextContextMenu) {
|
||||
this.suppressNextContextMenu = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/** starts a session for the pointerdown if it qualifies; returns whether
|
||||
* it did */
|
||||
start = (event: React.PointerEvent<HTMLElement> | MouseEvent): boolean => {
|
||||
const { app } = this;
|
||||
// a new press supersedes whatever the previous session left pending
|
||||
this.suppressNextContextMenu = false;
|
||||
const isSecondary = event.button === POINTER_BUTTON.SECONDARY;
|
||||
if (
|
||||
!(
|
||||
this.dependencies.getPointerCount() <= 1 &&
|
||||
(((event.button === POINTER_BUTTON.WHEEL ||
|
||||
isSecondary ||
|
||||
(event.button === POINTER_BUTTON.MAIN && this.spaceHeld) ||
|
||||
isHandToolActive(app.state)) &&
|
||||
// reachable while non-interactive when the active tool is allowed
|
||||
// via `interaction.enabled.tools` — panning must remain gated on
|
||||
// `navigation` then
|
||||
(app.isInteractionEnabled() || app.isNavigationEnabled())) ||
|
||||
(app.state.viewModeEnabled && !app.isActiveToolPointerCapturing()))
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.active = true;
|
||||
this.secondary = isSecondary
|
||||
? { engaged: false, nativeMenuSeen: false }
|
||||
: null;
|
||||
|
||||
// due to event.preventDefault below, container wouldn't get focus
|
||||
// automatically
|
||||
app.focusContainer();
|
||||
|
||||
// preventing defualt while text editing messes with cursor/focus
|
||||
if (!app.state.editingTextElement) {
|
||||
// necessary to prevent browser from scrolling the page if excalidraw
|
||||
// not full-page #4489
|
||||
//
|
||||
// as such, the above is broken when panning canvas while in wysiwyg
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
let nextPastePrevented = false;
|
||||
const isLinux = /Linux/.test(app.ownerWindow.navigator.platform);
|
||||
|
||||
if (!this.secondary) {
|
||||
app.cursor.set(CURSOR_TYPE.GRABBING);
|
||||
}
|
||||
const { clientX: startX, clientY: startY } = event;
|
||||
let { clientX: lastX, clientY: lastY } = event;
|
||||
const onPointerMove = withBatchedUpdatesThrottled((event: PointerEvent) => {
|
||||
if (this.secondary && !this.secondary.engaged) {
|
||||
// a right-click until the pointer travels far enough for a drag
|
||||
if (
|
||||
Math.hypot(event.clientX - startX, event.clientY - startY) <=
|
||||
SECONDARY_BUTTON_PAN_THRESHOLD
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.secondary.engaged = true;
|
||||
app.cursor.set(CURSOR_TYPE.GRABBING);
|
||||
// pans from here on; the threshold distance is not caught up
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = lastX - event.clientX;
|
||||
const deltaY = lastY - event.clientY;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
|
||||
/*
|
||||
* Prevent paste event if we move while middle clicking on Linux.
|
||||
* See issue #1383.
|
||||
*/
|
||||
if (
|
||||
isLinux &&
|
||||
!nextPastePrevented &&
|
||||
(Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1)
|
||||
) {
|
||||
nextPastePrevented = true;
|
||||
|
||||
/* Prevent the next paste event */
|
||||
const preventNextPaste = (event: ClipboardEvent) => {
|
||||
app.ownerDocument.body.removeEventListener(
|
||||
EVENT.PASTE,
|
||||
preventNextPaste,
|
||||
);
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
/*
|
||||
* Reenable next paste in case of disabled middle click paste for
|
||||
* any reason:
|
||||
* - right click paste
|
||||
* - empty clipboard
|
||||
*/
|
||||
const enableNextPaste = () => {
|
||||
setTimeout(() => {
|
||||
app.ownerDocument.body.removeEventListener(
|
||||
EVENT.PASTE,
|
||||
preventNextPaste,
|
||||
);
|
||||
app.ownerWindow.removeEventListener(
|
||||
EVENT.POINTER_UP,
|
||||
enableNextPaste,
|
||||
);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
app.ownerDocument.body.addEventListener(EVENT.PASTE, preventNextPaste);
|
||||
app.ownerWindow.addEventListener(EVENT.POINTER_UP, enableNextPaste);
|
||||
}
|
||||
|
||||
// an updater, not a snapshot of `app.state`: a wheel zoom queued in
|
||||
// the same React flush would otherwise be overwritten by a pan
|
||||
// computed from the pre-zoom state
|
||||
app.viewport.translate((state) => ({
|
||||
scrollX: state.scrollX - deltaX / state.zoom.value,
|
||||
scrollY: state.scrollY - deltaY / state.zoom.value,
|
||||
}));
|
||||
});
|
||||
this.pendingMoveFlush = onPointerMove.flush;
|
||||
const teardown = withBatchedUpdates(
|
||||
(upEvent?: PointerEvent | FocusEvent) => {
|
||||
const { secondary } = this;
|
||||
this.teardown = null;
|
||||
this.pendingMoveFlush = null;
|
||||
this.active = false;
|
||||
this.secondary = null;
|
||||
if (secondary?.engaged && !secondary.nativeMenuSeen) {
|
||||
// a drag: the platform's `contextmenu` still to come is no click
|
||||
this.suppressNextContextMenu = true;
|
||||
}
|
||||
if (!this.spaceHeld) {
|
||||
app.cursor.reset();
|
||||
}
|
||||
app.setState(
|
||||
{
|
||||
cursorButton: "up",
|
||||
},
|
||||
// Wait for the trailing pan move to commit before converting the
|
||||
// pointer to scene coordinates or starting the snap-back.
|
||||
() => {
|
||||
// Missing-pointerup cleanup can run during a new press. Don't
|
||||
// overwrite the newer button state with this pan's release.
|
||||
if (app.state.cursorButton === "up") {
|
||||
const pointer =
|
||||
upEvent && "clientX" in upEvent
|
||||
? upEvent
|
||||
: { clientX: lastX, clientY: lastY };
|
||||
app.savePointer(pointer.clientX, pointer.clientY, "up");
|
||||
}
|
||||
app.viewport.releaseOverscroll();
|
||||
},
|
||||
);
|
||||
app.ownerWindow.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
||||
app.ownerWindow.removeEventListener(EVENT.POINTER_UP, teardown);
|
||||
app.ownerWindow.removeEventListener(EVENT.BLUR, teardown);
|
||||
onPointerMove.flush();
|
||||
|
||||
// released without a drag: a right-click. Where the platform's
|
||||
// `contextmenu` came with the press it was swallowed, so the menu
|
||||
// opens here, at the release point; where it is still to come it
|
||||
// opens the menu itself, as it always has
|
||||
if (
|
||||
secondary &&
|
||||
!secondary.engaged &&
|
||||
secondary.nativeMenuSeen &&
|
||||
upEvent &&
|
||||
"clientX" in upEvent
|
||||
) {
|
||||
app.openContextMenu({
|
||||
clientX: upEvent.clientX,
|
||||
clientY: upEvent.clientY,
|
||||
button: upEvent.button,
|
||||
pointerType: upEvent.pointerType,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
this.teardown = teardown;
|
||||
app.ownerWindow.addEventListener(EVENT.BLUR, teardown);
|
||||
app.ownerWindow.addEventListener(EVENT.POINTER_MOVE, onPointerMove, {
|
||||
passive: true,
|
||||
});
|
||||
app.ownerWindow.addEventListener(EVENT.POINTER_UP, teardown);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
import { flushSync } from "react-dom";
|
||||
|
||||
import {
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
TEXT_MAX_WRAP_WIDTH,
|
||||
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
||||
TEXT_VIEWPORT_PADDING,
|
||||
VERTICAL_ALIGN,
|
||||
getFontString,
|
||||
getLineHeight,
|
||||
sceneCoordsToViewportCoords,
|
||||
type Bounds,
|
||||
type StylesPanelMode,
|
||||
} from "@excalidraw/common";
|
||||
import { pointFrom, type Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
DEFAULT_BOUND_TEXT_LABEL_POSITION,
|
||||
fixBindingsAfterDeletion,
|
||||
getActiveTextElement,
|
||||
getApproxMinLineHeight,
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getContainerCenter,
|
||||
getContainerElement,
|
||||
getElementAbsoluteCoords,
|
||||
getElementBounds,
|
||||
getLineHeightInPx,
|
||||
getStickyNoteLayout,
|
||||
hitElementItself,
|
||||
isArrowElement,
|
||||
isFrameLikeElement,
|
||||
isNonDeletedElement,
|
||||
isStickyNoteElement,
|
||||
isTextBindableContainer,
|
||||
isTextElement,
|
||||
isValidTextContainer,
|
||||
makeNextSelectedElementIds,
|
||||
newElementWith,
|
||||
newTextElement,
|
||||
refreshTextDimensions,
|
||||
updateBoundElements,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { ArrowEndpoint } from "@excalidraw/element";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { actionTextAutoResize } from "../actions/actionTextAutoResize";
|
||||
import { withBatchedUpdates } from "../reactUtils";
|
||||
import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle";
|
||||
import { scrollBoundsIntoView } from "../viewport";
|
||||
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
|
||||
|
||||
import type App from "./App";
|
||||
import type { AppState, Offsets, ViewportUIName } from "../types";
|
||||
|
||||
type AppTextDependencies = {
|
||||
getContainer: () => HTMLDivElement | null;
|
||||
getStylesPanelMode: () => StylesPanelMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Texts, but for the text tool's pointer interaction (`AppTextTool`):
|
||||
*
|
||||
* - the editing session: what text to edit, or create, and where
|
||||
* (`startTextEditing`), and the editor's lifecycle (`handleTextWysiwyg`)
|
||||
* - the part of the canvas a typed or pasted text is kept within
|
||||
* (`getTextViewportOffsets`, `getMaxTextWidth`)
|
||||
* - the queries that find the text or container to edit at a position
|
||||
* - the handle that turns a fixed-width text back into a growing one
|
||||
*
|
||||
* Every way into editing ends up here: Enter, double-click, pointerup on a
|
||||
* selected text or container, the text tool, a drag-created text or sticky
|
||||
* note, and a sticky note dropped from the toolbar.
|
||||
*/
|
||||
export class AppText {
|
||||
constructor(private app: App, private dependencies: AppTextDependencies) {}
|
||||
|
||||
public textWysiwygSubmitHandler: ReturnType<typeof textWysiwyg> | null = null;
|
||||
private getTextCreationGridPoint = (x: number, y: number) => {
|
||||
const effectiveGridSize = this.app.getEffectiveGridSize();
|
||||
|
||||
if (effectiveGridSize === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getTextCreationGridCoordinate = (coordinate: number) => {
|
||||
const topLeftGridPoint =
|
||||
Math.floor(coordinate / effectiveGridSize) * effectiveGridSize;
|
||||
|
||||
return topLeftGridPoint;
|
||||
};
|
||||
|
||||
return {
|
||||
x: getTextCreationGridCoordinate(x),
|
||||
y: getTextCreationGridCoordinate(y),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The side panels, besides the sidebar, that a typed or pasted text is
|
||||
* kept clear of: the stats panel, and the full styles panel (desktop),
|
||||
* which shows next to a text while it's edited, and once it's pasted and
|
||||
* selected.
|
||||
*/
|
||||
public getTextSidePanels = (): ViewportUIName[] =>
|
||||
this.dependencies.getStylesPanelMode() === "full" &&
|
||||
this.app.isDefaultUIEnabled() &&
|
||||
!this.app.state.zenModeEnabled
|
||||
? ["stats", "stylesPanel"]
|
||||
: ["stats"];
|
||||
|
||||
/**
|
||||
* The part of the canvas a typed or pasted text is kept within, as offsets
|
||||
* from its edges (screen px): all of it but the sidebar and the side
|
||||
* panels (see getTextSidePanels), less some room at each side. The stats
|
||||
* panel covers the top of a side only, and counts only for a text beside
|
||||
* it: one (given by its scene bounds) with rows within that room of it.
|
||||
*/
|
||||
public getTextViewportOffsets = (textBounds?: Bounds): Required<Offsets> => {
|
||||
const padding = TEXT_VIEWPORT_PADDING;
|
||||
const stats = textBounds && this.app.viewport.getSideUIRect("stats");
|
||||
const { scrollY, zoom } = this.app.state;
|
||||
const isBesideStats =
|
||||
!!stats &&
|
||||
!!textBounds &&
|
||||
(textBounds[1] + scrollY) * zoom.value < stats.bottom + padding &&
|
||||
(textBounds[3] + scrollY) * zoom.value > stats.top - padding;
|
||||
const { left, right } = this.app.viewport.getSideInsets(
|
||||
[
|
||||
"sidebar",
|
||||
...this.getTextSidePanels().filter(
|
||||
(name) => name !== "stats" || isBesideStats,
|
||||
),
|
||||
],
|
||||
// the styles panel isn't shown yet when pasting with nothing selected
|
||||
{ reserve: { stylesPanel: true } },
|
||||
);
|
||||
return {
|
||||
top: padding,
|
||||
right: right + padding,
|
||||
bottom: padding,
|
||||
left: left + padding,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The widest a text may grow to as it's typed or pasted, in scene units:
|
||||
* TEXT_MAX_WRAP_WIDTH, or less, so that a text never outgrows the part of
|
||||
* the canvas it's kept within.
|
||||
*/
|
||||
public getMaxTextWidth = (offsets: Required<Offsets>) => {
|
||||
const { left, right } = offsets;
|
||||
const viewWidth = this.app.state.width - left - right;
|
||||
return Math.min(
|
||||
viewWidth > 0 ? viewWidth / this.app.state.zoom.value : Infinity,
|
||||
TEXT_MAX_WRAP_WIDTH,
|
||||
);
|
||||
};
|
||||
|
||||
public handleTextWysiwyg(
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
{
|
||||
isExistingElement = false,
|
||||
initialCaretSceneCoords = null,
|
||||
}: {
|
||||
isExistingElement?: boolean;
|
||||
/**
|
||||
* supply null if no caret positioning is desired, and instead
|
||||
* text should be auto-selected
|
||||
*/
|
||||
initialCaretSceneCoords?: { x: number; y: number } | null;
|
||||
},
|
||||
) {
|
||||
const elementsMap = this.app.scene.getElementsMapIncludingDeleted();
|
||||
|
||||
const updateElement = (nextOriginalText: string, isDeleted: boolean) => {
|
||||
const latestTextElement =
|
||||
this.app.scene.getElement<ExcalidrawTextElement>(element.id);
|
||||
|
||||
if (!latestTextElement || !isTextElement(latestTextElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = getContainerElement(latestTextElement, elementsMap);
|
||||
const stickyContainer =
|
||||
container && isStickyNoteElement(container) ? container : null;
|
||||
// sticky notes: the fit owns both the label and the note geometry
|
||||
const stickyLayout = stickyContainer
|
||||
? getStickyNoteLayout(stickyContainer, latestTextElement, {
|
||||
originalText: nextOriginalText,
|
||||
})
|
||||
: null;
|
||||
// a free text stops growing at the view's width (between the side
|
||||
// panels beside its rows), or TEXT_MAX_WRAP_WIDTH, and wraps from there
|
||||
const textViewportOffsets = this.getTextViewportOffsets(
|
||||
getElementBounds(latestTextElement, elementsMap),
|
||||
);
|
||||
const maxWidth = this.getMaxTextWidth(textViewportOffsets);
|
||||
|
||||
this.app.scene.replaceAllElements([
|
||||
// Not sure why we include deleted elements as well hence using deleted elements map
|
||||
...this.app.scene.getElementsIncludingDeleted().map((_element) => {
|
||||
if (
|
||||
stickyLayout &&
|
||||
_element.id === stickyContainer?.id &&
|
||||
isStickyNoteElement(_element)
|
||||
) {
|
||||
return newElementWith(_element, stickyLayout.container);
|
||||
}
|
||||
if (_element.id === latestTextElement.id && isTextElement(_element)) {
|
||||
return newElementWith(_element, {
|
||||
originalText: nextOriginalText,
|
||||
isDeleted: isDeleted ?? _element.isDeleted,
|
||||
...(stickyLayout?.text ??
|
||||
// returns (wrapped) text and new dimensions
|
||||
refreshTextDimensions(
|
||||
_element,
|
||||
getContainerElement(_element, elementsMap),
|
||||
elementsMap,
|
||||
nextOriginalText,
|
||||
maxWidth,
|
||||
)),
|
||||
});
|
||||
}
|
||||
return _element;
|
||||
}),
|
||||
]);
|
||||
|
||||
const updatedTextElement = this.app.scene.getNonDeletedElement(
|
||||
latestTextElement.id,
|
||||
);
|
||||
if (
|
||||
latestTextElement.autoResize &&
|
||||
updatedTextElement &&
|
||||
isTextElement(updatedTextElement) &&
|
||||
!updatedTextElement.autoResize
|
||||
) {
|
||||
// it just started wrapping: bring all of it into view, between the
|
||||
// side panels (with the same room at the edges as the width left)
|
||||
// — vertically only if it fits; the caret follows the rest
|
||||
const scroll = scrollBoundsIntoView({
|
||||
bounds: getElementBounds(
|
||||
updatedTextElement,
|
||||
this.app.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
appState: this.app.state,
|
||||
offsets: textViewportOffsets,
|
||||
tooLarge: "leave",
|
||||
});
|
||||
if (scroll) {
|
||||
this.app.viewport.translate(scroll);
|
||||
}
|
||||
}
|
||||
|
||||
if (stickyContainer) {
|
||||
// the note may have grown or shrunk — arrows bound to it must follow
|
||||
const latestContainer = this.app.scene.getNonDeletedElement(
|
||||
stickyContainer.id,
|
||||
);
|
||||
if (latestContainer) {
|
||||
updateBoundElements(latestContainer, this.app.scene);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.textWysiwygSubmitHandler = textWysiwyg({
|
||||
canvas: this.app.canvas,
|
||||
getViewportCoords: (x, y) => {
|
||||
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
|
||||
{
|
||||
sceneX: x,
|
||||
sceneY: y,
|
||||
},
|
||||
this.app.state,
|
||||
);
|
||||
return [
|
||||
viewportX - this.app.state.offsetLeft,
|
||||
viewportY - this.app.state.offsetTop,
|
||||
];
|
||||
},
|
||||
onChange: withBatchedUpdates((nextOriginalText) => {
|
||||
updateElement(nextOriginalText, false);
|
||||
if (isNonDeletedElement(element)) {
|
||||
updateBoundElements(element, this.app.scene);
|
||||
}
|
||||
}),
|
||||
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
|
||||
this.textWysiwygSubmitHandler = null;
|
||||
|
||||
const isDeleted = !nextOriginalText.trim();
|
||||
updateElement(nextOriginalText, isDeleted);
|
||||
|
||||
// keyboard-submit keeps focus on the edited object. For bound text, keep
|
||||
// the container selected even if the text becomes empty and is deleted.
|
||||
// The autoshape tool stays active through the editing session and never
|
||||
// selects anything — don't fight the finalize action's selection reset.
|
||||
const elementIdToSelect =
|
||||
viaKeyboard &&
|
||||
!this.app.isToolLocked() &&
|
||||
this.app.state.activeTool.type !== "autoshape"
|
||||
? element.containerId || (!isDeleted ? element.id : null)
|
||||
: null;
|
||||
|
||||
if (elementIdToSelect) {
|
||||
// needed to ensure state is updated before "finalize" action
|
||||
// that's invoked on keyboard-submit as well
|
||||
// TODO either move this into finalize as well, or handle all state
|
||||
// updates in one place, skipping finalize action
|
||||
flushSync(() => {
|
||||
this.app.setState((prevState) => ({
|
||||
selectedElementIds: makeNextSelectedElementIds(
|
||||
{
|
||||
...prevState.selectedElementIds,
|
||||
[elementIdToSelect]: true,
|
||||
},
|
||||
prevState,
|
||||
),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
if (isDeleted) {
|
||||
fixBindingsAfterDeletion(this.app.scene.getNonDeletedElements(), [
|
||||
element,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!isDeleted || isExistingElement) {
|
||||
this.app.store.scheduleCapture();
|
||||
}
|
||||
|
||||
flushSync(() => {
|
||||
this.app.setState({
|
||||
newElement: null,
|
||||
editingTextElement: null,
|
||||
});
|
||||
});
|
||||
|
||||
// tools that survive the submit (locked, or autoshape's
|
||||
// double-click-to-type flow) need their cursor back
|
||||
if (
|
||||
this.app.isToolLocked() ||
|
||||
this.app.state.activeTool.type === "autoshape"
|
||||
) {
|
||||
this.app.cursor.applyForTool();
|
||||
}
|
||||
|
||||
this.app.focusContainer();
|
||||
}),
|
||||
element,
|
||||
excalidrawContainer: this.dependencies.getContainer(),
|
||||
app: this.app,
|
||||
initialCaretSceneCoords,
|
||||
// when text is selected, it's hard (at least on iOS) to re-position the
|
||||
// caret (i.e. deselect). There's not much use for always selecting
|
||||
// the text on edit anyway (and users can select-all from contextmenu
|
||||
// if needed)
|
||||
autoSelect: !this.app.editorInterface.isTouchScreen,
|
||||
});
|
||||
// deselect all other elements when inserting text
|
||||
this.app.deselectElements();
|
||||
|
||||
// do an initial update to re-initialize element position since we were
|
||||
// modifying element's x/y for sake of editor (case: syncing to remote)
|
||||
updateElement(element.originalText, false);
|
||||
}
|
||||
|
||||
private getSelectedTextElement(
|
||||
container?: ExcalidrawTextContainer | null,
|
||||
): NonDeleted<ExcalidrawTextElement> | null {
|
||||
const selectedElements = this.app.scene.getSelectedElements(this.app.state);
|
||||
|
||||
if (selectedElements.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedElement = selectedElements[0]!;
|
||||
|
||||
if (isTextElement(selectedElement)) {
|
||||
return selectedElement;
|
||||
}
|
||||
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getBoundTextElement(
|
||||
selectedElement,
|
||||
this.app.scene.getNonDeletedElementsMap(),
|
||||
) as NonDeleted<ExcalidrawTextElement> | null;
|
||||
}
|
||||
|
||||
public getSelectedTextEditingContainerAtPosition(
|
||||
hitElement: NonDeletedExcalidrawElement | null,
|
||||
sceneCoords: { x: number; y: number },
|
||||
): ExcalidrawTextContainer | null | undefined {
|
||||
const selectedElements = this.app.scene.getSelectedElements(this.app.state);
|
||||
|
||||
if (
|
||||
selectedElements.length !== 1 ||
|
||||
!hitElement ||
|
||||
hitElement.id !== selectedElements[0]!.id
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedElement = selectedElements[0]!;
|
||||
|
||||
if (isTextElement(selectedElement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isValidTextContainer(selectedElement)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const textElement = this.getSelectedTextElement(selectedElement);
|
||||
const hitTextElement = this.getTextElementAtPosition(
|
||||
sceneCoords.x,
|
||||
sceneCoords.y,
|
||||
);
|
||||
|
||||
if (!textElement || hitTextElement?.id !== textElement.id) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return selectedElement;
|
||||
}
|
||||
|
||||
getTextElementAtPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
): NonDeleted<ExcalidrawTextElement> | null {
|
||||
const element = this.app.getElementAtPosition(x, y, {
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
if (element && isTextElement(element) && !element.isDeleted) {
|
||||
return element;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public isHittingTextAutoResizeHandle = (
|
||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||
point: Readonly<{ x: number; y: number }>,
|
||||
): boolean => {
|
||||
const activeTextElement = getActiveTextElement(
|
||||
selectedElements,
|
||||
this.app.state,
|
||||
);
|
||||
|
||||
if (
|
||||
activeTextElement &&
|
||||
!activeTextElement.isDeleted &&
|
||||
!activeTextElement.autoResize &&
|
||||
isPointHittingTextAutoResizeHandle(
|
||||
point,
|
||||
activeTextElement,
|
||||
this.app.state.zoom.value,
|
||||
this.app.editorInterface.formFactor,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
public handleTextAutoResizeHandlePointerDown = (
|
||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||
point: Readonly<{ x: number; y: number }>,
|
||||
) => {
|
||||
const activeTextElement = getActiveTextElement(
|
||||
selectedElements,
|
||||
this.app.state,
|
||||
);
|
||||
if (
|
||||
!activeTextElement ||
|
||||
!this.isHittingTextAutoResizeHandle(selectedElements, point)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.app.actionManager.executeAction(
|
||||
actionTextAutoResize,
|
||||
"ui",
|
||||
// we need to pass down the element since it may already be deselected
|
||||
// due to the pointerdown
|
||||
activeTextElement,
|
||||
);
|
||||
this.app.cursor.reset();
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* The text container at a position — an arrow hit on its path, any other
|
||||
* container hit anywhere in its bounds (frames are skipped so a container
|
||||
* inside one can be hit). Purely positional: the selection plays no part.
|
||||
*/
|
||||
getTextBindableContainerAtPosition(x: number, y: number) {
|
||||
const elements = this.app.scene.getNonDeletedElements();
|
||||
let hitElement = null;
|
||||
// We need to do hit testing from front (end of the array) to back (beginning of the array)
|
||||
for (let index = elements.length - 1; index >= 0; --index) {
|
||||
if (elements[index].isDeleted) {
|
||||
continue;
|
||||
}
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(
|
||||
elements[index],
|
||||
this.app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
if (
|
||||
isArrowElement(elements[index]) &&
|
||||
hitElementItself({
|
||||
point: pointFrom(x, y),
|
||||
element: elements[index],
|
||||
elementsMap: this.app.scene.getNonDeletedElementsMap(),
|
||||
threshold: this.app.getElementHitThreshold(elements[index]),
|
||||
})
|
||||
) {
|
||||
hitElement = elements[index];
|
||||
break;
|
||||
} else if (x1 < x && x < x2 && y1 < y && y < y2) {
|
||||
// to allow binding to containers within frames,
|
||||
// ignore frames in hit testing
|
||||
if (isFrameLikeElement(elements[index])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
hitElement = elements[index];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return isTextBindableContainer(hitElement, false) ? hitElement : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a text element's content is still being authored.
|
||||
*
|
||||
* Creating a text reverts the tool to selection during pointerdown, so the
|
||||
* pointerup that follows looks like an ordinary canvas click and would
|
||||
* capture the still-empty element as a history entry of its own. Undo would
|
||||
* then rewind only the typing, restoring an invisible, zero-content element
|
||||
* (and, for an endpoint label, leaving the arrow bound to it) rather than
|
||||
* removing it. The editor's own submit captures the finished text instead,
|
||||
* so the whole create-and-type lands in a single entry.
|
||||
*/
|
||||
public isEditingTextContent() {
|
||||
return (
|
||||
!!this.app.state.editingTextElement ||
|
||||
isTextElement(this.app.state.newElement)
|
||||
);
|
||||
}
|
||||
|
||||
public startTextEditing = ({
|
||||
sceneX,
|
||||
sceneY,
|
||||
insertAtParentCenter = true,
|
||||
container,
|
||||
autoEdit = true,
|
||||
initialCaretSceneCoords,
|
||||
arrowEndpoint,
|
||||
textElement,
|
||||
}: {
|
||||
/** X position to insert text at */
|
||||
sceneX: number;
|
||||
/** Y position to insert text at */
|
||||
sceneY: number;
|
||||
/** whether to attempt to insert at element center if applicable */
|
||||
insertAtParentCenter?: boolean;
|
||||
container?: ExcalidrawTextContainer | null;
|
||||
autoEdit?: boolean;
|
||||
initialCaretSceneCoords?: { x: number; y: number };
|
||||
/**
|
||||
* creates the text as a label for this arrow endpoint: the binding then
|
||||
* dictates the text's position and alignment, overriding (sceneX, sceneY)
|
||||
*/
|
||||
arrowEndpoint?: ArrowEndpoint | null;
|
||||
/**
|
||||
* the text to edit: an element to edit exactly that one; `null` to always
|
||||
* create, never adopting a selected text or one under the pointer;
|
||||
* `undefined` to resolve it here — a single selected text, the label of a
|
||||
* selected or passed arrow container, else the text at (sceneX, sceneY)
|
||||
*/
|
||||
textElement?: NonDeleted<ExcalidrawTextElement> | null;
|
||||
}) => {
|
||||
let shouldBindToContainer = false;
|
||||
|
||||
// Resolved here rather than by the caller so that the stroke width the
|
||||
// binding gap derives from (see `getBindingGap`) is, by construction, the
|
||||
// one the text is created with below.
|
||||
const arrowEndpointBinding =
|
||||
arrowEndpoint &&
|
||||
this.app.arrowText.getTextBinding(
|
||||
arrowEndpoint,
|
||||
this.app.getCurrentItemStrokeWidth("text"),
|
||||
);
|
||||
|
||||
if (arrowEndpointBinding) {
|
||||
// an arrow endpoint is not a text container — the text is a sibling the
|
||||
// arrow binds to, not a label inside it
|
||||
container = null;
|
||||
insertAtParentCenter = false;
|
||||
// the scene position of the text's bound side midpoint, not a caret
|
||||
// position
|
||||
sceneX = arrowEndpointBinding.anchor[0];
|
||||
sceneY = arrowEndpointBinding.anchor[1];
|
||||
}
|
||||
|
||||
let parentCenterPosition =
|
||||
insertAtParentCenter &&
|
||||
this.getTextWysiwygSnappedToCenterPosition(
|
||||
sceneX,
|
||||
sceneY,
|
||||
this.app.state,
|
||||
container,
|
||||
);
|
||||
if (container && parentCenterPosition) {
|
||||
const boundTextElementToContainer = getBoundTextElement(
|
||||
container,
|
||||
this.app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
if (!boundTextElementToContainer) {
|
||||
shouldBindToContainer = true;
|
||||
}
|
||||
}
|
||||
const existingTextElement = arrowEndpointBinding
|
||||
? null
|
||||
: textElement !== undefined
|
||||
? textElement
|
||||
: this.getSelectedTextElement(container) ||
|
||||
(container && isArrowElement(container)
|
||||
? getBoundTextElement(
|
||||
container,
|
||||
this.app.scene.getNonDeletedElementsMap(),
|
||||
)
|
||||
: null) ||
|
||||
this.getTextElementAtPosition(sceneX, sceneY);
|
||||
|
||||
const fontFamily =
|
||||
existingTextElement?.fontFamily || this.app.state.currentItemFontFamily;
|
||||
|
||||
const lineHeight =
|
||||
existingTextElement?.lineHeight || getLineHeight(fontFamily);
|
||||
const fontSize = this.app.state.currentItemFontSize;
|
||||
|
||||
if (
|
||||
!existingTextElement &&
|
||||
shouldBindToContainer &&
|
||||
container &&
|
||||
!isArrowElement(container) &&
|
||||
!isStickyNoteElement(container)
|
||||
) {
|
||||
const fontString = {
|
||||
fontSize,
|
||||
fontFamily,
|
||||
};
|
||||
const minWidth = getApproxMinLineWidth(
|
||||
getFontString(fontString),
|
||||
lineHeight,
|
||||
);
|
||||
const minHeight = getApproxMinLineHeight(fontSize, lineHeight);
|
||||
const newHeight = Math.max(container.height, minHeight);
|
||||
const newWidth = Math.max(container.width, minWidth);
|
||||
this.app.scene.mutateElement(container, {
|
||||
height: newHeight,
|
||||
width: newWidth,
|
||||
});
|
||||
sceneX = container.x + newWidth / 2;
|
||||
sceneY = container.y + newHeight / 2;
|
||||
if (parentCenterPosition) {
|
||||
parentCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
|
||||
sceneX,
|
||||
sceneY,
|
||||
this.app.state,
|
||||
container,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const textCreationGridPoint = this.getTextCreationGridPoint(sceneX, sceneY);
|
||||
|
||||
const newTextElementPosition = arrowEndpointBinding
|
||||
? // the anchor is dictated by the arrow, so neither the grid nor the
|
||||
// caret-centering fudge may nudge it
|
||||
{ x: sceneX, y: sceneY }
|
||||
: parentCenterPosition
|
||||
? {
|
||||
x: parentCenterPosition.elementCenterX,
|
||||
y: parentCenterPosition.elementCenterY,
|
||||
}
|
||||
: !existingTextElement
|
||||
? {
|
||||
x: textCreationGridPoint?.x ?? sceneX,
|
||||
y:
|
||||
textCreationGridPoint === null
|
||||
? // Free text starts from a point cursor, so center the first line box on it.
|
||||
sceneY - getLineHeightInPx(fontSize, lineHeight) / 2
|
||||
: textCreationGridPoint.y,
|
||||
}
|
||||
: {
|
||||
x: sceneX,
|
||||
y: sceneY,
|
||||
};
|
||||
|
||||
const topLayerFrame = this.app.getTopLayerFrameAtSceneCoords({
|
||||
x: newTextElementPosition.x,
|
||||
y: newTextElementPosition.y,
|
||||
});
|
||||
|
||||
// container has higher priority. Only add to frame if container is in the same frame.
|
||||
const frameId =
|
||||
topLayerFrame &&
|
||||
(!shouldBindToContainer ||
|
||||
!container ||
|
||||
container.frameId === topLayerFrame.id)
|
||||
? topLayerFrame.id
|
||||
: null;
|
||||
|
||||
const element =
|
||||
existingTextElement ||
|
||||
newTextElement({
|
||||
x: newTextElementPosition.x,
|
||||
y: newTextElementPosition.y,
|
||||
// a note's stroke color is its text color: the label inherits it
|
||||
strokeColor:
|
||||
shouldBindToContainer && isStickyNoteElement(container)
|
||||
? container.strokeColor
|
||||
: this.app.state.currentItemStrokeColor,
|
||||
backgroundColor: this.app.state.currentItemBackgroundColor,
|
||||
fillStyle: this.app.state.currentItemFillStyle,
|
||||
strokeWidth: this.app.getCurrentItemStrokeWidth("text"),
|
||||
strokeStyle: this.app.state.currentItemStrokeStyle,
|
||||
roughness: this.app.state.currentItemRoughness,
|
||||
opacity: this.app.state.currentItemOpacity,
|
||||
text: "",
|
||||
fontSize,
|
||||
baseFontSize:
|
||||
shouldBindToContainer && isStickyNoteElement(container)
|
||||
? fontSize
|
||||
: null,
|
||||
fontFamily,
|
||||
textAlign:
|
||||
arrowEndpointBinding?.textAlign ??
|
||||
(parentCenterPosition
|
||||
? "center"
|
||||
: this.app.state.currentItemTextAlign),
|
||||
verticalAlign:
|
||||
arrowEndpointBinding?.verticalAlign ??
|
||||
(parentCenterPosition
|
||||
? VERTICAL_ALIGN.MIDDLE
|
||||
: DEFAULT_VERTICAL_ALIGN),
|
||||
containerId: shouldBindToContainer ? container?.id : undefined,
|
||||
labelPosition:
|
||||
shouldBindToContainer && container && isArrowElement(container)
|
||||
? DEFAULT_BOUND_TEXT_LABEL_POSITION
|
||||
: null,
|
||||
groupIds: shouldBindToContainer ? container?.groupIds ?? [] : [],
|
||||
lineHeight,
|
||||
angle:
|
||||
shouldBindToContainer && container && !isArrowElement(container)
|
||||
? container.angle
|
||||
: (0 as Radians),
|
||||
frameId,
|
||||
});
|
||||
|
||||
if (!existingTextElement && shouldBindToContainer && container) {
|
||||
this.app.scene.mutateElement(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: element.id,
|
||||
}),
|
||||
});
|
||||
}
|
||||
this.app.setState({ editingTextElement: element });
|
||||
|
||||
if (!existingTextElement) {
|
||||
if (container && shouldBindToContainer) {
|
||||
const containerIndex = this.app.scene.getElementIndex(container.id);
|
||||
// TODO should use insertNewElement, after we update it to handle
|
||||
// elements with containerId + frameId at the same time (containerId
|
||||
// should take precedence when it comes to z-index)
|
||||
this.app.scene.insertElementsAtIndex([element], containerIndex + 1);
|
||||
} else {
|
||||
this.app.insertNewElement(element);
|
||||
}
|
||||
}
|
||||
|
||||
if (arrowEndpoint && arrowEndpointBinding) {
|
||||
this.app.arrowText.bindText(
|
||||
arrowEndpoint,
|
||||
element,
|
||||
arrowEndpointBinding.fixedPoint,
|
||||
);
|
||||
}
|
||||
|
||||
// A nearby container only skips drag sizing when the text binds to it.
|
||||
if (autoEdit || existingTextElement || shouldBindToContainer) {
|
||||
this.handleTextWysiwyg(element, {
|
||||
isExistingElement: !!existingTextElement,
|
||||
initialCaretSceneCoords: existingTextElement
|
||||
? initialCaretSceneCoords
|
||||
: null,
|
||||
});
|
||||
} else {
|
||||
this.app.setState({
|
||||
newElement: element,
|
||||
multiElement: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
getTextWysiwygSnappedToCenterPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
appState: AppState,
|
||||
container?: ExcalidrawTextContainer | null,
|
||||
) {
|
||||
if (container) {
|
||||
let elementCenterX = container.x + container.width / 2;
|
||||
let elementCenterY = container.y + container.height / 2;
|
||||
|
||||
const elementCenter = getContainerCenter(
|
||||
container,
|
||||
this.app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
if (elementCenter) {
|
||||
elementCenterX = elementCenter.x;
|
||||
elementCenterY = elementCenter.y;
|
||||
}
|
||||
const distanceToCenter = Math.hypot(
|
||||
x - elementCenterX,
|
||||
y - elementCenterY,
|
||||
);
|
||||
const isSnappedToCenter =
|
||||
distanceToCenter < TEXT_TO_CENTER_SNAP_THRESHOLD;
|
||||
if (isSnappedToCenter) {
|
||||
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
|
||||
{ sceneX: elementCenterX, sceneY: elementCenterY },
|
||||
appState,
|
||||
);
|
||||
return { viewportX, viewportY, elementCenterX, elementCenterY };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import { flushSync } from "react-dom";
|
||||
|
||||
import {
|
||||
CURSOR_TYPE,
|
||||
EVENT,
|
||||
KEYS,
|
||||
TEXT_AUTOWRAP_THRESHOLD,
|
||||
updateActiveTool,
|
||||
viewportCoordsToSceneCoords,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isArrowElement,
|
||||
isTextBindableContainer,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { ArrowEndpoint } from "@excalidraw/element";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
NonDeleted,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type React from "react";
|
||||
|
||||
import type App from "./App";
|
||||
import type { AppState, PointerDownState } from "../types";
|
||||
|
||||
type ScenePoint = { x: number; y: number };
|
||||
|
||||
/** the modifier state a target depends on — off a pointer or keyboard event */
|
||||
export type Modifiers = Pick<KeyboardEvent, "ctrlKey" | "metaKey">;
|
||||
|
||||
/** What a text-tool click at a position does. */
|
||||
export type TextToolTarget =
|
||||
/** binds a new text to a free arrow endpoint */
|
||||
| { type: "endpoint"; endpoint: ArrowEndpoint }
|
||||
/** edits this text (a label included, at its derived position) */
|
||||
| { type: "text"; element: NonDeleted<ExcalidrawTextElement> }
|
||||
/** binds a new label to this empty container (an arrow included) */
|
||||
| { type: "container"; element: NonDeleted<ExcalidrawTextContainer> }
|
||||
/** creates free text at the pointer */
|
||||
| { type: "free" };
|
||||
|
||||
const toHoverState = (target: TextToolTarget): AppState["textToolHover"] => {
|
||||
switch (target.type) {
|
||||
case "endpoint":
|
||||
return {
|
||||
type: "arrow",
|
||||
elementId: target.endpoint.arrow.id,
|
||||
anchor: target.endpoint.startOrEnd,
|
||||
};
|
||||
case "text":
|
||||
return { type: "text", elementId: target.element.id };
|
||||
case "container":
|
||||
// a shape is outlined whole (the label centers in it); an arrow gets
|
||||
// its midpoint pointed at
|
||||
return isArrowElement(target.element)
|
||||
? { type: "arrow", elementId: target.element.id, anchor: "label" }
|
||||
: { type: "container", elementId: target.element.id };
|
||||
case "free":
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isSameHover = (
|
||||
a: AppState["textToolHover"],
|
||||
b: AppState["textToolHover"],
|
||||
) => {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
if (!a || !b || a.type !== b.type || a.elementId !== b.elementId) {
|
||||
return false;
|
||||
}
|
||||
return a.type !== "arrow" || b.type !== "arrow" || a.anchor === b.anchor;
|
||||
};
|
||||
|
||||
/**
|
||||
* The text tool's pointer interaction.
|
||||
*
|
||||
* Everything hangs off one question — what would a click at this position
|
||||
* do (`getTargetAt`)? — answered once and used for the hover affordance
|
||||
* (`appState.textToolHover`), the cursor and the pointerdown itself, so the
|
||||
* affordance can never promise something the click won't deliver.
|
||||
*
|
||||
* A click on an empty container's center is armed on pointerdown and
|
||||
* committed on pointerup as a bound label; dragging past the autowrap
|
||||
* threshold first turns it into a free fixed-width text at the origin
|
||||
* instead.
|
||||
*
|
||||
* Text editing itself (`app.text.startTextEditing`, see `AppText`) is
|
||||
* shared with Enter, double-click and the other entry points.
|
||||
*/
|
||||
export class AppTextTool {
|
||||
constructor(private app: App) {}
|
||||
|
||||
/**
|
||||
* A click on an empty container's center, armed on pointerdown and decided
|
||||
* on the pointer's way out: a click binds a label, a drag creates free
|
||||
* text instead. Deferring is what keeps the drag from first binding to
|
||||
* (and provisionally enlarging) the container.
|
||||
*/
|
||||
private pending: {
|
||||
containerId: ExcalidrawElement["id"];
|
||||
origin: ScenePoint;
|
||||
} | null = null;
|
||||
|
||||
/**
|
||||
* The modifiers the hover was last resolved with — what a refresh without
|
||||
* an event of its own (the viewport moving under a still pointer) uses.
|
||||
*/
|
||||
private lastModifiers: Modifiers = { ctrlKey: false, metaKey: false };
|
||||
|
||||
/**
|
||||
* What a click at this position would do, in the order the click resolves
|
||||
* it: a free arrow endpoint (the smaller, more deliberate target; z-aware,
|
||||
* and off while ctrl/cmd disables binding) → the text under the pointer,
|
||||
* which the click edits → an empty container near its center, which gets
|
||||
* a label unless ctrl/cmd opts out → free text at the pointer.
|
||||
*
|
||||
* Ctrl/cmd is "no binding" throughout the tool, as elsewhere in the
|
||||
* editor. For the container it is read off the event rather than the
|
||||
* arrow-binding preference: a label is not an arrow binding.
|
||||
*/
|
||||
getTargetAt = (
|
||||
scenePointer: ScenePoint,
|
||||
modifiers: Modifiers,
|
||||
): TextToolTarget => {
|
||||
const { x, y } = scenePointer;
|
||||
|
||||
const endpoint = this.app.arrowText.getBindableEndpointAtPosition(x, y);
|
||||
if (endpoint) {
|
||||
return { type: "endpoint", endpoint };
|
||||
}
|
||||
|
||||
const text = this.app.text.getTextElementAtPosition(x, y);
|
||||
if (text) {
|
||||
return { type: "text", element: text };
|
||||
}
|
||||
|
||||
if (!modifiers[KEYS.CTRL_OR_CMD]) {
|
||||
const container = this.app.text.getTextBindableContainerAtPosition(x, y);
|
||||
if (
|
||||
container &&
|
||||
// an existing label is edited by hitting the text itself (above)
|
||||
!hasBoundTextElement(container) &&
|
||||
this.app.text.getTextWysiwygSnappedToCenterPosition(
|
||||
x,
|
||||
y,
|
||||
this.app.state,
|
||||
container,
|
||||
)
|
||||
) {
|
||||
return { type: "container", element: container };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "free" };
|
||||
};
|
||||
|
||||
/**
|
||||
* Keeps `appState.textToolHover` — the affordance for what a click would
|
||||
* do — in sync with the pointer, and returns the target (for the cursor).
|
||||
* Suppressed while something else owns the interaction: a text being
|
||||
* edited or drag-sized, a multi-point element, a box selection, a drag of
|
||||
* the selection, or the pointer over a scrollbar.
|
||||
*/
|
||||
updateHover = (
|
||||
scenePointer: ScenePoint,
|
||||
modifiers: Modifiers,
|
||||
isOverScrollBar = false,
|
||||
): TextToolTarget | null => {
|
||||
const { state } = this.app;
|
||||
if (state.activeTool.type !== "text") {
|
||||
return null;
|
||||
}
|
||||
// a pending center click keeps the affordance it was armed with until
|
||||
// it resolves (the pointer may leave the snap radius before the drag
|
||||
// threshold, and a release would still bind)
|
||||
if (this.pending) {
|
||||
return null;
|
||||
}
|
||||
this.lastModifiers = {
|
||||
ctrlKey: modifiers.ctrlKey,
|
||||
metaKey: modifiers.metaKey,
|
||||
};
|
||||
const target =
|
||||
state.editingTextElement ||
|
||||
state.newElement ||
|
||||
state.multiElement ||
|
||||
state.selectionElement ||
|
||||
state.selectedElementsAreBeingDragged ||
|
||||
isOverScrollBar
|
||||
? null
|
||||
: this.getTargetAt(scenePointer, modifiers);
|
||||
this.setHover(target && toHoverState(target));
|
||||
return target;
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-resolves the hover — and the cursor that goes with it — where the
|
||||
* pointer last was, for what changes a click's outcome without the pointer
|
||||
* moving: the ctrl/cmd toggle (pass its event), or the viewport scrolling
|
||||
* or zooming under the pointer (no event; the last modifiers stand). The
|
||||
* scene position is re-derived from the pointer's viewport position, so a
|
||||
* moved viewport resolves what is under the pointer now.
|
||||
*
|
||||
* Nothing to refresh with another tool or without a hovering pointer — a
|
||||
* finger never hovers (its last move is where it lifted), and the
|
||||
* affordance a press armed is cleared when the press resolves or is
|
||||
* canceled. The cursor is left to viewport navigation (space/wheel
|
||||
* panning, a scrollbar drag, the hand tool), as on pointermove.
|
||||
*/
|
||||
refresh = (modifiers: Modifiers = this.lastModifiers) => {
|
||||
const pointer = this.app.lastPointerMoveEvent;
|
||||
if (
|
||||
this.app.state.activeTool.type !== "text" ||
|
||||
!pointer ||
|
||||
pointer.pointerType === "touch"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const target = this.updateHover(
|
||||
viewportCoordsToSceneCoords(pointer, this.app.state),
|
||||
modifiers,
|
||||
);
|
||||
if (!this.app.pan.isNavigating()) {
|
||||
this.app.cursor.set(this.cursorFor(target));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Drops a pending center click without acting on it — the tool was left,
|
||||
* the browser took the pointer (pointercancel), or a second finger turned
|
||||
* the press into a pinch/pan — so no pointerup of ours will decide it. The
|
||||
* outline it armed goes with it: while pending, the hover is that click's.
|
||||
*/
|
||||
cancel = () => {
|
||||
if (this.pending) {
|
||||
this.pending = null;
|
||||
this.clearHover();
|
||||
}
|
||||
};
|
||||
|
||||
clearHover = () => {
|
||||
if (this.app.state.textToolHover) {
|
||||
this.app.setState({ textToolHover: null });
|
||||
}
|
||||
};
|
||||
|
||||
private setHover = (next: AppState["textToolHover"]) => {
|
||||
if (!isSameHover(this.app.state.textToolHover, next)) {
|
||||
this.app.setState({ textToolHover: next });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The cursor for what a click would do: a pointer over the arrow anchors
|
||||
* text would attach to, a text cursor over text the click would edit, the
|
||||
* tool's crosshair otherwise — also while the affordance is suppressed
|
||||
* (editing, drag-sizing), where a click has nothing to point at.
|
||||
*/
|
||||
cursorFor = (target: TextToolTarget | null): string => {
|
||||
if (
|
||||
target?.type === "endpoint" ||
|
||||
(target?.type === "container" && isArrowElement(target.element))
|
||||
) {
|
||||
return CURSOR_TYPE.POINTER;
|
||||
}
|
||||
if (target?.type === "text") {
|
||||
return CURSOR_TYPE.TEXT;
|
||||
}
|
||||
return CURSOR_TYPE.CROSSHAIR;
|
||||
};
|
||||
|
||||
handlePointerDown = (
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
pointerDownState: PointerDownState,
|
||||
): void => {
|
||||
// while editing, a click outside only finalizes the edit — it doesn't
|
||||
// create another text (irrespective of state.activeTool.locked)
|
||||
if (this.app.state.editingTextElement) {
|
||||
return;
|
||||
}
|
||||
const sceneX = pointerDownState.origin.x;
|
||||
const sceneY = pointerDownState.origin.y;
|
||||
const target = this.getTargetAt({ x: sceneX, y: sceneY }, event);
|
||||
|
||||
switch (target.type) {
|
||||
case "endpoint":
|
||||
this.app.text.startTextEditing({
|
||||
sceneX,
|
||||
sceneY,
|
||||
// the binding fixes the position, but the width is still the
|
||||
// user's to drag out (see `getEndpointBoundTextDragAnchor`)
|
||||
autoEdit: false,
|
||||
arrowEndpoint: target.endpoint,
|
||||
textElement: null,
|
||||
});
|
||||
break;
|
||||
case "text":
|
||||
this.app.text.startTextEditing({
|
||||
sceneX,
|
||||
sceneY,
|
||||
textElement: target.element,
|
||||
autoEdit: false,
|
||||
initialCaretSceneCoords: { x: sceneX, y: sceneY },
|
||||
});
|
||||
break;
|
||||
case "container":
|
||||
// decided on the way out (see handlePointerMove / handlePointerUp).
|
||||
// The affordance stays up meanwhile — set explicitly, since a tap
|
||||
// has no hover before it
|
||||
this.pending = {
|
||||
containerId: target.element.id,
|
||||
origin: { x: sceneX, y: sceneY },
|
||||
};
|
||||
this.setHover(toHoverState(target));
|
||||
return;
|
||||
case "free":
|
||||
this.app.text.startTextEditing({
|
||||
sceneX,
|
||||
sceneY,
|
||||
container: null,
|
||||
textElement: null,
|
||||
insertAtParentCenter: false,
|
||||
autoEdit: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
this.finish();
|
||||
};
|
||||
|
||||
/**
|
||||
* The pointer-move half of a pending center click. Once the pointer has
|
||||
* moved past the autowrap threshold the click is a drag: free text is
|
||||
* created at the origin and sized by the drag from here on. Horizontal
|
||||
* only (a text is only ever sized that way) and in screen space, and
|
||||
* deliberately wider than the generic drag threshold so a jittery click
|
||||
* still binds. Returns whether a pending click owned the move.
|
||||
*/
|
||||
handlePointerMove = (
|
||||
event: PointerEvent,
|
||||
pointerDownState: PointerDownState,
|
||||
): boolean => {
|
||||
if (!this.pending) {
|
||||
return false;
|
||||
}
|
||||
// a tool switch mid-press orphans the click
|
||||
if (this.app.state.activeTool.type !== "text") {
|
||||
this.cancel();
|
||||
return true;
|
||||
}
|
||||
const pointerCoords = viewportCoordsToSceneCoords(event, this.app.state);
|
||||
if (this.isDrag(pointerCoords)) {
|
||||
this.resolvePending(true, pointerCoords, pointerDownState, event);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* The pointer-up half: a genuine pointerup binds the label. The
|
||||
* missing-pointerup cleanup replays this with the pointerdown event (a
|
||||
* second finger landing mid-press), and a tool switch orphans the click —
|
||||
* both discard it instead.
|
||||
*/
|
||||
handlePointerUp = (
|
||||
event: PointerEvent,
|
||||
pointerDownState: PointerDownState,
|
||||
): void => {
|
||||
if (!this.pending) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.type !== EVENT.POINTER_UP ||
|
||||
this.app.state.activeTool.type !== "text"
|
||||
) {
|
||||
this.cancel();
|
||||
return;
|
||||
}
|
||||
const pointerCoords = viewportCoordsToSceneCoords(event, this.app.state);
|
||||
this.resolvePending(
|
||||
this.isDrag(pointerCoords),
|
||||
pointerCoords,
|
||||
pointerDownState,
|
||||
event,
|
||||
);
|
||||
};
|
||||
|
||||
private isDrag = (pointerCoords: ScenePoint) =>
|
||||
Math.abs(pointerCoords.x - this.pending!.origin.x) *
|
||||
this.app.state.zoom.value >
|
||||
TEXT_AUTOWRAP_THRESHOLD;
|
||||
|
||||
private resolvePending = (
|
||||
isDrag: boolean,
|
||||
pointerCoords: ScenePoint,
|
||||
pointerDownState: PointerDownState,
|
||||
event: PointerEvent,
|
||||
) => {
|
||||
const { containerId, origin } = this.pending!;
|
||||
this.pending = null;
|
||||
|
||||
// gone mid-press (a collaborator deleted it): nothing to create, but the
|
||||
// click still ends the tool's turn
|
||||
const container = this.app.scene.getNonDeletedElement(containerId);
|
||||
const created = isTextBindableContainer(container, false);
|
||||
|
||||
// flushed so the drag below reads the created element off the state
|
||||
flushSync(() => {
|
||||
if (created) {
|
||||
this.app.text.startTextEditing({
|
||||
sceneX: origin.x,
|
||||
sceneY: origin.y,
|
||||
container: isDrag ? null : container,
|
||||
textElement: null,
|
||||
insertAtParentCenter: !isDrag,
|
||||
autoEdit: !isDrag,
|
||||
});
|
||||
}
|
||||
this.finish();
|
||||
});
|
||||
|
||||
if (created && isDrag) {
|
||||
pointerDownState.lastCoords.x = pointerCoords.x;
|
||||
pointerDownState.lastCoords.y = pointerCoords.y;
|
||||
this.app.maybeDragNewGenericElement(pointerDownState, event);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The click has been acted on: drop the affordance, revert the tool unless
|
||||
* it is locked, restore the cursor.
|
||||
*/
|
||||
private finish = () => {
|
||||
this.clearHover();
|
||||
if (!this.app.isToolLocked()) {
|
||||
this.app.setState(
|
||||
{
|
||||
activeTool: updateActiveTool(this.app.state, {
|
||||
type: this.app.state.preferredSelectionTool.type,
|
||||
}),
|
||||
},
|
||||
// reset once the tool revert has settled
|
||||
() => this.app.cursor.reset(),
|
||||
);
|
||||
} else {
|
||||
this.app.cursor.reset();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
DEFAULT_STICKY_NOTE_SIZE,
|
||||
DRAGGING_THRESHOLD,
|
||||
EVENT,
|
||||
KEYS,
|
||||
getGridPoint,
|
||||
viewportCoordsToSceneCoords,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { isStickyNoteElement, newStickyNoteElement } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawNonSelectionElement,
|
||||
NonDeleted,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
import type App from "./App";
|
||||
import type { ToolType } from "../types";
|
||||
|
||||
type ScenePoint = { x: number; y: number };
|
||||
|
||||
/** what a drag places: any element the new-element canvas can preview */
|
||||
type DraggedElement = NonDeleted<ExcalidrawNonSelectionElement>;
|
||||
|
||||
/**
|
||||
* A tool the user can drag out of the toolbar to drop a default-sized element
|
||||
* where the pointer is released.
|
||||
*/
|
||||
type DraggableTool = {
|
||||
/** the element the drop creates, centered on the pointer */
|
||||
createElement: (app: App, center: ScenePoint) => DraggedElement;
|
||||
/** what the tool does once its element is on the canvas (e.g. start editing) */
|
||||
onDrop?: (app: App, element: DraggedElement) => void;
|
||||
};
|
||||
|
||||
const centered = (center: ScenePoint, width: number, height: number) => ({
|
||||
x: center.x - width / 2,
|
||||
y: center.y - height / 2,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
/**
|
||||
* The tools that can be dragged out of the toolbar. Add an entry to make a
|
||||
* tool's button draggable — the gesture, preview and drop are shared.
|
||||
*/
|
||||
export const DRAGGABLE_TOOLS: Partial<Record<ToolType, DraggableTool>> = {
|
||||
stickynote: {
|
||||
createElement: (app, center) => {
|
||||
const size = DEFAULT_STICKY_NOTE_SIZE;
|
||||
const { state } = app;
|
||||
return newStickyNoteElement({
|
||||
type: "stickynote",
|
||||
...centered(center, size, size),
|
||||
baseHeight: size,
|
||||
strokeColor: state.currentItemStickynoteStrokeColor,
|
||||
backgroundColor: state.currentItemStickynoteBackgroundColor,
|
||||
fillStyle: state.currentItemFillStyle,
|
||||
strokeWidth: app.getCurrentItemStrokeWidth("stickynote"),
|
||||
strokeStyle: state.currentItemStrokeStyle,
|
||||
roughness: state.currentItemRoughness,
|
||||
opacity: state.currentItemOpacity,
|
||||
roundness: app.getCurrentItemRoundness("stickynote"),
|
||||
locked: false,
|
||||
frameId: app.getTopLayerFrameAtSceneCoords(center)?.id ?? null,
|
||||
});
|
||||
},
|
||||
onDrop: (app, element) => {
|
||||
if (isStickyNoteElement(element)) {
|
||||
app.text.startTextEditing({
|
||||
sceneX: element.x + element.width / 2,
|
||||
sceneY: element.y + element.height / 2,
|
||||
container: element,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** CSS opacity of the preview canvas while dragging */
|
||||
export const TOOL_DRAG_PREVIEW_OPACITY = 0.5;
|
||||
|
||||
/**
|
||||
* Dragging a tool out of the toolbar. The preview element exists only here
|
||||
* until the drop — it is not in the scene, the store or the history, so
|
||||
* collaborators never see it and an aborted drag leaves nothing behind. It
|
||||
* is painted by the `NewElementCanvas`, drawn exactly as it will land, with
|
||||
* the canvas itself made translucent.
|
||||
*
|
||||
* The button arms on pointerdown; the drag starts once the pointer has
|
||||
* moved `DRAGGING_THRESHOLD`, so a plain click still selects the tool. The
|
||||
* gesture lives on the owner window (the pointer leaves the button at once),
|
||||
* like the other pointer gestures in `App`.
|
||||
*/
|
||||
export class AppToolDrag {
|
||||
constructor(private app: App) {}
|
||||
|
||||
/** the element under the pointer while dragging, `null` otherwise */
|
||||
public preview: DraggedElement | null = null;
|
||||
|
||||
private armed: {
|
||||
type: ToolType;
|
||||
pointerId: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
} | null = null;
|
||||
|
||||
private tool: DraggableTool | null = null;
|
||||
private type: ToolType | null = null;
|
||||
|
||||
static isDraggable = (type: ToolType) => type in DRAGGABLE_TOOLS;
|
||||
|
||||
isDragging = () => this.preview !== null;
|
||||
|
||||
/**
|
||||
* Arm a drag from a tool button's pointerdown. Returns whether the tool is
|
||||
* draggable (the caller lets a non-draggable press fall through).
|
||||
*/
|
||||
handleButtonPointerDown = (type: ToolType, event: PointerEvent) => {
|
||||
const tool = DRAGGABLE_TOOLS[type];
|
||||
if (!tool || event.button !== 0 || !this.app.isInteractionEnabled()) {
|
||||
return false;
|
||||
}
|
||||
this.cancel();
|
||||
this.armed = {
|
||||
type,
|
||||
pointerId: event.pointerId,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
};
|
||||
this.tool = tool;
|
||||
this.type = type;
|
||||
this.addListeners();
|
||||
return true;
|
||||
};
|
||||
|
||||
private onPointerMove = (event: PointerEvent) => {
|
||||
if (!this.armed || event.pointerId !== this.armed.pointerId) {
|
||||
return;
|
||||
}
|
||||
if (!this.preview) {
|
||||
const moved = Math.hypot(
|
||||
event.clientX - this.armed.clientX,
|
||||
event.clientY - this.armed.clientY,
|
||||
);
|
||||
if (moved < DRAGGING_THRESHOLD) {
|
||||
return;
|
||||
}
|
||||
trackEvent("toolbar", this.armed.type, "drag");
|
||||
}
|
||||
const preview = this.tool!.createElement(
|
||||
this.app,
|
||||
viewportCoordsToSceneCoords(event, this.app.state),
|
||||
);
|
||||
// Snap after the tool centers its element, just like click-to-place.
|
||||
const [x, y] = getGridPoint(
|
||||
preview.x,
|
||||
preview.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.app.getEffectiveGridSize(),
|
||||
);
|
||||
this.preview = { ...preview, x, y };
|
||||
this.app.triggerRender();
|
||||
};
|
||||
|
||||
private onPointerUp = (event: PointerEvent) => {
|
||||
if (!this.armed || event.pointerId !== this.armed.pointerId) {
|
||||
return;
|
||||
}
|
||||
const { preview, tool, type } = this;
|
||||
// the button's own click handles a press that never became a drag
|
||||
if (!preview) {
|
||||
this.cancel();
|
||||
return;
|
||||
}
|
||||
this.cancel();
|
||||
if (!this.isOverCanvas(event)) {
|
||||
return;
|
||||
}
|
||||
const { app } = this;
|
||||
app.insertNewElement(preview);
|
||||
app.store.scheduleCapture();
|
||||
app.setState({
|
||||
selectedElementIds: { [preview.id]: true },
|
||||
selectedGroupIds: {},
|
||||
});
|
||||
// the drop is a one-off placement: whatever tool was active (an arrow,
|
||||
// say) must not stay armed under the dropped element or its editor
|
||||
app.setActiveTool(
|
||||
{ type: app.state.preferredSelectionTool.type },
|
||||
{ keepSelection: true },
|
||||
);
|
||||
trackEvent("toolbar", type!, "drop");
|
||||
tool!.onDrop?.(app, preview);
|
||||
};
|
||||
|
||||
private onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === KEYS.ESCAPE) {
|
||||
event.stopPropagation();
|
||||
this.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
/** drop the gesture without a trace */
|
||||
cancel = () => {
|
||||
const wasDragging = this.preview !== null;
|
||||
this.armed = null;
|
||||
this.tool = null;
|
||||
this.type = null;
|
||||
this.preview = null;
|
||||
this.removeListeners();
|
||||
if (wasDragging) {
|
||||
this.app.triggerRender();
|
||||
}
|
||||
};
|
||||
|
||||
private isOverCanvas = (event: PointerEvent) => {
|
||||
// the element under the pointer. `elementFromPoint` sees through the
|
||||
// implicit capture a touch gives the button; jsdom doesn't implement it,
|
||||
// so fall back to the event's target
|
||||
const target = (this.app.ownerDocument.elementFromPoint?.(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
) ?? event.target) as Element | null;
|
||||
return !!target?.closest?.(".excalidraw__canvas");
|
||||
};
|
||||
|
||||
private addListeners = () => {
|
||||
const { ownerWindow } = this.app;
|
||||
ownerWindow.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
||||
ownerWindow.addEventListener(EVENT.POINTER_UP, this.onPointerUp);
|
||||
ownerWindow.addEventListener(EVENT.POINTER_CANCEL, this.cancel);
|
||||
ownerWindow.addEventListener(EVENT.BLUR, this.cancel);
|
||||
ownerWindow.addEventListener(EVENT.KEYDOWN, this.onKeyDown, true);
|
||||
};
|
||||
|
||||
private removeListeners = () => {
|
||||
const { ownerWindow } = this.app;
|
||||
ownerWindow.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
||||
ownerWindow.removeEventListener(EVENT.POINTER_UP, this.onPointerUp);
|
||||
ownerWindow.removeEventListener(EVENT.POINTER_CANCEL, this.cancel);
|
||||
ownerWindow.removeEventListener(EVENT.BLUR, this.cancel);
|
||||
ownerWindow.removeEventListener(EVENT.KEYDOWN, this.onKeyDown, true);
|
||||
};
|
||||
}
|
||||
+993
-1979
File diff suppressed because it is too large
Load Diff
@@ -468,6 +468,114 @@ export class AppViewport {
|
||||
this.uiLastMeasured.delete(name);
|
||||
};
|
||||
|
||||
/**
|
||||
* The side a rendered side UI (`data-viewport-ui="side"`) docks to, and
|
||||
* how far it reaches into the canvas from it, in screen px.
|
||||
*/
|
||||
private measureSide = (node: HTMLElement, containerRect: DOMRect) => {
|
||||
const domRect = node.getBoundingClientRect();
|
||||
const left = domRect.left - containerRect.left;
|
||||
const right = domRect.right - containerRect.left;
|
||||
return left + domRect.width / 2 < this.app.state.width / 2
|
||||
? { side: "left" as const, offset: right }
|
||||
: { side: "right" as const, offset: this.app.state.width - left };
|
||||
};
|
||||
|
||||
/**
|
||||
* The side a named surface docks to while it's hidden, and how far it
|
||||
* reaches into the canvas from it: as last measured, else approximately.
|
||||
*/
|
||||
private getReservedSide = (
|
||||
name: keyof NonNullable<ViewportOffsetsOptions["reserve"]>,
|
||||
) => {
|
||||
const isRTL = getLanguage().rtl;
|
||||
return (
|
||||
this.uiLastMeasured.get(name) ??
|
||||
(name === "stylesPanel"
|
||||
? {
|
||||
side: isRTL ? ("right" as const) : ("left" as const),
|
||||
offset:
|
||||
this.dependencies.getStylesPanelMode() === "compact"
|
||||
? STYLES_PANEL_APPROX_WIDTH.compact
|
||||
: STYLES_PANEL_APPROX_WIDTH.full,
|
||||
}
|
||||
: {
|
||||
side: isRTL ? ("left" as const) : ("right" as const),
|
||||
offset: RIGHT_SIDEBAR_WIDTH,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
private querySideUI = (container: HTMLElement, name: ViewportUIName) =>
|
||||
container.querySelector<HTMLElement>(
|
||||
`[data-viewport-ui="side"][data-viewport-ui-name="${name}"]`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Where a rendered side UI is, relative to the editor's container, in
|
||||
* screen px; null when it isn't rendered.
|
||||
*/
|
||||
getSideUIRect = (name: ViewportUIName) => {
|
||||
const container = this.dependencies.getContainer();
|
||||
const node = container && this.querySideUI(container, name);
|
||||
if (!container || !node) {
|
||||
return null;
|
||||
}
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const rect = node.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left - containerRect.left,
|
||||
right: rect.right - containerRect.left,
|
||||
top: rect.top - containerRect.top,
|
||||
bottom: rect.bottom - containerRect.top,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* How far the named side UIs reach into the canvas, in screen px, from
|
||||
* the left and the right: the part of the canvas they cover. Zero at a
|
||||
* side none of them is rendered at — e.g. with the sidebar closed, or on
|
||||
* phones, where it's an overlay that doesn't count as viewport UI — unless
|
||||
* a hidden one is reserved (see `ViewportOffsetsOptions.reserve`).
|
||||
*/
|
||||
getSideInsets = (
|
||||
names: readonly ViewportUIName[],
|
||||
opts?: Pick<ViewportOffsetsOptions, "reserve">,
|
||||
) => {
|
||||
const insets = { left: 0, right: 0 };
|
||||
const container = this.dependencies.getContainer();
|
||||
if (!container) {
|
||||
return insets;
|
||||
}
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
for (const name of names) {
|
||||
const node = this.querySideUI(container, name);
|
||||
let footprint: { side: "left" | "right"; offset: number } | null = null;
|
||||
if (node) {
|
||||
footprint = this.measureSide(node, containerRect);
|
||||
if (footprint.offset > 0) {
|
||||
this.uiLastMeasured.set(name, footprint);
|
||||
}
|
||||
} else if (
|
||||
name !== "stats" &&
|
||||
opts?.reserve?.[name] &&
|
||||
this.app.editorInterface.formFactor !== "phone"
|
||||
) {
|
||||
footprint = this.getReservedSide(name);
|
||||
}
|
||||
if (footprint) {
|
||||
insets[footprint.side] = Math.max(
|
||||
insets[footprint.side],
|
||||
footprint.offset,
|
||||
);
|
||||
}
|
||||
}
|
||||
return insets;
|
||||
};
|
||||
|
||||
/** How far the sidebar reaches into the canvas (see `getSideInsets`). */
|
||||
getSidebarInsets = () => this.getSideInsets(["sidebar"]);
|
||||
|
||||
/**
|
||||
* Resolves user-supplied viewport offsets into concrete per-side pixel
|
||||
* values. Static sides take precedence over UI-derived sides.
|
||||
@@ -532,10 +640,10 @@ export class AppViewport {
|
||||
);
|
||||
break;
|
||||
case "side": {
|
||||
const [side, offset] =
|
||||
rect.left + rect.width / 2 < this.app.state.width / 2
|
||||
? (["left", rect.right] as const)
|
||||
: (["right", this.app.state.width - rect.left] as const);
|
||||
const { side, offset } = this.measureSide(
|
||||
node,
|
||||
excalidrawContainerRect,
|
||||
);
|
||||
|
||||
measuredOffsets[side] = Math.max(measuredOffsets[side], offset);
|
||||
|
||||
@@ -555,31 +663,11 @@ export class AppViewport {
|
||||
}
|
||||
|
||||
if (opts?.reserve && this.app.editorInterface.formFactor !== "phone") {
|
||||
const reserveSurface = (
|
||||
name: ViewportUIName,
|
||||
fallback: { side: "left" | "right"; offset: number },
|
||||
) => {
|
||||
if (renderedSurfaces.has(name)) {
|
||||
return;
|
||||
for (const name of ["stylesPanel", "sidebar"] as const) {
|
||||
if (opts.reserve[name] && !renderedSurfaces.has(name)) {
|
||||
const { side, offset } = this.getReservedSide(name);
|
||||
measuredOffsets[side] = Math.max(measuredOffsets[side], offset);
|
||||
}
|
||||
const { side, offset } = this.uiLastMeasured.get(name) ?? fallback;
|
||||
measuredOffsets[side] = Math.max(measuredOffsets[side], offset);
|
||||
};
|
||||
|
||||
if (opts.reserve.stylesPanel) {
|
||||
reserveSurface("stylesPanel", {
|
||||
side: isRTL ? "right" : "left",
|
||||
offset:
|
||||
this.dependencies.getStylesPanelMode() === "compact"
|
||||
? STYLES_PANEL_APPROX_WIDTH.compact
|
||||
: STYLES_PANEL_APPROX_WIDTH.full,
|
||||
});
|
||||
}
|
||||
if (opts.reserve.sidebar) {
|
||||
reserveSurface("sidebar", {
|
||||
side: isRTL ? "left" : "right",
|
||||
offset: RIGHT_SIDEBAR_WIDTH,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,7 +718,10 @@ export class AppViewport {
|
||||
});
|
||||
};
|
||||
|
||||
/** Navigates to a target and optionally installs scroll/zoom constraints. */
|
||||
/**
|
||||
* Navigates to a target and optionally installs scroll/zoom constraints.
|
||||
* Navigating stops following a collaborator, as a user's pan does.
|
||||
*/
|
||||
setViewport = (opts: SetViewportOptions | null) => {
|
||||
if (opts === null) {
|
||||
this.cancelTransition();
|
||||
@@ -664,6 +755,10 @@ export class AppViewport {
|
||||
return;
|
||||
}
|
||||
|
||||
// the view is about to show something other than what the followed
|
||||
// user sees
|
||||
this.app.requestUnfollow();
|
||||
|
||||
const viewportUpdate = getConstrainedTargetViewport(
|
||||
this.app.state,
|
||||
bounds,
|
||||
@@ -744,27 +839,40 @@ export class AppViewport {
|
||||
return false;
|
||||
}
|
||||
|
||||
// a user gesture interrupting an animated `setViewport` takes over from
|
||||
// frames that draw from zoom-scaled bitmaps, and the completion handler
|
||||
// that would switch back to crisp ones no longer runs. Otherwise the
|
||||
// flag is left alone: wheel and touch zoom set it and clear it on a
|
||||
// debounce once the gesture is over, and a pan frame landing between two
|
||||
// zoom ticks must not force every element to be re-rasterized at the
|
||||
// in-progress zoom
|
||||
if (this.activeTransition) {
|
||||
this.app.setState({ shouldCacheIgnoreZoom: false });
|
||||
}
|
||||
this.cancelTransition();
|
||||
if (!opts?.preserveScrollConstraintsSnapBack) {
|
||||
AnimationController.cancel(SCROLL_CONSTRAINTS_SNAP_BACK_ANIMATION_KEY);
|
||||
}
|
||||
this.app.setState({ shouldCacheIgnoreZoom: false });
|
||||
this.app.requestUnfollow();
|
||||
|
||||
const prevZoom = this.app.state.zoom.value;
|
||||
this.app.setState(state);
|
||||
|
||||
this.app.setState((prevState) => {
|
||||
if (!prevState.scrollConstraints) {
|
||||
this.app.setState((prevState, props) => {
|
||||
const update =
|
||||
typeof state === "function" ? state(prevState, props) : state;
|
||||
if (!update) {
|
||||
return null;
|
||||
}
|
||||
const nextState = { ...prevState, ...update };
|
||||
if (!nextState.scrollConstraints) {
|
||||
return update;
|
||||
}
|
||||
const zoomed =
|
||||
!opts?.zoomPreConstrained && prevState.zoom.value !== prevZoom;
|
||||
const overscroll = zoomed ? 0 : prevState.scrollConstraints.overscroll;
|
||||
!opts?.zoomPreConstrained &&
|
||||
nextState.zoom.value !== prevState.zoom.value;
|
||||
const overscroll = zoomed ? 0 : nextState.scrollConstraints.overscroll;
|
||||
if (overscroll > 0) {
|
||||
this.snapBackDebounced();
|
||||
}
|
||||
return constrainScrollState(prevState, overscroll);
|
||||
return { ...nextState, ...constrainScrollState(nextState, overscroll) };
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { CLASSES, KEYS, MIN_ZOOM, ZOOM_STEP } from "@excalidraw/common";
|
||||
|
||||
import { resolveInputDevice } from "../appState";
|
||||
import { withBatchedUpdates } from "../reactUtils";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { getViewportForZoomWithScrollConstraints } from "../viewport";
|
||||
|
||||
import type App from "./App";
|
||||
|
||||
/** `MouseEvent.buttons` bit of the wheel (middle) button */
|
||||
const WHEEL_BUTTON_MASK = 4;
|
||||
|
||||
/**
|
||||
* Wheel input over the editor: pans the canvas, or zooms it around the
|
||||
* pointer (`viewport.lastPosition`) — on ctrl/cmd+wheel (which is also how
|
||||
* a trackpad pinch is delivered), while the wheel button itself is held
|
||||
* down, or on a plain wheel when the input device is a mouse
|
||||
* (`appState.inputDevice`). Shift+wheel pans horizontally;
|
||||
* ctrl/cmd+shift+wheel pans vertically.
|
||||
*/
|
||||
export class AppWheel {
|
||||
constructor(private app: App) {}
|
||||
|
||||
/** the editor surfaces whose wheel input the editor consumes; everywhere
|
||||
* else (menus, sidebars, …) the DOM keeps scrolling. The frame-name labels
|
||||
* are DOM, but sit inside the container this listener is attached to */
|
||||
private isOverEditorSurface = (event: WheelEvent) => {
|
||||
const { ownerWindow } = this.app;
|
||||
return (
|
||||
event.target instanceof ownerWindow.HTMLCanvasElement ||
|
||||
event.target instanceof ownerWindow.HTMLTextAreaElement ||
|
||||
event.target instanceof ownerWindow.HTMLIFrameElement ||
|
||||
(event.target instanceof ownerWindow.HTMLElement &&
|
||||
event.target.classList.contains(CLASSES.FRAME_NAME))
|
||||
);
|
||||
};
|
||||
|
||||
handle = withBatchedUpdates((event: WheelEvent) => {
|
||||
// NOTE no preventDefault so the page can scroll over the editor
|
||||
if (!this.app.isNavigationEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (!this.isOverEditorSurface(event)) {
|
||||
// prevent zooming the browser (but allow scrolling DOM)
|
||||
if (event[KEYS.CTRL_OR_CMD]) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// scrolling with the wheel button held down zooms: the wheel sits under
|
||||
// the same finger, so a wheel-button pan can be zoomed without reaching
|
||||
// for a modifier — hence it wins over any modifier, and over the
|
||||
// drag-pan the button itself started
|
||||
const isWheelButtonHeld = !!(event.buttons & WHEEL_BUTTON_MASK);
|
||||
|
||||
// a drag-pan (wheel button, space+drag, hand tool) in progress owns the
|
||||
// viewport; wheel input is ignored meanwhile — except for the wheel
|
||||
// button's own zoom
|
||||
if (this.app.pan.isActive()) {
|
||||
if (!isWheelButtonHeld) {
|
||||
return;
|
||||
}
|
||||
// the drag-pan applies pointer movement once per frame; a move still
|
||||
// waiting for its frame has to land before the zoom, or it lands after
|
||||
// it — at the new zoom, on a viewport the zoom anchored without it —
|
||||
// and the point grabbed by the pan drifts from under the cursor a
|
||||
// little on every tick
|
||||
this.app.pan.flushMove();
|
||||
}
|
||||
|
||||
const { deltaX, deltaY } = event;
|
||||
if (!deltaX && !deltaY) {
|
||||
return;
|
||||
}
|
||||
// note that event.ctrlKey is necessary to handle pinch zooming
|
||||
const hasZoomModifier = event.metaKey || event.ctrlKey;
|
||||
const shouldZoom =
|
||||
// a horizontal-only wheel (tilt wheel, sideways two-finger scroll)
|
||||
// has nothing to zoom by; it follows the pan mappings below instead
|
||||
deltaY !== 0 &&
|
||||
(isWheelButtonHeld ||
|
||||
(!event.shiftKey &&
|
||||
(hasZoomModifier ||
|
||||
resolveInputDevice(this.app.state.inputDevice) === "mouse")));
|
||||
if (shouldZoom) {
|
||||
this.zoomBy(deltaY);
|
||||
return;
|
||||
}
|
||||
|
||||
// Shift pans horizontally; ctrl/cmd+shift pans vertically.
|
||||
if (event.shiftKey) {
|
||||
// on Mac, shift+wheel tends to result in deltaX
|
||||
const delta = deltaY || deltaX;
|
||||
this.app.viewport.translate(({ zoom, scrollX, scrollY }) => ({
|
||||
scrollX: hasZoomModifier ? scrollX : scrollX - delta / zoom.value,
|
||||
scrollY: hasZoomModifier ? scrollY - delta / zoom.value : scrollY,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
this.app.viewport.translate(({ zoom, scrollX, scrollY }) => ({
|
||||
scrollX: scrollX - deltaX / zoom.value,
|
||||
scrollY: scrollY - deltaY / zoom.value,
|
||||
}));
|
||||
});
|
||||
|
||||
/**
|
||||
* Prevents the browser's own zoom over the non-interactive editor while
|
||||
* letting regular scroll through (trackpad pinch is delivered as
|
||||
* ctrl+wheel).
|
||||
*/
|
||||
preventBrowserZoom = (event: WheelEvent) => {
|
||||
if (event[KEYS.CTRL_OR_CMD]) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
/** zooms around the pointer by a wheel delta (positive = zoom out) */
|
||||
private zoomBy = (deltaY: number) => {
|
||||
const { viewport } = this.app;
|
||||
|
||||
const sign = Math.sign(deltaY);
|
||||
const MAX_STEP = ZOOM_STEP * 100;
|
||||
const absDelta = Math.abs(deltaY);
|
||||
let delta = deltaY;
|
||||
if (absDelta > MAX_STEP) {
|
||||
delta = MAX_STEP * sign;
|
||||
}
|
||||
|
||||
// where the pointer is now — the updater below runs when React flushes,
|
||||
// by which time the pointer may have moved on (`lastPosition` is live)
|
||||
const { x: viewportX, y: viewportY } = viewport.lastPosition;
|
||||
|
||||
const didTranslate = viewport.translate(
|
||||
// computed from the state the zoom applies to, not `app.state`: wheel
|
||||
// ticks arriving before React has flushed the previous one would
|
||||
// otherwise all start from the same zoom and collapse into one step
|
||||
(state) => {
|
||||
let newZoom = state.zoom.value - delta / 100;
|
||||
// increase zoom steps the more zoomed-in we are (applies to >100% only)
|
||||
newZoom +=
|
||||
Math.log10(Math.max(1, state.zoom.value)) *
|
||||
-sign *
|
||||
// reduced amplification for small deltas (small movements on a trackpad)
|
||||
Math.min(1, absDelta / 20);
|
||||
|
||||
const minZoom = state.scrollConstraints?.lockZoom
|
||||
? state.scrollConstraints.zoom
|
||||
: MIN_ZOOM;
|
||||
const nextZoom = getNormalizedZoom(Math.max(newZoom, minZoom));
|
||||
if (nextZoom === state.zoom.value) {
|
||||
// at a zoom limit there is nothing to do; flipping the bitmap-cache
|
||||
// flag would only redraw the scene on every tick
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...getViewportForZoomWithScrollConstraints(
|
||||
{ viewportX, viewportY, nextZoom },
|
||||
state,
|
||||
),
|
||||
shouldCacheIgnoreZoom: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
zoomPreConstrained: true,
|
||||
preserveScrollConstraintsSnapBack: true,
|
||||
},
|
||||
);
|
||||
if (didTranslate) {
|
||||
this.app.resetShouldCacheIgnoreZoomDebounced();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,11 @@
|
||||
@use "../../css/variables.module" as *;
|
||||
|
||||
.excalidraw {
|
||||
--color-outline: var(--color-gray-20);
|
||||
&.theme--dark {
|
||||
--color-outline: var(--color-gray-80);
|
||||
}
|
||||
|
||||
.focus-visible-none {
|
||||
&:focus-visible {
|
||||
outline: none !important;
|
||||
@@ -22,7 +27,7 @@
|
||||
|
||||
.color-picker-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 20px 1.625rem;
|
||||
grid-template-columns: 1fr 15px 1.625rem;
|
||||
padding: 0.25rem 0px;
|
||||
align-items: center;
|
||||
|
||||
@@ -41,6 +46,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.color-picker__button {
|
||||
@@ -48,7 +54,7 @@
|
||||
--size: 1.375rem;
|
||||
|
||||
&.has-outline {
|
||||
box-shadow: inset 0 0 0 1px #d9d9d9;
|
||||
box-shadow: 0 0 0 1px var(--color-outline);
|
||||
}
|
||||
|
||||
padding: 0;
|
||||
@@ -57,28 +63,22 @@
|
||||
height: var(--size);
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
filter: var(--theme-filter);
|
||||
background-color: var(--swatch-color);
|
||||
background-position: left center;
|
||||
position: relative;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:hover:not(.active):not(.color-picker__button--large) {
|
||||
transform: scale(1.075);
|
||||
}
|
||||
|
||||
&:hover:not(.active).color-picker__button--large {
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
box-shadow: 0 0 0 1px var(--color-gray-30);
|
||||
border-radius: var(--radius);
|
||||
filter: var(--theme-filter);
|
||||
box-shadow: inset 0 0 0 1px var(--color-primary);
|
||||
}
|
||||
&:active {
|
||||
box-shadow: 0 0 0 1px var(--color-primary) !important;
|
||||
}
|
||||
&:active.color-picker__button--large {
|
||||
box-shadow: none !important;
|
||||
&.has-outline {
|
||||
box-shadow: 0 0 0 1px var(--color-outline) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
&.active:not(:active) {
|
||||
.color-picker__button-outline {
|
||||
position: absolute;
|
||||
--offset: -1px;
|
||||
@@ -114,7 +114,6 @@
|
||||
box-shadow: 0 0 0 1px var(--color-primary-darkest);
|
||||
z-index: 1; // due hover state so this has preference
|
||||
border-radius: var(--radius);
|
||||
filter: var(--theme-filter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +139,7 @@
|
||||
}
|
||||
|
||||
&--large {
|
||||
--radius: 0.5rem;
|
||||
--radius: 6px;
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
}
|
||||
@@ -171,16 +170,16 @@
|
||||
}
|
||||
|
||||
&.mobile-border {
|
||||
border: 1px solid var(--mobile-color-border);
|
||||
border: 1px solid var(--default-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker__button__hotkey-label {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 3px;
|
||||
filter: none;
|
||||
font-size: 11px;
|
||||
right: 4px;
|
||||
bottom: 2px;
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -288,10 +287,6 @@
|
||||
|
||||
&-colors {
|
||||
padding: 0.5rem 0;
|
||||
|
||||
.color-picker-swatch {
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,41 +294,6 @@
|
||||
grid-column: 1 / span 5;
|
||||
}
|
||||
|
||||
.color-picker-swatch {
|
||||
position: relative;
|
||||
height: 1.875rem;
|
||||
width: 1.875rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ddd;
|
||||
background-color: currentColor !important;
|
||||
filter: var(--theme-filter);
|
||||
|
||||
&:focus {
|
||||
/* TODO: only show the border when the color is too light to see as a shadow */
|
||||
box-shadow: 0 0 4px 1px currentColor;
|
||||
border-color: var(--select-highlight-color);
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-transparent {
|
||||
border-radius: 4px;
|
||||
box-shadow: color.adjust(#000, $alpha: -0.9) 0 0 0 1px inset;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.color-picker-transparent,
|
||||
.color-picker-label-swatch {
|
||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
||||
left center;
|
||||
}
|
||||
|
||||
.color-picker-hash {
|
||||
height: var(--default-button-size);
|
||||
flex-shrink: 0;
|
||||
@@ -445,74 +405,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-label-swatch-container {
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
width: var(--default-button-size);
|
||||
height: var(--default-button-size);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
// top-picks drag & drop (pinning + reordering) — shared parts live in
|
||||
// TopPicksDnD.scss
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
.color-picker-label-swatch {
|
||||
@include outlineButtonStyles;
|
||||
background-color: var(--swatch-color) !important;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
filter: var(--theme-filter);
|
||||
border: 0 !important;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--swatch-color);
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-keybinding {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
font-size: 0.7em;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
display: none;
|
||||
}
|
||||
// slot a dragged swatch would replace
|
||||
.color-picker__button.is-dnd-target {
|
||||
transform: scale(1.2);
|
||||
box-shadow: 0 0 0 1px var(--color-primary-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-type-canvasBackground .color-picker-keybinding {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.color-picker-type-elementBackground .color-picker-keybinding {
|
||||
color: #fff;
|
||||
}
|
||||
// drag & drop ghost content (see TopPicksDnD.scss) — outside the .excalidraw
|
||||
// scope
|
||||
.excalidraw-color-dnd-ghost-swatch {
|
||||
--top-picks-dnd-ghost-ring: 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
|
||||
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.color-picker-type-elementStroke .color-picker-keybinding {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
border-radius: 4px;
|
||||
|
||||
&.theme--dark {
|
||||
.color-picker-type-elementBackground .color-picker-keybinding {
|
||||
color: #000;
|
||||
}
|
||||
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
|
||||
color: #000;
|
||||
}
|
||||
&.is-transparent {
|
||||
background-color: #fff;
|
||||
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==");
|
||||
background-position: left center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { Popover } from "radix-ui";
|
||||
import clsx from "clsx";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { memo, useRef, useEffect } from "react";
|
||||
|
||||
import {
|
||||
applyDarkModeFilter,
|
||||
COLOR_OUTLINE_CONTRAST_THRESHOLD,
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
DEFAULT_ELEMENT_STROKE_PICKS,
|
||||
isColorDark,
|
||||
isWritableElement,
|
||||
THEME,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
import type { ExcalidrawElement, Theme } from "@excalidraw/element/types";
|
||||
|
||||
import { useAtom } from "../../editor-jotai";
|
||||
import { t } from "../../i18n";
|
||||
import { useExcalidrawContainer, useStylesPanelMode } from "../App";
|
||||
import { useApp, useExcalidrawContainer, useStylesPanelMode } from "../App";
|
||||
import { ButtonSeparator } from "../ButtonSeparator";
|
||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||
import { PropertiesPopover } from "../PropertiesPopover";
|
||||
@@ -31,6 +35,11 @@ import { Picker } from "./Picker";
|
||||
import PickerHeading from "./PickerHeading";
|
||||
import { TopPicks } from "./TopPicks";
|
||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||
import {
|
||||
ColorPickerDnDContext,
|
||||
useColorPickerDnD,
|
||||
useColorTopPicksDnD,
|
||||
} from "./colorTopPicksDnD";
|
||||
|
||||
import "./ColorPicker.scss";
|
||||
|
||||
@@ -54,6 +63,10 @@ interface ColorPickerProps {
|
||||
updateData: (formData?: any) => void;
|
||||
/** palette colors to hide from the popup, keeping hotkey positions */
|
||||
excludedColors?: readonly string[];
|
||||
/** allow users to customize the top picks strip by drag & dropping colors
|
||||
* from the picker popup onto it. The value names the
|
||||
* `appState.colorTopPicks` slot the customization is stored in */
|
||||
customizableTopPicks?: keyof AppState["colorTopPicks"];
|
||||
}
|
||||
|
||||
const ColorPickerPopupContent = ({
|
||||
@@ -67,6 +80,7 @@ const ColorPickerPopupContent = ({
|
||||
getOpenPopup,
|
||||
appState,
|
||||
excludedColors,
|
||||
onResetTopPicks,
|
||||
}: Pick<
|
||||
ColorPickerProps,
|
||||
| "type"
|
||||
@@ -80,8 +94,11 @@ const ColorPickerPopupContent = ({
|
||||
| "excludedColors"
|
||||
> & {
|
||||
getOpenPopup: () => AppState["openPopup"];
|
||||
/** present only while the top picks are customized */
|
||||
onResetTopPicks?: () => void;
|
||||
}) => {
|
||||
const { container } = useExcalidrawContainer();
|
||||
const app = useApp();
|
||||
const stylesPanelMode = useStylesPanelMode();
|
||||
const isCompactMode = stylesPanelMode !== "full";
|
||||
const isMobileMode = stylesPanelMode === "mobile";
|
||||
@@ -117,9 +134,33 @@ const ColorPickerPopupContent = ({
|
||||
// Improve focus handling for text editing scenarios
|
||||
preventAutoFocusOnTouch={!!appState.editingTextElement}
|
||||
onFocusOutside={(event) => {
|
||||
const target = event.target;
|
||||
// focus moving into the top-picks context menu — let the menu keep
|
||||
// it (stealing it back would clear the menu's focus-driven
|
||||
// `data-highlighted` styling and break its keyboard navigation)
|
||||
if (
|
||||
target instanceof app.ownerWindow.HTMLElement &&
|
||||
target.closest(".top-picks-dnd__context-menu")
|
||||
) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
// refocus due to eye dropper
|
||||
if (!isWritableElement(event.target)) {
|
||||
focusPickerContent();
|
||||
if (
|
||||
target instanceof app.ownerWindow.HTMLElement &&
|
||||
target.matches(":active")
|
||||
) {
|
||||
// the focus escaped because a button outside is being pressed
|
||||
// (e.g. a top pick) — stealing focus mid-press makes the browser
|
||||
// drop the button's activation (`:active` styling), so wait for
|
||||
// the release
|
||||
app.ownerWindow.addEventListener("pointerup", focusPickerContent, {
|
||||
once: true,
|
||||
});
|
||||
} else {
|
||||
focusPickerContent();
|
||||
}
|
||||
}
|
||||
event.preventDefault();
|
||||
}}
|
||||
@@ -140,8 +181,8 @@ const ColorPickerPopupContent = ({
|
||||
|
||||
// Refocus text editor when popover closes if we were editing text
|
||||
if (appState.editingTextElement) {
|
||||
setTimeout(() => {
|
||||
const textEditor = document.querySelector(
|
||||
app.ownerWindow.setTimeout(() => {
|
||||
const textEditor = app.ownerDocument.querySelector(
|
||||
".excalidraw-wysiwyg",
|
||||
) as HTMLTextAreaElement;
|
||||
if (textEditor) {
|
||||
@@ -156,18 +197,20 @@ const ColorPickerPopupContent = ({
|
||||
ref={colorPickerContentRef}
|
||||
palette={palette}
|
||||
excludedColors={excludedColors}
|
||||
onResetTopPicks={onResetTopPicks}
|
||||
color={color}
|
||||
theme={appState.theme}
|
||||
onChange={(changedColor) => {
|
||||
// Save caret position before color change if editing text
|
||||
const savedSelection = appState.editingTextElement
|
||||
? saveCaretPosition()
|
||||
? saveCaretPosition(app.ownerDocument)
|
||||
: null;
|
||||
|
||||
onChange(changedColor);
|
||||
|
||||
// Restore caret position after color change if editing text
|
||||
if (appState.editingTextElement && savedSelection) {
|
||||
restoreCaretPosition(savedSelection);
|
||||
restoreCaretPosition(savedSelection, app.ownerDocument);
|
||||
}
|
||||
}}
|
||||
onEyeDropperToggle={(force) => {
|
||||
@@ -214,10 +257,18 @@ const ColorPickerPopupContent = ({
|
||||
);
|
||||
};
|
||||
|
||||
const isColorPickerPopup = (
|
||||
popup: AppState["openPopup"],
|
||||
): popup is ColorPickerType =>
|
||||
popup === "elementStroke" ||
|
||||
popup === "elementBackground" ||
|
||||
popup === "canvasBackground";
|
||||
|
||||
const ColorPickerTrigger = ({
|
||||
label,
|
||||
color,
|
||||
type,
|
||||
theme,
|
||||
mode = "background",
|
||||
onToggle,
|
||||
editingTextElement,
|
||||
@@ -225,13 +276,19 @@ const ColorPickerTrigger = ({
|
||||
color: string | null;
|
||||
label: string;
|
||||
type: ColorPickerType;
|
||||
theme: Theme;
|
||||
mode?: "background" | "stroke";
|
||||
onToggle: () => void;
|
||||
editingTextElement?: boolean;
|
||||
}) => {
|
||||
const app = useApp();
|
||||
const stylesPanelMode = useStylesPanelMode();
|
||||
const isCompactMode = stylesPanelMode !== "full";
|
||||
const isMobileMode = stylesPanelMode === "mobile";
|
||||
const dnd = useColorPickerDnD();
|
||||
const displayColor = color
|
||||
? applyDarkModeFilter(color, theme === THEME.DARK)
|
||||
: null;
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// use pointerdown so we run before outside-close logic
|
||||
e.preventDefault();
|
||||
@@ -239,7 +296,7 @@ const ColorPickerTrigger = ({
|
||||
|
||||
// If editing text, temporarily disable the wysiwyg blur event
|
||||
if (editingTextElement) {
|
||||
temporarilyDisableTextEditorBlur();
|
||||
temporarilyDisableTextEditorBlur(app.ownerDocument);
|
||||
}
|
||||
|
||||
onToggle();
|
||||
@@ -256,7 +313,7 @@ const ColorPickerTrigger = ({
|
||||
"mobile-border": isMobileMode,
|
||||
})}
|
||||
aria-label={label}
|
||||
style={color ? { "--swatch-color": color } : undefined}
|
||||
style={displayColor ? { "--swatch-color": displayColor } : undefined}
|
||||
title={
|
||||
type === "elementStroke"
|
||||
? t("labels.showStroke")
|
||||
@@ -264,6 +321,11 @@ const ColorPickerTrigger = ({
|
||||
}
|
||||
data-openpopup={type}
|
||||
onClick={handleClick}
|
||||
// the active-color swatch can be dragged onto the top-picks strip to
|
||||
// pin the current (possibly custom) color
|
||||
onPointerDown={
|
||||
dnd ? (event) => dnd.startSourceDrag(event, color) : undefined
|
||||
}
|
||||
>
|
||||
<div className="color-picker__button-outline">{!color && slashIcon}</div>
|
||||
{isCompactMode && color && mode === "stroke" && (
|
||||
@@ -271,7 +333,8 @@ const ColorPickerTrigger = ({
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
|
||||
displayColor &&
|
||||
isColorDark(displayColor, COLOR_OUTLINE_CONTRAST_THRESHOLD)
|
||||
? "#fff"
|
||||
: "#111",
|
||||
}}
|
||||
@@ -284,7 +347,7 @@ const ColorPickerTrigger = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const ColorPicker = ({
|
||||
const ColorPickerComponent = ({
|
||||
type,
|
||||
color,
|
||||
onChange,
|
||||
@@ -295,6 +358,7 @@ export const ColorPicker = ({
|
||||
updateData,
|
||||
appState,
|
||||
excludedColors,
|
||||
customizableTopPicks,
|
||||
}: ColorPickerProps) => {
|
||||
const openRef = useRef(appState.openPopup);
|
||||
useEffect(() => {
|
||||
@@ -303,8 +367,55 @@ export const ColorPicker = ({
|
||||
const stylesPanelMode = useStylesPanelMode();
|
||||
const isCompactMode = stylesPanelMode !== "full";
|
||||
|
||||
const isTopPicksCustomizable = !!customizableTopPicks && !isCompactMode;
|
||||
|
||||
// user-pinned picks trump the (host-provided or default) baseline
|
||||
const customTopPicks =
|
||||
isTopPicksCustomizable && customizableTopPicks
|
||||
? appState.colorTopPicks?.[customizableTopPicks]
|
||||
: null;
|
||||
|
||||
// fully-resolved picks currently displayed in the strip — the baseline the
|
||||
// drag & drop customization starts from
|
||||
const effectiveTopPicks = customTopPicks?.length
|
||||
? customTopPicks
|
||||
: topPicks ??
|
||||
(type === "elementStroke"
|
||||
? DEFAULT_ELEMENT_STROKE_PICKS
|
||||
: DEFAULT_ELEMENT_BACKGROUND_PICKS);
|
||||
|
||||
const resetTopPicks =
|
||||
isTopPicksCustomizable && customizableTopPicks
|
||||
? () => {
|
||||
updateData({
|
||||
colorTopPicks: {
|
||||
...appState.colorTopPicks,
|
||||
[customizableTopPicks]: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const dnd = useColorTopPicksDnD({
|
||||
enabled: isTopPicksCustomizable,
|
||||
picks: effectiveTopPicks,
|
||||
onPicksChange: (picks) => {
|
||||
if (!customizableTopPicks) {
|
||||
return;
|
||||
}
|
||||
// NOTE the comparator comparing `appState.colorTopPicks` is what keeps
|
||||
// this captured spread fresh — see `areColorPickerPropsEqual`
|
||||
updateData({
|
||||
colorTopPicks: {
|
||||
...appState.colorTopPicks,
|
||||
[customizableTopPicks]: picks,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ColorPickerDnDContext.Provider value={isTopPicksCustomizable ? dnd : null}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -314,10 +425,26 @@ export const ColorPicker = ({
|
||||
>
|
||||
{!isCompactMode && (
|
||||
<TopPicks
|
||||
theme={appState.theme}
|
||||
activeColor={color}
|
||||
onChange={onChange}
|
||||
onChange={(pickedColor) => {
|
||||
onChange(pickedColor);
|
||||
// if another color-picker popup is open, follow the user's
|
||||
// focus over to this picker (same as clicking its trigger)
|
||||
if (
|
||||
isColorPickerPopup(appState.openPopup) &&
|
||||
appState.openPopup !== type
|
||||
) {
|
||||
// deferred: each updateData spreads the full pre-commit
|
||||
// appState, so issuing this in the same task would clobber
|
||||
// the color change committed by `onChange` above
|
||||
setTimeout(() => updateData({ openPopup: type }), 0);
|
||||
}
|
||||
}}
|
||||
type={type}
|
||||
topPicks={topPicks}
|
||||
topPicks={customTopPicks?.length ? customTopPicks : topPicks}
|
||||
isCustomized={!!customTopPicks?.length}
|
||||
onReset={resetTopPicks}
|
||||
/>
|
||||
)}
|
||||
{!isCompactMode && <ButtonSeparator />}
|
||||
@@ -334,6 +461,7 @@ export const ColorPicker = ({
|
||||
color={color}
|
||||
label={label}
|
||||
type={type}
|
||||
theme={appState.theme}
|
||||
mode={type === "elementStroke" ? "stroke" : "background"}
|
||||
editingTextElement={!!appState.editingTextElement}
|
||||
onToggle={() => {
|
||||
@@ -362,10 +490,58 @@ export const ColorPicker = ({
|
||||
updateData={updateData}
|
||||
getOpenPopup={() => openRef.current}
|
||||
appState={appState}
|
||||
onResetTopPicks={
|
||||
customTopPicks?.length ? resetTopPicks : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</div>
|
||||
</ColorPickerDnDContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* ColorPicker renders on every styles-panel render (selection churn during
|
||||
* box-select and the like), while its actual inputs are a handful of resolved
|
||||
* primitives and reference-stable objects — cheap `===` checks, no deep
|
||||
* comparisons:
|
||||
* - `palette` / `topPicks` / `excludedColors` are module constants,
|
||||
* - `appState.colorTopPicks` identity changes exactly when its content does.
|
||||
*
|
||||
* Function props (`onChange`, `updateData`) are deliberately ignored — they
|
||||
* are recreated every render but semantically stable (they read fresh state
|
||||
* at call time). Same for `elements`, which is only sampled once when the
|
||||
* popup opens (most-used custom colors).
|
||||
*/
|
||||
const areColorPickerPropsEqual = (
|
||||
prev: ColorPickerProps,
|
||||
next: ColorPickerProps,
|
||||
) => {
|
||||
// the open popup is interactive (hotkeys, eye dropper, most-used custom
|
||||
// colors) — never memoize it, including the open/close transitions
|
||||
if (
|
||||
prev.appState.openPopup === prev.type ||
|
||||
next.appState.openPopup === next.type
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
prev.type === next.type &&
|
||||
prev.color === next.color &&
|
||||
prev.label === next.label &&
|
||||
// popup open/close is rare, and the strip's click handler branches on
|
||||
// which popup is open (popup-switching) — keep its closure fresh
|
||||
prev.appState.openPopup === next.appState.openPopup &&
|
||||
prev.palette === next.palette &&
|
||||
prev.topPicks === next.topPicks &&
|
||||
prev.excludedColors === next.excludedColors &&
|
||||
prev.customizableTopPicks === next.customizableTopPicks &&
|
||||
prev.appState.theme === next.appState.theme &&
|
||||
prev.appState.colorTopPicks === next.appState.colorTopPicks &&
|
||||
// the trigger tweaks its click behavior while a text element is edited
|
||||
!!prev.appState.editingTextElement === !!next.appState.editingTextElement
|
||||
);
|
||||
};
|
||||
|
||||
export const ColorPicker = memo(ColorPickerComponent, areColorPickerPropsEqual);
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { applyDarkModeFilter, THEME } from "@excalidraw/common";
|
||||
|
||||
import type { Theme } from "@excalidraw/element/types";
|
||||
|
||||
import { useAtom } from "../../editor-jotai";
|
||||
|
||||
import HotkeyLabel from "./HotkeyLabel";
|
||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||
import { useColorPickerDnD } from "./colorTopPicksDnD";
|
||||
|
||||
interface CustomColorListProps {
|
||||
theme: Theme;
|
||||
colors: string[];
|
||||
color: string | null;
|
||||
onChange: (color: string) => void;
|
||||
@@ -14,6 +20,7 @@ interface CustomColorListProps {
|
||||
}
|
||||
|
||||
export const CustomColorList = ({
|
||||
theme,
|
||||
colors,
|
||||
color,
|
||||
onChange,
|
||||
@@ -22,6 +29,7 @@ export const CustomColorList = ({
|
||||
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
|
||||
activeColorPickerSectionAtom,
|
||||
);
|
||||
const dnd = useColorPickerDnD();
|
||||
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -34,6 +42,7 @@ export const CustomColorList = ({
|
||||
return (
|
||||
<div className="color-picker-content--default">
|
||||
{colors.map((c, i) => {
|
||||
const displayColor = applyDarkModeFilter(c, theme === THEME.DARK);
|
||||
return (
|
||||
<button
|
||||
ref={color === c ? btnRef : undefined}
|
||||
@@ -50,13 +59,16 @@ export const CustomColorList = ({
|
||||
onChange(c);
|
||||
setActiveColorPickerSection("custom");
|
||||
}}
|
||||
onPointerDown={
|
||||
dnd ? (event) => dnd.startSourceDrag(event, c) : undefined
|
||||
}
|
||||
title={c}
|
||||
aria-label={label}
|
||||
style={{ "--swatch-color": c }}
|
||||
style={{ "--swatch-color": displayColor }}
|
||||
key={i}
|
||||
>
|
||||
<div className="color-picker__button-outline" />
|
||||
<HotkeyLabel color={c} keyLabel={i + 1} />
|
||||
<HotkeyLabel color={displayColor} keyLabel={i + 1} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
KEYS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
import type { ExcalidrawElement, Theme } from "@excalidraw/element/types";
|
||||
|
||||
import type { ColorPaletteCustom } from "@excalidraw/common";
|
||||
|
||||
import { useAtom } from "../../editor-jotai";
|
||||
import { t } from "../../i18n";
|
||||
import { TopPicksTip } from "../TopPicksDnD/TopPicksTip";
|
||||
|
||||
import { CustomColorList } from "./CustomColorList";
|
||||
import PickerColorList from "./PickerColorList";
|
||||
@@ -26,10 +27,12 @@ import {
|
||||
isCustomColor,
|
||||
} from "./colorPickerUtils";
|
||||
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
|
||||
import { useColorPickerDnD } from "./colorTopPicksDnD";
|
||||
|
||||
import type { ColorPickerType } from "./colorPickerUtils";
|
||||
|
||||
interface PickerProps {
|
||||
theme: Theme;
|
||||
color: string | null;
|
||||
onChange: (color: string) => void;
|
||||
type: ColorPickerType;
|
||||
@@ -42,11 +45,14 @@ interface PickerProps {
|
||||
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
|
||||
showHotKey?: boolean;
|
||||
excludedColors?: readonly string[];
|
||||
/** present only while the top picks are customized */
|
||||
onResetTopPicks?: () => void;
|
||||
}
|
||||
|
||||
export const Picker = React.forwardRef(
|
||||
(
|
||||
{
|
||||
theme,
|
||||
color,
|
||||
onChange,
|
||||
type,
|
||||
@@ -59,6 +65,7 @@ export const Picker = React.forwardRef(
|
||||
onEscape,
|
||||
showHotKey = true,
|
||||
excludedColors,
|
||||
onResetTopPicks,
|
||||
}: PickerProps,
|
||||
ref,
|
||||
) => {
|
||||
@@ -80,6 +87,7 @@ export const Picker = React.forwardRef(
|
||||
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
|
||||
activeColorPickerSectionAtom,
|
||||
);
|
||||
const dnd = useColorPickerDnD();
|
||||
|
||||
const colorObj = getColorNameAndShadeFromColor({
|
||||
color,
|
||||
@@ -177,6 +185,7 @@ export const Picker = React.forwardRef(
|
||||
{t("colorPicker.mostUsedCustomColors")}
|
||||
</PickerHeading>
|
||||
<CustomColorList
|
||||
theme={theme}
|
||||
colors={customColors}
|
||||
color={color}
|
||||
label={t("colorPicker.mostUsedCustomColors")}
|
||||
@@ -188,6 +197,7 @@ export const Picker = React.forwardRef(
|
||||
<div>
|
||||
<PickerHeading>{t("colorPicker.colors")}</PickerHeading>
|
||||
<PickerColorList
|
||||
theme={theme}
|
||||
color={color}
|
||||
palette={palette}
|
||||
onChange={onChange}
|
||||
@@ -200,6 +210,7 @@ export const Picker = React.forwardRef(
|
||||
<div>
|
||||
<PickerHeading>{t("colorPicker.shades")}</PickerHeading>
|
||||
<ShadeList
|
||||
theme={theme}
|
||||
color={color}
|
||||
onChange={onChange}
|
||||
palette={palette}
|
||||
@@ -207,6 +218,14 @@ export const Picker = React.forwardRef(
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
{/* dnd context is only provided when top picks are customizable */}
|
||||
{dnd && (
|
||||
<TopPicksTip
|
||||
tip={t("colorPicker.topPicksTip")}
|
||||
onReset={onResetTopPicks}
|
||||
resetTitle={t("colorPicker.resetTopPicks")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { ColorPaletteCustom } from "@excalidraw/common";
|
||||
import {
|
||||
applyDarkModeFilter,
|
||||
isColorDark,
|
||||
THEME,
|
||||
type ColorPaletteCustom,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { Theme } from "@excalidraw/element/types";
|
||||
|
||||
import { useAtom } from "../../editor-jotai";
|
||||
import { t } from "../../i18n";
|
||||
@@ -12,10 +19,12 @@ import {
|
||||
colorPickerHotkeyBindings,
|
||||
getColorNameAndShadeFromColor,
|
||||
} from "./colorPickerUtils";
|
||||
import { useColorPickerDnD } from "./colorTopPicksDnD";
|
||||
|
||||
import type { TranslationKeys } from "../../i18n";
|
||||
|
||||
interface PickerColorListProps {
|
||||
theme: Theme;
|
||||
palette: ColorPaletteCustom;
|
||||
color: string | null;
|
||||
onChange: (color: string) => void;
|
||||
@@ -29,6 +38,7 @@ interface PickerColorListProps {
|
||||
}
|
||||
|
||||
const PickerColorList = ({
|
||||
theme,
|
||||
palette,
|
||||
color,
|
||||
onChange,
|
||||
@@ -43,6 +53,7 @@ const PickerColorList = ({
|
||||
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
|
||||
activeColorPickerSectionAtom,
|
||||
);
|
||||
const dnd = useColorPickerDnD();
|
||||
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -72,6 +83,7 @@ const PickerColorList = ({
|
||||
}
|
||||
|
||||
const keybinding = colorPickerHotkeyBindings[index];
|
||||
const displayColor = applyDarkModeFilter(color, theme === THEME.DARK);
|
||||
const label = t(
|
||||
`colors.${key.replace(/\d+/, "")}` as unknown as TranslationKeys,
|
||||
null,
|
||||
@@ -84,8 +96,9 @@ const PickerColorList = ({
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
className={clsx(
|
||||
"color-picker__button color-picker__button--large has-outline",
|
||||
"color-picker__button color-picker__button--large",
|
||||
{
|
||||
"has-outline": !isColorDark(color, 255),
|
||||
active: colorObj?.colorName === key,
|
||||
"is-transparent": color === "transparent" || !color,
|
||||
},
|
||||
@@ -94,16 +107,21 @@ const PickerColorList = ({
|
||||
onChange(color);
|
||||
setActiveColorPickerSection("baseColors");
|
||||
}}
|
||||
onPointerDown={
|
||||
dnd ? (event) => dnd.startSourceDrag(event, color) : undefined
|
||||
}
|
||||
title={`${label}${
|
||||
color.startsWith("#") ? ` ${color}` : ""
|
||||
} — ${keybinding}`}
|
||||
aria-label={`${label} — ${keybinding}`}
|
||||
style={color ? { "--swatch-color": color } : undefined}
|
||||
style={color ? { "--swatch-color": displayColor } : undefined}
|
||||
data-testid={`color-${key}`}
|
||||
key={key}
|
||||
>
|
||||
<div className="color-picker__button-outline" />
|
||||
{showHotKey && <HotkeyLabel color={color} keyLabel={keybinding} />}
|
||||
{showHotKey && (
|
||||
<HotkeyLabel color={displayColor} keyLabel={keybinding} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { ColorPaletteCustom } from "@excalidraw/common";
|
||||
import {
|
||||
applyDarkModeFilter,
|
||||
THEME,
|
||||
type ColorPaletteCustom,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { Theme } from "@excalidraw/element/types";
|
||||
|
||||
import { useAtom } from "../../editor-jotai";
|
||||
import { t } from "../../i18n";
|
||||
@@ -11,8 +17,10 @@ import {
|
||||
activeColorPickerSectionAtom,
|
||||
getColorNameAndShadeFromColor,
|
||||
} from "./colorPickerUtils";
|
||||
import { useColorPickerDnD } from "./colorTopPicksDnD";
|
||||
|
||||
interface ShadeListProps {
|
||||
theme: Theme;
|
||||
color: string | null;
|
||||
onChange: (color: string) => void;
|
||||
palette: ColorPaletteCustom;
|
||||
@@ -20,6 +28,7 @@ interface ShadeListProps {
|
||||
}
|
||||
|
||||
export const ShadeList = ({
|
||||
theme,
|
||||
color,
|
||||
onChange,
|
||||
palette,
|
||||
@@ -33,6 +42,7 @@ export const ShadeList = ({
|
||||
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
|
||||
activeColorPickerSectionAtom,
|
||||
);
|
||||
const dnd = useColorPickerDnD();
|
||||
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -50,34 +60,45 @@ export const ShadeList = ({
|
||||
if (Array.isArray(shades)) {
|
||||
return (
|
||||
<div className="color-picker-content--default shades">
|
||||
{shades.map((color, i) => (
|
||||
<button
|
||||
ref={
|
||||
i === shade && activeColorPickerSection === "shades"
|
||||
? btnRef
|
||||
: undefined
|
||||
}
|
||||
tabIndex={-1}
|
||||
key={i}
|
||||
type="button"
|
||||
className={clsx(
|
||||
"color-picker__button color-picker__button--large has-outline",
|
||||
{ active: i === shade },
|
||||
)}
|
||||
aria-label="Shade"
|
||||
title={`${colorName} - ${i + 1}`}
|
||||
style={color ? { "--swatch-color": color } : undefined}
|
||||
onClick={() => {
|
||||
onChange(color);
|
||||
setActiveColorPickerSection("shades");
|
||||
}}
|
||||
>
|
||||
<div className="color-picker__button-outline" />
|
||||
{showHotKey && (
|
||||
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{shades.map((color, i) => {
|
||||
const displayColor = applyDarkModeFilter(
|
||||
color,
|
||||
theme === THEME.DARK,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
ref={
|
||||
i === shade && activeColorPickerSection === "shades"
|
||||
? btnRef
|
||||
: undefined
|
||||
}
|
||||
tabIndex={-1}
|
||||
key={i}
|
||||
type="button"
|
||||
className={clsx(
|
||||
"color-picker__button color-picker__button--large",
|
||||
{
|
||||
active: i === shade,
|
||||
},
|
||||
)}
|
||||
aria-label="Shade"
|
||||
title={`${colorName} - ${i + 1}`}
|
||||
style={color ? { "--swatch-color": displayColor } : undefined}
|
||||
onClick={() => {
|
||||
onChange(color);
|
||||
setActiveColorPickerSection("shades");
|
||||
}}
|
||||
onPointerDown={
|
||||
dnd ? (event) => dnd.startSourceDrag(event, color) : undefined
|
||||
}
|
||||
>
|
||||
<div className="color-picker__button-outline" />
|
||||
{showHotKey && (
|
||||
<HotkeyLabel color={displayColor} keyLabel={i + 1} isShade />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user