Compare commits

..

4 Commits

Author SHA1 Message Date
Mark Tolmacs 4e01cd73a7 feat: Attempt simple chaining of ovoids
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-01-05 16:51:25 +00:00
Mark Tolmacs c92ae37f50 fix: Endcap fix, but loop still closes
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-01-05 14:58:40 +00:00
Mark Tolmacs 071043adae feat: Add polygon debugging
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-01-05 12:59:02 +00:00
Mark Tolmacs 9616e63e23 feat: Ovoids
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-01-05 10:51:10 +00:00
295 changed files with 3854 additions and 18756 deletions
+2 -2
View File
@@ -12,7 +12,7 @@ VITE_APP_WS_SERVER_URL=http://localhost:3002
VITE_APP_PLUS_LP=https://plus.excalidraw.com VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=http://localhost:3000 VITE_APP_PLUS_APP=http://localhost:3000
VITE_APP_AI_BACKEND=http://localhost:3016 VITE_APP_AI_BACKEND=http://localhost:3015
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}' VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
@@ -27,7 +27,7 @@ VITE_APP_ENABLE_TRACKING=true
FAST_REFRESH=false FAST_REFRESH=false
# The port the run the dev server # The port the run the dev server
VITE_APP_PORT=3001 VITE_APP_PORT=3000
#Debug flags #Debug flags
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
export SENTRY_RELEASE=$(sentry-cli releases propose-version) export SENTRY_RELEASE=$(sentry-cli releases propose-version)
sentry-cli releases new $SENTRY_RELEASE --project $SENTRY_PROJECT sentry-cli releases new $SENTRY_RELEASE --project $SENTRY_PROJECT
sentry-cli releases set-commits --auto $SENTRY_RELEASE sentry-cli releases set-commits --auto $SENTRY_RELEASE
sentry-cli sourcemaps upload --release $SENTRY_RELEASE --no-rewrite ./build/static/js/ --url-prefix "~/static/js" sentry-cli releases files $SENTRY_RELEASE upload-sourcemaps --no-rewrite ./build/static/js/ --url-prefix "~/static/js"
sentry-cli releases finalize $SENTRY_RELEASE sentry-cli releases finalize $SENTRY_RELEASE
sentry-cli releases deploys $SENTRY_RELEASE new -e production sentry-cli releases deploys $SENTRY_RELEASE new -e production
env: env:
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
- name: "Install Node" - name: "Install Node"
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: "20.x" node-version: "18.x"
- name: "Install Deps" - name: "Install Deps"
run: yarn install run: yarn install
- name: "Test Coverage" - name: "Test Coverage"
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup Node.js - name: Setup Node.js 18.x
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 18.x
- name: Install and test - name: Install and test
run: | run: |
yarn install yarn install
+2 -6
View File
@@ -266,7 +266,7 @@ const initializeScene = async (opts: {
repairBindings: true, repairBindings: true,
deleteInvisibleElements: true, deleteInvisibleElements: true,
}), }),
scene.elements, localDataState?.elements,
), ),
appState: restoreAppState( appState: restoreAppState(
imported.appState, imported.appState,
@@ -551,11 +551,7 @@ const ExcalidrawWrapper = () => {
const username = importUsernameFromLocalStorage(); const username = importUsernameFromLocalStorage();
setLangCode(getPreferredLanguage()); setLangCode(getPreferredLanguage());
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
elements: restoreElements(localDataState?.elements, null, { ...localDataState,
repairBindings: true,
deleteInvisibleElements: true,
}),
appState: restoreAppState(localDataState?.appState, null),
captureUpdate: CaptureUpdateAction.NEVER, captureUpdate: CaptureUpdateAction.NEVER,
}); });
LibraryIndexedDBAdapter.load().then((data) => { LibraryIndexedDBAdapter.load().then((data) => {
-1
View File
@@ -46,7 +46,6 @@ export const STORAGE_KEYS = {
VERSION_FILES: "version-files", VERSION_FILES: "version-files",
IDB_LIBRARY: "excalidraw-library", IDB_LIBRARY: "excalidraw-library",
IDB_TTD_CHATS: "excalidraw-ttd-chats",
// do not use apart from migrations // do not use apart from migrations
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library", __LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
+52 -17
View File
@@ -4,15 +4,12 @@ import {
getTextFromElements, getTextFromElements,
MIME_TYPES, MIME_TYPES,
TTDDialog, TTDDialog,
TTDStreamFetch,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { getDataURL } from "@excalidraw/excalidraw/data/blob"; import { getDataURL } from "@excalidraw/excalidraw/data/blob";
import { safelyParseJSON } from "@excalidraw/common"; import { safelyParseJSON } from "@excalidraw/common";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { TTDIndexedDBAdapter } from "../data/TTDStorage";
export const AIComponents = ({ export const AIComponents = ({
excalidrawAPI, excalidrawAPI,
}: { }: {
@@ -102,23 +99,61 @@ export const AIComponents = ({
/> />
<TTDDialog <TTDDialog
onTextSubmit={async (props) => { onTextSubmit={async (input) => {
const { onChunk, onStreamCreated, signal, messages } = props; try {
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/generate`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt: input }),
},
);
const result = await TTDStreamFetch({ const rateLimit = response.headers.has("X-Ratelimit-Limit")
url: `${ ? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
import.meta.env.VITE_APP_AI_BACKEND : undefined;
}/v1/ai/text-to-diagram/chat-streaming`,
messages,
onChunk,
onStreamCreated,
extractRateLimits: true,
signal,
});
return result; const rateLimitRemaining = response.headers.has(
"X-Ratelimit-Remaining",
)
? parseInt(
response.headers.get("X-Ratelimit-Remaining") || "0",
10,
)
: undefined;
const json = await response.json();
if (!response.ok) {
if (response.status === 429) {
return {
rateLimit,
rateLimitRemaining,
error: new Error(
"Too many requests today, please try again tomorrow!",
),
};
}
throw new Error(json.message || "Generation failed...");
}
const generatedResponse = json.generatedResponse;
if (!generatedResponse) {
throw new Error("Generation failed...");
}
return { generatedResponse, rateLimit, rateLimitRemaining };
} catch (err: any) {
throw new Error("Request failed");
}
}} }}
persistenceAdapter={TTDIndexedDBAdapter}
/> />
</> </>
); );
+1 -2
View File
@@ -62,7 +62,7 @@ export const AppMainMenu: React.FC<{
{isDevEnv() && ( {isDevEnv() && (
<MainMenu.Item <MainMenu.Item
icon={eyeIcon} icon={eyeIcon}
onSelect={() => { onClick={() => {
if (window.visualDebug) { if (window.visualDebug) {
delete window.visualDebug; delete window.visualDebug;
saveDebugState({ enabled: false }); saveDebugState({ enabled: false });
@@ -77,7 +77,6 @@ export const AppMainMenu: React.FC<{
</MainMenu.Item> </MainMenu.Item>
)} )}
<MainMenu.Separator /> <MainMenu.Separator />
<MainMenu.DefaultItems.Preferences />
<MainMenu.DefaultItems.ToggleTheme <MainMenu.DefaultItems.ToggleTheme
allowSystemTheme allowSystemTheme
theme={props.theme} theme={props.theme}
@@ -33,15 +33,7 @@ export const AppWelcomeScreen: React.FC<{
return bit; return bit;
}); });
} else { } else {
headingContent = ( headingContent = t("welcomeScreen.app.center_heading");
<>
{t("welcomeScreen.app.center_heading")}
<br />
{t("welcomeScreen.app.center_heading_line2")}
<br />
{t("welcomeScreen.app.center_heading_line3")}
</>
);
} }
return ( return (
+23 -26
View File
@@ -27,10 +27,7 @@ import { isCurve } from "@excalidraw/math/curve";
import React from "react"; import React from "react";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import type { import type { DebugElement } from "@excalidraw/common";
DebugElement,
DebugPolygon,
} from "@excalidraw/element/visualdebug";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
@@ -78,44 +75,39 @@ const renderCubicBezier = (
context.restore(); context.restore();
}; };
const isPolygon = (data: any): data is GlobalPoint[] => {
return (
Array.isArray(data) &&
data.every((point) => Array.isArray(point) && point.length === 2)
);
};
const renderPolygon = ( const renderPolygon = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
zoom: number, zoom: number,
polygon: DebugPolygon, points: GlobalPoint[],
color: string, color: string,
) => { ) => {
const { points, fill, close } = polygon; if (points.length < 3) {
if (points.length < 2) {
return; return;
} }
context.save(); context.save();
context.fillStyle = color;
context.globalAlpha = 0.3;
context.beginPath(); context.beginPath();
context.moveTo(points[0][0] * zoom, points[0][1] * zoom); context.moveTo(points[0][0] * zoom, points[0][1] * zoom);
for (let i = 1; i < points.length; i += 1) { for (let i = 1; i < points.length; i++) {
context.lineTo(points[i][0] * zoom, points[i][1] * zoom); context.lineTo(points[i][0] * zoom, points[i][1] * zoom);
} }
if (close !== false) { context.closePath();
context.closePath(); context.fill();
} context.globalAlpha = 1.0;
if (fill) {
context.save();
context.globalAlpha = 0.15;
context.fillStyle = color;
context.fill();
context.restore();
}
context.strokeStyle = color; context.strokeStyle = color;
context.stroke(); context.stroke();
context.restore(); context.restore();
}; };
const isDebugPolygon = (data: DebugElement["data"]): data is DebugPolygon =>
(data as DebugPolygon).type === "polygon";
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => { const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
context.strokeStyle = "#888"; context.strokeStyle = "#888";
context.save(); context.save();
@@ -321,8 +313,13 @@ const render = (
el.color, el.color,
); );
break; break;
case isDebugPolygon(el.data): case isPolygon(el.data):
renderPolygon(context, appState.zoom.value, el.data, el.color); renderPolygon(
context,
appState.zoom.value,
el.data as GlobalPoint[],
el.color,
);
break; break;
default: default:
throw new Error(`Unknown element type ${JSON.stringify(el)}`); throw new Error(`Unknown element type ${JSON.stringify(el)}`);
@@ -0,0 +1,46 @@
import { THEME } from "@excalidraw/common";
import oc from "open-color";
import React from "react";
import type { Theme } from "@excalidraw/element/types";
// https://github.com/tholman/github-corners
export const GitHubCorner = React.memo(
({ theme, dir }: { theme: Theme; dir: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 250 250"
className="rtl-mirror"
style={{
marginTop: "calc(var(--space-factor) * -1)",
[dir === "rtl" ? "marginLeft" : "marginRight"]:
"calc(var(--space-factor) * -1)",
}}
>
<a
href="https://github.com/excalidraw/excalidraw"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub repository"
>
<path
d="M0 0l115 115h15l12 27 108 108V0z"
fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
/>
<path
className="octo-arm"
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
style={{ transformOrigin: "130px 106px" }}
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
/>
<path
className="octo-body"
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
/>
</a>
</svg>
),
);
+1 -3
View File
@@ -86,11 +86,9 @@ const saveDataStateToLocalStorage = (
_appState.openSidebar = null; _appState.openSidebar = null;
} }
const persistedElements = getNonDeletedElements(elements);
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(persistedElements), JSON.stringify(getNonDeletedElements(elements)),
); );
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
-51
View File
@@ -1,51 +0,0 @@
import { createStore, get, set } from "idb-keyval";
import type { SavedChats } from "@excalidraw/excalidraw/components/TTDDialog/types";
import { STORAGE_KEYS } from "../app_constants";
/**
* IndexedDB adapter for TTD chat storage.
* Implements TTDPersistenceAdapter interface.
*/
export class TTDIndexedDBAdapter {
/** IndexedDB database name */
private static idb_name = STORAGE_KEYS.IDB_TTD_CHATS;
/** Store key for chat data */
private static key = "ttdChats";
private static store = createStore(
`${TTDIndexedDBAdapter.idb_name}-db`,
`${TTDIndexedDBAdapter.idb_name}-store`,
);
/**
* Load saved chats from IndexedDB.
* @returns Promise resolving to saved chats array (empty if none found)
*/
static async loadChats(): Promise<SavedChats> {
try {
const data = await get<SavedChats>(
TTDIndexedDBAdapter.key,
TTDIndexedDBAdapter.store,
);
return data || [];
} catch (error) {
console.warn("Failed to load TTD chats from IndexedDB:", error);
return [];
}
}
/**
* Save chats to IndexedDB.
* @param chats - The chats array to persist
*/
static async saveChats(chats: SavedChats): Promise<void> {
try {
await set(TTDIndexedDBAdapter.key, chats, TTDIndexedDBAdapter.store);
} catch (error) {
console.warn("Failed to save TTD chats to IndexedDB:", error);
throw error;
}
}
}
+6 -6
View File
@@ -10,6 +10,8 @@ const lessPrecise = (num: number, precision = 5) =>
const getAvgFrameTime = (times: number[]) => const getAvgFrameTime = (times: number[]) =>
lessPrecise(times.reduce((a, b) => a + b) / times.length); lessPrecise(times.reduce((a, b) => a + b) / times.length);
const getFps = (frametime: number) => lessPrecise(1000 / frametime);
export class Debug { export class Debug {
public static DEBUG_LOG_TIMES = true; public static DEBUG_LOG_TIMES = true;
@@ -64,14 +66,12 @@ export class Debug {
} }
for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) { for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
if (times.length) { if (times.length) {
// const avgFrameTime = getAvgFrameTime(times); const avgFrameTime = getAvgFrameTime(times);
const totalTime = times.reduce((a, b) => a + b);
const avgFrameTime = lessPrecise(totalTime / Debug.FRAME_COUNT);
console.info( console.info(
name, name,
`- ${times.length} calls - ${avgFrameTime}ms/frame across ${ `${times.length} runs: ${avgFrameTime}ms across ${
Debug.FRAME_COUNT Debug.FRAME_COUNT
} frames (${lessPrecise( } frames (${getFps(avgFrameTime)} fps ~ ${lessPrecise(
(avgFrameTime / 16.67) * 100, (avgFrameTime / 16.67) * 100,
1, 1,
)}% of frame budget)`, )}% of frame budget)`,
@@ -136,7 +136,7 @@ export class Debug {
return (...args: T) => { return (...args: T) => {
const t0 = performance.now(); const t0 = performance.now();
const ret = fn(...args); const ret = fn(...args);
Debug[type](performance.now() - t0, name); Debug.logTime(performance.now() - t0, name);
return ret; return ret;
}; };
}; };
@@ -50,11 +50,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
<div <div
class="welcome-screen-center__heading welcome-screen-decor excalifont" class="welcome-screen-center__heading welcome-screen-decor excalifont"
> >
Your drawings are saved in your browser's storage. All your data is saved locally in your browser.
<br />
Browser storage can be cleared unexpectedly.
<br />
Save your work to a file regularly to avoid losing it.
</div> </div>
<div <div
class="welcome-screen-menu" class="welcome-screen-menu"
+14 -125
View File
@@ -69,114 +69,6 @@ vi.mock("socket.io-client", () => {
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously. * i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
*/ */
describe("collaboration", () => { describe("collaboration", () => {
it("should preserve future element fields across collab reconciliation", async () => {
await render(<ExcalidrawApp />);
const frame = API.createElement({
type: "frame",
id: "A",
width: 100,
height: 100,
x: 0,
y: 0,
backgroundColor: "#ff0000",
});
const frameWithFutureFields = {
...frame,
schemaState: {
tracks: {
...frame.schemaState.tracks,
"host.myapp.frame": 1,
},
},
futureField: "keep-me",
} as typeof frame & {
schemaState: { tracks: Record<string, number> };
futureField: string;
};
API.updateScene({
elements: [frameWithFutureFields],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
await waitFor(() => {
expect((h.elements[0] as any).futureField).toBe("keep-me");
expect((h.elements[0] as any).schemaState).toEqual(
frameWithFutureFields.schemaState,
);
expect(h.elements[0].backgroundColor).toBe("#ff0000");
});
const remoteMovedFrame = newElementWith(h.elements[0] as any, {
x: 120,
y: 80,
});
const reconciled = (window.collab as any)._reconcileElements([
remoteMovedFrame,
]);
expect(reconciled[0]).toEqual(
expect.objectContaining({
x: 120,
y: 80,
backgroundColor: "#ff0000",
}),
);
expect((reconciled[0] as any).futureField).toBe("keep-me");
expect((reconciled[0] as any).schemaState).toEqual(
frameWithFutureFields.schemaState,
);
});
it("should preserve future element fields on local edits before broadcast", async () => {
await render(<ExcalidrawApp />);
const rect = API.createElement({
type: "rectangle",
id: "A",
width: 100,
height: 100,
x: 0,
y: 0,
});
const rectWithFutureFields = {
...rect,
schemaState: {
tracks: {
...rect.schemaState.tracks,
"host.myapp.rect": 1,
},
},
futureField: { value: "keep-me" },
} as typeof rect & {
schemaState: { tracks: Record<string, number> };
futureField: { value: string };
};
API.updateScene({
elements: [rectWithFutureFields],
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
const locallyEdited = newElementWith(h.elements[0] as any, { x: 200 });
API.updateScene({
elements: [locallyEdited],
captureUpdate: CaptureUpdateAction.NEVER,
});
await waitFor(() => {
expect((h.elements[0] as any).futureField).toEqual({ value: "keep-me" });
expect((h.elements[0] as any).schemaState).toEqual(
rectWithFutureFields.schemaState,
);
expect(h.elements[0]).toEqual(expect.objectContaining({ x: 200 }));
});
});
it("should emit two ephemeral increments even though updates get batched", async () => { it("should emit two ephemeral increments even though updates get batched", async () => {
const durableIncrements: DurableIncrement[] = []; const durableIncrements: DurableIncrement[] = [];
const ephemeralIncrements: EphemeralIncrement[] = []; const ephemeralIncrements: EphemeralIncrement[] = [];
@@ -191,18 +83,14 @@ describe("collaboration", () => {
} }
}); });
// Ensure this test starts from a deterministic scene regardless of previous // eslint-disable-next-line dot-notation
// test state restored from persistence. expect(h.store["scheduledMicroActions"].length).toBe(0);
API.updateScene({ expect(durableIncrements.length).toBe(0);
elements: [], expect(ephemeralIncrements.length).toBe(0);
captureUpdate: CaptureUpdateAction.NEVER,
});
const durableBaseline = durableIncrements.length;
const ephemeralBaseline = ephemeralIncrements.length;
const rectProps = { const rectProps = {
type: "rectangle", type: "rectangle",
id: "A",
height: 200, height: 200,
width: 100, width: 100,
x: 0, x: 0,
@@ -217,7 +105,8 @@ describe("collaboration", () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(durableIncrements.length).toBe(durableBaseline + 1); // expect(commitSpy).toHaveBeenCalledTimes(1);
expect(durableIncrements.length).toBe(1);
}); });
// simulate two batched remote updates // simulate two batched remote updates
@@ -241,13 +130,13 @@ describe("collaboration", () => {
// altough the updates get batched, // altough the updates get batched,
// we expect two ephemeral increments for each update, // we expect two ephemeral increments for each update,
// and each such update should have the expected change // and each such update should have the expected change
expect(ephemeralIncrements.length).toBe(ephemeralBaseline + 2); expect(ephemeralIncrements.length).toBe(2);
expect( expect(ephemeralIncrements[0].change.elements.A).toEqual(
ephemeralIncrements[ephemeralBaseline].change.elements[rect.id], expect.objectContaining({ x: 100 }),
).toEqual(expect.objectContaining({ x: 100 })); );
expect( expect(ephemeralIncrements[1].change.elements.A).toEqual(
ephemeralIncrements[ephemeralBaseline + 1].change.elements[rect.id], expect.objectContaining({ x: 200 }),
).toEqual(expect.objectContaining({ x: 200 })); );
// eslint-disable-next-line dot-notation // eslint-disable-next-line dot-notation
expect(h.store["scheduledMicroActions"].length).toBe(0); expect(h.store["scheduledMicroActions"].length).toBe(0);
}); });
-5
View File
@@ -102,10 +102,6 @@ export default defineConfig(({ mode }) => {
// Taking the substring after "locales/" // Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`; return `locales/${id.substring(index + 8)}`;
} }
if (id.includes("@excalidraw/mermaid-to-excalidraw")) {
return "mermaid-to-excalidraw";
}
}, },
}, },
}, },
@@ -200,7 +196,6 @@ export default defineConfig(({ mode }) => {
}, },
}, },
], ],
maximumFileSizeToCacheInBytes: 2.3 * 1024 ** 2, // 2.3MB
}, },
manifest: { manifest: {
short_name: "Excalidraw", short_name: "Excalidraw",
@@ -1,97 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`COLOR_PALETTE > color palette doesn't regress 1`] = `
{
"black": "#1e1e1e",
"blue": [
"#e7f5ff",
"#a5d8ff",
"#4dabf7",
"#228be6",
"#1971c2",
],
"bronze": [
"#f8f1ee",
"#eaddd7",
"#d2bab0",
"#a18072",
"#846358",
],
"cyan": [
"#e3fafc",
"#99e9f2",
"#3bc9db",
"#15aabf",
"#0c8599",
],
"grape": [
"#f8f0fc",
"#eebefa",
"#da77f2",
"#be4bdb",
"#9c36b5",
],
"gray": [
"#f8f9fa",
"#e9ecef",
"#ced4da",
"#868e96",
"#343a40",
],
"green": [
"#ebfbee",
"#b2f2bb",
"#69db7c",
"#40c057",
"#2f9e44",
],
"orange": [
"#fff4e6",
"#ffd8a8",
"#ffa94d",
"#fd7e14",
"#e8590c",
],
"pink": [
"#fff0f6",
"#fcc2d7",
"#f783ac",
"#e64980",
"#c2255c",
],
"red": [
"#fff5f5",
"#ffc9c9",
"#ff8787",
"#fa5252",
"#e03131",
],
"teal": [
"#e6fcf5",
"#96f2d7",
"#38d9a9",
"#12b886",
"#099268",
],
"transparent": "transparent",
"violet": [
"#f3f0ff",
"#d0bfff",
"#9775fa",
"#7950f2",
"#6741d9",
],
"white": "#ffffff",
"yellow": [
"#fff9db",
"#ffec99",
"#ffd43b",
"#fab005",
"#f08c00",
],
}
`;
exports[`applyDarkModeFilter > COLOR_PALETTE regression tests > matches snapshot for all palette colors 1`] = ` exports[`applyDarkModeFilter > COLOR_PALETTE regression tests > matches snapshot for all palette colors 1`] = `
{ {
"black": "#d3d3d3", "black": "#d3d3d3",
-6
View File
@@ -4,12 +4,6 @@ import {
rgbToHex, rgbToHex,
} from "@excalidraw/common"; } from "@excalidraw/common";
describe("COLOR_PALETTE", () => {
it("color palette doesn't regress", () => {
expect(COLOR_PALETTE).toMatchSnapshot();
});
});
describe("applyDarkModeFilter", () => { describe("applyDarkModeFilter", () => {
describe("basic transformations", () => { describe("basic transformations", () => {
it("transforms black to near-white", () => { it("transforms black to near-white", () => {
+48 -102
View File
@@ -1,3 +1,4 @@
import oc from "open-color";
import tinycolor from "tinycolor2"; import tinycolor from "tinycolor2";
import { clamp } from "@excalidraw/math"; import { clamp } from "@excalidraw/math";
@@ -5,14 +6,18 @@ import { degreesToRadians } from "@excalidraw/math";
import type { Degrees } from "@excalidraw/math"; import type { Degrees } from "@excalidraw/math";
// --------------------------------------------------------------------------- import type { Merge } from "./utility-types";
// Dark mode color transformation
// --------------------------------------------------------------------------- export { tinycolor };
// Browser-only cache to avoid memory leaks on server // Browser-only cache to avoid memory leaks on server
const DARK_MODE_COLORS_CACHE: Map<string, string> | null = const DARK_MODE_COLORS_CACHE: Map<string, string> | null =
typeof window !== "undefined" ? new Map() : null; typeof window !== "undefined" ? new Map() : null;
// ---------------------------------------------------------------------------
// Dark mode color transformation
// ---------------------------------------------------------------------------
function cssHueRotate( function cssHueRotate(
red: number, red: number,
green: number, green: number,
@@ -110,8 +115,8 @@ export const applyDarkModeFilter = (color: string): string => {
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Color palette
// --------------------------------------------------------------------------- export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
// FIXME can't put to utils.ts rn because of circular dependency // FIXME can't put to utils.ts rn because of circular dependency
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>( const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
@@ -126,7 +131,15 @@ const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
}, {} as Pick<R, K[number]>) as Pick<R, K[number]>; }, {} as Pick<R, K[number]>) as Pick<R, K[number]>;
}; };
export type ColorPickerColor =
| Exclude<keyof oc, "indigo" | "lime">
| "transparent"
| "bronze";
export type ColorTuple = readonly [string, string, string, string, string]; export type ColorTuple = readonly [string, string, string, string, string];
export type ColorPalette = Merge<
Record<ColorPickerColor, ColorTuple>,
{ black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" }
>;
// used general type instead of specific type (ColorPalette) to support custom colors // used general type instead of specific type (ColorPalette) to support custom colors
export type ColorPaletteCustom = { [key: string]: ColorTuple | string }; export type ColorPaletteCustom = { [key: string]: ColorTuple | string };
@@ -139,30 +152,38 @@ export const DEFAULT_CHART_COLOR_INDEX = 4;
export const DEFAULT_ELEMENT_STROKE_COLOR_INDEX = 4; export const DEFAULT_ELEMENT_STROKE_COLOR_INDEX = 4;
export const DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX = 1; export const DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX = 1;
export const ELEMENTS_PALETTE_SHADE_INDEXES = [0, 2, 4, 6, 8] as const;
export const CANVAS_PALETTE_SHADE_INDEXES = [0, 1, 2, 3, 4] as const;
export const getSpecificColorShades = (
color: Exclude<
ColorPickerColor,
"transparent" | "white" | "black" | "bronze"
>,
indexArr: Readonly<ColorShadesIndexes>,
) => {
return indexArr.map((index) => oc[color][index]) as any as ColorTuple;
};
export const COLOR_PALETTE = { export const COLOR_PALETTE = {
transparent: "transparent", transparent: "transparent",
black: "#1e1e1e", black: "#1e1e1e",
white: "#ffffff", white: "#ffffff",
// open-color from https://github.com/yeun/open-color/blob/master/open-color.js // open-colors
// corresponds to indexes [0,2,4,6,8] (weights: 50, 200, 400, 600, 800) gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES),
gray: ["#f8f9fa", "#e9ecef", "#ced4da", "#868e96", "#343a40"], red: getSpecificColorShades("red", ELEMENTS_PALETTE_SHADE_INDEXES),
red: ["#fff5f5", "#ffc9c9", "#ff8787", "#fa5252", "#e03131"], pink: getSpecificColorShades("pink", ELEMENTS_PALETTE_SHADE_INDEXES),
pink: ["#fff0f6", "#fcc2d7", "#f783ac", "#e64980", "#c2255c"], grape: getSpecificColorShades("grape", ELEMENTS_PALETTE_SHADE_INDEXES),
grape: ["#f8f0fc", "#eebefa", "#da77f2", "#be4bdb", "#9c36b5"], violet: getSpecificColorShades("violet", ELEMENTS_PALETTE_SHADE_INDEXES),
violet: ["#f3f0ff", "#d0bfff", "#9775fa", "#7950f2", "#6741d9"], blue: getSpecificColorShades("blue", ELEMENTS_PALETTE_SHADE_INDEXES),
blue: ["#e7f5ff", "#a5d8ff", "#4dabf7", "#228be6", "#1971c2"], cyan: getSpecificColorShades("cyan", ELEMENTS_PALETTE_SHADE_INDEXES),
cyan: ["#e3fafc", "#99e9f2", "#3bc9db", "#15aabf", "#0c8599"], teal: getSpecificColorShades("teal", ELEMENTS_PALETTE_SHADE_INDEXES),
teal: ["#e6fcf5", "#96f2d7", "#38d9a9", "#12b886", "#099268"], green: getSpecificColorShades("green", ELEMENTS_PALETTE_SHADE_INDEXES),
green: ["#ebfbee", "#b2f2bb", "#69db7c", "#40c057", "#2f9e44"], yellow: getSpecificColorShades("yellow", ELEMENTS_PALETTE_SHADE_INDEXES),
yellow: ["#fff9db", "#ffec99", "#ffd43b", "#fab005", "#f08c00"], orange: getSpecificColorShades("orange", ELEMENTS_PALETTE_SHADE_INDEXES),
orange: ["#fff4e6", "#ffd8a8", "#ffa94d", "#fd7e14", "#e8590c"], // radix bronze shades 3,5,7,9,11
// radix bronze shades [3,5,7,9,11]
bronze: ["#f8f1ee", "#eaddd7", "#d2bab0", "#a18072", "#846358"], bronze: ["#f8f1ee", "#eaddd7", "#d2bab0", "#a18072", "#846358"],
} as const; } as ColorPalette;
export type ColorPalette = typeof COLOR_PALETTE;
export type ColorPickerColor = keyof typeof COLOR_PALETTE;
const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
"cyan", "cyan",
@@ -177,6 +198,7 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
"red", "red",
]); ]);
// -----------------------------------------------------------------------------
// quick picks defaults // quick picks defaults
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -211,6 +233,7 @@ export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
"#fdf8f6", "#fdf8f6",
] as ColorTuple; ] as ColorTuple;
// -----------------------------------------------------------------------------
// palette defaults // palette defaults
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -236,7 +259,8 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
...COMMON_ELEMENT_SHADES, ...COMMON_ELEMENT_SHADES,
} as const; } as const;
// color palette helpers // -----------------------------------------------------------------------------
// helpers
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!! // !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!!
@@ -257,10 +281,6 @@ export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
COLOR_PALETTE.red[index], COLOR_PALETTE.red[index],
] as const; ] as const;
// -----------------------------------------------------------------------------
// other helpers
// -----------------------------------------------------------------------------
export const rgbToHex = (r: number, g: number, b: number, a?: number) => { export const rgbToHex = (r: number, g: number, b: number, a?: number) => {
// (1 << 24) adds 0x1000000 to ensure the hex string is always 7 chars, // (1 << 24) adds 0x1000000 to ensure the hex string is always 7 chars,
// then slice(1) removes the leading "1" to get exactly 6 hex digits // then slice(1) removes the leading "1" to get exactly 6 hex digits
@@ -279,78 +299,4 @@ export const rgbToHex = (r: number, g: number, b: number, a?: number) => {
return hex6; return hex6;
}; };
/**
* @returns #RRGGBB or #RRGGBBAA based on color containing non-opaque alpha,
* null if not valid color
*/
export const colorToHex = (color: string): string | null => {
const tc = tinycolor(color);
if (!tc.isValid()) {
return null;
}
const { r, g, b, a } = tc.toRgb();
return rgbToHex(r, g, b, a);
};
export const isTransparent = (color: string) => {
return tinycolor(color).getAlpha() === 0;
};
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// color contract helpers
// -----------------------------------------------------------------------------
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
const calculateContrast = (r: number, g: number, b: number): number => {
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq;
};
// YIQ algo, inspiration from https://stackoverflow.com/a/11868398
export const isColorDark = (color: string, threshold = 160): boolean => {
// no color ("") -> assume it default to black
if (!color) {
return true;
}
if (isTransparent(color)) {
return false;
}
const tc = tinycolor(color);
if (!tc.isValid()) {
// invalid color -> assume it defaults to black
return true;
}
const { r, g, b } = tc.toRgb();
return calculateContrast(r, g, b) < threshold;
};
// -----------------------------------------------------------------------------
// normalization
// -----------------------------------------------------------------------------
/**
* tries to keep the input color as-is if it's valid, making minimal adjustments
* (trimming whitespace or adding `#` to hex colors)
*/
export const normalizeInputColor = (color: string): string | null => {
color = color.trim();
if (isTransparent(color)) {
return color;
}
const tc = tinycolor(color);
if (tc.isValid()) {
// testing for `#` first fixes a bug on Electron (more specfically, an
// Obsidian popout window), where a hex color without `#` is considered valid
if (["hex", "hex8"].includes(tc.getFormat()) && !color.startsWith("#")) {
return `#${color}`;
}
return color;
}
return null;
};
-4
View File
@@ -106,7 +106,6 @@ export const CLASSES = {
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup", CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope", SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
FRAME_NAME: "frame-name", FRAME_NAME: "frame-name",
DROPDOWN_MENU_EVENT_WRAPPER: "dropdown-menu-event-wrapper",
}; };
export const FONT_SIZES = { export const FONT_SIZES = {
@@ -191,8 +190,6 @@ export const THEME = {
DARK: "dark", DARK: "dark",
} as const; } as const;
export const DARK_THEME_FILTER = "invert(93%) hue-rotate(180deg)";
export const FRAME_STYLE = { export const FRAME_STYLE = {
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"], strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
strokeWidth: 2 as ExcalidrawElement["strokeWidth"], strokeWidth: 2 as ExcalidrawElement["strokeWidth"],
@@ -252,7 +249,6 @@ export const STRING_MIME_TYPES = {
json: "application/json", json: "application/json",
// excalidraw data // excalidraw data
excalidraw: "application/vnd.excalidraw+json", excalidraw: "application/vnd.excalidraw+json",
excalidrawClipboard: "application/vnd.excalidraw.clipboard+json",
// LEGACY: fully-qualified library JSON data // LEGACY: fully-qualified library JSON data
excalidrawlib: "application/vnd.excalidrawlib+json", excalidrawlib: "application/vnd.excalidrawlib+json",
// list of excalidraw library item ids // list of excalidraw library item ids
+3 -2
View File
@@ -16,6 +16,7 @@ export type EditorInterface = Readonly<{
const DESKTOP_UI_MODE_STORAGE_KEY = "excalidraw.desktopUIMode"; const DESKTOP_UI_MODE_STORAGE_KEY = "excalidraw.desktopUIMode";
// breakpoints // breakpoints
// mobile: up to 699px
export const MQ_MAX_MOBILE = 599; export const MQ_MAX_MOBILE = 599;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000; export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
@@ -23,9 +24,9 @@ export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
// tablets // tablets
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones) export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
export const MQ_MAX_TABLET = 1180; // ipad air export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
// desktop/laptop (NOTE: not used for form factor detection) // desktop/laptop
export const MQ_MIN_WIDTH_DESKTOP = 1440; export const MQ_MIN_WIDTH_DESKTOP = 1440;
// sidebar // sidebar
+1
View File
@@ -11,4 +11,5 @@ export * from "./random";
export * from "./url"; export * from "./url";
export * from "./utils"; export * from "./utils";
export * from "./emitter"; export * from "./emitter";
export * from "./visualdebug";
export * from "./editorInterface"; export * from "./editorInterface";
+5
View File
@@ -10,6 +10,7 @@ import type {
Zoom, Zoom,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import { tinycolor } from "./colors";
import { import {
DEFAULT_VERSION, DEFAULT_VERSION,
ENV, ENV,
@@ -547,6 +548,10 @@ export const mapFind = <T, K>(
return undefined; return undefined;
}; };
export const isTransparent = (color: string) => {
return tinycolor(color).getAlpha() === 0;
};
export type ResolvablePromise<T> = Promise<T> & { export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined] resolve: [T] extends [undefined]
? (value?: MaybePromise<Awaited<T>>) => void ? (value?: MaybePromise<Awaited<T>>) => void
@@ -1,24 +1,16 @@
import { import {
isLineSegment, isLineSegment,
lineSegment, lineSegment,
pointDistanceSq,
pointFrom, pointFrom,
type GlobalPoint, type GlobalPoint,
type LocalPoint, type LocalPoint,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { type Bounds, isBounds } from "@excalidraw/common";
import {
getElementBounds,
intersectElementWithLineSegment,
isFreeDrawElement,
isLinearElement,
isPathALoop,
} from "@excalidraw/element";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import type { LineSegment } from "@excalidraw/utils"; import type { LineSegment } from "@excalidraw/utils";
import { type Bounds, isBounds } from "./bounds";
// The global data holder to collect the debug operations // The global data holder to collect the debug operations
declare global { declare global {
interface Window { interface Window {
@@ -29,71 +21,14 @@ declare global {
} }
} }
export type Polygon = GlobalPoint[];
export type DebugElement = { export type DebugElement = {
color: string; color: string;
data: LineSegment<GlobalPoint> | Curve<GlobalPoint> | DebugPolygon; data: LineSegment<GlobalPoint> | Curve<GlobalPoint> | Polygon;
permanent: boolean; permanent: boolean;
}; };
export type DebugPolygon = {
type: "polygon";
points: GlobalPoint[];
fill?: boolean;
close?: boolean;
};
export const debugDrawHitVolume = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
options?: {
rays?: number;
color?: string;
fill?: boolean;
},
) => {
if (
(isLinearElement(element) || isFreeDrawElement(element)) &&
!isPathALoop(element.points)
) {
return;
}
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
const rays = options?.rays ?? 100;
const radius = Math.max(x2 - x1, y2 - y1) * 2;
const points: GlobalPoint[] = [];
for (let i = 0; i < rays; i += 1) {
const angle = (i / rays) * Math.PI * 2;
const end = pointFrom<GlobalPoint>(
center[0] + Math.cos(angle) * radius,
center[1] + Math.sin(angle) * radius,
);
const hits = intersectElementWithLineSegment(
element,
elementsMap,
lineSegment(center, end),
);
if (hits.length === 0) {
continue;
}
hits.sort(pointDistanceSq);
points.push(hits[0]);
}
if (points.length >= 3) {
debugDrawPolygon(points, {
color: options?.color ?? "orange",
fill: options?.fill ?? true,
});
} else {
console.warn(
`debugDrawHitVolume: could not compute hit volume for element ${element.id}`,
);
}
};
export const debugDrawCubicBezier = ( export const debugDrawCubicBezier = (
c: Curve<GlobalPoint>, c: Curve<GlobalPoint>,
opts?: { opts?: {
@@ -128,31 +63,6 @@ export const debugDrawLine = (
); );
}; };
export const debugDrawPolygon = (
points: GlobalPoint[],
opts?: {
color?: string;
permanent?: boolean;
fill?: boolean;
close?: boolean;
},
) => {
if (points.length < 2) {
return;
}
addToCurrentFrame({
color: opts?.color ?? "orange",
permanent: !!opts?.permanent,
data: {
type: "polygon",
points,
fill: opts?.fill,
close: opts?.close,
},
});
};
export const debugDrawPoint = ( export const debugDrawPoint = (
p: GlobalPoint, p: GlobalPoint,
opts?: { opts?: {
@@ -193,7 +103,7 @@ export const debugDrawBounds = (
permanent?: boolean; permanent?: boolean;
}, },
) => { ) => {
(isBounds(box) ? [box] : box).forEach((bbox: Bounds) => (isBounds(box) ? [box] : box).forEach((bbox) =>
debugDrawLine( debugDrawLine(
[ [
lineSegment( lineSegment(
@@ -221,6 +131,20 @@ export const debugDrawBounds = (
); );
}; };
export const debugDrawPolygon = (
points: GlobalPoint[],
opts?: {
color?: string;
permanent?: boolean;
},
) => {
addToCurrentFrame({
color: opts?.color ?? "blue",
data: points,
permanent: !!opts?.permanent,
});
};
export const debugDrawPoints = ( export const debugDrawPoints = (
{ {
x, x,
-6
View File
@@ -17,12 +17,6 @@
"development": "./dist/dev/index.js", "development": "./dist/dev/index.js",
"production": "./dist/prod/index.js", "production": "./dist/prod/index.js",
"default": "./dist/prod/index.js" "default": "./dist/prod/index.js"
},
"./visualdebug": {
"types": "./dist/types/element/src/visualdebug.d.ts",
"development": "./dist/dev/visualdebug.js",
"production": "./dist/prod/visualdebug.js",
"default": "./dist/prod/visualdebug.js"
} }
}, },
"files": [ "files": [
@@ -27,9 +27,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#66a80f", "strokeColor": "#66a80f",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -67,9 +64,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#9c36b5", "strokeColor": "#9c36b5",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -122,9 +116,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -186,9 +177,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -235,9 +223,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -281,9 +266,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"originalText": "HEYYYYY", "originalText": "HEYYYYY",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -330,9 +312,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"originalText": "Whats up ?", "originalText": "Whats up ?",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -393,9 +372,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -443,9 +419,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"originalText": "HELLO WORLD!!", "originalText": "HELLO WORLD!!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -506,9 +479,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -556,9 +526,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"originalText": "HELLO WORLD!!", "originalText": "HELLO WORLD!!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -599,9 +566,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -639,9 +603,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -699,9 +660,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -749,9 +707,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"originalText": "HELLO WORLD!!", "originalText": "HELLO WORLD!!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -798,9 +753,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"originalText": "HEYYYYY", "originalText": "HEYYYYY",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -847,9 +799,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"originalText": "WHATS UP ?", "originalText": "WHATS UP ?",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -885,9 +834,6 @@ exports[`Test Transform > should not allow duplicate ids 1`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -933,9 +879,6 @@ exports[`Test Transform > should transform linear elements 1`] = `
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -983,9 +926,6 @@ exports[`Test Transform > should transform linear elements 2`] = `
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": "dot", "startArrowhead": "dot",
"startBinding": null, "startBinding": null,
@@ -1033,9 +973,6 @@ exports[`Test Transform > should transform linear elements 3`] = `
"polygon": false, "polygon": false,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -1083,9 +1020,6 @@ exports[`Test Transform > should transform linear elements 4`] = `
"polygon": false, "polygon": false,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -1120,9 +1054,6 @@ exports[`Test Transform > should transform regular shapes 1`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1155,9 +1086,6 @@ exports[`Test Transform > should transform regular shapes 2`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1190,9 +1118,6 @@ exports[`Test Transform > should transform regular shapes 3`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1225,9 +1150,6 @@ exports[`Test Transform > should transform regular shapes 4`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1260,9 +1182,6 @@ exports[`Test Transform > should transform regular shapes 5`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "dotted", "strokeStyle": "dotted",
@@ -1295,9 +1214,6 @@ exports[`Test Transform > should transform regular shapes 6`] = `
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1971c2", "strokeColor": "#1971c2",
"strokeStyle": "dashed", "strokeStyle": "dashed",
@@ -1336,9 +1252,6 @@ exports[`Test Transform > should transform text element 1`] = `
"originalText": "HELLO WORLD!", "originalText": "HELLO WORLD!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1380,9 +1293,6 @@ exports[`Test Transform > should transform text element 2`] = `
"originalText": "STYLED HELLO WORLD!", "originalText": "STYLED HELLO WORLD!",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#5f3dc4", "strokeColor": "#5f3dc4",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1429,9 +1339,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1471,9 +1378,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1517,9 +1421,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1567,9 +1468,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1629,9 +1527,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"roundness": { "roundness": {
"type": 2, "type": 2,
}, },
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -1696,9 +1591,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"roundness": { "roundness": {
"type": 2, "type": 2,
}, },
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
@@ -1748,9 +1640,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "B", "originalText": "B",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1794,9 +1683,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "A", "originalText": "A",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1840,9 +1726,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "Alice", "originalText": "Alice",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1886,9 +1769,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "Bob", "originalText": "Bob",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1930,9 +1810,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "How are you?", "originalText": "How are you?",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -1974,9 +1851,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"originalText": "Friendship", "originalText": "Friendship",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2030,9 +1904,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -2085,9 +1956,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -2140,9 +2008,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -2195,9 +2060,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
], ],
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -2238,9 +2100,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "LABELED ARROW", "originalText": "LABELED ARROW",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2282,9 +2141,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "STYLED LABELED ARROW", "originalText": "STYLED LABELED ARROW",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#099268", "strokeColor": "#099268",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2326,9 +2182,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "ANOTHER STYLED LABELLED ARROW", "originalText": "ANOTHER STYLED LABELLED ARROW",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1098ad", "strokeColor": "#1098ad",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2371,9 +2224,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"originalText": "ANOTHER STYLED LABELLED ARROW", "originalText": "ANOTHER STYLED LABELLED ARROW",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#099268", "strokeColor": "#099268",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2415,9 +2265,6 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2455,9 +2302,6 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2495,9 +2339,6 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2535,9 +2376,6 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2575,9 +2413,6 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2615,9 +2450,6 @@ exports[`Test Transform > should transform to text containers when label provide
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#f08c00", "strokeColor": "#f08c00",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2656,9 +2488,6 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "RECTANGLE TEXT CONTAINER", "originalText": "RECTANGLE TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2700,9 +2529,6 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "ELLIPSE TEXT CONTAINER", "originalText": "ELLIPSE TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2746,9 +2572,6 @@ exports[`Test Transform > should transform to text containers when label provide
TEXT CONTAINER", TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2792,9 +2615,6 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "STYLED DIAMOND TEXT CONTAINER", "originalText": "STYLED DIAMOND TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#099268", "strokeColor": "#099268",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2837,9 +2657,6 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER", "originalText": "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -2883,9 +2700,6 @@ exports[`Test Transform > should transform to text containers when label provide
"originalText": "STYLED ELLIPSE TEXT CONTAINER", "originalText": "STYLED ELLIPSE TEXT CONTAINER",
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"schemaState": {
"tracks": {},
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#c2255c", "strokeColor": "#c2255c",
"strokeStyle": "solid", "strokeStyle": "solid",
-558
View File
@@ -1,558 +0,0 @@
import { pointDistance, pointFrom, type GlobalPoint } from "@excalidraw/math";
import { invariant } from "@excalidraw/common";
import type { AppState, NullableGridSize } from "@excalidraw/excalidraw/types";
import {
bindBindingElement,
calculateFixedPointForNonElbowArrowBinding,
FOCUS_POINT_SIZE,
getBindingGap,
getGlobalFixedPointForBindableElement,
isBindingEnabled,
maxBindingDistance_simple,
unbindBindingElement,
updateBoundPoint,
} from "../binding";
import {
isBindableElement,
isBindingElement,
isElbowArrow,
} from "../typeChecks";
import { LinearElementEditor } from "../linearElementEditor";
import { getHoveredElementForFocusPoint, hitElementItself } from "../collision";
import { moveArrowAboveBindable } from "../zindex";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
NonDeletedSceneElementsMap,
PointsPositionUpdates,
} from "../types";
import type { Scene } from "../Scene";
export const isFocusPointVisible = (
focusPoint: GlobalPoint,
arrow: ExcalidrawArrowElement,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
appState: {
isBindingEnabled: AppState["isBindingEnabled"];
zoom: AppState["zoom"];
},
startOrEnd: "start" | "end",
ignoreOverlap = false,
): boolean => {
// No focus point management for elbow arrows, because elbow arrows
// always have their focus point at the arrow point itself
if (
isElbowArrow(arrow) ||
!isBindingEnabled(appState) ||
arrow.points.length !== 2
) {
return false;
}
// Avoid showing the focus point indicator if the focus point is essentially
// on top of the arrow point it belongs to itself, if not ignoring specifically
if (!ignoreOverlap) {
const associatedPointIdx =
arrow.startBinding?.elementId === bindableElement.id
? 0
: arrow.points.length - 1;
const associatedArrowPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
associatedPointIdx,
elementsMap,
);
if (
pointDistance(focusPoint, associatedArrowPoint) <
(FOCUS_POINT_SIZE * 1.5) / appState.zoom.value
) {
return false;
}
}
const arrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "end" ? arrow.points.length - 1 : 0,
elementsMap,
);
// Check if the focus point is within the element's shape bounds
// Endpoint dragging takes precedence
return (
pointDistance(focusPoint, arrowPoint) >=
(FOCUS_POINT_SIZE * 1.5) / appState.zoom.value &&
hitElementItself({
element: bindableElement,
elementsMap,
point: focusPoint,
threshold: getBindingGap(bindableElement, arrow),
overrideShouldTestInside: true,
})
);
};
// Updates the arrow endpoints in "orbit" configuration
const focusPointUpdate = (
arrow: ExcalidrawArrowElement,
bindableElement: ExcalidrawBindableElement | null,
isStartBinding: boolean,
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
appState: AppState,
switchToInsideBinding: boolean,
) => {
const pointUpdates = new Map();
const bindingField = isStartBinding ? "startBinding" : "endBinding";
const adjacentBindingField = isStartBinding ? "endBinding" : "startBinding";
let currentBinding = arrow[bindingField];
let adjacentBinding = arrow[adjacentBindingField];
// Update the dragged focus point related end
if (currentBinding && bindableElement) {
// Update the targeted bindings
const boundToSameElement =
bindableElement &&
adjacentBinding &&
currentBinding.elementId === adjacentBinding.elementId;
if (switchToInsideBinding || boundToSameElement) {
currentBinding = {
...currentBinding,
mode: "inside",
};
} else {
currentBinding = {
...currentBinding,
mode: "orbit",
};
}
const pointIndex = isStartBinding ? 0 : arrow.points.length - 1;
const newPoint = updateBoundPoint(
arrow,
bindingField as "startBinding" | "endBinding",
currentBinding,
bindableElement,
elementsMap,
true,
);
if (newPoint) {
pointUpdates.set(pointIndex, { point: newPoint });
}
}
// Also update the adjacent end if it has a binding
if (adjacentBinding && adjacentBinding.mode === "orbit") {
const adjacentBindableElement = elementsMap.get(
adjacentBinding.elementId,
) as ExcalidrawBindableElement;
if (
adjacentBindableElement &&
isBindableElement(adjacentBindableElement) &&
isBindingEnabled(appState)
) {
// Same shape bound on both ends
const boundToSameElementAfterUpdate =
bindableElement && adjacentBinding.elementId === bindableElement.id;
if (switchToInsideBinding || boundToSameElementAfterUpdate) {
adjacentBinding = {
...adjacentBinding,
mode: "inside",
};
} else {
adjacentBinding = {
...adjacentBinding,
mode: "orbit",
};
}
const adjacentPointIndex = isStartBinding ? arrow.points.length - 1 : 0;
const adjacentNewPoint = updateBoundPoint(
arrow,
adjacentBindingField,
adjacentBinding,
adjacentBindableElement,
elementsMap,
);
if (adjacentNewPoint) {
pointUpdates.set(adjacentPointIndex, {
point: adjacentNewPoint,
});
}
}
}
if (pointUpdates.size > 0) {
LinearElementEditor.movePoints(arrow, scene, pointUpdates, {
[bindingField]: currentBinding,
[adjacentBindingField]: adjacentBinding,
});
}
};
export const handleFocusPointDrag = (
linearElementEditor: LinearElementEditor,
elementsMap: NonDeletedSceneElementsMap,
pointerCoords: { x: number; y: number },
scene: Scene,
appState: AppState,
gridSize: NullableGridSize,
switchToInsideBinding: boolean,
) => {
const arrow = LinearElementEditor.getElement(
linearElementEditor.elementId,
elementsMap,
) as any;
// Sanity checks
if (
!arrow ||
!isBindingElement(arrow) ||
isElbowArrow(arrow) ||
!linearElementEditor.hoveredFocusPointBinding ||
!linearElementEditor.draggedFocusPointBinding
) {
return;
}
const isStartBinding =
linearElementEditor.draggedFocusPointBinding === "start";
const binding = isStartBinding ? arrow.startBinding : arrow.endBinding;
const { x: offsetX, y: offsetY } = linearElementEditor.pointerOffset;
const point = pointFrom<GlobalPoint>(
pointerCoords.x - offsetX,
pointerCoords.y - offsetY,
);
const bindingField = isStartBinding ? "startBinding" : "endBinding";
const hit = getHoveredElementForFocusPoint(
point,
arrow,
scene.getNonDeletedElements(),
elementsMap,
maxBindingDistance_simple(appState.zoom),
);
// Hovering a bindable element
if (hit && isBindingEnabled(appState)) {
// Break existing binding if bound to another shape or if binding is disabled
if (arrow[bindingField] && hit.id !== binding?.elementId) {
unbindBindingElement(
arrow,
linearElementEditor.draggedFocusPointBinding,
scene,
);
}
// Handle binding mode switch
const newMode =
switchToInsideBinding && arrow[bindingField]?.mode === "orbit"
? "inside"
: !switchToInsideBinding && arrow[bindingField]?.mode === "inside"
? "orbit"
: null;
// If no existing binding, create it
if (!arrow[bindingField] || newMode) {
// Create a new binding if none exists
bindBindingElement(
arrow,
hit,
newMode || "orbit",
linearElementEditor.draggedFocusPointBinding,
scene,
point,
);
}
// Update the binding's fixed point
scene.mutateElement(arrow, {
[bindingField]: {
...arrow[bindingField],
elementId: hit.id,
mode: newMode || arrow[bindingField]?.mode || "orbit",
...calculateFixedPointForNonElbowArrowBinding(
arrow,
hit,
linearElementEditor.draggedFocusPointBinding,
elementsMap,
point,
),
},
});
} else {
// Not hovering any bindable element, move the arrow endpoint
const pointUpdates: PointsPositionUpdates = new Map();
const pointIndex = isStartBinding ? 0 : arrow.points.length - 1;
pointUpdates.set(pointIndex, {
point: LinearElementEditor.createPointAt(
arrow,
elementsMap,
point[0],
point[1],
gridSize,
),
});
LinearElementEditor.movePoints(arrow, scene, pointUpdates);
if (arrow[bindingField]) {
unbindBindingElement(arrow, isStartBinding ? "start" : "end", scene);
}
}
// Update the arrow endpoints
focusPointUpdate(
arrow,
hit,
isStartBinding,
elementsMap,
scene,
appState,
switchToInsideBinding,
);
if (hit && isBindingEnabled(appState)) {
moveArrowAboveBindable(
point,
arrow,
scene.getElementsIncludingDeleted(),
elementsMap,
scene,
hit,
);
}
};
export const handleFocusPointPointerDown = (
arrow: ExcalidrawArrowElement,
pointerDownState: { origin: { x: number; y: number } },
elementsMap: NonDeletedSceneElementsMap,
appState: AppState,
): {
hitFocusPoint: "start" | "end" | null;
pointerOffset: { x: number; y: number };
} => {
const pointerPos = pointFrom(
pointerDownState.origin.x,
pointerDownState.origin.y,
);
const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value;
// Check start binding focus point
if (arrow.startBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.startBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"start",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return {
hitFocusPoint: "start",
pointerOffset: {
x: pointerPos[0] - focusPoint[0],
y: pointerPos[1] - focusPoint[1],
},
};
}
}
}
// Check end binding focus point (only if start not already hit)
if (arrow.endBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.endBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"end",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return {
hitFocusPoint: "end",
pointerOffset: {
x: pointerPos[0] - focusPoint[0],
y: pointerPos[1] - focusPoint[1],
},
};
}
}
}
return {
hitFocusPoint: null,
pointerOffset: { x: 0, y: 0 },
};
};
export const handleFocusPointPointerUp = (
linearElementEditor: LinearElementEditor,
scene: Scene,
) => {
invariant(
linearElementEditor.draggedFocusPointBinding,
"Must have a dragged focus point at pointer release",
);
const arrow = LinearElementEditor.getElement<ExcalidrawArrowElement>(
linearElementEditor.elementId,
scene.getNonDeletedElementsMap(),
);
invariant(arrow, "Arrow must be in the scene");
// Clean up
const bindingKey =
linearElementEditor.draggedFocusPointBinding === "start"
? "startBinding"
: "endBinding";
const otherBindingKey =
linearElementEditor.draggedFocusPointBinding === "start"
? "endBinding"
: "startBinding";
const boundElementId = arrow[bindingKey]?.elementId;
const otherBoundElementId = arrow[otherBindingKey]?.elementId;
const oldBoundElement =
boundElementId &&
scene
.getNonDeletedElements()
.find(
(element) =>
element.id !== boundElementId &&
element.id !== otherBoundElementId &&
isBindableElement(element) &&
element.boundElements?.find(({ id }) => id === arrow.id),
);
if (oldBoundElement) {
scene.mutateElement(oldBoundElement, {
boundElements: oldBoundElement.boundElements?.filter(
({ id }) => id !== arrow.id,
),
});
}
// Record the new bound element
const boundElement =
boundElementId && scene.getNonDeletedElementsMap().get(boundElementId);
if (boundElement) {
scene.mutateElement(boundElement, {
boundElements: [
...(boundElement.boundElements || [])?.filter(
({ id }) => id !== arrow.id,
),
{
id: arrow.id,
type: "arrow",
},
],
});
}
};
export const handleFocusPointHover = (
arrow: ExcalidrawArrowElement,
scenePointerX: number,
scenePointerY: number,
scene: Scene,
appState: AppState,
): "start" | "end" | null => {
const elementsMap = scene.getNonDeletedElementsMap();
const pointerPos = pointFrom(scenePointerX, scenePointerY);
const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value;
// Check start binding focus point
if (arrow.startBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.startBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"start",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return "start";
}
}
}
// Check end binding focus point (only if start not already hovered)
if (arrow.endBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.endBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"end",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return "end";
}
}
}
return null;
};
-45
View File
@@ -1,45 +0,0 @@
import type { App } from "@excalidraw/excalidraw/types";
import { LinearElementEditor } from "../linearElementEditor";
import { handleFocusPointDrag } from "./focus";
export const maybeHandleArrowPointlikeDrag = ({
app,
event,
}: {
app: App;
event: KeyboardEvent | React.KeyboardEvent<Element> | PointerEvent;
}): boolean => {
const appState = app.state;
if (appState.selectedLinearElement && app.lastPointerMoveCoords) {
// Update focus point status if the binding mode is changing
if (appState.selectedLinearElement.draggedFocusPointBinding) {
handleFocusPointDrag(
appState.selectedLinearElement,
app.scene.getNonDeletedElementsMap(),
app.lastPointerMoveCoords,
app.scene,
appState,
app.getEffectiveGridSize(),
event.altKey,
);
return true;
} else if (
appState.selectedLinearElement.hoverPointIndex !== null &&
app.lastPointerMoveEvent &&
appState.selectedLinearElement.initialState.lastClickedPoint >= 0 &&
appState.selectedLinearElement.isDragging
) {
LinearElementEditor.handlePointDragging(
app.lastPointerMoveEvent,
app,
app.lastPointerMoveCoords.x,
app.lastPointerMoveCoords.y,
appState.selectedLinearElement,
);
return true;
}
}
return false;
};
+185 -308
View File
@@ -15,7 +15,6 @@ import {
pointFrom, pointFrom,
pointFromVector, pointFromVector,
pointRotateRads, pointRotateRads,
pointsEqual,
vectorFromPoint, vectorFromPoint,
vectorNormalize, vectorNormalize,
vectorScale, vectorScale,
@@ -27,11 +26,14 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types"; import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
import type { Bounds } from "@excalidraw/common"; import type { Bounds } from "@excalidraw/common";
import { getCenterForBounds } from "./bounds"; import {
doBoundsIntersect,
getCenterForBounds,
getElementBounds,
} from "./bounds";
import { import {
getAllHoveredElementAtPoint, getAllHoveredElementAtPoint,
getHoveredElementForBinding, getHoveredElementForBinding,
hitElementItself,
intersectElementWithLineSegment, intersectElementWithLineSegment,
isBindableElementInsideOtherBindable, isBindableElementInsideOtherBindable,
isPointInElement, isPointInElement,
@@ -110,10 +112,8 @@ export type BindingStrategy =
* *
* IMPORTANT: currently must be > 0 (this also applies to the computed gap) * IMPORTANT: currently must be > 0 (this also applies to the computed gap)
*/ */
export const BASE_BINDING_GAP = 5; export const BASE_BINDING_GAP = 10;
export const BASE_BINDING_GAP_ELBOW = 5; export const BASE_BINDING_GAP_ELBOW = 5;
export const BASE_ARROW_MIN_LENGTH = 10;
export const FOCUS_POINT_SIZE = 10 / 1.5;
export const getBindingGap = ( export const getBindingGap = (
bindTarget: ExcalidrawBindableElement, bindTarget: ExcalidrawBindableElement,
@@ -143,9 +143,7 @@ export const shouldEnableBindingForPointerEvent = (
return !event[KEYS.CTRL_OR_CMD]; return !event[KEYS.CTRL_OR_CMD];
}; };
export const isBindingEnabled = (appState: { export const isBindingEnabled = (appState: AppState): boolean => {
isBindingEnabled: AppState["isBindingEnabled"];
}): boolean => {
return appState.isBindingEnabled; return appState.isBindingEnabled;
}; };
@@ -259,7 +257,7 @@ const bindingStrategyForElbowArrowEndpointDragging = (
globalPoint, globalPoint,
elements, elements,
elementsMap, elementsMap,
maxBindingDistance_simple(zoom), (element) => maxBindingDistance_simple(zoom),
); );
const current = hit const current = hit
@@ -684,7 +682,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
globalPoint, globalPoint,
elements, elements,
elementsMap, elementsMap,
maxBindingDistance_simple(appState.zoom), (e) => maxBindingDistance_simple(appState.zoom),
); );
const pointInElement = const pointInElement =
hit && hit &&
@@ -711,13 +709,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
const otherFocusPointIsInElement = const otherFocusPointIsInElement =
otherBindableElement && otherBindableElement &&
otherFocusPoint && otherFocusPoint &&
hitElementItself({ isPointInElement(otherFocusPoint, otherBindableElement, elementsMap);
point: otherFocusPoint,
element: otherBindableElement,
elementsMap,
threshold: 0,
overrideShouldTestInside: true,
});
// Handle outside-outside binding to the same element // Handle outside-outside binding to the same element
if (otherBinding && otherBinding.elementId === hit?.id) { if (otherBinding && otherBinding.elementId === hit?.id) {
@@ -797,7 +789,6 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
hit, hit,
startDragged ? "start" : "end", startDragged ? "start" : "end",
elementsMap, elementsMap,
appState.zoom,
) || globalPoint, ) || globalPoint,
} }
: { mode: null }; : { mode: null };
@@ -807,24 +798,11 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
startDragged ? -1 : 0, startDragged ? -1 : 0,
elementsMap, elementsMap,
); );
const pointIsCloseToOtherElement =
otherFocusPoint && const other: BindingStrategy =
otherBindableElement && otherBindableElement &&
hitElementItself({ !otherFocusPointIsInElement &&
point: globalPoint, appState.selectedLinearElement?.initialState.altFocusPoint
element: otherBindableElement,
elementsMap,
threshold: maxBindingDistance_simple(appState.zoom),
overrideShouldTestInside: true,
});
const otherNeverOverride = opts?.newArrow
? appState.selectedLinearElement?.initialState.arrowStartIsInside
: otherBinding?.mode === "inside";
const other: BindingStrategy = !otherNeverOverride
? otherBindableElement &&
!otherFocusPointIsInElement &&
!pointIsCloseToOtherElement &&
appState.selectedLinearElement?.initialState.altFocusPoint
? { ? {
mode: "orbit", mode: "orbit",
element: otherBindableElement, element: otherBindableElement,
@@ -841,11 +819,9 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
otherBindableElement, otherBindableElement,
startDragged ? "end" : "start", startDragged ? "end" : "start",
elementsMap, elementsMap,
appState.zoom,
) || otherEndpoint, ) || otherEndpoint,
} }
: { mode: undefined } : { mode: undefined };
: { mode: undefined };
return { return {
start: startDragged ? current : other, start: startDragged ? current : other,
@@ -1109,7 +1085,7 @@ export const updateBoundElements = (
}); });
} }
const visitor = (element: ExcalidrawElement | undefined) => { boundElementsVisitor(elementsMap, changedElement, (element) => {
if (!isArrowElement(element) || element.isDeleted) { if (!isArrowElement(element) || element.isDeleted) {
return; return;
} }
@@ -1181,71 +1157,7 @@ export const updateBoundElements = (
if (boundText && !boundText.isDeleted) { if (boundText && !boundText.isDeleted) {
handleBindTextResize(element, scene, false); handleBindTextResize(element, scene, false);
} }
}; });
boundElementsVisitor(elementsMap, changedElement, visitor);
};
const updateArrowBindings = (
latestElement: ExcalidrawArrowElement,
startOrEnd: "startBinding" | "endBinding",
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
appState: AppState,
) => {
invariant(
!isElbowArrow(latestElement),
"Elbow arrows not supported for indirect updates",
);
const binding = latestElement[startOrEnd];
const bindableElement =
binding &&
(elementsMap.get(binding.elementId) as ExcalidrawBindableElement);
const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
latestElement,
startOrEnd === "startBinding" ? 0 : -1,
elementsMap,
);
const hit =
bindableElement &&
hitElementItself({
element: bindableElement,
point,
elementsMap,
threshold: maxBindingDistance_simple(appState.zoom),
});
const strategyName = startOrEnd === "startBinding" ? "start" : "end";
unbindBindingElement(latestElement, strategyName, scene);
if (hit) {
const pointIdx =
startOrEnd === "startBinding" ? 0 : latestElement.points.length - 1;
const localPoint = latestElement.points[pointIdx];
const strategy =
getBindingStrategyForDraggingBindingElementEndpoints_simple(
latestElement,
new Map([[pointIdx, { point: localPoint }]]),
point[0],
point[1],
elementsMap,
scene.getNonDeletedElements(),
appState,
);
if (
strategy[strategyName] &&
strategy[strategyName].element?.id === bindableElement.id &&
strategy[strategyName].mode
) {
bindBindingElement(
latestElement,
bindableElement,
strategy[strategyName].mode,
strategyName,
scene,
strategy[strategyName].focusPoint,
);
}
}
}; };
export const updateBindings = ( export const updateBindings = (
@@ -1258,27 +1170,14 @@ export const updateBindings = (
}, },
) => { ) => {
if (isArrowElement(latestElement)) { if (isArrowElement(latestElement)) {
const elementsMap = scene.getNonDeletedElementsMap(); bindOrUnbindBindingElement(
latestElement,
if (latestElement.startBinding) { new Map(),
updateArrowBindings( Infinity,
latestElement, Infinity,
"startBinding", scene,
elementsMap, appState,
scene, );
appState,
);
}
if (latestElement.endBinding) {
updateArrowBindings(
latestElement,
"endBinding",
elementsMap,
scene,
appState,
);
}
} else { } else {
updateBoundElements(latestElement, scene, { updateBoundElements(latestElement, scene, {
...options, ...options,
@@ -1392,16 +1291,14 @@ export const bindPointToSnapToElementOutline = (
headingForPointFromElement(bindableElement, aabb, point), headingForPointFromElement(bindableElement, aabb, point),
); );
const snapPoint = snapToMid( const snapPoint = snapToMid(
arrowElement,
bindableElement, bindableElement,
elementsMap, elementsMap,
edgePoint, edgePoint,
0.05,
arrowElement,
); );
const resolved = snapPoint || point;
const otherPoint = pointFrom<GlobalPoint>( const otherPoint = pointFrom<GlobalPoint>(
isHorizontal ? bindableCenter[0] : resolved[0], isHorizontal ? bindableCenter[0] : snapPoint[0],
!isHorizontal ? bindableCenter[1] : resolved[1], !isHorizontal ? bindableCenter[1] : snapPoint[1],
); );
const intersector = const intersector =
customIntersector ?? customIntersector ??
@@ -1409,7 +1306,7 @@ export const bindPointToSnapToElementOutline = (
otherPoint, otherPoint,
pointFromVector( pointFromVector(
vectorScale( vectorScale(
vectorNormalize(vectorFromPoint(resolved, otherPoint)), vectorNormalize(vectorFromPoint(snapPoint, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2, Math.max(bindableElement.width, bindableElement.height) * 2,
), ),
otherPoint, otherPoint,
@@ -1424,14 +1321,14 @@ export const bindPointToSnapToElementOutline = (
if (!intersection) { if (!intersection) {
const anotherPoint = pointFrom<GlobalPoint>( const anotherPoint = pointFrom<GlobalPoint>(
!isHorizontal ? bindableCenter[0] : resolved[0], !isHorizontal ? bindableCenter[0] : snapPoint[0],
isHorizontal ? bindableCenter[1] : resolved[1], isHorizontal ? bindableCenter[1] : snapPoint[1],
); );
const anotherIntersector = lineSegment( const anotherIntersector = lineSegment(
anotherPoint, anotherPoint,
pointFromVector( pointFromVector(
vectorScale( vectorScale(
vectorNormalize(vectorFromPoint(resolved, anotherPoint)), vectorNormalize(vectorFromPoint(snapPoint, anotherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2, Math.max(bindableElement.width, bindableElement.height) * 2,
), ),
anotherPoint, anotherPoint,
@@ -1578,18 +1475,18 @@ export const avoidRectangularCorner = (
return p; return p;
}; };
export const snapToMid = ( const snapToMid = (
arrowElement: ExcalidrawArrowElement,
bindTarget: ExcalidrawBindableElement, bindTarget: ExcalidrawBindableElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
tolerance: number = 0.05, tolerance: number = 0.05,
arrowElement?: ExcalidrawArrowElement, ): GlobalPoint => {
): GlobalPoint | undefined => {
const { x, y, width, height, angle } = bindTarget; const { x, y, width, height, angle } = bindTarget;
const center = elementCenterPoint(bindTarget, elementsMap, -0.1, -0.1); const center = elementCenterPoint(bindTarget, elementsMap, -0.1, -0.1);
const nonRotated = pointRotateRads(p, center, -angle as Radians); const nonRotated = pointRotateRads(p, center, -angle as Radians);
const bindingGap = arrowElement ? getBindingGap(bindTarget, arrowElement) : 0; const bindingGap = getBindingGap(bindTarget, arrowElement);
// snap-to-center point is adaptive to element size, but we don't want to go // snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance // above and below certain px distance
@@ -1598,7 +1495,7 @@ export const snapToMid = (
// Too close to the center makes it hard to resolve direction precisely // Too close to the center makes it hard to resolve direction precisely
if (pointDistance(center, nonRotated) < bindingGap) { if (pointDistance(center, nonRotated) < bindingGap) {
return undefined; return p;
} }
if ( if (
@@ -1607,8 +1504,8 @@ export const snapToMid = (
nonRotated[1] < center[1] + verticalThreshold nonRotated[1] < center[1] + verticalThreshold
) { ) {
// LEFT // LEFT
return pointRotateRads( return pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(x - bindingGap, center[1]), pointFrom(x - bindingGap, center[1]),
center, center,
angle, angle,
); );
@@ -1618,11 +1515,7 @@ export const snapToMid = (
nonRotated[0] < center[0] + horizontalThreshold nonRotated[0] < center[0] + horizontalThreshold
) { ) {
// TOP // TOP
return pointRotateRads( return pointRotateRads(pointFrom(center[0], y - bindingGap), center, angle);
pointFrom<GlobalPoint>(center[0], y - bindingGap),
center,
angle,
);
} else if ( } else if (
nonRotated[0] >= x + width / 2 && nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThreshold && nonRotated[1] > center[1] - verticalThreshold &&
@@ -1630,7 +1523,7 @@ export const snapToMid = (
) { ) {
// RIGHT // RIGHT
return pointRotateRads( return pointRotateRads(
pointFrom<GlobalPoint>(x + width + bindingGap, center[1]), pointFrom(x + width + bindingGap, center[1]),
center, center,
angle, angle,
); );
@@ -1641,7 +1534,7 @@ export const snapToMid = (
) { ) {
// DOWN // DOWN
return pointRotateRads( return pointRotateRads(
pointFrom<GlobalPoint>(center[0], y + height + bindingGap), pointFrom(center[0], y + height + bindingGap),
center, center,
angle, angle,
); );
@@ -1690,44 +1583,13 @@ export const snapToMid = (
} }
} }
return undefined; return p;
}; };
const extractBinding = ( const compareElementArea = (
arrow: ExcalidrawArrowElement, a: ExcalidrawBindableElement,
startOrEnd: "startBinding" | "endBinding", b: ExcalidrawBindableElement,
elementsMap: ElementsMap, ) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2);
) => {
const binding = arrow[startOrEnd];
if (!binding) {
return {
element: null,
fixedPoint: null,
focusPoint: null,
binding,
mode: null,
};
}
const element = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
return {
element,
fixedPoint: binding.fixedPoint,
focusPoint: getGlobalFixedPointForBindableElement(
normalizeFixedPoint(binding.fixedPoint),
element,
elementsMap,
),
binding,
mode: binding.mode,
};
};
const elementArea = (element: ExcalidrawBindableElement) =>
element.width * element.height;
export const updateBoundPoint = ( export const updateBoundPoint = (
arrow: NonDeleted<ExcalidrawArrowElement>, arrow: NonDeleted<ExcalidrawArrowElement>,
@@ -1735,151 +1597,162 @@ export const updateBoundPoint = (
binding: FixedPointBinding | null | undefined, binding: FixedPointBinding | null | undefined,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
dragging?: boolean, customIntersector?: LineSegment<GlobalPoint>,
): LocalPoint | null => { ): LocalPoint | null => {
if ( if (
binding == null || binding == null ||
// We only need to update the other end if this is a 2 point line element // We only need to update the other end if this is a 2 point line element
(binding.elementId !== bindableElement.id && arrow.points.length > 2) || (binding.elementId !== bindableElement.id && arrow.points.length > 2)
// Initial arrow created on pointer down needs to not update the points
pointsEqual(
arrow.points[arrow.points.length - 1],
pointFrom<LocalPoint>(0, 0),
)
) { ) {
return null; return null;
} }
const focusPoint = getGlobalFixedPointForBindableElement( const global = getGlobalFixedPointForBindableElement(
normalizeFixedPoint(binding.fixedPoint), normalizeFixedPoint(binding.fixedPoint),
bindableElement, bindableElement,
elementsMap, elementsMap,
); );
const pointIndex =
startOrEnd === "startBinding" ? 0 : arrow.points.length - 1;
const elbowed = isElbowArrow(arrow);
const otherBinding =
startOrEnd === "startBinding" ? arrow.endBinding : arrow.startBinding;
const otherBindableElement =
otherBinding &&
(elementsMap.get(otherBinding.elementId)! as ExcalidrawBindableElement);
const bounds = getElementBounds(bindableElement, elementsMap);
const otherBounds =
otherBindableElement && getElementBounds(otherBindableElement, elementsMap);
const isLargerThanOther =
otherBindableElement &&
compareElementArea(bindableElement, otherBindableElement) <
// if both shapes the same size, pretend the other is larger
(startOrEnd === "endBinding" ? 1 : 0);
const isOverlapping = otherBounds && doBoundsIntersect(bounds, otherBounds);
// 0. Short-circuit for inside binding as it doesn't require any // GOAL: If the arrow becomes too short, we want to jump the arrow endpoints
// calculations and is not affected by other bindings // to the exact focus points on the elements.
if (binding.mode === "inside") { // INTUITION: We're not interested in the exacts length of the arrow (which
return LinearElementEditor.createPointAt( // will change if we change where we route it), we want to know the length of
arrow, // the part which lies outside of both shapes and consider that as a trigger
elementsMap, // to change where we point the arrow. Avoids jumping the arrow in and out
focusPoint[0], // at every frame.
focusPoint[1], let arrowTooShort = false;
null,
);
}
const { element: otherBindable, focusPoint: otherFocusPoint } =
extractBinding(
arrow,
startOrEnd === "startBinding" ? "endBinding" : "startBinding",
elementsMap,
);
const otherArrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "startBinding" ? -1 : 0,
elementsMap,
);
const otherFocusPointOrArrowPoint = otherFocusPoint || otherArrowPoint;
const intersector =
otherFocusPointOrArrowPoint &&
lineSegment(focusPoint, otherFocusPointOrArrowPoint);
const otherOutlinePoint =
otherBindable &&
intersector &&
intersectElementWithLineSegment(
otherBindable,
elementsMap,
intersector,
getBindingGap(otherBindable, arrow),
).sort(
(a, b) => pointDistanceSq(a, focusPoint) - pointDistanceSq(b, focusPoint),
)[0];
const outlinePoint =
intersector &&
intersectElementWithLineSegment(
bindableElement,
elementsMap,
intersector,
getBindingGap(bindableElement, arrow),
).sort(
(a, b) =>
pointDistanceSq(a, otherFocusPointOrArrowPoint) -
pointDistanceSq(b, otherFocusPointOrArrowPoint),
)[0];
const startHasArrowhead = arrow.startArrowhead !== null;
const endHasArrowhead = arrow.endArrowhead !== null;
const resolvedTarget =
(!startHasArrowhead && !endHasArrowhead) ||
(startOrEnd === "startBinding" && startHasArrowhead) ||
(startOrEnd === "endBinding" && endHasArrowhead)
? focusPoint
: outlinePoint || focusPoint;
// 1. Handle case when the outline point (or focus point) is inside
// the other shape by short-circuiting to the focus point, otherwise
// the arrow would invert
if ( if (
otherBindable && !isOverlapping &&
outlinePoint && !elbowed &&
!dragging && arrow.startBinding &&
// Arbitrary threshold to handle wireframing use cases arrow.endBinding &&
elementArea(otherBindable) < elementArea(bindableElement) * 2 && otherBindableElement &&
hitElementItself({ arrow.points.length === 2
element: otherBindable,
point: outlinePoint,
elementsMap,
threshold: getBindingGap(otherBindable, arrow),
overrideShouldTestInside: true,
})
) { ) {
return LinearElementEditor.createPointAt( const startFocusPoint = getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
startOrEnd === "startBinding" ? bindableElement : otherBindableElement,
elementsMap,
);
const endFocusPoint = getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
startOrEnd === "endBinding" ? bindableElement : otherBindableElement,
elementsMap,
);
const segment = lineSegment(startFocusPoint, endFocusPoint);
const startIntersection = intersectElementWithLineSegment(
startOrEnd === "endBinding" ? bindableElement : otherBindableElement,
elementsMap,
segment,
0,
true,
);
const endIntersection = intersectElementWithLineSegment(
startOrEnd === "startBinding" ? bindableElement : otherBindableElement,
elementsMap,
segment,
0,
true,
);
if (startIntersection.length > 0 && endIntersection.length > 0) {
const len = pointDistance(startIntersection[0], endIntersection[0]);
arrowTooShort = len < 40;
}
}
const isNested = (arrowTooShort || isOverlapping) && isLargerThanOther;
let _customIntersector = customIntersector;
if (!elbowed && !_customIntersector) {
const [x1, y1, x2, y2] = LinearElementEditor.getElementAbsoluteCoords(
arrow, arrow,
elementsMap, elementsMap,
resolvedTarget[0], );
resolvedTarget[1], const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
null, const edgePoint = isRectanguloidElement(bindableElement)
? avoidRectangularCorner(arrow, bindableElement, elementsMap, global)
: global;
const adjacentPoint = pointRotateRads(
pointFrom<GlobalPoint>(
arrow.x +
arrow.points[pointIndex === 0 ? 1 : arrow.points.length - 2][0],
arrow.y +
arrow.points[pointIndex === 0 ? 1 : arrow.points.length - 2][1],
),
center,
arrow.angle as Radians,
);
const bindingGap = getBindingGap(bindableElement, arrow);
const halfVector = vectorScale(
vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)),
pointDistance(edgePoint, adjacentPoint) +
Math.max(bindableElement.width, bindableElement.height) +
bindingGap * 2,
);
_customIntersector = lineSegment(
pointFromVector(halfVector, adjacentPoint),
pointFromVector(vectorScale(halfVector, -1), adjacentPoint),
); );
} }
const otherTargetPoint = otherBindable const maybeOutlineGlobal =
? otherOutlinePoint || otherFocusPoint || otherArrowPoint binding.mode === "orbit" && bindableElement
: otherArrowPoint; ? isNested
const arrowTooShort = ? global
pointDistance(otherTargetPoint, outlinePoint || focusPoint) <= : bindPointToSnapToElementOutline(
BASE_ARROW_MIN_LENGTH; {
...arrow,
points: [
pointIndex === 0
? LinearElementEditor.createPointAt(
arrow,
elementsMap,
global[0],
global[1],
null,
)
: arrow.points[0],
...arrow.points.slice(1, -1),
pointIndex === arrow.points.length - 1
? LinearElementEditor.createPointAt(
arrow,
elementsMap,
global[0],
global[1],
null,
)
: arrow.points[arrow.points.length - 1],
],
},
bindableElement,
pointIndex === 0 ? "start" : "end",
elementsMap,
_customIntersector,
)
: global;
// 2. If the arrow is unconnected at the other end, just check arrow size
// and short-circuit to the focus point if the arrow is too short to
// avoid inversion
if (!otherBindable) {
return LinearElementEditor.createPointAt(
arrow,
elementsMap,
arrowTooShort ? focusPoint[0] : outlinePoint?.[0] ?? focusPoint[0],
arrowTooShort ? focusPoint[1] : outlinePoint?.[1] ?? focusPoint[1],
null,
);
}
// 3. If the arrow is too short while connected on both ends and
// the other arrow endpoint will not be inside the bindable, just
// check the arrow size and make a decision based on that
if (arrowTooShort) {
return LinearElementEditor.createPointAt(
arrow,
elementsMap,
resolvedTarget?.[0] || focusPoint[0],
resolvedTarget?.[1] || focusPoint[1],
null,
);
}
// 4. In the general case, snap to the outline if possible
return LinearElementEditor.createPointAt( return LinearElementEditor.createPointAt(
arrow, arrow,
elementsMap, elementsMap,
outlinePoint?.[0] || focusPoint[0], maybeOutlineGlobal[0],
outlinePoint?.[1] || focusPoint[1], maybeOutlineGlobal[1],
null, null,
); );
}; };
@@ -1929,7 +1802,7 @@ export const calculateFixedPointForNonElbowArrowBinding = (
elementsMap: ElementsMap, elementsMap: ElementsMap,
focusPoint?: GlobalPoint, focusPoint?: GlobalPoint,
): { fixedPoint: FixedPoint } => { ): { fixedPoint: FixedPoint } => {
const edgePoint: GlobalPoint = focusPoint const edgePoint = focusPoint
? focusPoint ? focusPoint
: LinearElementEditor.getPointAtIndexGlobalCoordinates( : LinearElementEditor.getPointAtIndexGlobalCoordinates(
linearElement, linearElement,
@@ -1937,7 +1810,11 @@ export const calculateFixedPointForNonElbowArrowBinding = (
elementsMap, elementsMap,
); );
const elementCenter = elementCenterPoint(hoveredElement, elementsMap); // Convert the global point to element-local coordinates
const elementCenter = pointFrom(
hoveredElement.x + hoveredElement.width / 2,
hoveredElement.y + hoveredElement.height / 2,
);
// Rotate the point to account for element rotation // Rotate the point to account for element rotation
const nonRotatedPoint = pointRotateRads( const nonRotatedPoint = pointRotateRads(
+6 -97
View File
@@ -59,11 +59,8 @@ import { LinearElementEditor } from "./linearElementEditor";
import { distanceToElement } from "./distance"; import { distanceToElement } from "./distance";
import { getBindingGap } from "./binding";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement, ExcalidrawElement,
@@ -78,12 +75,7 @@ import type {
} from "./types"; } from "./types";
export const shouldTestInside = (element: ExcalidrawElement) => { export const shouldTestInside = (element: ExcalidrawElement) => {
if ( if (element.type === "arrow") {
element.type === "arrow" ||
// frame elements should ignore inside hit test even if background is not
// transparent, so we can select children easily
isFrameLikeElement(element)
) {
return false; return false;
} }
@@ -113,12 +105,6 @@ export type HitTestArgs = {
overrideShouldTestInside?: boolean; overrideShouldTestInside?: boolean;
}; };
let cachedPoint: GlobalPoint | null = null;
let cachedElement: WeakRef<ExcalidrawElement> | null = null;
let cachedThreshold: number = Infinity;
let cachedHit: boolean = false;
let cachedOverrideShouldTestInside = false;
export const hitElementItself = ({ export const hitElementItself = ({
point, point,
element, element,
@@ -127,24 +113,6 @@ export const hitElementItself = ({
frameNameBound = null, frameNameBound = null,
overrideShouldTestInside = false, overrideShouldTestInside = false,
}: HitTestArgs) => { }: HitTestArgs) => {
// Return cached result if the same point and element version is tested again
if (
cachedPoint &&
pointsEqual(point, cachedPoint) &&
cachedThreshold <= threshold &&
overrideShouldTestInside === cachedOverrideShouldTestInside
) {
const derefElement = cachedElement?.deref();
if (
derefElement &&
derefElement.id === element.id &&
derefElement.version === element.version &&
derefElement.versionNonce === element.versionNonce
) {
return cachedHit;
}
}
// Hit test against a frame's name // Hit test against a frame's name
const hitFrameName = frameNameBound const hitFrameName = frameNameBound
? isPointWithinBounds( ? isPointWithinBounds(
@@ -185,16 +153,7 @@ export const hitElementItself = ({
isPointOnElementOutline(point, element, elementsMap, threshold) isPointOnElementOutline(point, element, elementsMap, threshold)
: isPointOnElementOutline(point, element, elementsMap, threshold); : isPointOnElementOutline(point, element, elementsMap, threshold);
const result = hitElement || hitFrameName; return hitElement || hitFrameName;
// Cache end result
cachedPoint = point;
cachedElement = new WeakRef(element);
cachedThreshold = threshold;
cachedOverrideShouldTestInside = overrideShouldTestInside;
cachedHit = result;
return result;
}; };
export const hitElementBoundingBox = ( export const hitElementBoundingBox = (
@@ -298,7 +257,7 @@ export const getAllHoveredElementAtPoint = (
point: Readonly<GlobalPoint>, point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
tolerance?: number, toleranceFn?: (element: ExcalidrawBindableElement) => number,
): NonDeleted<ExcalidrawBindableElement>[] => { ): NonDeleted<ExcalidrawBindableElement>[] => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = []; const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array) // We need to to hit testing from front (end of the array) to back (beginning of the array)
@@ -314,7 +273,7 @@ export const getAllHoveredElementAtPoint = (
if ( if (
isBindableElement(element, false) && isBindableElement(element, false) &&
bindingBorderTest(element, point, elementsMap, tolerance) bindingBorderTest(element, point, elementsMap, toleranceFn?.(element))
) { ) {
candidateElements.push(element); candidateElements.push(element);
@@ -331,13 +290,13 @@ export const getHoveredElementForBinding = (
point: Readonly<GlobalPoint>, point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
tolerance?: number, toleranceFn?: (element: ExcalidrawBindableElement) => number,
): NonDeleted<ExcalidrawBindableElement> | null => { ): NonDeleted<ExcalidrawBindableElement> | null => {
const candidateElements = getAllHoveredElementAtPoint( const candidateElements = getAllHoveredElementAtPoint(
point, point,
elements, elements,
elementsMap, elementsMap,
tolerance, toleranceFn,
); );
if (!candidateElements || candidateElements.length === 0) { if (!candidateElements || candidateElements.length === 0) {
@@ -356,56 +315,6 @@ export const getHoveredElementForBinding = (
.pop() as NonDeleted<ExcalidrawBindableElement>; .pop() as NonDeleted<ExcalidrawBindableElement>;
}; };
export const getHoveredElementForFocusPoint = (
point: GlobalPoint,
arrow: ExcalidrawArrowElement,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
tolerance?: number,
): 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>;
};
/** /**
* Intersect a line with an element for binding test * Intersect a line with an element for binding test
* *
+9 -20
View File
@@ -1,12 +1,10 @@
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds"; import { getCommonBoundingBox } from "./bounds";
import { newElementWith } from "./mutateElement";
import { getSelectedElementsByGroup } from "./groups"; import { getSelectedElementsByGroup } from "./groups";
import type { Scene } from "./Scene";
import type { ElementsMap, ExcalidrawElement } from "./types"; import type { ElementsMap, ExcalidrawElement } from "./types";
export interface Distribution { export interface Distribution {
@@ -19,7 +17,6 @@ export const distributeElements = (
elementsMap: ElementsMap, elementsMap: ElementsMap,
distribution: Distribution, distribution: Distribution,
appState: Readonly<AppState>, appState: Readonly<AppState>,
scene: Scene,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const [start, mid, end, extent] = const [start, mid, end, extent] =
distribution.axis === "x" distribution.axis === "x"
@@ -69,16 +66,12 @@ export const distributeElements = (
translation[distribution.axis] = pos - box[mid]; translation[distribution.axis] = pos - box[mid];
} }
return group.map((element) => { return group.map((element) =>
const updatedElement = scene.mutateElement(element, { newElementWith(element, {
x: element.x + translation.x, x: element.x + translation.x,
y: element.y + translation.y, y: element.y + translation.y,
}); }),
updateBoundElements(element, scene, { );
simultaneouslyUpdated: group,
});
return updatedElement;
});
}); });
} }
@@ -97,15 +90,11 @@ export const distributeElements = (
pos += step; pos += step;
pos += box[extent]; pos += box[extent];
return group.map((element) => { return group.map((element) =>
const updatedElement = scene.mutateElement(element, { newElementWith(element, {
x: element.x + translation.x, x: element.x + translation.x,
y: element.y + translation.y, y: element.y + translation.y,
}); }),
updateBoundElements(element, scene, { );
simultaneouslyUpdated: group,
});
return updatedElement;
});
}); });
}; };
+1 -1
View File
@@ -2276,7 +2276,7 @@ const getHoveredElement = (
origPoint, origPoint,
elements, elements,
elementsMap, elementsMap,
maxBindingDistance_simple(zoom), (element) => maxBindingDistance_simple(zoom),
); );
}; };
+2 -79
View File
@@ -56,7 +56,7 @@ const RE_REDDIT =
const RE_REDDIT_EMBED = const RE_REDDIT_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i; /^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
const parseYouTubeLikeTimestamp = (url: string): number => { const parseYouTubeTimestamp = (url: string): number => {
let timeParam: string | null | undefined; let timeParam: string | null | undefined;
try { try {
@@ -85,57 +85,11 @@ const parseYouTubeLikeTimestamp = (url: string): number => {
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds); return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
}; };
const parseGoogleDriveVideoLink = (
url: string,
): { fileId: string; resourceKey?: string; timestamp?: number } | null => {
try {
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
const hostname = urlObj.hostname.replace(/^www\./, "");
if (hostname !== "drive.google.com") {
return null;
}
let fileId: string | null = null;
const pathMatch = urlObj.pathname.match(/^\/file\/d\/([^/]+)(?:\/|$)/);
if (pathMatch?.[1]) {
fileId = pathMatch[1];
} else if (urlObj.pathname === "/open" || urlObj.pathname === "/uc") {
// Shared Drive links can be emitted as:
// - /open?id=<fileId> (common "open in Drive" format)
// - /uc?...&id=<fileId> (download/export endpoint often seen in copied links)
fileId = urlObj.searchParams.get("id");
}
if (!fileId || !/^[a-zA-Z0-9_-]+$/.test(fileId)) {
return null;
}
// Some Drive share links include `resourcekey` for access to link-shared
// files; preserve it in the preview URL so embeds keep working.
const resourceKey = urlObj.searchParams.get("resourcekey");
const timestamp = parseYouTubeLikeTimestamp(urlObj.toString());
return {
fileId,
resourceKey:
resourceKey && /^[a-zA-Z0-9_-]+$/.test(resourceKey)
? resourceKey
: undefined,
// Drive accepts YouTube-like `t` formats (e.g. `t=90`, `t=1m30s`);
// normalize to seconds for a stable preview URL.
timestamp: timestamp > 0 ? timestamp : undefined,
};
} catch (error) {
return null;
}
};
const ALLOWED_DOMAINS = new Set([ const ALLOWED_DOMAINS = new Set([
"youtube.com", "youtube.com",
"youtu.be", "youtu.be",
"vimeo.com", "vimeo.com",
"player.vimeo.com", "player.vimeo.com",
"drive.google.com",
"figma.com", "figma.com",
"link.excalidraw.com", "link.excalidraw.com",
"gist.github.com", "gist.github.com",
@@ -154,7 +108,6 @@ const ALLOW_SAME_ORIGIN = new Set([
"youtu.be", "youtu.be",
"vimeo.com", "vimeo.com",
"player.vimeo.com", "player.vimeo.com",
"drive.google.com",
"figma.com", "figma.com",
"twitter.com", "twitter.com",
"x.com", "x.com",
@@ -189,7 +142,7 @@ export const getEmbedLink = (
let aspectRatio = { w: 560, h: 840 }; let aspectRatio = { w: 560, h: 840 };
const ytLink = link.match(RE_YOUTUBE); const ytLink = link.match(RE_YOUTUBE);
if (ytLink?.[2]) { if (ytLink?.[2]) {
const startTime = parseYouTubeLikeTimestamp(originalLink); const startTime = parseYouTubeTimestamp(originalLink);
const time = startTime > 0 ? `&start=${startTime}` : ``; const time = startTime > 0 ? `&start=${startTime}` : ``;
const isPortrait = link.includes("shorts"); const isPortrait = link.includes("shorts");
type = "video"; type = "video";
@@ -248,36 +201,6 @@ export const getEmbedLink = (
}; };
} }
const googleDriveVideo = parseGoogleDriveVideoLink(link);
if (googleDriveVideo) {
type = "video";
const searchParams = new URLSearchParams();
if (googleDriveVideo.resourceKey) {
searchParams.set("resourcekey", googleDriveVideo.resourceKey);
}
if (googleDriveVideo.timestamp) {
searchParams.set("t", `${googleDriveVideo.timestamp}`);
}
const search = searchParams.toString();
link = `https://drive.google.com/file/d/${googleDriveVideo.fileId}/preview${
search ? `?${search}` : ""
}`;
aspectRatio = { w: 560, h: 315 };
embeddedLinkCache.set(originalLink, {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
};
}
const figmaLink = link.match(RE_FIGMA); const figmaLink = link.match(RE_FIGMA);
if (figmaLink) { if (figmaLink) {
type = "generic"; type = "generic";
+546
View File
@@ -0,0 +1,546 @@
import {
pointFrom,
pointDistance,
type LocalPoint,
vectorFromPoint,
vectorNormalize,
lineSegment,
type GlobalPoint,
type Polygon,
polygonFromPoints,
} from "@excalidraw/math";
import { debugDrawLine, debugDrawPolygon } from "@excalidraw/common";
import type { ExcalidrawFreeDrawElement } from "./types";
// Number of segments to approximate each semicircular cap
const CAP_SEGMENTS = 20;
// Minimum radius to avoid degenerate shapes
const MIN_RADIUS = 0.05;
// Pressure to radius multiplier (scaled by strokeWidth)
const PRESSURE_RADIUS_MULTIPLIER = 2.0;
// Minimum distance between points to avoid numerical instability
const MIN_POINT_DISTANCE = 0.001;
// Epsilon for filtering near-duplicate points in polygons
const EPSILON = 0.01;
// Simple union implementation taking advantage of the following facts:
// - The ovoids are generated in sequence along the stroke path
// - Each ovoid overlaps only with its immediate neighbors
// - The ovoids are convex shapes
// Therefore, we can simply stitch together the outer edges of the ovoids
// by taking the first half of the first ovoid and the second half of the last ovoid,
// and connecting them with the outer edges of the intermediate ovoids. The overlapping
// ovoid caps are always the same radius at the shared points, so they align perfectly.
// It should be easy to find the closest point to the side of the previous side segment and
// one of the closest points on the next ovoid's start cap.
function chainOvoidsIntoSinglePolygon<P extends LocalPoint | GlobalPoint>(
records: {
polygon: Polygon<P>;
firstPoint: P;
secondPoint: P;
}[],
): Polygon<P> | null {
if (records.length === 0) {
return null;
}
if (records.length === 1) {
return records[0].polygon;
}
const capPointCount = CAP_SEGMENTS + 1;
const isClosedPolygon = (points: P[]) => {
if (points.length < 2) {
return false;
}
const first = points[0];
const last = points[points.length - 1];
return first[0] === last[0] && first[1] === last[1];
};
const openPolygon = (points: P[]) =>
isClosedPolygon(points) ? points.slice(0, -1) : points.slice();
const distanceSq = (a: P, b: P) => {
const dx = a[0] - b[0];
const dy = a[1] - b[1];
return dx * dx + dy * dy;
};
const distanceToSegmentSq = (p: P, a: P, b: P) => {
const abx = b[0] - a[0];
const aby = b[1] - a[1];
const apx = p[0] - a[0];
const apy = p[1] - a[1];
const abLenSq = abx * abx + aby * aby;
if (abLenSq === 0) {
return distanceSq(p, a);
}
const t = Math.max(0, Math.min(1, (apx * abx + apy * aby) / abLenSq));
const closest = pointFrom<P>(a[0] + abx * t, a[1] + aby * t);
return distanceSq(p, closest);
};
const closestIndexToSegment = (points: P[], a: P, b: P) => {
let bestIndex = 0;
let bestDistance = Number.POSITIVE_INFINITY;
for (let i = 0; i < points.length; i++) {
const dist = distanceToSegmentSq(points[i], a, b);
if (dist < bestDistance) {
bestDistance = dist;
bestIndex = i;
}
}
return bestIndex;
};
const pushIfDistinct = (points: P[], point: P) => {
if (points.length === 0) {
points.push(point);
return;
}
if (distanceSq(points[points.length - 1], point) > EPSILON * EPSILON) {
points.push(point);
}
};
const ovoids = records.map((record) => {
const open = openPolygon(record.polygon);
if (open.length < capPointCount * 2) {
return {
cap1: open,
cap2: [] as P[],
p1Right: open[0],
p1Left: open[open.length - 1],
p2Left: open[0],
p2Right: open[open.length - 1],
};
}
const cap1 = open.slice(0, capPointCount);
const cap2 = open.slice(capPointCount, capPointCount * 2);
return {
cap1,
cap2,
p1Right: cap1[0],
p1Left: cap1[cap1.length - 1],
p2Left: cap2[0],
p2Right: cap2[cap2.length - 1],
};
});
const leftChain: P[] = [];
const rightChain: P[] = [];
ovoids[0].cap1.forEach((point) => pushIfDistinct(leftChain, point));
pushIfDistinct(rightChain, ovoids[0].p1Right);
for (let i = 0; i < ovoids.length; i++) {
const current = ovoids[i];
pushIfDistinct(leftChain, current.p2Left);
pushIfDistinct(rightChain, current.p2Right);
if (i + 1 >= ovoids.length) {
continue;
}
const next = ovoids[i + 1];
const leftIndex = closestIndexToSegment(
next.cap1,
current.p1Left,
current.p2Left,
);
for (let j = leftIndex; j < next.cap1.length; j++) {
pushIfDistinct(leftChain, next.cap1[j]);
}
const rightIndex = closestIndexToSegment(
next.cap1,
current.p1Right,
current.p2Right,
);
for (let j = rightIndex; j >= 0; j--) {
pushIfDistinct(rightChain, next.cap1[j]);
}
}
const lastOvoid = ovoids[ovoids.length - 1];
lastOvoid.cap2.forEach((point) => pushIfDistinct(leftChain, point));
const rightChainReversed = rightChain.slice(0, -1).reverse();
const outline = filterNearDuplicates<P>([
...leftChain,
...rightChainReversed,
]);
return polygonFromPoints<P>(outline);
}
/**
* Compute the radius for a point based on pressure and strokeWidth.
* Pressure is typically in [0, 1] range, default to 0.5 if simulating.
*/
function getRadiusForPressure(pressure: number, strokeWidth: number): number {
return Math.max(
MIN_RADIUS,
pressure * strokeWidth * PRESSURE_RADIUS_MULTIPLIER,
);
}
/**
* Generate points along a semicircular arc (dome/cap).
* The arc goes from startAngle to endAngle (counterclockwise).
*
* @param center - Center point of the arc
* @param radius - Radius of the arc
* @param startAngle - Start angle in radians
* @param endAngle - End angle in radians (counterclockwise from start)
* @param segments - Number of segments to divide the arc into
* @returns Array of points along the arc
*/
function generateArcPoints<P extends LocalPoint | GlobalPoint>(
center: LocalPoint,
radius: number,
startAngle: number,
endAngle: number,
segments: number,
): P[] {
const points: P[] = [];
const angleStep = (endAngle - startAngle) / segments;
for (let i = 0; i <= segments; i++) {
const angle = startAngle + i * angleStep;
points.push(
pointFrom<P>(
center[0] + radius * Math.cos(angle),
center[1] + radius * Math.sin(angle),
),
);
}
return points;
}
/**
* Create an ovoid shape between two consecutive points.
* The ovoid consists of:
* - A semicircular cap at the first point (facing away from point 2)
* - A semicircular cap at the second point (facing away from point 1)
* - Connecting lines (tangent lines between the two circles)
*
* @param p1 - First point
* @param r1 - Radius at first point (from pressure)
* @param p2 - Second point
* @param r2 - Radius at second point (from pressure)
* @param forcePerpendicularCap1 - Force cap1 to be perpendicular (for stroke start)
* @param forcePerpendicularCap2 - Force cap2 to be perpendicular (for stroke end)
* @returns Array of points forming the ovoid polygon
*/
function createOvoid<P extends LocalPoint | GlobalPoint>(
p1: LocalPoint,
r1: number,
p2: LocalPoint,
r2: number,
forcePerpendicularCap1: boolean = false,
forcePerpendicularCap2: boolean = false,
): Polygon<P> {
const dist = pointDistance(p1, p2);
// If points are too close, create a circle at the midpoint
if (dist < MIN_POINT_DISTANCE) {
const avgRadius = (r1 + r2) / 2;
return polygonFromPoints<P>(
generateArcPoints(p1, avgRadius, 0, Math.PI * 2, CAP_SEGMENTS * 2),
);
}
// Direction vector from p1 to p2
const dirVec = vectorFromPoint(p2, p1);
const normalizedDir = vectorNormalize(dirVec);
// Calculate the angle of the direction vector
const baseAngle = Math.atan2(normalizedDir[1], normalizedDir[0]);
// For connecting the circles with tangent lines when radii differ,
// we need to compute the tangent points
// When r1 != r2, the tangent lines are not perpendicular to the center line
// Tangent angle offset (when radii differ)
const radiusDiff = r1 - r2;
const tangentAngleOffset =
dist > Math.abs(radiusDiff) ? Math.asin(radiusDiff / dist) : 0;
// Compute tangent points on each circle
// The perpendicular offset needs to be adjusted for the tangent angle
const tangentPerpAngle = baseAngle + Math.PI / 2 + tangentAngleOffset;
const purePerpAngle = baseAngle + Math.PI / 2;
// Cap at p1 (semicircle facing AWAY from p2, i.e., toward baseAngle + PI)
// Use perpendicular cap for stroke start, otherwise use tangent-adjusted
const p1PerpAngle = forcePerpendicularCap1 ? purePerpAngle : tangentPerpAngle;
const p1RightAngle = p1PerpAngle;
const p1LeftAngle = p1PerpAngle + Math.PI;
const cap1Points = generateArcPoints<P>(
p1,
r1,
p1RightAngle,
p1LeftAngle,
CAP_SEGMENTS,
);
// Cap at p2 (semicircle facing AWAY from p1, i.e., toward baseAngle)
// Use perpendicular cap for stroke end, otherwise use tangent-adjusted
const p2PerpAngle = forcePerpendicularCap2 ? purePerpAngle : tangentPerpAngle;
const p2LeftAngle = p2PerpAngle + Math.PI;
const p2RightAngle = p2LeftAngle + Math.PI;
const cap2Points = generateArcPoints<P>(
p2,
r2,
p2LeftAngle,
p2RightAngle,
CAP_SEGMENTS,
);
// Assemble the ovoid polygon:
// cap1 goes around the back of p1, cap2 goes around the front of p2
// The arc endpoints naturally connect with the tangent lines
const ovoidPoints: P[] = [
...cap1Points, // p1's back cap
...cap2Points, // p2's front cap
];
// Filter out near-duplicate consecutive points to avoid numerical issues
return polygonFromPoints<P>(filterNearDuplicates<P>(ovoidPoints));
}
/**
* Filter out consecutive points that are too close together.
* This prevents numerical instability in polygon boolean operations.
*/
function filterNearDuplicates<P extends LocalPoint | GlobalPoint>(
points: P[],
): P[] {
if (points.length < 2) {
return points;
}
const filtered: P[] = [points[0]];
for (let i = 1; i < points.length; i++) {
const prev = filtered[filtered.length - 1];
const curr = points[i];
const dx = curr[0] - prev[0];
const dy = curr[1] - prev[1];
const distSq = dx * dx + dy * dy;
// Only add if far enough from previous point
if (distSq > EPSILON * EPSILON) {
filtered.push(curr);
}
}
// Also check if last point is too close to first point
if (filtered.length > 2) {
const first = filtered[0];
const last = filtered[filtered.length - 1];
const dx = last[0] - first[0];
const dy = last[1] - first[1];
const distSq = dx * dx + dy * dy;
if (distSq < EPSILON * EPSILON) {
filtered.pop();
}
}
return filtered.length >= 3 ? filtered : points;
}
/**
* Generate the outline of a freedraw element using the ovoid-union approach.
*
* This creates ovoid shapes between each consecutive pair of points,
* where each ovoid is defined by:
* - Semicircular caps at each point (radius based on pressure)
* - Connecting tangent lines between the caps
*
* All ovoids are then unioned together to form the final outline.
*
* @param element - The freedraw element to generate outline for
* @returns Array of [x, y] points representing the outline polygon
*/
export function generateFreeDrawOvoidOutline(
element: ExcalidrawFreeDrawElement,
): [number, number][] {
const { x, y, points, pressures, simulatePressure, strokeWidth } = element;
// Debug draw the raw segments from the freedraw element points
const colors = ["red", "green", "blue", "orange", "purple"];
points.forEach((pt, i) => {
if (i === points.length - 1) {
return;
}
debugDrawLine(
lineSegment(
pointFrom<GlobalPoint>(x + pt[0], y + pt[1]),
pointFrom<GlobalPoint>(x + points[i + 1][0], y + points[i + 1][1]),
),
{
color: colors[i % colors.length],
permanent: true,
},
);
});
if (points.length === 0) {
return [];
}
// Single point: just return a circle
if (points.length === 1) {
const pressure = simulatePressure ? 0.5 : pressures[0] ?? 0.5;
const radius = getRadiusForPressure(pressure, strokeWidth);
const circlePoints = generateArcPoints(
points[0] as LocalPoint,
radius,
0,
Math.PI * 2,
CAP_SEGMENTS * 2,
);
return circlePoints.map((p) => [p[0], p[1]]);
}
// Generate ovoids for each consecutive pair of points
const ovoids: Polygon<LocalPoint>[] = [];
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i] as LocalPoint;
const p2 = points[i + 1] as LocalPoint;
// Get pressures (use 0.5 as default when simulating)
const pressure1 = simulatePressure ? 0.5 : pressures[i] ?? 0.5;
const pressure2 = simulatePressure ? 0.5 : pressures[i + 1] ?? 0.5;
const r1 = getRadiusForPressure(pressure1, strokeWidth);
const r2 = getRadiusForPressure(pressure2, strokeWidth);
// Force perpendicular caps at stroke endpoints
const isFirstSegment = i === 0;
const isLastSegment = i === points.length - 2;
const ovoidPoints = createOvoid<LocalPoint>(
p1,
r1,
p2,
r2,
isFirstSegment, // Force perpendicular cap at stroke start
isLastSegment, // Force perpendicular cap at stroke end
);
// Draw the ovoid with different colors for debugging
debugDrawPolygon(
ovoidPoints.map((p) => pointFrom<GlobalPoint>(x + p[0], y + p[1])),
{
color: colors[i % colors.length],
permanent: true,
},
);
if (ovoidPoints.length >= 3) {
ovoids.push(ovoidPoints);
}
}
if (ovoids.length === 0) {
return [];
}
// Union all ovoids together
const result = chainOvoidsIntoSinglePolygon<LocalPoint>(
ovoids.map((poly, i) => ({
polygon: poly,
firstPoint: points[i],
secondPoint: points[i + 1],
})),
);
if (result === null) {
return [];
}
if (result === null) {
return [];
}
// Return the first (outer) polygon
return result;
}
/**
* Convert the ovoid outline to an SVG path string. Uses quadratic curves
* for smoothing.
*/
export function generateFreeDrawOvoidSvgPath(
element: ExcalidrawFreeDrawElement,
): string {
const points = generateFreeDrawOvoidOutline(element);
if (points.length === 0) {
console.warn("No outline points generated for freedraw element");
return "";
}
if (points.length < 3) {
console.warn("Not enough outline points to form a closed path");
return "";
}
// Use a similar approach to the original getSvgPathFromStroke
// but with the ovoid-generated points
const med = (a: number[], b: number[]) => [
(a[0] + b[0]) / 2,
(a[1] + b[1]) / 2,
];
const pathData = points
// Build a closed, smoothed SVG path from the outline polygon
.reduce(
(acc: (string | number[])[], point, i, arr) => {
if (i === points.length - 1) {
// For the last point, add a line-to ("L") back to the first point and close
// ("Z").
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
} else {
// Use a single quadratic command ("Q") and then emit point + midpoint pairs
// so each segment curves through the current point toward the midpoint of
// the next segment.
acc.push(point, med(point, arr[i + 1]));
}
return acc;
},
// Start with a move-to ("M") to the first point.
["M", points[0], "Q"],
)
.join(" ")
// Trim excessive float precision to keep the path string compact/stable.
.replace(/(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g, "$1");
return pathData;
}
-3
View File
@@ -70,7 +70,6 @@ export * from "./elbowArrow";
export * from "./elementLink"; export * from "./elementLink";
export * from "./embeddable"; export * from "./embeddable";
export * from "./flowchart"; export * from "./flowchart";
export * from "./arrows/focus";
export * from "./fractionalIndex"; export * from "./fractionalIndex";
export * from "./frame"; export * from "./frame";
export * from "./groups"; export * from "./groups";
@@ -83,7 +82,6 @@ export * from "./positionElementsOnGrid";
export * from "./renderElement"; export * from "./renderElement";
export * from "./resizeElements"; export * from "./resizeElements";
export * from "./resizeTest"; export * from "./resizeTest";
export * from "./schema";
export * from "./Scene"; export * from "./Scene";
export * from "./selection"; export * from "./selection";
export * from "./shape"; export * from "./shape";
@@ -99,4 +97,3 @@ export * from "./transformHandles";
export * from "./typeChecks"; export * from "./typeChecks";
export * from "./utils"; export * from "./utils";
export * from "./zindex"; export * from "./zindex";
export * from "./arrows/helpers";
+38 -128
View File
@@ -9,6 +9,7 @@ import {
vectorFromPoint, vectorFromPoint,
curveLength, curveLength,
curvePointAtLength, curvePointAtLength,
lineSegment,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape"; import { getCurvePathOps } from "@excalidraw/utils/shape";
@@ -25,7 +26,6 @@ import {
import { import {
deconstructLinearOrFreeDrawElement, deconstructLinearOrFreeDrawElement,
getSnapOutlineMidPoint,
isPathALoop, isPathALoop,
moveArrowAboveBindable, moveArrowAboveBindable,
projectFixedPointOntoDiagonal, projectFixedPointOntoDiagonal,
@@ -48,7 +48,6 @@ import {
calculateFixedPointForNonElbowArrowBinding, calculateFixedPointForNonElbowArrowBinding,
getBindingStrategyForDraggingBindingElementEndpoints, getBindingStrategyForDraggingBindingElementEndpoints,
isBindingEnabled, isBindingEnabled,
snapToMid,
updateBoundPoint, updateBoundPoint,
} from "./binding"; } from "./binding";
import { import {
@@ -150,8 +149,6 @@ export class LinearElementEditor {
public readonly pointerOffset: Readonly<{ x: number; y: number }>; public readonly pointerOffset: Readonly<{ x: number; y: number }>;
public readonly hoverPointIndex: number; public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly hoveredFocusPointBinding: "start" | "end" | null;
public readonly draggedFocusPointBinding: "start" | "end" | null;
public readonly elbowed: boolean; public readonly elbowed: boolean;
public readonly customLineAngle: number | null; public readonly customLineAngle: number | null;
public readonly isEditing: boolean; public readonly isEditing: boolean;
@@ -197,8 +194,6 @@ export class LinearElementEditor {
}; };
this.hoverPointIndex = -1; this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null; this.segmentMidPointHoveredCoords = null;
this.hoveredFocusPointBinding = null;
this.draggedFocusPointBinding = null;
this.elbowed = isElbowArrow(element) && element.elbowed; this.elbowed = isElbowArrow(element) && element.elbowed;
this.customLineAngle = null; this.customLineAngle = null;
this.isEditing = isEditing; this.isEditing = isEditing;
@@ -356,7 +351,6 @@ export class LinearElementEditor {
app, app,
shouldRotateWithDiscreteAngle(event), shouldRotateWithDiscreteAngle(event),
event.altKey, event.altKey,
linearElementEditor,
); );
LinearElementEditor.movePoints(element, app.scene, positions, { LinearElementEditor.movePoints(element, app.scene, positions, {
@@ -410,14 +404,13 @@ export class LinearElementEditor {
altFocusPoint: altFocusPoint:
!linearElementEditor.initialState.altFocusPoint && !linearElementEditor.initialState.altFocusPoint &&
startBindingElement && startBindingElement &&
updates?.suggestedBinding?.element.id !== startBindingElement.id updates?.suggestedBinding?.id !== startBindingElement.id
? projectFixedPointOntoDiagonal( ? projectFixedPointOntoDiagonal(
element, element,
pointFrom<GlobalPoint>(element.x, element.y), pointFrom<GlobalPoint>(element.x, element.y),
startBindingElement, startBindingElement,
"start", "start",
elementsMap, elementsMap,
app.state.zoom,
) )
: linearElementEditor.initialState.altFocusPoint, : linearElementEditor.initialState.altFocusPoint,
}, },
@@ -535,7 +528,6 @@ export class LinearElementEditor {
app, app,
shouldRotateWithDiscreteAngle(event) && singlePointDragged, shouldRotateWithDiscreteAngle(event) && singlePointDragged,
event.altKey, event.altKey,
linearElementEditor,
); );
LinearElementEditor.movePoints(element, app.scene, positions, { LinearElementEditor.movePoints(element, app.scene, positions, {
@@ -611,11 +603,11 @@ export class LinearElementEditor {
const altFocusPointBindableElement = const altFocusPointBindableElement =
endIsSelected && // The "other" end (i.e. "end") is dragged endIsSelected && // The "other" end (i.e. "end") is dragged
startBindingElement && startBindingElement &&
updates?.suggestedBinding?.element.id !== startBindingElement.id // The end point is not hovering the start bindable + it's binding gap updates?.suggestedBinding?.id !== startBindingElement.id // The end point is not hovering the start bindable + it's binding gap
? startBindingElement ? startBindingElement
: startIsSelected && // The "other" end (i.e. "start") is dragged : startIsSelected && // The "other" end (i.e. "start") is dragged
endBindingElement && endBindingElement &&
updates?.suggestedBinding?.element.id !== endBindingElement.id // The start point is not hovering the end bindable + it's binding gap updates?.suggestedBinding?.id !== endBindingElement.id // The start point is not hovering the end bindable + it's binding gap
? endBindingElement ? endBindingElement
: null; : null;
@@ -635,7 +627,6 @@ export class LinearElementEditor {
altFocusPointBindableElement, altFocusPointBindableElement,
"start", "start",
elementsMap, elementsMap,
app.state.zoom,
) )
: linearElementEditor.initialState.altFocusPoint, : linearElementEditor.initialState.altFocusPoint,
}, },
@@ -733,6 +724,7 @@ export class LinearElementEditor {
? [pointerDownState.lastClickedPoint] ? [pointerDownState.lastClickedPoint]
: selectedPointsIndices, : selectedPointsIndices,
isDragging: false, isDragging: false,
pointerOffset: { x: 0, y: 0 },
customLineAngle: null, customLineAngle: null,
initialState: { initialState: {
...editingLinearElement.initialState, ...editingLinearElement.initialState,
@@ -2085,7 +2077,6 @@ const pointDraggingUpdates = (
app: AppClassProperties, app: AppClassProperties,
angleLocked: boolean, angleLocked: boolean,
altKey: boolean, altKey: boolean,
linearElementEditor: LinearElementEditor,
): { ): {
positions: PointsPositionUpdates; positions: PointsPositionUpdates;
updates?: PointMoveOtherUpdates; updates?: PointMoveOtherUpdates;
@@ -2133,89 +2124,18 @@ const pointDraggingUpdates = (
); );
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
const suggestedBindingElement = startIsDragged
? start.element
: endIsDragged
? end.element
: null;
return { return {
positions: naiveDraggingPoints, positions: naiveDraggingPoints,
updates: { updates: {
suggestedBinding: suggestedBindingElement suggestedBinding: startIsDragged
? { ? start.element
element: suggestedBindingElement, : endIsDragged
midPoint: snapToMid( ? end.element
suggestedBindingElement,
elementsMap,
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
),
}
: null, : null,
}, },
}; };
} }
// Handle the case where neither endpoint is being dragged
// but we need to update bound endpoints
if (!startIsDragged && !endIsDragged) {
const nextArrow = {
...element,
points: element.points.map((p, idx) => {
return naiveDraggingPoints.get(idx)?.point ?? p;
}),
};
const positions = new Map(naiveDraggingPoints);
if (element.startBinding) {
const startBindable = elementsMap.get(element.startBinding.elementId) as
| ExcalidrawBindableElement
| undefined;
if (startBindable) {
const startPoint =
updateBoundPoint(
nextArrow,
"startBinding",
element.startBinding,
startBindable,
elementsMap,
) ?? null;
if (startPoint) {
positions.set(0, { point: startPoint, isDragging: true });
}
}
}
if (element.endBinding) {
const endBindable = elementsMap.get(element.endBinding.elementId) as
| ExcalidrawBindableElement
| undefined;
if (endBindable) {
const endPoint =
updateBoundPoint(
nextArrow,
"endBinding",
element.endBinding,
endBindable,
elementsMap,
) ?? null;
if (endPoint) {
positions.set(element.points.length - 1, {
point: endPoint,
isDragging: true,
});
}
}
}
return {
positions,
};
}
if (startIsDragged === endIsDragged) { if (startIsDragged === endIsDragged) {
return { return {
positions: naiveDraggingPoints, positions: naiveDraggingPoints,
@@ -2246,20 +2166,7 @@ const pointDraggingUpdates = (
(updates.startBinding.mode === "orbit" || (updates.startBinding.mode === "orbit" ||
!getFeatureFlag("COMPLEX_BINDINGS")) !getFeatureFlag("COMPLEX_BINDINGS"))
) { ) {
updates.suggestedBinding = start.element updates.suggestedBinding = start.element;
? {
element: start.element,
midPoint: getSnapOutlineMidPoint(
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
start.element,
elementsMap,
app.state.zoom,
),
}
: null;
} }
} else if (startIsDragged) { } else if (startIsDragged) {
updates.suggestedBinding = app.state.suggestedBinding; updates.suggestedBinding = app.state.suggestedBinding;
@@ -2285,20 +2192,7 @@ const pointDraggingUpdates = (
(updates.endBinding.mode === "orbit" || (updates.endBinding.mode === "orbit" ||
!getFeatureFlag("COMPLEX_BINDINGS")) !getFeatureFlag("COMPLEX_BINDINGS"))
) { ) {
updates.suggestedBinding = end.element updates.suggestedBinding = end.element;
? {
element: end.element,
midPoint: getSnapOutlineMidPoint(
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
end.element,
elementsMap,
app.state.zoom,
),
}
: null;
} }
} else if (endIsDragged) { } else if (endIsDragged) {
updates.suggestedBinding = app.state.suggestedBinding; updates.suggestedBinding = app.state.suggestedBinding;
@@ -2338,6 +2232,19 @@ const pointDraggingUpdates = (
: updates.endBinding, : updates.endBinding,
}; };
// We need to use a custom intersector to ensure that if there is a big "jump"
// in the arrow's position, we can position it with outline avoidance
// pixel-perfectly and avoid "dancing" arrows.
// NOTE: Direction matters here, so we create two intersectors
const startCustomIntersector =
start.focusPoint && end.focusPoint
? lineSegment(start.focusPoint, end.focusPoint)
: undefined;
const endCustomIntersector =
start.focusPoint && end.focusPoint
? lineSegment(end.focusPoint, start.focusPoint)
: undefined;
// Needed to handle a special case where an existing arrow is dragged over // Needed to handle a special case where an existing arrow is dragged over
// the same element it is bound to on the other side // the same element it is bound to on the other side
const startIsDraggingOverEndElement = const startIsDraggingOverEndElement =
@@ -2373,7 +2280,7 @@ const pointDraggingUpdates = (
nextArrow.endBinding, nextArrow.endBinding,
endBindable, endBindable,
elementsMap, elementsMap,
endIsDragged, endCustomIntersector,
) || nextArrow.points[nextArrow.points.length - 1] ) || nextArrow.points[nextArrow.points.length - 1]
: nextArrow.points[nextArrow.points.length - 1]; : nextArrow.points[nextArrow.points.length - 1];
@@ -2396,7 +2303,7 @@ const pointDraggingUpdates = (
: startIsDraggingOverEndElement && : startIsDraggingOverEndElement &&
app.state.bindMode !== "inside" && app.state.bindMode !== "inside" &&
getFeatureFlag("COMPLEX_BINDINGS") getFeatureFlag("COMPLEX_BINDINGS")
? endLocalPoint ? nextArrow.points[nextArrow.points.length - 1]
: startBindable : startBindable
? updateBoundPoint( ? updateBoundPoint(
element, element,
@@ -2404,18 +2311,15 @@ const pointDraggingUpdates = (
nextArrow.startBinding, nextArrow.startBinding,
startBindable, startBindable,
elementsMap, elementsMap,
startIsDragged, startCustomIntersector,
) || nextArrow.points[0] ) || nextArrow.points[0]
: nextArrow.points[0]; : nextArrow.points[0];
const endChanged = const endChanged =
!startIsDraggingOverEndElement && pointDistance(
!( endLocalPoint,
endIsDraggingOverStartElement && nextArrow.points[nextArrow.points.length - 1],
app.state.bindMode !== "inside" && ) !== 0;
getFeatureFlag("COMPLEX_BINDINGS")
) &&
!!endBindable;
const startChanged = const startChanged =
pointDistance(startLocalPoint, nextArrow.points[0]) !== 0; pointDistance(startLocalPoint, nextArrow.points[0]) !== 0;
@@ -2429,7 +2333,13 @@ const pointDraggingUpdates = (
const indices = Array.from(indicesSet); const indices = Array.from(indicesSet);
return { return {
updates, updates:
updates.startBinding || updates.suggestedBinding
? {
startBinding: updates.startBinding,
suggestedBinding: updates.suggestedBinding,
}
: undefined,
positions: new Map( positions: new Map(
indices.map((idx) => { indices.map((idx) => {
return [ return [
+1 -14
View File
@@ -11,7 +11,6 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./shape"; import { ShapeCache } from "./shape";
import { updateElbowArrowPoints } from "./elbowArrow"; import { updateElbowArrowPoints } from "./elbowArrow";
import { ensureSchemaStateForElementType } from "./schema";
import { isElbowArrow } from "./typeChecks"; import { isElbowArrow } from "./typeChecks";
@@ -138,10 +137,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element.version = updates.version ?? element.version + 1; element.version = updates.version ?? element.version + 1;
element.versionNonce = updates.versionNonce ?? randomInteger(); element.versionNonce = updates.versionNonce ?? randomInteger();
element.updated = getUpdatedTimestamp(); element.updated = getUpdatedTimestamp();
element.schemaState = ensureSchemaStateForElementType(
element.schemaState,
element.type,
) as TElement["schemaState"];
return element; return element;
}; };
@@ -171,21 +166,13 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return element; return element;
} }
const updatedElement = { return {
...element, ...element,
...updates, ...updates,
version: updates.version ?? element.version + 1, version: updates.version ?? element.version + 1,
versionNonce: updates.versionNonce ?? randomInteger(), versionNonce: updates.versionNonce ?? randomInteger(),
updated: getUpdatedTimestamp(), updated: getUpdatedTimestamp(),
}; };
return {
...updatedElement,
schemaState: ensureSchemaStateForElementType(
updatedElement.schemaState,
updatedElement.type,
),
};
}; };
/** /**
-3
View File
@@ -20,7 +20,6 @@ import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
getResizedElementAbsoluteCoords, getResizedElementAbsoluteCoords,
} from "./bounds"; } from "./bounds";
import { ensureSchemaStateForElementType } from "./schema";
import { newElementWith } from "./mutateElement"; import { newElementWith } from "./mutateElement";
import { getBoundTextMaxWidth } from "./textElement"; import { getBoundTextMaxWidth } from "./textElement";
import { normalizeText, measureText } from "./textMeasurements"; import { normalizeText, measureText } from "./textMeasurements";
@@ -71,7 +70,6 @@ export type ElementConstructorOpts = MarkOptional<
| "roughness" | "roughness"
| "strokeWidth" | "strokeWidth"
| "roundness" | "roundness"
| "schemaState"
| "locked" | "locked"
| "opacity" | "opacity"
| "customData" | "customData"
@@ -146,7 +144,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
roundness, roundness,
seed: rest.seed ?? randomInteger(), seed: rest.seed ?? randomInteger(),
version: rest.version || 1, version: rest.version || 1,
schemaState: ensureSchemaStateForElementType(rest.schemaState, type),
versionNonce: rest.versionNonce ?? 0, versionNonce: rest.versionNonce ?? 0,
isDeleted: false as false, isDeleted: false as false,
boundElements, boundElements,
+15 -124
View File
@@ -14,7 +14,6 @@ import {
DEFAULT_REDUCED_GLOBAL_ALPHA, DEFAULT_REDUCED_GLOBAL_ALPHA,
ELEMENT_READY_TO_ERASE_OPACITY, ELEMENT_READY_TO_ERASE_OPACITY,
FRAME_STYLE, FRAME_STYLE,
DARK_THEME_FILTER,
MIME_TYPES, MIME_TYPES,
THEME, THEME,
distance, distance,
@@ -22,9 +21,7 @@ import {
isRTL, isRTL,
getVerticalOffset, getVerticalOffset,
invariant, invariant,
isTransparent,
applyDarkModeFilter, applyDarkModeFilter,
isSafari,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@@ -79,7 +76,6 @@ import type {
ExcalidrawFrameLikeElement, ExcalidrawFrameLikeElement,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
ElementsMap, ElementsMap,
ExcalidrawFrameElement,
} from "./types"; } from "./types";
import type { RoughCanvas } from "roughjs/bin/canvas"; import type { RoughCanvas } from "roughjs/bin/canvas";
@@ -363,9 +359,8 @@ IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
const drawImagePlaceholder = ( const drawImagePlaceholder = (
element: ExcalidrawImageElement, element: ExcalidrawImageElement,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
theme: StaticCanvasRenderConfig["theme"],
) => { ) => {
context.fillStyle = theme === THEME.DARK ? "#2E2E2E" : "#E7E7E7"; context.fillStyle = "#E7E7E7";
context.fillRect(0, 0, element.width, element.height); context.fillRect(0, 0, element.width, element.height);
const imageMinWidthOrHeight = Math.min(element.width, element.height); const imageMinWidthOrHeight = Math.min(element.width, element.height);
@@ -438,15 +433,9 @@ const drawElementOnCanvas = (
break; break;
} }
case "image": { case "image": {
context.save();
const cacheEntry =
element.fileId !== null
? renderConfig.imageCache.get(element.fileId)
: null;
const img = isInitializedImageElement(element) const img = isInitializedImageElement(element)
? cacheEntry?.image ? renderConfig.imageCache.get(element.fileId)?.image
: undefined; : undefined;
if (img != null && !(img instanceof Promise)) { if (img != null && !(img instanceof Promise)) {
if (element.roundness && context.roundRect) { if (element.roundness && context.roundRect) {
context.beginPath(); context.beginPath();
@@ -469,80 +458,20 @@ const drawElementOnCanvas = (
height: img.naturalHeight, height: img.naturalHeight,
}; };
const shouldInvertImage = context.drawImage(
renderConfig.theme === THEME.DARK && img,
cacheEntry?.mimeType === MIME_TYPES.svg; x,
y,
if (shouldInvertImage && isSafari) { width,
const devicePixelRatio = window.devicePixelRatio || 1; height,
const tempCanvas = document.createElement("canvas"); 0 /* hardcoded for the selection box*/,
tempCanvas.width = element.width * devicePixelRatio; 0,
tempCanvas.height = element.height * devicePixelRatio; element.width,
const tempContext = tempCanvas.getContext("2d"); element.height,
);
if (tempContext) {
tempContext.scale(devicePixelRatio, devicePixelRatio);
tempContext.drawImage(
img,
x,
y,
width,
height,
0,
0,
element.width,
element.height,
);
const imageData = tempContext.getImageData(
0,
0,
tempCanvas.width,
tempCanvas.height,
);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
data[i] = 255 - data[i];
data[i + 1] = 255 - data[i + 1];
data[i + 2] = 255 - data[i + 2];
}
tempContext.putImageData(imageData, 0, 0);
context.drawImage(
tempCanvas,
0,
0,
tempCanvas.width,
tempCanvas.height,
0,
0,
element.width,
element.height,
);
}
} else {
if (shouldInvertImage) {
context.filter = DARK_THEME_FILTER;
}
context.drawImage(
img,
x,
y,
width,
height,
0 /* hardcoded for the selection box*/,
0,
element.width,
element.height,
);
}
} else { } else {
drawImagePlaceholder(element, context, renderConfig.theme); drawImagePlaceholder(element, context);
} }
context.restore();
break; break;
} }
default: { default: {
@@ -779,45 +708,6 @@ export const renderSelectionElement = (
context.restore(); context.restore();
}; };
export const renderFrameBackground = (
frame: ExcalidrawFrameElement,
context: CanvasRenderingContext2D,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
opts?: {
roundCorners?: boolean;
},
) => {
if (isTransparent(frame.backgroundColor)) {
return;
}
context.save();
context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY);
context.fillStyle =
appState.theme === THEME.DARK
? applyDarkModeFilter(frame.backgroundColor)
: frame.backgroundColor;
const shouldRoundCorners = opts?.roundCorners ?? true;
if (shouldRoundCorners && FRAME_STYLE.radius && context.roundRect) {
context.beginPath();
context.roundRect(
0,
0,
frame.width,
frame.height,
FRAME_STYLE.radius / appState.zoom.value,
);
context.fill();
context.closePath();
} else {
context.fillRect(0, 0, frame.width, frame.height);
}
context.restore();
};
export const renderElement = ( export const renderElement = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
elementsMap: RenderableElementsMap, elementsMap: RenderableElementsMap,
@@ -849,6 +739,7 @@ export const renderElement = (
element.x + appState.scrollX, element.x + appState.scrollX,
element.y + appState.scrollY, element.y + appState.scrollY,
); );
context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = context.strokeStyle =
-91
View File
@@ -1,91 +0,0 @@
/**
* Shared schema primitives used by element types and higher-level migrations.
*/
export const SCHEMA_INITIAL_TRACK_VERSION = 1 as const;
/** Core namespace reserved for built-in Excalidraw migrations. */
export const SCHEMA_CORE_NAMESPACE = "core" as const;
export type SchemaNamespace = typeof SCHEMA_CORE_NAMESPACE | `host.${string}`;
/**
* A schema track is an independent version line:
* - core tracks: "excalidraw.*"
* - host tracks: "host.<appId>.<track>"
*/
export type SchemaTrack = `excalidraw.${string}` | `host.${string}.${string}`;
export type ElementSchemaState = Readonly<{
tracks: Readonly<Record<string, number>>;
}>;
/** Core frame track id used by the frame background migration. */
export const CORE_FRAME_SCHEMA_TRACK = "excalidraw.shape.frame" as const;
/** Latest core track versions supported by this build. */
export const CORE_SUPPORTED_TRACKS = {
[CORE_FRAME_SCHEMA_TRACK]: 2,
} as const;
const getRequiredCoreTracksForElementType = (type: string) => {
if (type === "frame") {
return {
[CORE_FRAME_SCHEMA_TRACK]: CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK],
} as const;
}
return {} as const;
};
const isValidTrackVersion = (version: unknown): version is number =>
typeof version === "number" &&
Number.isInteger(version) &&
version >= SCHEMA_INITIAL_TRACK_VERSION;
/**
* Ensures an element schema state is normalized and satisfies type defaults.
* Required core tracks are only ever bumped forward (never downgraded).
*/
export const ensureSchemaStateForElementType = (
schemaState: ElementSchemaState | undefined,
type: string,
): ElementSchemaState => {
const requiredTracks = getRequiredCoreTracksForElementType(type);
const currentTracks = schemaState?.tracks || {};
const nextTracks: Record<string, number> = {};
let didChange = !schemaState;
for (const [track, version] of Object.entries(
currentTracks as Record<string, unknown>,
)) {
if (isValidTrackVersion(version)) {
nextTracks[track] = version;
continue;
}
nextTracks[track] = SCHEMA_INITIAL_TRACK_VERSION;
didChange = true;
}
for (const [track, requiredVersion] of Object.entries(requiredTracks)) {
const currentVersion = nextTracks[track];
if (
!isValidTrackVersion(currentVersion) ||
currentVersion < requiredVersion
) {
nextTracks[track] = requiredVersion;
didChange = true;
}
}
if (!didChange) {
return schemaState!;
}
return { tracks: nextTracks };
};
/**
* Default schema state for newly created elements.
* New frames are created at the latest supported frame track version.
*/
export const getDefaultSchemaStateForElementType = (
type: string,
): ElementSchemaState => ensureSchemaStateForElementType(undefined, type);
+11 -2
View File
@@ -63,6 +63,7 @@ import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
} from "./bounds"; } from "./bounds";
import { shouldTestInside } from "./collision"; import { shouldTestInside } from "./collision";
import { generateFreeDrawOvoidSvgPath } from "./freedraw";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@@ -78,6 +79,10 @@ import type {
import type { Drawable, Options } from "roughjs/bin/core"; import type { Drawable, Options } from "roughjs/bin/core";
import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { Point as RoughPoint } from "roughjs/bin/geometry";
// Toggle between old (perfect-freehand) and new (ovoid-union) freedraw rendering
// Set to true to use the new ovoid-based implementation
export const USE_NEW_FREEDRAW_RENDERER = true;
export class ShapeCache { export class ShapeCache {
private static rg = new RoughGenerator(); private static rg = new RoughGenerator();
private static cache = new WeakMap< private static cache = new WeakMap<
@@ -852,8 +857,12 @@ const _generateElementShape = (
); );
} }
// (2) stroke // (2) stroke - use new ovoid renderer or legacy perfect-freehand
shapes.push(getFreeDrawSvgPath(element)); if (USE_NEW_FREEDRAW_RENDERER) {
shapes.push(generateFreeDrawOvoidSvgPath(element) as SVGPathString);
} else {
shapes.push(getFreeDrawSvgPath(element));
}
return shapes; return shapes;
} }
-4
View File
@@ -15,8 +15,6 @@ import type {
ValueOf, ValueOf,
} from "@excalidraw/common/utility-types"; } from "@excalidraw/common/utility-types";
import type { ElementSchemaState } from "./schema";
export type ChartType = "bar" | "line"; export type ChartType = "bar" | "line";
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag"; export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
export type FontFamilyKeys = keyof typeof FONT_FAMILY; export type FontFamilyKeys = keyof typeof FONT_FAMILY;
@@ -60,8 +58,6 @@ type _ExcalidrawElementBase = Readonly<{
/** Integer that is sequentially incremented on each change. Used to reconcile /** Integer that is sequentially incremented on each change. Used to reconcile
elements during collaboration or when saving to server. */ elements during collaboration or when saving to server. */
version: number; version: number;
/** Per-track schema state used by migrations during restore. */
schemaState: ElementSchemaState;
/** Random integer that is regenerated on each change. /** Random integer that is regenerated on each change.
Used for deterministic reconciliation of updates during collaboration, Used for deterministic reconciliation of updates during collaboration,
in case the versions (see above) are identical. */ in case the versions (see above) are identical. */
+26 -149
View File
@@ -7,7 +7,6 @@ import {
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
bezierEquation,
curve, curve,
curveCatmullRomCubicApproxPoints, curveCatmullRomCubicApproxPoints,
curveOffsetPoints, curveOffsetPoints,
@@ -28,30 +27,19 @@ import {
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math"; import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
import type { import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
AppState,
NormalizedZoomValue,
Zoom,
} from "@excalidraw/excalidraw/types";
import { elementCenterPoint, getDiamondPoints } from "./bounds"; import { elementCenterPoint, getDiamondPoints } from "./bounds";
import { generateLinearCollisionShape } from "./shape"; import { generateLinearCollisionShape } from "./shape";
import { hitElementItself, isPointInElement } from "./collision"; import { isPointInElement } from "./collision";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { isRectangularElement } from "./typeChecks"; import { isRectangularElement } from "./typeChecks";
import { maxBindingDistance_simple } from "./binding";
import {
getGlobalFixedPointForBindableElement,
normalizeFixedPoint,
} from "./binding";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
@@ -341,10 +329,24 @@ export function deconstructRectanguloidElement(
return shape; return shape;
} }
export function getDiamondBaseCorners( /**
* Get the **unrotated** building components of a diamond element
* in the form of line segments and curves as a tuple, in this order.
*
* @param element The element to deconstruct
* @param offset An optional offset
* @returns Tuple of line **unrotated** segments (0) and curves (1)
*/
export function deconstructDiamondElement(
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
offset: number = 0, offset: number = 0,
): Curve<GlobalPoint>[] { ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element); getDiamondPoints(element);
const verticalRadius = element.roundness const verticalRadius = element.roundness
@@ -361,7 +363,7 @@ export function getDiamondBaseCorners(
pointFrom(element.x + leftX, element.y + leftY), pointFrom(element.x + leftX, element.y + leftY),
]; ];
return [ const baseCorners = [
curve( curve(
pointFrom<GlobalPoint>( pointFrom<GlobalPoint>(
right[0] - verticalRadius, right[0] - verticalRadius,
@@ -411,27 +413,6 @@ export function getDiamondBaseCorners(
), ),
), // TOP ), // TOP
]; ];
}
/**
* Get the **unrotated** building components of a diamond element
* in the form of line segments and curves as a tuple, in this order.
*
* @param element The element to deconstruct
* @param offset An optional offset
* @returns Tuple of line **unrotated** segments (0) and curves (1)
*/
export function deconstructDiamondElement(
element: ExcalidrawDiamondElement,
offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
const baseCorners = getDiamondBaseCorners(element, offset);
const corners = baseCorners.map( const corners = baseCorners.map(
(corner) => (corner) =>
@@ -589,128 +570,28 @@ const getDiagonalsForBindableElement = (
return [diagonalOne, diagonalTwo]; return [diagonalOne, diagonalTwo];
}; };
export const getSnapOutlineMidPoint = (
point: GlobalPoint,
element: ExcalidrawBindableElement,
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);
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
})
: [
// RIGHT midpoint
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width,
element.y + element.height / 2,
),
center,
element.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 &&
!hitElementItself({
point,
element,
threshold: 0,
elementsMap,
overrideShouldTestInside: true,
}),
);
return candidate;
};
export const projectFixedPointOntoDiagonal = ( export const projectFixedPointOntoDiagonal = (
arrow: ExcalidrawArrowElement, arrow: ExcalidrawArrowElement,
point: GlobalPoint, point: GlobalPoint,
element: ExcalidrawBindableElement, element: ExcalidrawElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom: AppState["zoom"],
): GlobalPoint | null => { ): GlobalPoint | null => {
invariant(arrow.points.length >= 2, "Arrow must have at least two points"); invariant(arrow.points.length >= 2, "Arrow must have at least two points");
if (arrow.width < 3 && arrow.height < 3) { if (arrow.width < 3 && arrow.height < 3) {
return null; return null;
} }
const sideMidPoint = getSnapOutlineMidPoint(
point,
element,
elementsMap,
zoom,
);
if (sideMidPoint) {
return sideMidPoint;
}
// Do the projection onto the diagonals (or center lines
// for non-rectangular shapes)
const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement( const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement(
element, element,
elementsMap, elementsMap,
); );
// To avoid working with stale arrow state, we use the opposite focus point const a = LinearElementEditor.getPointAtIndexGlobalCoordinates(
// of the current endpoint, which will always be unchanged during moving of
// the endpoint. This is only needed when the arrow has only two points.
let a = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow, arrow,
startOrEnd === "start" ? 1 : arrow.points.length - 2, startOrEnd === "start" ? 1 : arrow.points.length - 2,
elementsMap, elementsMap,
); );
if (arrow.points.length === 2) {
const otherBinding =
startOrEnd === "start" ? arrow.endBinding : arrow.startBinding;
const otherBindable =
otherBinding &&
(elementsMap.get(otherBinding.elementId) as
| ExcalidrawBindableElement
| undefined);
const otherFocusPoint =
otherBinding &&
otherBindable &&
getGlobalFixedPointForBindableElement(
normalizeFixedPoint(otherBinding.fixedPoint),
otherBindable,
elementsMap,
);
if (otherFocusPoint) {
a = otherFocusPoint;
}
}
const b = pointFromVector<GlobalPoint>( const b = pointFromVector<GlobalPoint>(
vectorScale( vectorScale(
vectorFromPoint(point, a), vectorFromPoint(point, a),
@@ -722,22 +603,18 @@ export const projectFixedPointOntoDiagonal = (
), ),
a, a,
); );
const intersector = lineSegment<GlobalPoint>(b, a); const intersector = lineSegment<GlobalPoint>(point, b);
const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector); const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector);
const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector); const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector);
const d1 = p1 && pointDistance(a, p1); const d1 = p1 && pointDistance(a, p1);
const d2 = p2 && pointDistance(a, p2); const d2 = p2 && pointDistance(a, p2);
let projection = null; let p = null;
if (d1 != null && d2 != null) { if (d1 != null && d2 != null) {
projection = d1 < d2 ? p1 : p2; p = d1 < d2 ? p1 : p2;
} else { } else {
projection = p1 || p2 || null; p = p1 || p2 || null;
} }
if (projection && isPointInElement(projection, element, elementsMap)) { return p && isPointInElement(p, element, elementsMap) ? p : null;
return projection;
}
return null;
}; };
+5 -4
View File
@@ -156,11 +156,12 @@ export const moveArrowAboveBindable = (
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
scene: Scene, scene: Scene,
hit?: NonDeletedExcalidrawElement,
): readonly OrderedExcalidrawElement[] => { ): readonly OrderedExcalidrawElement[] => {
const hoveredElement = hit const hoveredElement = getHoveredElementForBinding(
? hit point,
: getHoveredElementForBinding(point, elements, elementsMap); elements,
elementsMap,
);
if (!hoveredElement) { if (!hoveredElement) {
return elements; return elements;
+2 -229
View File
@@ -1,12 +1,9 @@
import { arrayToMap } from "@excalidraw/common";
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math"; import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui"; import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import "@excalidraw/utils/test-utils"; import "@excalidraw/utils/test-utils";
import { render } from "@excalidraw/excalidraw/tests/test-utils"; import { render } from "@excalidraw/excalidraw/tests/test-utils";
import * as distance from "../src/distance";
import { hitElementItself } from "../src/collision"; import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => { describe("check rotated elements can be hit:", () => {
@@ -28,6 +25,8 @@ describe("check rotated elements can be hit:", () => {
[-4, -302], [-4, -302],
] as LocalPoint[], ] as LocalPoint[],
}); });
//const p = [120, -211];
//const p = [0, 13];
const hit = hitElementItself({ const hit = hitElementItself({
point: pointFrom<GlobalPoint>(88, -68), point: pointFrom<GlobalPoint>(88, -68),
element: window.h.elements[0], element: window.h.elements[0],
@@ -37,229 +36,3 @@ describe("check rotated elements can be hit:", () => {
expect(hit).toBe(true); expect(hit).toBe(true);
}); });
}); });
describe("frame hit testing", () => {
it.each(["transparent", "#ffffff"])(
"does not hit frame inside regardless of background color (%s)",
(backgroundColor) => {
const element = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor,
});
const elementsMap = arrayToMap([element]);
expect(
hitElementItself({
point: pointFrom<GlobalPoint>(50, 50),
element,
threshold: 10,
elementsMap,
}),
).toBe(false);
},
);
it("hits frame outline", () => {
const element = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
});
const elementsMap = arrayToMap([element]);
expect(
hitElementItself({
point: pointFrom<GlobalPoint>(0, 50),
element,
threshold: 1,
elementsMap,
}),
).toBe(true);
});
});
describe("hitElementItself cache", () => {
beforeEach(async () => {
// reset cache
hitElementItself({
point: pointFrom<GlobalPoint>(50, 50),
element: API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
}),
threshold: Infinity,
elementsMap: new Map([]),
});
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("reuses cached result when threshold increases", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(100.5, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 1,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
distanceSpy.mockRestore();
});
it("does not reuse cache when threshold decreases", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(105, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
expect(
hitElementItself({
point,
element,
threshold: 6,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(2);
distanceSpy.mockRestore();
});
it("invalidates cache when element version changes", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(100.5, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 1,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
const movedElement = {
...element,
version: element.version + 1,
versionNonce: element.versionNonce + 1,
};
expect(
hitElementItself({
point,
element: movedElement,
threshold: 1,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(2);
distanceSpy.mockRestore();
});
it("override does not affect caching", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(50, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
}),
).toBe(false);
expect(distanceSpy).toHaveBeenCalledTimes(1);
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
overrideShouldTestInside: true,
}),
).toBe(true);
});
});
+1 -81
View File
@@ -1,4 +1,4 @@
import { embeddableURLValidator, getEmbedLink } from "../src/embeddable"; import { getEmbedLink } from "../src/embeddable";
describe("YouTube timestamp parsing", () => { describe("YouTube timestamp parsing", () => {
it("should parse YouTube URLs with timestamp in seconds", () => { it("should parse YouTube URLs with timestamp in seconds", () => {
@@ -151,83 +151,3 @@ describe("YouTube timestamp parsing", () => {
} }
}); });
}); });
describe("Google Drive video embedding", () => {
it.each([
{
url: "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?usp=sharing",
expectedLink:
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
},
{
url: "https://drive.google.com/open?id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456",
expectedLink:
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
},
{
url: "https://drive.google.com/uc?export=download&id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456",
expectedLink:
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
},
])("should normalize Google Drive link: $url", ({ url, expectedLink }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(expectedLink);
}
expect(result?.intrinsicSize).toEqual({ w: 560, h: 315 });
});
it("should preserve resourcekey when available", () => {
const url =
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456",
);
}
});
it("should preserve timestamp when available", () => {
const url =
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?t=9";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?t=9",
);
}
});
it("should preserve resourcekey and timestamp together", () => {
const url =
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456&t=9";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456&t=9",
);
}
});
it("should validate Google Drive domain by default", () => {
expect(
embeddableURLValidator(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view",
undefined,
),
).toBe(true);
});
});
@@ -218,7 +218,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint // drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta)); drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(3); expect(line.points.length).toEqual(3);
expect(line.points).toMatchInlineSnapshot(` expect(line.points).toMatchInlineSnapshot(`
[ [
@@ -378,7 +378,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint // drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta)); drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -419,7 +419,7 @@ describe("Test Linear Elements", () => {
fireEvent.click(screen.getByTitle("Round")); fireEvent.click(screen.getByTitle("Round"));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`10`, `9`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@@ -480,7 +480,7 @@ describe("Test Linear Elements", () => {
drag(startPoint, endPoint); drag(startPoint, endPoint);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -548,7 +548,7 @@ describe("Test Linear Elements", () => {
); );
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`15`, `14`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
@@ -599,7 +599,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta)); drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -640,7 +640,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -688,7 +688,7 @@ describe("Test Linear Elements", () => {
deletePoint(points[2]); deletePoint(points[2]);
expect(line.points.length).toEqual(3); expect(line.points.length).toEqual(3);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`18`, `17`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`);
@@ -746,7 +746,7 @@ describe("Test Linear Elements", () => {
), ),
); );
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`15`, `14`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
expect(line.points.length).toEqual(5); expect(line.points.length).toEqual(5);
@@ -844,7 +844,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@@ -1317,7 +1317,7 @@ describe("Test Linear Elements", () => {
const textElement = h.elements[2] as ExcalidrawTextElementWithContainer; const textElement = h.elements[2] as ExcalidrawTextElementWithContainer;
expect(arrow.endBinding?.elementId).toBe(rect.id); expect(arrow.endBinding?.elementId).toBe(rect.id);
expect(arrow.width).toBeCloseTo(404); expect(arrow.width).toBeCloseTo(399);
expect(rect.x).toBe(400); expect(rect.x).toBe(400);
expect(rect.y).toBe(0); expect(rect.y).toBe(0);
expect( expect(
@@ -1336,7 +1336,7 @@ describe("Test Linear Elements", () => {
mouse.downAt(rect.x, rect.y); mouse.downAt(rect.x, rect.y);
mouse.moveTo(200, 0); mouse.moveTo(200, 0);
mouse.upAt(200, 0); mouse.upAt(200, 0);
expect(arrow.width).toBeCloseTo(204); expect(arrow.width).toBeCloseTo(199);
expect(rect.x).toBe(200); expect(rect.x).toBe(200);
expect(rect.y).toBe(0); expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith( expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
+2 -2
View File
@@ -1350,8 +1350,8 @@ describe("multiple selection", () => {
expect(boundArrow.x).toBeCloseTo(380 * scaleX); expect(boundArrow.x).toBeCloseTo(380 * scaleX);
expect(boundArrow.y).toBeCloseTo(240 * scaleY); expect(boundArrow.y).toBeCloseTo(240 * scaleY);
expect(boundArrow.points[1][0]).toBeCloseTo(63.40354208105561); expect(boundArrow.points[1][0]).toBeCloseTo(59.7979);
expect(boundArrow.points[1][1]).toBeCloseTo(-84.53805610807356); expect(boundArrow.points[1][1]).toBeCloseTo(-79.7305);
expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo( expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo(
boundArrow.x + boundArrow.points[1][0] / 2, boundArrow.x + boundArrow.points[1][0] / 2,
@@ -58,7 +58,6 @@ const distributeSelectedElements = (
app.scene.getNonDeletedElementsMap(), app.scene.getNonDeletedElementsMap(),
distribution, distribution,
appState, appState,
app.scene,
); );
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
+1 -5
View File
@@ -271,11 +271,7 @@ export const actionLoadScene = register({
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
files, files,
} = await loadFromJSON( } = await loadFromJSON(appState, elements);
appState,
elements,
app.getSchemaMigrationRegistry(),
);
return { return {
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
+5 -11
View File
@@ -82,10 +82,7 @@ export const actionFinalize = register<FormData>({
app.scene, app.scene,
); );
if ( if (isBindingElement(element)) {
isBindingElement(element) &&
!appState.selectedLinearElement.segmentMidPointHoveredCoords
) {
const newArrow = !!appState.newElement; const newArrow = !!appState.newElement;
const selectedPointsIndices = const selectedPointsIndices =
@@ -98,21 +95,19 @@ export const actionFinalize = register<FormData>({
map.set(index, { map.set(index, {
point: LinearElementEditor.pointFromAbsoluteCoords( point: LinearElementEditor.pointFromAbsoluteCoords(
element, element,
pointFrom<GlobalPoint>( pointFrom<GlobalPoint>(sceneCoords.x, sceneCoords.y),
sceneCoords.x - linearElementEditor.pointerOffset.x,
sceneCoords.y - linearElementEditor.pointerOffset.y,
),
elementsMap, elementsMap,
), ),
}); });
return map; return map;
}, new Map()) ?? new Map(); }, new Map()) ?? new Map();
bindOrUnbindBindingElement( bindOrUnbindBindingElement(
element, element,
draggedPoints, draggedPoints,
sceneCoords.x - linearElementEditor.pointerOffset.x, sceneCoords.x,
sceneCoords.y - linearElementEditor.pointerOffset.y, sceneCoords.y,
scene, scene,
appState, appState,
{ {
@@ -175,7 +170,6 @@ export const actionFinalize = register<FormData>({
...linearElementEditor.initialState, ...linearElementEditor.initialState,
lastClickedPoint: -1, lastClickedPoint: -1,
}, },
pointerOffset: { x: 0, y: 0 },
}, },
selectionElement: null, selectionElement: null,
suggestedBinding: null, suggestedBinding: null,
@@ -6,23 +6,11 @@ import {
FONT_FAMILY, FONT_FAMILY,
STROKE_WIDTH, STROKE_WIDTH,
} from "@excalidraw/common"; } from "@excalidraw/common";
import {
CORE_FRAME_SCHEMA_TRACK,
CORE_SUPPORTED_TRACKS,
} from "@excalidraw/element";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { UI } from "../tests/helpers/ui"; import { UI } from "../tests/helpers/ui";
import { act, render } from "../tests/test-utils"; import { render } from "../tests/test-utils";
import {
actionChangeBackgroundColor,
actionChangeRoundness,
actionChangeStrokeWidth,
} from "./actionProperties";
const { h } = window;
describe("element locking", () => { describe("element locking", () => {
beforeEach(async () => { beforeEach(async () => {
@@ -121,21 +109,6 @@ describe("element locking", () => {
expect(crossHatchButton).toBe(null); expect(crossHatchButton).toBe(null);
}); });
it("should show background color picker for selected frame", () => {
const frame = API.createElement({
type: "frame",
});
API.setElements([frame]);
API.setSelectedElements([frame]);
expect(
queryByTestId(
document.body,
`color-top-pick-${DEFAULT_ELEMENT_BACKGROUND_PICKS[0]}`,
),
).not.toBe(null);
});
it("should highlight common stroke width of selected elements", () => { it("should highlight common stroke width of selected elements", () => {
const rect1 = API.createElement({ const rect1 = API.createElement({
type: "rectangle", type: "rectangle",
@@ -196,77 +169,5 @@ describe("element locking", () => {
"active", "active",
); );
}); });
it("should not update text background when changing background in mixed frame selection", () => {
const frame = API.createElement({
type: "frame",
schemaState: { tracks: {} },
});
const text = API.createElement({
type: "text",
backgroundColor: COLOR_PALETTE.transparent,
});
API.setElements([text, frame]);
API.setSelectedElements([text, frame]);
act(() => {
h.app.actionManager.executeAction(actionChangeBackgroundColor, "ui", {
viewBackgroundColor: h.state.viewBackgroundColor,
currentItemBackgroundColor: "#ffc9c9",
});
});
expect(API.getElement(frame).backgroundColor).toBe("#ffc9c9");
expect(API.getElement(text).backgroundColor).toBe(
COLOR_PALETTE.transparent,
);
expect(
API.getElement(frame).schemaState.tracks[CORE_FRAME_SCHEMA_TRACK],
).toBe(CORE_SUPPORTED_TRACKS[CORE_FRAME_SCHEMA_TRACK]);
});
it("should not update frame stroke width when changing stroke width in mixed selection", () => {
const frame = API.createElement({
type: "frame",
});
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
API.setElements([rect, frame]);
API.setSelectedElements([rect, frame]);
const originalFrameStrokeWidth = API.getElement(frame).strokeWidth;
act(() => {
h.app.actionManager.executeAction(
actionChangeStrokeWidth,
"ui",
STROKE_WIDTH.extraBold,
);
});
expect(API.getElement(rect).strokeWidth).toBe(STROKE_WIDTH.extraBold);
expect(API.getElement(frame).strokeWidth).toBe(originalFrameStrokeWidth);
});
it("should not update frame roundness when changing roundness in mixed selection", () => {
const frame = API.createElement({
type: "frame",
});
const rect = API.createElement({
type: "rectangle",
roundness: null,
});
API.setElements([rect, frame]);
API.setSelectedElements([rect, frame]);
act(() => {
h.app.actionManager.executeAction(actionChangeRoundness, "ui", "round");
});
expect(API.getElement(rect).roundness).not.toBe(null);
expect(API.getElement(frame).roundness).toBe(null);
});
}); });
}); });
@@ -45,7 +45,6 @@ import {
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
isFrameElement,
isElbowArrow, isElbowArrow,
isLinearElement, isLinearElement,
isLineElement, isLineElement,
@@ -53,13 +52,7 @@ import {
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { import { hasStrokeColor } from "@excalidraw/element";
canChangeRoundness,
hasBackground,
hasStrokeColor,
hasStrokeStyle,
hasStrokeWidth,
} from "@excalidraw/element";
import { import {
updateElbowArrowPoints, updateElbowArrowPoints,
@@ -416,18 +409,11 @@ export const actionChangeBackgroundColor = register<
return el; return el;
}); });
} else { } else {
nextElements = changeProperty(elements, appState, (el) => { nextElements = changeProperty(elements, appState, (el) =>
if (isFrameElement(el)) { newElementWith(el, {
return newElementWith(el, { backgroundColor: value.currentItemBackgroundColor,
backgroundColor: value.currentItemBackgroundColor, }),
}); );
}
return hasBackground(el.type)
? newElementWith(el, {
backgroundColor: value.currentItemBackgroundColor,
})
: el;
});
} }
return { return {
@@ -458,12 +444,7 @@ export const actionChangeBackgroundColor = register<
(element) => element.backgroundColor, (element) => element.backgroundColor,
true, true,
(hasSelection) => (hasSelection) =>
!hasSelection !hasSelection ? appState.currentItemBackgroundColor : null,
? appState.activeTool.type === "frame"
? // background default shouldn't apply to new frames
"transparent"
: appState.currentItemBackgroundColor
: null,
)} )}
onChange={(color) => onChange={(color) =>
updateData({ currentItemBackgroundColor: color }) updateData({ currentItemBackgroundColor: color })
@@ -490,13 +471,11 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
})`, })`,
); );
return { return {
elements: changeProperty(elements, appState, (el) => { elements: changeProperty(elements, appState, (el) =>
return hasBackground(el.type) newElementWith(el, {
? newElementWith(el, { fillStyle: value,
fillStyle: value, }),
}) ),
: el;
}),
appState: { ...appState, currentItemFillStyle: value }, appState: { ...appState, currentItemFillStyle: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -569,13 +548,11 @@ export const actionChangeStrokeWidth = register<
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => { elements: changeProperty(elements, appState, (el) =>
return hasStrokeWidth(el.type) newElementWith(el, {
? newElementWith(el, { strokeWidth: value,
strokeWidth: value, }),
}) ),
: el;
}),
appState: { ...appState, currentItemStrokeWidth: value }, appState: { ...appState, currentItemStrokeWidth: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -627,14 +604,12 @@ export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => { elements: changeProperty(elements, appState, (el) =>
return hasStrokeStyle(el.type) newElementWith(el, {
? newElementWith(el, { seed: randomInteger(),
seed: randomInteger(), roughness: value,
roughness: value, }),
}) ),
: el;
}),
appState: { ...appState, currentItemRoughness: value }, appState: { ...appState, currentItemRoughness: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -685,13 +660,11 @@ export const actionChangeStrokeStyle = register<
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => { elements: changeProperty(elements, appState, (el) =>
return hasStrokeStyle(el.type) newElementWith(el, {
? newElementWith(el, { strokeStyle: value,
strokeStyle: value, }),
}) ),
: el;
}),
appState: { ...appState, currentItemStrokeStyle: value }, appState: { ...appState, currentItemStrokeStyle: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
@@ -1503,7 +1476,7 @@ export const actionChangeRoundness = register<"sharp" | "round">({
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
elements: changeProperty(elements, appState, (el) => { elements: changeProperty(elements, appState, (el) => {
if (isElbowArrow(el) || !canChangeRoundness(el.type)) { if (isElbowArrow(el)) {
return el; return el;
} }
@@ -1582,7 +1555,7 @@ const getArrowheadOptions = (flip: boolean) => {
value: null, value: null,
text: t("labels.arrowhead_none"), text: t("labels.arrowhead_none"),
keyBinding: "q", keyBinding: "q",
icon: <ArrowheadNoneIcon flip={flip} />, icon: ArrowheadNoneIcon,
}, },
{ {
value: "arrow", value: "arrow",
@@ -1710,8 +1683,7 @@ export const actionChangeArrowhead = register<{
? element.startArrowhead ? element.startArrowhead
: appState.currentItemStartArrowhead, : appState.currentItemStartArrowhead,
true, true,
(hasSelection) => appState.currentItemStartArrowhead,
hasSelection ? null : appState.currentItemStartArrowhead,
)} )}
onChange={(value) => updateData({ position: "start", type: value })} onChange={(value) => updateData({ position: "start", type: value })}
numberOfOptionsToAlwaysShow={4} numberOfOptionsToAlwaysShow={4}
@@ -1728,8 +1700,7 @@ export const actionChangeArrowhead = register<{
? element.endArrowhead ? element.endArrowhead
: appState.currentItemEndArrowhead, : appState.currentItemEndArrowhead,
true, true,
(hasSelection) => appState.currentItemEndArrowhead,
hasSelection ? null : appState.currentItemEndArrowhead,
)} )}
onChange={(value) => updateData({ position: "end", type: value })} onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4} numberOfOptionsToAlwaysShow={4}
+1 -3
View File
@@ -55,8 +55,7 @@ export type ShortcutName =
| "saveScene" | "saveScene"
| "imageExport" | "imageExport"
| "commandPalette" | "commandPalette"
| "searchMenu" | "searchMenu";
| "toolLock";
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")], toggleTheme: [getShortcutKey("Shift+Alt+D")],
@@ -118,7 +117,6 @@ const shortcutMap: Record<ShortcutName, string[]> = {
toggleShortcuts: [getShortcutKey("?")], toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")], searchMenu: [getShortcutKey("CtrlOrCmd+F")],
wrapSelectionInFrame: [], wrapSelectionInFrame: [],
toolLock: [getShortcutKey("Q")],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
+3 -71
View File
@@ -54,13 +54,7 @@ describe("parseClipboard()", () => {
}), }),
), ),
); );
expect(clipboardData.elements).toEqual([ expect(clipboardData.elements).toEqual([rect]);
expect.objectContaining({
id: rect.id,
type: rect.type,
schemaState: rect.schemaState,
}),
]);
}); });
it("should parse valid excalidraw JSON if inside text/html", async () => { it("should parse valid excalidraw JSON if inside text/html", async () => {
@@ -79,13 +73,7 @@ describe("parseClipboard()", () => {
}), }),
), ),
); );
expect(clipboardData.elements).toEqual([ expect(clipboardData.elements).toEqual([rect]);
expect.objectContaining({
id: rect.id,
type: rect.type,
schemaState: rect.schemaState,
}),
]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null }); json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
@@ -97,66 +85,10 @@ describe("parseClipboard()", () => {
}), }),
), ),
); );
expect(clipboardData.elements).toEqual([ expect(clipboardData.elements).toEqual([rect]);
expect.objectContaining({
id: rect.id,
type: rect.type,
schemaState: rect.schemaState,
}),
]);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
}); });
it("should preserve per-element schema on clipboard payload", async () => {
const rect = API.createElement({ type: "rectangle" });
const clipboardPayload = JSON.parse(
serializeAsClipboardJSON({ elements: [rect], files: null }),
);
const clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/plain": JSON.stringify(clipboardPayload),
},
}),
),
);
expect(clipboardData.elements?.[0]).toEqual(
expect.objectContaining({
id: rect.id,
schemaState: rect.schemaState,
}),
);
});
it("should not upcast legacy elements to latest schema on clipboard serialize", async () => {
const rect = API.createElement({ type: "rectangle" });
const legacyRect = { ...(rect as any) };
delete legacyRect.schemaState;
const clipboardPayload = JSON.parse(
serializeAsClipboardJSON({
elements: [legacyRect as typeof rect],
files: null,
}),
);
expect(clipboardPayload.elements[0]).not.toHaveProperty("schemaState");
const clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/plain": JSON.stringify(clipboardPayload),
},
}),
),
);
expect(clipboardData.elements?.[0]).not.toHaveProperty("schemaState");
});
it("should parse <image> `src` urls out of text/html", async () => { it("should parse <image> `src` urls out of text/html", async () => {
let clipboardData; let clipboardData;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
+26 -73
View File
@@ -79,10 +79,7 @@ export const probablySupportsClipboardBlob =
const clipboardContainsElements = ( const clipboardContainsElements = (
contents: any, contents: any,
): contents is { ): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
elements: ExcalidrawElement[];
files?: BinaryFiles;
} => {
if ( if (
[ [
EXPORT_DATA_TYPES.excalidraw, EXPORT_DATA_TYPES.excalidraw,
@@ -207,13 +204,8 @@ export const copyToClipboard = async (
/** supply if available to make the operation more certain to succeed */ /** supply if available to make the operation more certain to succeed */
clipboardEvent?: ClipboardEvent | null, clipboardEvent?: ClipboardEvent | null,
) => { ) => {
const json = serializeAsClipboardJSON({ elements, files });
await copyTextToSystemClipboard( await copyTextToSystemClipboard(
{ serializeAsClipboardJSON({ elements, files }),
[MIME_TYPES.excalidrawClipboard]: json,
[MIME_TYPES.text]: json,
},
clipboardEvent, clipboardEvent,
); );
}; };
@@ -409,7 +401,7 @@ export type ParsedDataTransferFile = Extract<
{ kind: "file" } { kind: "file" }
>; >;
export type ParsedDataTranferList = ParsedDataTransferItem[] & { type ParsedDataTranferList = ParsedDataTransferItem[] & {
/** /**
* Only allows filtering by known `string` data types, since `file` * Only allows filtering by known `string` data types, since `file`
* types can have multiple items of the same type (e.g. multiple image files) * types can have multiple items of the same type (e.g. multiple image files)
@@ -460,29 +452,6 @@ const getDataTransferFiles = function (
); );
}; };
/** @returns list of MIME types, synchronously */
export const parseDataTransferEventMimeTypes = (
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
): Set<string> => {
let items: DataTransferItemList | undefined = undefined;
if (isClipboardEvent(event)) {
items = event.clipboardData?.items;
} else {
items = event.dataTransfer?.items;
}
const types: Set<string> = new Set();
for (const item of Array.from(items || [])) {
if (!types.has(item.type)) {
types.add(item.type);
}
}
return types;
};
export const parseDataTransferEvent = async ( export const parseDataTransferEvent = async (
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>, event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
): Promise<ParsedDataTranferList> => { ): Promise<ParsedDataTranferList> => {
@@ -491,7 +460,8 @@ export const parseDataTransferEvent = async (
if (isClipboardEvent(event)) { if (isClipboardEvent(event)) {
items = event.clipboardData?.items; items = event.clipboardData?.items;
} else { } else {
items = event.dataTransfer?.items; const dragEvent = event;
items = dragEvent.dataTransfer?.items;
} }
const dataItems = ( const dataItems = (
@@ -597,7 +567,7 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
// ClipboardItem constructor, but throws on an unrelated MIME type error. // ClipboardItem constructor, but throws on an unrelated MIME type error.
// So we need to await this and fallback to awaiting the blob if applicable. // So we need to await this and fallback to awaiting the blob if applicable.
await navigator.clipboard.write([ await navigator.clipboard.write([
new ClipboardItem({ new window.ClipboardItem({
[MIME_TYPES.png]: blob, [MIME_TYPES.png]: blob,
}), }),
]); ]);
@@ -606,7 +576,7 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
// with resolution value instead // with resolution value instead
if (isPromiseLike(blob)) { if (isPromiseLike(blob)) {
await navigator.clipboard.write([ await navigator.clipboard.write([
new ClipboardItem({ new window.ClipboardItem({
[MIME_TYPES.png]: await blob, [MIME_TYPES.png]: await blob,
}), }),
]); ]);
@@ -616,27 +586,28 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
} }
}; };
export const copyTextToSystemClipboard = async < export const copyTextToSystemClipboard = async (
MimeType extends ValueOf<typeof STRING_MIME_TYPES>, text: string | null,
>(
text: string | { [K in MimeType]: string } | null,
clipboardEvent?: ClipboardEvent | null, clipboardEvent?: ClipboardEvent | null,
) => { ) => {
text = text || ""; // (1) first try using Async Clipboard API
if (probablySupportsClipboardWriteText) {
try {
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
// not focused
await navigator.clipboard.writeText(text || "");
return;
} catch (error: any) {
console.error(error);
}
}
const entries = Object.entries( // (2) if fails and we have access to ClipboardEvent, use plain old setData()
typeof text === "string" ? { [MIME_TYPES.text]: text } : text,
);
// (1) if we have clipboardEvent, try using it first as it's the most
// versatile
try { try {
if (clipboardEvent) { if (clipboardEvent) {
for (const [mimeType, value] of entries) { clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || "");
clipboardEvent.clipboardData?.setData(mimeType, value); if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) {
if (clipboardEvent.clipboardData?.getData(mimeType) !== value) { throw new Error("Failed to setData on clipboardEvent");
throw new Error("Failed to setData on clipboardEvent");
}
} }
return; return;
} }
@@ -644,26 +615,8 @@ export const copyTextToSystemClipboard = async <
console.error(error); console.error(error);
} }
const plainTextEntry = entries.find( // (3) if that fails, use document.execCommand
([mimeType]) => mimeType === MIME_TYPES.text, if (!copyTextViaExecCommand(text)) {
);
// (2) if we don't have access to clipboardEvent, or that fails,
// at least try setting text/plain via navigator.clipboard.writeText
// (navigator.clipboard.write doesn't work with non-standard mime types)
if (probablySupportsClipboardWriteText && plainTextEntry) {
try {
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
// not focused
await navigator.clipboard.writeText(plainTextEntry[1]);
return;
} catch (error: any) {
console.error(error);
}
}
// (3) if previous fails, use document.execCommand
if (plainTextEntry && !copyTextViaExecCommand(plainTextEntry[1])) {
throw new Error("Error copying to clipboard."); throw new Error("Error copying to clipboard.");
} }
}; };
+3 -7
View File
@@ -1,6 +1,6 @@
import clsx from "clsx"; import clsx from "clsx";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { Popover } from "radix-ui"; import * as Popover from "@radix-ui/react-popover";
import { import {
CLASSES, CLASSES,
@@ -20,7 +20,6 @@ import {
isArrowElement, isArrowElement,
hasStrokeColor, hasStrokeColor,
toolIsArrow, toolIsArrow,
isFrameElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { import type {
@@ -130,11 +129,8 @@ export const canChangeBackgroundColor = (
targetElements: ExcalidrawElement[], targetElements: ExcalidrawElement[],
) => { ) => {
return ( return (
// frame tool shouldn't allow to set background until frame is created
hasBackground(appState.activeTool.type) || hasBackground(appState.activeTool.type) ||
targetElements.some( targetElements.some((element) => hasBackground(element.type))
(element) => hasBackground(element.type) || isFrameElement(element),
)
); );
}; };
@@ -1267,9 +1263,9 @@ export const ShapesSwitcher = ({
onSelect={() => app.onMagicframeToolSelect()} onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon} icon={MagicIcon}
data-testid="toolbar-magicframe" data-testid="toolbar-magicframe"
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
> >
{t("toolBar.magicframe")} {t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item> </DropdownMenu.Item>
)} )}
</DropdownMenu.Content> </DropdownMenu.Content>
+134 -367
View File
@@ -11,6 +11,7 @@ import {
pointDistance, pointDistance,
vector, vector,
pointRotateRads, pointRotateRads,
vectorScale,
vectorFromPoint, vectorFromPoint,
vectorSubtract, vectorSubtract,
vectorDot, vectorDot,
@@ -249,14 +250,6 @@ import {
maxBindingDistance_simple, maxBindingDistance_simple,
convertToExcalidrawElements, convertToExcalidrawElements,
type ExcalidrawElementSkeleton, type ExcalidrawElementSkeleton,
getSnapOutlineMidPoint,
handleFocusPointDrag,
handleFocusPointHover,
handleFocusPointPointerDown,
handleFocusPointPointerUp,
maybeHandleArrowPointlikeDrag,
getUncroppedWidthAndHeight,
isFrameElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -354,7 +347,6 @@ import {
import { exportCanvas, loadFromBlob } from "../data"; import { exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { restoreAppState, restoreElements } from "../data/restore"; import { restoreAppState, restoreElements } from "../data/restore";
import { createSchemaMigrationRegistry } from "../data/schema";
import { getCenter, getDistance } from "../gesture"; import { getCenter, getDistance } from "../gesture";
import { History } from "../history"; import { History } from "../history";
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n"; import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
@@ -444,7 +436,10 @@ import { searchItemInFocusAtom } from "./SearchMenu";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { StaticCanvas, InteractiveCanvas } from "./canvases"; import { StaticCanvas, InteractiveCanvas } from "./canvases";
import NewElementCanvas from "./canvases/NewElementCanvas"; import NewElementCanvas from "./canvases/NewElementCanvas";
import { isPointHittingLink } from "./hyperlink/helpers"; import {
isPointHittingLink,
isPointHittingLinkIcon,
} from "./hyperlink/helpers";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import { Toast } from "./Toast"; import { Toast } from "./Toast";
@@ -461,7 +456,6 @@ import type {
import type { ClipboardData, PastedMixedContent } from "../clipboard"; import type { ClipboardData, PastedMixedContent } from "../clipboard";
import type { ExportedElements } from "../data"; import type { ExportedElements } from "../data";
import type { SchemaMigrationRegistry } from "../data/schema";
import type { ContextMenuItems } from "./ContextMenu"; import type { ContextMenuItems } from "./ContextMenu";
import type { FileSystemHandle } from "../data/filesystem"; import type { FileSystemHandle } from "../data/filesystem";
@@ -608,7 +602,6 @@ class App extends React.Component<AppProps, AppState> {
public fonts: Fonts; public fonts: Fonts;
public renderer: Renderer; public renderer: Renderer;
public visibleElements: readonly NonDeletedExcalidrawElement[]; public visibleElements: readonly NonDeletedExcalidrawElement[];
private schemaMigrationRegistry: SchemaMigrationRegistry;
private resizeObserver: ResizeObserver | undefined; private resizeObserver: ResizeObserver | undefined;
public library: AppClassProperties["library"]; public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined; public libraryItemsFromStorage: LibraryItems | undefined;
@@ -650,10 +643,7 @@ class App extends React.Component<AppProps, AppState> {
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null = lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
null; null;
lastPointerMoveEvent: PointerEvent | null = null; lastPointerMoveEvent: PointerEvent | null = null;
/** current frame pointer cords */
lastPointerMoveCoords: { x: number; y: number } | null = null; lastPointerMoveCoords: { x: number; y: number } | null = null;
/** previous frame pointer coords */
previousPointerMoveCoords: { x: number; y: number } | null = null;
lastViewportPosition = { x: 0, y: 0 }; lastViewportPosition = { x: 0, y: 0 };
animationFrameHandler = new AnimationFrameHandler(); animationFrameHandler = new AnimationFrameHandler();
@@ -725,9 +715,6 @@ class App extends React.Component<AppProps, AppState> {
this.stylesPanelMode = deriveStylesPanelMode(this.editorInterface); this.stylesPanelMode = deriveStylesPanelMode(this.editorInterface);
this.id = nanoid(); this.id = nanoid();
this.schemaMigrationRegistry = createSchemaMigrationRegistry(
props.schemaPlugins,
);
this.library = new Library(this); this.library = new Library(this);
this.actionManager = new ActionManager( this.actionManager = new ActionManager(
this.syncActionResult, this.syncActionResult,
@@ -774,7 +761,6 @@ class App extends React.Component<AppProps, AppState> {
setCursor: this.setCursor, setCursor: this.setCursor,
resetCursor: this.resetCursor, resetCursor: this.resetCursor,
getEditorInterface: () => this.editorInterface, getEditorInterface: () => this.editorInterface,
getSchemaMigrationRegistry: this.getSchemaMigrationRegistry,
updateFrameRendering: this.updateFrameRendering, updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar, toggleSidebar: this.toggleSidebar,
onChange: (cb) => this.onChangeEmitter.on(cb), onChange: (cb) => this.onChangeEmitter.on(cb),
@@ -1215,99 +1201,12 @@ class App extends React.Component<AppProps, AppState> {
return this.iFrameRefs.get(element.id); return this.iFrameRefs.get(element.id);
} }
private handleIframeLikeElementHover = ({ private handleEmbeddableCenterClick(element: ExcalidrawIframeLikeElement) {
hitElement,
scenePointer,
moveEvent,
}: {
hitElement: NonDeleted<ExcalidrawElement> | null;
scenePointer: { x: number; y: number };
moveEvent: React.PointerEvent<HTMLCanvasElement>;
}): boolean => {
if ( if (
hitElement && this.state.activeEmbeddable?.element === element &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
moveEvent,
scenePointer.x,
scenePointer.y,
)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
this.setState({
activeEmbeddable: { element: hitElement, state: "hover" },
});
return true;
} else if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
}
return false;
};
/** @returns true if iframe-like element click handled */
private handleIframeLikeCenterClick(): boolean {
if (!this.lastPointerDownEvent || !this.lastPointerUpEvent) {
return false;
}
const scenePointerStart = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerDownEvent.clientX,
clientY: this.lastPointerDownEvent.clientY,
},
this.state,
);
const scenePointerEnd = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerUpEvent.clientX,
clientY: this.lastPointerUpEvent.clientY,
},
this.state,
);
const hitElementStart = this.getElementAtPosition(
scenePointerStart.x,
scenePointerStart.y,
);
const hitElementEnd = this.getElementAtPosition(
scenePointerEnd.x,
scenePointerEnd.y,
);
if (
!hitElementStart ||
!hitElementEnd ||
hitElementStart !== hitElementEnd ||
this.lastPointerUpEvent.timeStamp - this.lastPointerDownEvent.timeStamp >
300 ||
gesture.pointers.size > 1 ||
!isIframeLikeElement(hitElementStart) ||
!isIframeLikeElement(hitElementEnd) ||
!this.isIframeLikeElementCenter(
hitElementStart,
this.lastPointerUpEvent,
scenePointerStart.x,
scenePointerStart.y,
) ||
!this.isIframeLikeElementCenter(
hitElementEnd,
this.lastPointerUpEvent,
scenePointerEnd.x,
scenePointerEnd.y,
)
) {
return false;
}
const iframeLikeElement = hitElementEnd;
if (
this.state.activeEmbeddable?.element === iframeLikeElement &&
this.state.activeEmbeddable?.state === "active" this.state.activeEmbeddable?.state === "active"
) { ) {
return true; return;
} }
// The delay serves two purposes // The delay serves two purposes
@@ -1318,34 +1217,31 @@ class App extends React.Component<AppProps, AppState> {
// in fullscreen mode // in fullscreen mode
setTimeout(() => { setTimeout(() => {
this.setState({ this.setState({
activeEmbeddable: { element: iframeLikeElement, state: "active" }, activeEmbeddable: { element, state: "active" },
selectedElementIds: { [iframeLikeElement.id]: true }, selectedElementIds: { [element.id]: true },
newElement: null, newElement: null,
selectionElement: null, selectionElement: null,
}); });
}, 100); }, 100);
if (isIframeElement(iframeLikeElement)) { if (isIframeElement(element)) {
return true; return;
} }
const iframe = this.getHTMLIFrameElement(iframeLikeElement); const iframe = this.getHTMLIFrameElement(element);
if (!iframe?.contentWindow) { if (!iframe?.contentWindow) {
return true; return;
} }
if (iframe.src.includes("youtube")) { if (iframe.src.includes("youtube")) {
const state = YOUTUBE_VIDEO_STATES.get(iframeLikeElement.id); const state = YOUTUBE_VIDEO_STATES.get(element.id);
if (!state) { if (!state) {
YOUTUBE_VIDEO_STATES.set( YOUTUBE_VIDEO_STATES.set(element.id, YOUTUBE_STATES.UNSTARTED);
iframeLikeElement.id,
YOUTUBE_STATES.UNSTARTED,
);
iframe.contentWindow.postMessage( iframe.contentWindow.postMessage(
JSON.stringify({ JSON.stringify({
event: "listening", event: "listening",
id: iframeLikeElement.id, id: element.id,
}), }),
"*", "*",
); );
@@ -1382,8 +1278,6 @@ class App extends React.Component<AppProps, AppState> {
"*", "*",
); );
} }
return true;
} }
private isIframeLikeElementCenter( private isIframeLikeElementCenter(
@@ -2327,10 +2221,6 @@ class App extends React.Component<AppProps, AppState> {
return this.scene.getNonDeletedElements(); return this.scene.getNonDeletedElements();
}; };
public getSchemaMigrationRegistry = () => {
return this.schemaMigrationRegistry;
};
public onInsertElements = (elements: readonly ExcalidrawElement[]) => { public onInsertElements = (elements: readonly ExcalidrawElement[]) => {
this.addElementsFromPasteOrLibrary({ this.addElementsFromPasteOrLibrary({
elements, elements,
@@ -2819,7 +2709,6 @@ class App extends React.Component<AppProps, AppState> {
const restoredElements = restoreElements(initialData?.elements, null, { const restoredElements = restoreElements(initialData?.elements, null, {
repairBindings: true, repairBindings: true,
deleteInvisibleElements: true, deleteInvisibleElements: true,
schemaMigrationRegistry: this.schemaMigrationRegistry,
}); });
let restoredAppState = restoreAppState(initialData?.appState, null); let restoredAppState = restoreAppState(initialData?.appState, null);
const activeTool = restoredAppState.activeTool; const activeTool = restoredAppState.activeTool;
@@ -2891,7 +2780,7 @@ class App extends React.Component<AppProps, AppState> {
private getFormFactor = (editorWidth: number, editorHeight: number) => { private getFormFactor = (editorWidth: number, editorHeight: number) => {
return ( return (
this.props.UIOptions.getFormFactor?.(editorWidth, editorHeight) ?? this.props.UIOptions.formFactor ??
getFormFactor(editorWidth, editorHeight) getFormFactor(editorWidth, editorHeight)
); );
}; };
@@ -2915,7 +2804,10 @@ class App extends React.Component<AppProps, AppState> {
? this.props.UIOptions.dockedSidebarBreakpoint ? this.props.UIOptions.dockedSidebarBreakpoint
: MQ_RIGHT_SIDEBAR_MIN_WIDTH; : MQ_RIGHT_SIDEBAR_MIN_WIDTH;
const nextEditorInterface = updateObject(this.editorInterface, { const nextEditorInterface = updateObject(this.editorInterface, {
desktopUIMode: storedDesktopUIMode ?? this.editorInterface.desktopUIMode, desktopUIMode:
this.props.UIOptions.desktopUIMode ??
storedDesktopUIMode ??
this.editorInterface.desktopUIMode,
formFactor: this.getFormFactor(editorWidth, editorHeight), formFactor: this.getFormFactor(editorWidth, editorHeight),
userAgent: userAgentDescriptor, userAgent: userAgentDescriptor,
canFitSidebar: editorWidth > sidebarBreakpoint, canFitSidebar: editorWidth > sidebarBreakpoint,
@@ -3231,12 +3123,6 @@ class App extends React.Component<AppProps, AppState> {
} }
componentDidUpdate(prevProps: AppProps, prevState: AppState) { componentDidUpdate(prevProps: AppProps, prevState: AppState) {
if (prevProps.schemaPlugins !== this.props.schemaPlugins) {
this.schemaMigrationRegistry = createSchemaMigrationRegistry(
this.props.schemaPlugins,
);
}
this.updateEmbeddables(); this.updateEmbeddables();
const elements = this.scene.getElementsIncludingDeleted(); const elements = this.scene.getElementsIncludingDeleted();
const elementsMap = this.scene.getElementsMapIncludingDeleted(); const elementsMap = this.scene.getElementsMapIncludingDeleted();
@@ -3740,7 +3626,6 @@ class App extends React.Component<AppProps, AppState> {
}) => { }) => {
const elements = restoreElements(opts.elements, null, { const elements = restoreElements(opts.elements, null, {
deleteInvisibleElements: true, deleteInvisibleElements: true,
schemaMigrationRegistry: this.schemaMigrationRegistry,
}); });
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
@@ -4852,12 +4737,8 @@ class App extends React.Component<AppProps, AppState> {
} }
// Handle Alt key for bind mode // Handle Alt key for bind mode
if (event.key === KEYS.ALT) { if (event.key === KEYS.ALT && getFeatureFlag("COMPLEX_BINDINGS")) {
if (getFeatureFlag("COMPLEX_BINDINGS")) { this.handleSkipBindMode();
this.handleSkipBindMode();
} else {
maybeHandleArrowPointlikeDrag({ app: this, event });
}
} }
if (this.actionManager.handleKeyDown(event)) { if (this.actionManager.handleKeyDown(event)) {
@@ -4873,11 +4754,7 @@ class App extends React.Component<AppProps, AppState> {
this.resetDelayedBindMode(); this.resetDelayedBindMode();
} }
flushSync(() => { this.setState({ isBindingEnabled: false });
this.setState({ isBindingEnabled: false });
});
maybeHandleArrowPointlikeDrag({ app: this, event });
} }
if (isArrowKey(event.key)) { if (isArrowKey(event.key)) {
@@ -5057,8 +4934,6 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
event.key === KEYS.G && event.key === KEYS.G &&
(hasBackground(this.state.activeTool.type) || (hasBackground(this.state.activeTool.type) ||
this.state.activeTool.type === "frame" ||
selectedElements.some((element) => isFrameElement(element)) ||
selectedElements.some((element) => hasBackground(element.type))) selectedElements.some((element) => hasBackground(element.type)))
) { ) {
this.setState({ openPopup: "elementBackground" }); this.setState({ openPopup: "elementBackground" });
@@ -5152,11 +5027,6 @@ class App extends React.Component<AppProps, AppState> {
} }
isHoldingSpace = false; isHoldingSpace = false;
} }
if (event.key === KEYS.ALT) {
maybeHandleArrowPointlikeDrag({ app: this, event });
}
if ( if (
(event.key === KEYS.ALT && this.state.bindMode === "skip") || (event.key === KEYS.ALT && this.state.bindMode === "skip") ||
(!event[KEYS.CTRL_OR_CMD] && !isBindingEnabled(this.state)) (!event[KEYS.CTRL_OR_CMD] && !isBindingEnabled(this.state))
@@ -5167,7 +5037,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
// Restart the timer if we're creating/editing a linear element and hovering over an element // Restart the timer if we're creating/editing a linear element and hovering over an element
if (this.lastPointerMoveEvent && getFeatureFlag("COMPLEX_BINDINGS")) { if (this.lastPointerMoveEvent) {
const scenePointer = viewportCoordsToSceneCoords( const scenePointer = viewportCoordsToSceneCoords(
{ {
clientX: this.lastPointerMoveEvent.clientX, clientX: this.lastPointerMoveEvent.clientX,
@@ -5188,18 +5058,14 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
); );
if (isBindingElement(element)) { if (isBindingElement(element) && getFeatureFlag("COMPLEX_BINDINGS")) {
this.handleDelayedBindModeChange(element, hoveredElement); this.handleDelayedBindModeChange(element, hoveredElement);
} }
} }
} }
} }
if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) { if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) {
flushSync(() => { this.setState({ isBindingEnabled: true });
this.setState({ isBindingEnabled: true });
});
maybeHandleArrowPointlikeDrag({ app: this, event });
} }
if (isArrowKey(event.key)) { if (isArrowKey(event.key)) {
bindOrUnbindBindingElements( bindOrUnbindBindingElements(
@@ -6512,28 +6378,15 @@ class App extends React.Component<AppProps, AppState> {
// and point // and point
const { newElement } = this.state; const { newElement } = this.state;
if (!newElement && isBindingEnabled(this.state)) { if (!newElement && isBindingEnabled(this.state)) {
const globalPoint = pointFrom<GlobalPoint>(
scenePointerX,
scenePointerY,
);
const elementsMap = this.scene.getNonDeletedElementsMap();
const hoveredElement = getHoveredElementForBinding( const hoveredElement = getHoveredElementForBinding(
globalPoint, pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
elementsMap, this.scene.getNonDeletedElementsMap(),
maxBindingDistance_simple(this.state.zoom), (el) => maxBindingDistance_simple(this.state.zoom),
); );
if (hoveredElement) { if (hoveredElement) {
this.setState({ this.setState({
suggestedBinding: { suggestedBinding: hoveredElement,
element: hoveredElement,
midPoint: getSnapOutlineMidPoint(
globalPoint,
hoveredElement,
elementsMap,
this.state.zoom,
),
},
}); });
} else if (this.state.suggestedBinding) { } else if (this.state.suggestedBinding) {
this.setState({ this.setState({
@@ -6560,7 +6413,7 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<GlobalPoint>(scenePointerX, scenePointerY), pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
maxBindingDistance_simple(this.state.zoom), (el) => maxBindingDistance_simple(this.state.zoom),
); );
if (hoveredElement) { if (hoveredElement) {
this.actionManager.executeAction(actionFinalize, "ui", { this.actionManager.executeAction(actionFinalize, "ui", {
@@ -6714,33 +6567,26 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<GlobalPoint>(scenePointerX, scenePointerY), pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(), this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
maxBindingDistance_simple(this.state.zoom), (el) => maxBindingDistance_simple(this.state.zoom),
); );
const scenePointer = pointFrom<GlobalPoint>(scenePointerX, scenePointerY); if (
const elementsMap = this.scene.getNonDeletedElementsMap(); hit &&
if (hit && !isPointInElement(scenePointer, hit, elementsMap)) { !isPointInElement(
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
hit,
this.scene.getNonDeletedElementsMap(),
)
) {
this.setState({ this.setState({
suggestedBinding: { suggestedBinding: hit,
element: hit,
midPoint: getSnapOutlineMidPoint(
scenePointer,
hit,
elementsMap,
this.state.zoom,
),
},
}); });
} }
} }
const isPressingAnyButton = Boolean(event.buttons); const hasDeselectedButton = Boolean(event.buttons);
const isLaserTool = this.state.activeTool.type === "laser";
if ( if (
isPressingAnyButton || hasDeselectedButton ||
// checking against laser so that if you mouseover with a laser tool (this.state.activeTool.type !== "selection" &&
// over a link/embeddable, we change the cursor
(!isLaserTool &&
this.state.activeTool.type !== "selection" &&
this.state.activeTool.type !== "lasso" && this.state.activeTool.type !== "lasso" &&
this.state.activeTool.type !== "text" && this.state.activeTool.type !== "text" &&
this.state.activeTool.type !== "eraser") this.state.activeTool.type !== "eraser")
@@ -6860,14 +6706,6 @@ class App extends React.Component<AppProps, AppState> {
); );
} else { } else {
hideHyperlinkToolip(); hideHyperlinkToolip();
if (isLaserTool) {
this.handleIframeLikeElementHover({
hitElement,
scenePointer,
moveEvent: event,
});
return;
}
if ( if (
hitElement && hitElement &&
(hitElement.link || isEmbeddableElement(hitElement)) && (hitElement.link || isEmbeddableElement(hitElement)) &&
@@ -6900,15 +6738,24 @@ class App extends React.Component<AppProps, AppState> {
!hitElement?.locked !hitElement?.locked
) { ) {
if ( if (
!this.handleIframeLikeElementHover({ hitElement &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement, hitElement,
scenePointer, event,
moveEvent: event, scenePointerX,
}) && scenePointerY,
(!hitElement || )
// Elbow arrows can only be moved when unconnected ) {
!isElbowArrow(hitElement) || setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
!(hitElement.startBinding || hitElement.endBinding)) this.setState({
activeEmbeddable: { element: hitElement, state: "hover" },
});
} else if (
!hitElement ||
// Elbow arrows can only be moved when unconnected
!isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding)
) { ) {
if ( if (
this.state.activeTool.type !== "lasso" || this.state.activeTool.type !== "lasso" ||
@@ -6916,6 +6763,9 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} }
if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
}
} }
} }
} else { } else {
@@ -7076,37 +6926,6 @@ class App extends React.Component<AppProps, AppState> {
}, },
}); });
} }
// Check for focus point hover
let hoveredFocusPointBinding: "start" | "end" | null = null;
const arrow = element as any;
if (arrow.startBinding || arrow.endBinding) {
hoveredFocusPointBinding = handleFocusPointHover(
element as ExcalidrawArrowElement,
scenePointerX,
scenePointerY,
this.scene,
this.state,
);
}
if (
this.state.selectedLinearElement.hoveredFocusPointBinding !==
hoveredFocusPointBinding
) {
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
isDragging: false,
hoveredFocusPointBinding,
},
});
}
// Set cursor to pointer when hovering over a focus point
if (hoveredFocusPointBinding) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
}
} else { } else {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
} }
@@ -7567,9 +7386,26 @@ class App extends React.Component<AppProps, AppState> {
x: scenePointerX, x: scenePointerX,
y: scenePointerY, y: scenePointerY,
}; };
const clicklength =
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
if (this.handleIframeLikeCenterClick()) { if (this.editorInterface.formFactor === "phone" && clicklength < 300) {
return; const hitElement = this.getElementAtPosition(
scenePointer.x,
scenePointer.y,
);
if (
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
event,
scenePointer.x,
scenePointer.y,
)
) {
this.handleEmbeddableCenterClick(hitElement);
return;
}
} }
if (this.editorInterface.isTouchScreen) { if (this.editorInterface.isTouchScreen) {
@@ -7590,7 +7426,20 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement && this.hitLinkElement &&
!this.state.selectedElementIds[this.hitLinkElement.id] !this.state.selectedElementIds[this.hitLinkElement.id]
) { ) {
this.redirectToLink(event, this.editorInterface.isTouchScreen); if (
clicklength < 300 &&
isIframeLikeElement(this.hitLinkElement) &&
!isPointHittingLinkIcon(
this.hitLinkElement,
this.scene.getNonDeletedElementsMap(),
this.state,
pointFrom(scenePointer.x, scenePointer.y),
)
) {
this.handleEmbeddableCenterClick(this.hitLinkElement);
} else {
this.redirectToLink(event, this.editorInterface.isTouchScreen);
}
} else if (this.state.viewModeEnabled) { } else if (this.state.viewModeEnabled) {
this.setState({ this.setState({
activeEmbeddable: null, activeEmbeddable: null,
@@ -7998,37 +7847,6 @@ class App extends React.Component<AppProps, AppState> {
if (ret.didAddPoint) { if (ret.didAddPoint) {
return true; return true;
} }
// Also check at current pointer position if focus point is being hovered
// (in case we're clicking directly without a prior move event)
const elementsMap = this.scene.getNonDeletedElementsMap();
const arrow = LinearElementEditor.getElement(
linearElementEditor.elementId,
elementsMap,
) as any;
if (arrow && isBindingElement(arrow)) {
const { hitFocusPoint, pointerOffset } =
handleFocusPointPointerDown(
arrow,
pointerDownState,
elementsMap,
this.state,
);
// If focus point is hit, update state and prevent element selection
if (hitFocusPoint) {
this.setState({
selectedLinearElement: {
...linearElementEditor,
hoveredFocusPointBinding: hitFocusPoint,
draggedFocusPointBinding: hitFocusPoint,
pointerOffset,
},
});
return false;
}
}
} }
const allHitElements = this.getElementsAtPosition( const allHitElements = this.getElementsAtPosition(
@@ -8841,7 +8659,6 @@ class App extends React.Component<AppProps, AppState> {
selectedPointsIndices: [endIdx], selectedPointsIndices: [endIdx],
initialState: { initialState: {
...linearElementEditor.initialState, ...linearElementEditor.initialState,
arrowStartIsInside: event.altKey,
lastClickedPoint: endIdx, lastClickedPoint: endIdx,
origin: pointFrom<GlobalPoint>( origin: pointFrom<GlobalPoint>(
pointerDownState.origin.x, pointerDownState.origin.x,
@@ -8860,18 +8677,7 @@ class App extends React.Component<AppProps, AppState> {
bindMode: "orbit", bindMode: "orbit",
newElement: element, newElement: element,
startBoundElement: boundElement, startBoundElement: boundElement,
suggestedBinding: suggestedBinding: boundElement || null,
boundElement && isBindingElement(element)
? {
element: boundElement,
midPoint: getSnapOutlineMidPoint(
point,
boundElement,
elementsMap,
this.state.zoom,
),
}
: null,
selectedElementIds: nextSelectedElementIds, selectedElementIds: nextSelectedElementIds,
selectedLinearElement: linearElementEditor, selectedLinearElement: linearElementEditor,
}; };
@@ -9133,8 +8939,8 @@ class App extends React.Component<AppProps, AppState> {
} }
const lastPointerCoords = const lastPointerCoords =
this.previousPointerMoveCoords ?? pointerDownState.origin; this.lastPointerMoveCoords ?? pointerDownState.origin;
this.previousPointerMoveCoords = pointerCoords; this.lastPointerMoveCoords = pointerCoords;
// We need to initialize dragOffsetXY only after we've updated // We need to initialize dragOffsetXY only after we've updated
// `state.selectedElementIds` on pointerDown. Doing it here in pointerMove // `state.selectedElementIds` on pointerDown. Doing it here in pointerMove
@@ -9188,31 +8994,6 @@ class App extends React.Component<AppProps, AppState> {
if (this.state.selectedLinearElement) { if (this.state.selectedLinearElement) {
const linearElementEditor = this.state.selectedLinearElement; const linearElementEditor = this.state.selectedLinearElement;
// Handle focus point dragging if needed
if (linearElementEditor.draggedFocusPointBinding) {
handleFocusPointDrag(
linearElementEditor,
elementsMap,
pointerCoords,
this.scene,
this.state,
this.getEffectiveGridSize(),
event.altKey,
);
this.setState({
selectedLinearElement: {
...linearElementEditor,
isDragging: false,
selectedPointsIndices: [],
initialState: {
...linearElementEditor.initialState,
lastClickedPoint: -1,
},
},
});
return;
}
if ( if (
LinearElementEditor.shouldAddMidpoint( LinearElementEditor.shouldAddMidpoint(
this.state.selectedLinearElement, this.state.selectedLinearElement,
@@ -9451,21 +9232,14 @@ class App extends React.Component<AppProps, AppState> {
this.imageCache.get(croppingElement.fileId)?.image; this.imageCache.get(croppingElement.fileId)?.image;
if (image && !(image instanceof Promise)) { if (image && !(image instanceof Promise)) {
const uncroppedSize = const instantDragOffset = vectorScale(
getUncroppedWidthAndHeight(croppingElement); vector(
const instantDragOffset = vector( pointerCoords.x - lastPointerCoords.x,
pointerCoords.x - lastPointerCoords.x, pointerCoords.y - lastPointerCoords.y,
pointerCoords.y - lastPointerCoords.y, ),
Math.max(this.state.zoom.value, 2),
); );
// to reduce cursor:image drift, we need to take into account
// the canvas image element scaling so we can accurately
// track the pixels on movement
instantDragOffset[0] *=
image.naturalWidth / uncroppedSize.width;
instantDragOffset[1] *=
image.naturalHeight / uncroppedSize.height;
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
croppingElement, croppingElement,
elementsMap, elementsMap,
@@ -9508,13 +9282,13 @@ class App extends React.Component<AppProps, AppState> {
const nextCrop = { const nextCrop = {
...crop, ...crop,
x: clamp( x: clamp(
crop.x - crop.x +
offsetVector[0] * Math.sign(croppingElement.scale[0]), offsetVector[0] * Math.sign(croppingElement.scale[0]),
0, 0,
image.naturalWidth - crop.width, image.naturalWidth - crop.width,
), ),
y: clamp( y: clamp(
crop.y - crop.y +
offsetVector[1] * Math.sign(croppingElement.scale[1]), offsetVector[1] * Math.sign(croppingElement.scale[1]),
0, 0,
image.naturalHeight - crop.height, image.naturalHeight - crop.height,
@@ -10007,7 +9781,6 @@ class App extends React.Component<AppProps, AppState> {
// just in case, tool changes mid drag, always clean up // just in case, tool changes mid drag, always clean up
this.lassoTrail.endPath(); this.lassoTrail.endPath();
this.previousPointerMoveCoords = null;
SnapCache.setReferenceSnapPoints(null); SnapCache.setReferenceSnapPoints(null);
SnapCache.setVisibleGaps(null); SnapCache.setVisibleGaps(null);
@@ -10089,14 +9862,12 @@ class App extends React.Component<AppProps, AppState> {
// and sets binding element // and sets binding element
if ( if (
this.state.selectedLinearElement?.isEditing && this.state.selectedLinearElement?.isEditing &&
!this.state.newElement && !this.state.newElement
this.state.selectedLinearElement.draggedFocusPointBinding === null
) { ) {
if ( if (
!pointerDownState.boxSelection.hasOccurred && !pointerDownState.boxSelection.hasOccurred &&
pointerDownState.hit?.element?.id !== pointerDownState.hit?.element?.id !==
this.state.selectedLinearElement.elementId && this.state.selectedLinearElement.elementId
this.state.selectedLinearElement.draggedFocusPointBinding === null
) { ) {
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
} else { } else {
@@ -10132,18 +9903,7 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
if (this.state.selectedLinearElement.draggedFocusPointBinding) { if (
handleFocusPointPointerUp(
this.state.selectedLinearElement,
this.scene,
);
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
draggedFocusPointBinding: null,
},
});
} else if (
pointerDownState.hit?.element?.id !== pointerDownState.hit?.element?.id !==
this.state.selectedLinearElement.elementId this.state.selectedLinearElement.elementId
) { ) {
@@ -10153,12 +9913,6 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ selectedLinearElement: null }); this.setState({ selectedLinearElement: null });
} }
} else if (this.state.selectedLinearElement.isDragging) { } else if (this.state.selectedLinearElement.isDragging) {
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
isDragging: false,
},
});
this.actionManager.executeAction(actionFinalize, "ui", { this.actionManager.executeAction(actionFinalize, "ui", {
event: childEvent, event: childEvent,
sceneCoords, sceneCoords,
@@ -10301,7 +10055,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
} }
} else if (pointerDownState.drag.hasOccurred && !multiElement) { } else if (pointerDownState.drag.hasOccurred && !multiElement) {
if (isLinearElement(newElement)) { if (isBindingElement(newElement, false)) {
this.actionManager.executeAction(actionFinalize, "ui", { this.actionManager.executeAction(actionFinalize, "ui", {
event: childEvent, event: childEvent,
sceneCoords, sceneCoords,
@@ -10933,6 +10687,25 @@ class App extends React.Component<AppProps, AppState> {
suggestedBinding: null, suggestedBinding: null,
}); });
} }
if (
hitElement &&
this.lastPointerUpEvent &&
this.lastPointerDownEvent &&
this.lastPointerUpEvent.timeStamp -
this.lastPointerDownEvent.timeStamp <
300 &&
gesture.pointers.size <= 1 &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
this.lastPointerUpEvent,
pointerDownState.origin.x,
pointerDownState.origin.y,
)
) {
this.handleEmbeddableCenterClick(hitElement);
}
}); });
} }
@@ -11447,7 +11220,6 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
this.scene.getElementsIncludingDeleted(), this.scene.getElementsIncludingDeleted(),
fileHandle, fileHandle,
this.schemaMigrationRegistry,
); );
this.syncActionResult({ this.syncActionResult({
...scene, ...scene,
@@ -11494,11 +11266,7 @@ class App extends React.Component<AppProps, AppState> {
); );
// legacy library dataTransfer format // legacy library dataTransfer format
} else if (excalidrawLibrary_data) { } else if (excalidrawLibrary_data) {
libraryItems = parseLibraryJSON( libraryItems = parseLibraryJSON(excalidrawLibrary_data);
excalidrawLibrary_data,
"unpublished",
this.schemaMigrationRegistry,
);
} }
if (libraryItems?.length) { if (libraryItems?.length) {
libraryItems = libraryItems.map((item) => ({ libraryItems = libraryItems.map((item) => ({
@@ -11568,7 +11336,6 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
elements, elements,
fileHandle, fileHandle,
this.schemaMigrationRegistry,
); );
} catch (error: any) { } catch (error: any) {
const imageSceneDataError = error instanceof ImageSceneDataError; const imageSceneDataError = error instanceof ImageSceneDataError;
+1 -1
View File
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.Avatar { .Avatar {
+1 -1
View File
@@ -1,4 +1,4 @@
@use "../css/theme" as *; @import "../css/theme";
.excalidraw { .excalidraw {
.excalidraw-button { .excalidraw-button {
@@ -1,4 +1,4 @@
@use "../css/theme" as *; @import "../css/theme";
.excalidraw { .excalidraw {
button.standalone { button.standalone {
+3 -3
View File
@@ -1,4 +1,4 @@
@use "../css/variables.module.scss" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.Card { .Card {
@@ -19,7 +19,7 @@
padding: 1.4rem; padding: 1.4rem;
border-radius: 50%; border-radius: 50%;
background: var(--card-color); background: var(--card-color);
color: #fff; color: $oc-white;
svg { svg {
width: 2.8rem; width: 2.8rem;
@@ -46,7 +46,7 @@
background-color: var(--card-color-darkest); background-color: var(--card-color-darkest);
} }
.ToolIcon__label { .ToolIcon__label {
color: #fff; color: $oc-white;
} }
.Spinner { .Spinner {
+13 -23
View File
@@ -1,35 +1,25 @@
import OpenColor from "open-color";
import "./Card.scss"; import "./Card.scss";
// for open-color see https://github.com/yeun/open-color/blob/master/open-color.scss
const COLOR_MAP = {
primary: {
base: "var(--color-primary)",
darker: "var(--color-primary-darker)",
darkest: "var(--color-primary-darkest)",
},
lime: {
base: "#74b816", // open-color lime[7]
darker: "#66a80f", // open-color lime[8]
darkest: "#5c940d", // open-color lime[9]
},
pink: {
base: "#d6336c", // open-color pink[7]
darker: "#c2255c", // open-color pink[8]
darkest: "#a61e4d", // open-color pink[9]
},
};
export const Card: React.FC<{ export const Card: React.FC<{
color: "primary" | "lime" | "pink"; color: keyof OpenColor | "primary";
children?: React.ReactNode; children?: React.ReactNode;
}> = ({ children, color }) => { }> = ({ children, color }) => {
return ( return (
<div <div
className="Card" className="Card"
style={{ style={{
["--card-color" as any]: COLOR_MAP[color].base, ["--card-color" as any]:
["--card-color-darker" as any]: COLOR_MAP[color].darker, color === "primary" ? "var(--color-primary)" : OpenColor[color][7],
["--card-color-darkest" as any]: COLOR_MAP[color].darkest, ["--card-color-darker" as any]:
color === "primary"
? "var(--color-primary-darker)"
: OpenColor[color][8],
["--card-color-darkest" as any]:
color === "primary"
? "var(--color-primary-darkest)"
: OpenColor[color][9],
}} }}
> >
{children} {children}
@@ -1,5 +1,4 @@
@use "sass:color"; @import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.Checkbox { .Checkbox {
@@ -13,7 +12,7 @@
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
&:hover:not(.is-checked) .Checkbox-box:not(:focus) { &:hover:not(.is-checked) .Checkbox-box:not(:focus) {
box-shadow: 0 0 0 2px #{$color-blue-4}; box-shadow: 0 0 0 2px #{$oc-blue-4};
} }
&:hover:not(.is-checked) .Checkbox-box:not(:focus) { &:hover:not(.is-checked) .Checkbox-box:not(:focus) {
@@ -25,25 +24,25 @@
&:active { &:active {
.Checkbox-box { .Checkbox-box {
box-shadow: 0 0 2px 1px inset #{$color-blue-7} !important; box-shadow: 0 0 2px 1px inset #{$oc-blue-7} !important;
} }
} }
&:hover { &:hover {
.Checkbox-box { .Checkbox-box {
background-color: color.adjust($color-blue-1, $alpha: -0.8); background-color: fade-out($oc-blue-1, 0.8);
} }
} }
&.is-checked { &.is-checked {
.Checkbox-box { .Checkbox-box {
background-color: #{$color-blue-1}; background-color: #{$oc-blue-1};
svg { svg {
display: block; display: block;
} }
} }
&:hover .Checkbox-box { &:hover .Checkbox-box {
background-color: #{$color-blue-2}; background-color: #{$oc-blue-2};
} }
} }
@@ -59,16 +58,16 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 0 0 2px #{$color-blue-7}; box-shadow: 0 0 0 2px #{$oc-blue-7};
background-color: transparent; background-color: transparent;
border-radius: 4px; border-radius: 4px;
color: #{$color-blue-7}; color: #{$oc-blue-7};
border: 0; border: 0;
&:focus { &:focus {
box-shadow: 0 0 0 3px #{$color-blue-7}; box-shadow: 0 0 0 3px #{$oc-blue-7};
} }
svg { svg {
@@ -1,7 +1,9 @@
import clsx from "clsx"; import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { KEYS, normalizeInputColor } from "@excalidraw/common"; import { isTransparent, KEYS } from "@excalidraw/common";
import tinycolor from "tinycolor2";
import { getShortcutKey } from "../..//shortcut"; import { getShortcutKey } from "../..//shortcut";
import { useAtom } from "../../editor-jotai"; import { useAtom } from "../../editor-jotai";
@@ -14,6 +16,29 @@ import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import type { ColorPickerType } from "./colorPickerUtils"; import type { ColorPickerType } from "./colorPickerUtils";
/**
* tries to keep the input color as-is if it's valid, making minimal adjustments
* (trimming whitespace or adding `#` to hex colors)
*/
export const normalizeInputColor = (color: string): string | null => {
color = color.trim();
if (isTransparent(color)) {
return color;
}
const tc = tinycolor(color);
if (tc.isValid()) {
// testing for `#` first fixes a bug on Electron (more specfically, an
// Obsidian popout window), where a hex color without `#` is considered valid
if (tc.getFormat() === "hex" && !color.startsWith("#")) {
return `#${color}`;
}
return color;
}
return null;
};
export const ColorInput = ({ export const ColorInput = ({
color, color,
onChange, onChange,
@@ -1,5 +1,4 @@
@use "sass:color"; @import "../../css/variables.module.scss";
@use "../../css/variables.module" as *;
.excalidraw { .excalidraw {
.focus-visible-none { .focus-visible-none {
@@ -186,8 +185,8 @@
.color-picker { .color-picker {
background: var(--popup-bg-color); background: var(--popup-bg-color);
border: 0 solid color.adjust(#fff, $alpha: -0.75); border: 0 solid transparentize($oc-white, 0.75);
box-shadow: color.adjust(#000, $alpha: -0.75) 0 1px 4px; box-shadow: transparentize($oc-black, 0.75) 0 1px 4px;
border-radius: 4px; border-radius: 4px;
position: absolute; position: absolute;
@@ -244,7 +243,7 @@
} }
.color-picker-triangle-shadow { .color-picker-triangle-shadow {
border-color: transparent transparent color.adjust(#000, $alpha: -0.9); border-color: transparent transparent transparentize($oc-black, 0.9);
:root[dir="ltr"] & { :root[dir="ltr"] & {
left: -14px; left: -14px;
@@ -281,7 +280,7 @@
padding: 0.25rem; padding: 0.25rem;
&-title { &-title {
color: $color-gray-6; color: $oc-gray-6;
font-size: 12px; font-size: 12px;
padding: 0 0.25rem; padding: 0 0.25rem;
} }
@@ -320,7 +319,7 @@
.color-picker-transparent { .color-picker-transparent {
border-radius: 4px; border-radius: 4px;
box-shadow: color.adjust(#000, $alpha: -0.9) 0 0 0 1px inset; box-shadow: transparentize($oc-black, 0.9) 0 0 0 1px inset;
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
@@ -474,7 +473,7 @@
} }
.color-picker-type-elementBackground .color-picker-keybinding { .color-picker-type-elementBackground .color-picker-keybinding {
color: #fff; color: $oc-white;
} }
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding { .color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
@@ -487,10 +486,10 @@
&.theme--dark { &.theme--dark {
.color-picker-type-elementBackground .color-picker-keybinding { .color-picker-type-elementBackground .color-picker-keybinding {
color: #000; color: $oc-black;
} }
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding { .color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
color: #000; color: $oc-black;
} }
} }
} }
@@ -1,11 +1,10 @@
import { Popover } from "radix-ui"; import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx"; import clsx from "clsx";
import { useRef, useEffect } from "react"; import { useRef, useEffect } from "react";
import { import {
COLOR_OUTLINE_CONTRAST_THRESHOLD, COLOR_OUTLINE_CONTRAST_THRESHOLD,
COLOR_PALETTE, COLOR_PALETTE,
isColorDark,
isWritableElement, isWritableElement,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -30,7 +29,7 @@ import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker"; import { Picker } from "./Picker";
import PickerHeading from "./PickerHeading"; import PickerHeading from "./PickerHeading";
import { TopPicks } from "./TopPicks"; import { TopPicks } from "./TopPicks";
import { activeColorPickerSectionAtom } from "./colorPickerUtils"; import { activeColorPickerSectionAtom, isColorDark } from "./colorPickerUtils";
import "./ColorPicker.scss"; import "./ColorPicker.scss";
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { isColorDark } from "@excalidraw/common";
import { isColorDark } from "./colorPickerUtils";
interface HotkeyLabelProps { interface HotkeyLabelProps {
color: string; color: string;
@@ -5,9 +5,10 @@ import {
DEFAULT_CANVAS_BACKGROUND_PICKS, DEFAULT_CANVAS_BACKGROUND_PICKS,
DEFAULT_ELEMENT_BACKGROUND_PICKS, DEFAULT_ELEMENT_BACKGROUND_PICKS,
DEFAULT_ELEMENT_STROKE_PICKS, DEFAULT_ELEMENT_STROKE_PICKS,
isColorDark,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { isColorDark } from "./colorPickerUtils";
import type { ColorPickerType } from "./colorPickerUtils"; import type { ColorPickerType } from "./colorPickerUtils";
interface TopPicksProps { interface TopPicksProps {
@@ -1,4 +1,8 @@
import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "@excalidraw/common"; import {
isTransparent,
MAX_CUSTOM_COLORS_USED_IN_CANVAS,
tinycolor,
} from "@excalidraw/common";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
@@ -96,6 +100,32 @@ export type ActiveColorPickerSectionAtomType =
export const activeColorPickerSectionAtom = export const activeColorPickerSectionAtom =
atom<ActiveColorPickerSectionAtomType>(null); atom<ActiveColorPickerSectionAtomType>(null);
const calculateContrast = (r: number, g: number, b: number): number => {
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq;
};
// YIQ algo, inspiration from https://stackoverflow.com/a/11868398
export const isColorDark = (color: string, threshold = 160): boolean => {
// no color ("") -> assume it default to black
if (!color) {
return true;
}
if (isTransparent(color)) {
return false;
}
const tc = tinycolor(color);
if (!tc.isValid()) {
// invalid color -> assume it defaults to black
return true;
}
const { r, g, b } = tc.toRgb();
return calculateContrast(r, g, b) < threshold;
};
export type ColorPickerType = export type ColorPickerType =
| "canvasBackground" | "canvasBackground"
| "elementBackground" | "elementBackground"
@@ -1,4 +1,4 @@
@use "../../css/variables.module" as *; @import "../../css/variables.module.scss";
$verticalBreakpoint: 861px; $verticalBreakpoint: 861px;
@@ -44,6 +44,7 @@ import { getSelectedElements } from "../../scene";
import { import {
LockedIcon, LockedIcon,
UnlockedIcon, UnlockedIcon,
clockIcon,
searchIcon, searchIcon,
boltIcon, boltIcon,
bucketFillIcon, bucketFillIcon,
@@ -51,7 +52,6 @@ import {
mermaidLogoIcon, mermaidLogoIcon,
brainIconThin, brainIconThin,
LibraryIcon, LibraryIcon,
historyCommandIcon,
} from "../icons"; } from "../icons";
import { SHAPES } from "../shapes"; import { SHAPES } from "../shapes";
@@ -928,7 +928,7 @@ function CommandPaletteInner({
marginLeft: "6px", marginLeft: "6px",
}} }}
> >
{historyCommandIcon} {clockIcon}
</div> </div>
</div> </div>
<CommandItem <CommandItem
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.confirm-dialog { .confirm-dialog {
@@ -1,5 +1,4 @@
@use "sass:color"; @import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.context-menu-popover { .context-menu-popover {
@@ -9,7 +8,7 @@
.context-menu { .context-menu {
position: relative; position: relative;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 3px 10px color.adjust(#000, $alpha: -0.8); box-shadow: 0 3px 10px transparentize($oc-black, 0.8);
padding: 0; padding: 0;
list-style: none; list-style: none;
user-select: none; user-select: none;
@@ -50,7 +49,7 @@
&.dangerous { &.dangerous {
.context-menu-item__label { .context-menu-item__label {
color: $color-red-7; color: $oc-red-7;
} }
} }
@@ -74,7 +73,7 @@
.context-menu-item__label { .context-menu-item__label {
color: var(--popup-bg-color); color: var(--popup-bg-color);
} }
background-color: $color-red-6; background-color: $oc-red-6;
} }
} }
@@ -98,6 +97,6 @@
.context-menu-item-separator { .context-menu-item-separator {
border: none; border: none;
border-top: 1px solid $color-gray-5; border-top: 1px solid $oc-gray-5;
} }
} }
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css//variables.module.scss";
.excalidraw { .excalidraw {
.ConvertElementTypePopup { .ConvertElementTypePopup {
+1 -1
View File
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.Dialog { .Dialog {
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.ElementLinkDialog { .ElementLinkDialog {
@@ -59,7 +59,7 @@
} }
.ElementLinkDialog__remove { .ElementLinkDialog__remove {
color: $color-red-9; color: $oc-red-9;
margin-left: 1rem; margin-left: 1rem;
.ToolIcon__icon { .ToolIcon__icon {
@@ -68,7 +68,7 @@
} }
.ToolIcon__icon svg { .ToolIcon__icon svg {
color: $color-red-6; color: $oc-red-6;
} }
} }
} }
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.ExportDialog__preview { .ExportDialog__preview {
@@ -112,7 +112,7 @@
font-family: Cascadia; font-family: Cascadia;
font-size: 1.8em; font-size: 1.8em;
color: #fff; color: $oc-white;
&:hover { &:hover {
background-color: var(--button-color-darker); background-color: var(--button-color-darker);
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
@keyframes successStatusAnimation { @keyframes successStatusAnimation {
0% { 0% {
@@ -24,14 +24,6 @@
background-color: var(--back-color); background-color: var(--back-color);
border-color: var(--border-color); border-color: var(--border-color);
border-radius: 0.5rem;
border-width: 1px;
border-style: solid;
font-family: var(--font-family);
user-select: none;
&:hover { &:hover {
transition: all 150ms ease-out; transition: all 150ms ease-out;
} }
@@ -53,7 +45,6 @@
&.ExcButton--status-loading, &.ExcButton--status-loading,
&.ExcButton--status-success { &.ExcButton--status-success {
pointer-events: none; pointer-events: none;
background-color: var(--color-success);
.ExcButton__contents { .ExcButton__contents {
visibility: hidden; visibility: hidden;
@@ -61,19 +52,7 @@
} }
&[disabled] { &[disabled] {
cursor: not-allowed; pointer-events: none;
&.ExcButton--variant-filled,
&:hover {
--back-color: var(--color-surface-low) !important;
--text-color: var(--color-on-surface-variant) !important;
}
&.ExcButton--variant-outlined,
&.ExcButton--variant-icon {
--text-color: var(--color-on-surface-variant);
--border-color: var(--color-surface-high);
}
} }
&, &,
@@ -287,6 +266,14 @@
} }
} }
border-radius: 0.5rem;
border-width: 1px;
border-style: solid;
font-family: var(--font-family);
user-select: none;
&--size-large { &--size-large {
font-weight: 600; font-weight: 600;
font-size: 0.875rem; font-size: 0.875rem;
@@ -33,7 +33,6 @@ export type FilledButtonProps = {
fullWidth?: boolean; fullWidth?: boolean;
icon?: React.ReactNode; icon?: React.ReactNode;
disabled?: boolean;
}; };
export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>( export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
@@ -49,7 +48,6 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
fullWidth, fullWidth,
className, className,
status, status,
disabled,
}, },
ref, ref,
) => { ) => {
@@ -96,7 +94,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
type="button" type="button"
aria-label={label} aria-label={label}
ref={ref} ref={ref}
disabled={disabled || _status === "loading" || _status === "success"} disabled={_status === "loading" || _status === "success"}
> >
<div className="ExcButton__contents"> <div className="ExcButton__contents">
{_status === "loading" ? ( {_status === "loading" ? (
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.FixedSideContainer { .FixedSideContainer {
@@ -1,4 +1,4 @@
@use "../../css/variables.module" as *; @import "../../css/variables.module.scss";
.excalidraw { .excalidraw {
.FontPicker__container { .FontPicker__container {
@@ -1,31 +0,0 @@
import { KEYS } from "@excalidraw/common";
import { Excalidraw } from "../..";
import { Keyboard } from "../../tests/helpers/ui";
import { act, render } from "../../tests/test-utils";
describe("FontPicker", () => {
it("should be able to open font picker", async () => {
(global as any).ResizeObserver =
(global as any).ResizeObserver ||
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
const { queryByTestId } = await render(
<Excalidraw handleKeyboardGlobally={true} />,
);
Keyboard.keyPress(KEYS.T);
const fontPickerTrigger = queryByTestId("font-family-show-fonts");
expect(fontPickerTrigger).not.toBeNull();
act(() => {
fontPickerTrigger!.click();
});
});
});
@@ -1,4 +1,4 @@
import { Popover } from "radix-ui"; import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx"; import clsx from "clsx";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
@@ -30,12 +30,10 @@ import { PropertiesPopover } from "../PropertiesPopover";
import { QuickSearch } from "../QuickSearch"; import { QuickSearch } from "../QuickSearch";
import { ScrollableList } from "../ScrollableList"; import { ScrollableList } from "../ScrollableList";
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup"; import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
import { import DropdownMenuItem, {
DropDownMenuItemBadgeType, DropDownMenuItemBadgeType,
DropDownMenuItemBadge, DropDownMenuItemBadge,
} from "../dropdownMenu/DropdownMenuItem"; } from "../dropdownMenu/DropdownMenuItem";
import MenuItemContent from "../dropdownMenu/DropdownMenuItemContent";
import { getDropdownMenuItemClassName } from "../dropdownMenu/common";
import { import {
FontFamilyCodeIcon, FontFamilyCodeIcon,
FontFamilyHeadingIcon, FontFamilyHeadingIcon,
@@ -271,74 +269,43 @@ export const FontPickerList = React.memo(
[filteredFonts, sceneFamilies], [filteredFonts, sceneFamilies],
); );
const FontPickerListItem = ({ const renderFont = (font: FontDescriptor, index: number) => (
font, <DropdownMenuItem
order, key={font.value}
}: { icon={font.icon}
font: FontDescriptor; value={font.value}
order: number; order={index}
}) => { textStyle={{
const ref = useRef<HTMLButtonElement>(null); fontFamily: getFontFamilyString({ fontFamily: font.value }),
const isHovered = font.value === hoveredFont?.value; }}
const isSelected = font.value === selectedFontFamily; hovered={font.value === hoveredFont?.value}
selected={font.value === selectedFontFamily}
useEffect(() => { // allow to tab between search and selected font
if (!isHovered) { tabIndex={font.value === selectedFontFamily ? 0 : -1}
return; onClick={(e) => {
} wrappedOnSelect(Number(e.currentTarget.value));
if (order === 0) { }}
// scroll into the first item differently, so it's visible what is above (i.e. group title) onMouseMove={() => {
ref.current?.scrollIntoView?.({ block: "end" }); if (hoveredFont?.value !== font.value) {
} else { onHover(font.value);
ref.current?.scrollIntoView?.({ block: "nearest" }); }
} }}
}, [isHovered, order]); >
{font.text}
return ( {font.badge && (
<button <DropDownMenuItemBadge type={font.badge.type}>
ref={ref} {font.badge.placeholder}
type="button" </DropDownMenuItemBadge>
value={font.value} )}
className={getDropdownMenuItemClassName("", isSelected, isHovered)} </DropdownMenuItem>
title={font.text} );
// allow to tab between search and selected font
tabIndex={isSelected ? 0 : -1}
onClick={(e) => {
wrappedOnSelect(Number(e.currentTarget.value));
}}
onMouseMove={() => {
if (hoveredFont?.value !== font.value) {
onHover(font.value);
}
}}
>
<MenuItemContent
icon={font.icon}
badge={
font.badge && (
<DropDownMenuItemBadge type={font.badge.type}>
{font.badge.placeholder}
</DropDownMenuItemBadge>
)
}
textStyle={{
fontFamily: getFontFamilyString({ fontFamily: font.value }),
}}
>
{font.text}
</MenuItemContent>
</button>
);
};
const groups = []; const groups = [];
if (sceneFilteredFonts.length) { if (sceneFilteredFonts.length) {
groups.push( groups.push(
<DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1"> <DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1">
{sceneFilteredFonts.map((font, index) => ( {sceneFilteredFonts.map(renderFont)}
<FontPickerListItem key={font.value} font={font} order={index} />
))}
</DropdownMenuGroup>, </DropdownMenuGroup>,
); );
} }
@@ -346,13 +313,9 @@ export const FontPickerList = React.memo(
if (availableFilteredFonts.length) { if (availableFilteredFonts.length) {
groups.push( groups.push(
<DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2"> <DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2">
{availableFilteredFonts.map((font, index) => ( {availableFilteredFonts.map((font, index) =>
<FontPickerListItem renderFont(font, index + sceneFilteredFonts.length),
key={font.value} )}
font={font}
order={index + sceneFilteredFonts.length}
/>
))}
</DropdownMenuGroup>, </DropdownMenuGroup>,
); );
} }
@@ -1,4 +1,4 @@
import { Popover } from "radix-ui"; import * as Popover from "@radix-ui/react-popover";
import { MOBILE_ACTION_BUTTON_BG } from "@excalidraw/common"; import { MOBILE_ACTION_BUTTON_BG } from "@excalidraw/common";
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.HelpDialog { .HelpDialog {
@@ -60,12 +60,11 @@
&__islands-container { &__islands-container {
display: grid; display: grid;
grid-column-gap: 1.5rem;
grid-row-gap: 2rem;
@media screen and (min-width: 1024px) { @media screen and (min-width: 1024px) {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
grid-column-gap: 1.5rem;
grid-row-gap: 2rem;
} }
@media screen and (min-width: 1024px) { @media screen and (min-width: 1024px) {
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
// this is loosely based on the longest hint text // this is loosely based on the longest hint text
$wide-viewport-width: 1000px; $wide-viewport-width: 1000px;
@@ -1,11 +1,10 @@
@use "sass:color"; @import "../css/variables.module.scss";
@use "../css/variables.module" as *;
.excalidraw { .excalidraw {
.picker { .picker {
padding: 0.5rem; padding: 0.5rem;
background: var(--popup-bg-color); background: var(--popup-bg-color);
border: 0 solid color.adjust(#fff, $alpha: -0.75); border: 0 solid transparentize($oc-white, 0.75);
box-shadow: var(--shadow-island); box-shadow: var(--shadow-island);
border-radius: 4px; border-radius: 4px;
position: absolute; position: absolute;
@@ -88,7 +87,7 @@
} }
.picker-type-elementBackground .picker-keybinding { .picker-type-elementBackground .picker-keybinding {
color: #fff; color: $oc-white;
} }
.picker-swatch[aria-label="transparent"] .picker-keybinding { .picker-swatch[aria-label="transparent"] .picker-keybinding {
@@ -101,10 +100,10 @@
&.theme--dark { &.theme--dark {
.picker-type-elementBackground .picker-keybinding { .picker-type-elementBackground .picker-keybinding {
color: #000; color: $oc-black;
} }
.picker-swatch[aria-label="transparent"] .picker-keybinding { .picker-swatch[aria-label="transparent"] .picker-keybinding {
color: #000; color: $oc-black;
} }
} }
} }
@@ -1,4 +1,4 @@
import { Popover } from "radix-ui"; import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx"; import clsx from "clsx";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
--ImageExportModal-preview-border: #d6d6d6; --ImageExportModal-preview-border: #d6d6d6;
+2 -1
View File
@@ -1,4 +1,5 @@
@use "../css/variables.module" as *; @import "open-color/open-color";
@import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.layer-ui__wrapper.animate { .layer-ui__wrapper.animate {
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "open-color/open-color";
.excalidraw { .excalidraw {
.layer-ui__library { .layer-ui__library {
@@ -46,15 +46,15 @@
} }
&-close.ToolIcon_type_button { &-close.ToolIcon_type_button {
background-color: $color-blue-6; background-color: $oc-blue-6;
align-self: flex-end; align-self: flex-end;
&:hover { &:hover {
background-color: $color-blue-8; background-color: $oc-blue-8;
} }
.ToolIcon__icon { .ToolIcon__icon {
width: auto; width: auto;
font-size: 1rem; font-size: 1rem;
color: #fff; color: $oc-white;
padding: 0 0.5rem; padding: 0 0.5rem;
} }
} }
@@ -90,7 +90,7 @@
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
background-color: var(--color-primary); background-color: var(--color-primary);
color: #fff; color: $oc-white;
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
text-decoration: none !important; text-decoration: none !important;
@@ -126,10 +126,9 @@
.dropdown-menu-container { .dropdown-menu-container {
width: 196px; width: 196px;
box-shadow: var(--library-dropdown-shadow);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
--box-shadow: var(--library-dropdown-shadow);
} }
} }
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "open-color/open-color";
.excalidraw { .excalidraw {
--container-padding-y: 1rem; --container-padding-y: 1rem;
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.library-unit { .library-unit {
@@ -1,4 +1,5 @@
@use "../css/variables.module" as *; @import "open-color/open-color.scss";
@import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.mobile-toolbar { .mobile-toolbar {
@@ -375,7 +375,7 @@ export const MobileToolBar = ({
)} )}
{/* Other Shapes */} {/* Other Shapes */}
<DropdownMenu open={isOtherShapesMenuOpen}> <DropdownMenu open={isOtherShapesMenuOpen} placement="top">
<DropdownMenu.Trigger <DropdownMenu.Trigger
className={clsx( className={clsx(
"App-toolbar__extra-tools-trigger App-toolbar__extra-tools-trigger--mobile", "App-toolbar__extra-tools-trigger App-toolbar__extra-tools-trigger--mobile",
@@ -403,7 +403,6 @@ export const MobileToolBar = ({
onClickOutside={() => setIsOtherShapesMenuOpen(false)} onClickOutside={() => setIsOtherShapesMenuOpen(false)}
onSelect={() => setIsOtherShapesMenuOpen(false)} onSelect={() => setIsOtherShapesMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown" className="App-toolbar__extra-tools-dropdown"
align="start"
> >
{!showTextToolOutside && ( {!showTextToolOutside && (
<DropdownMenu.Item <DropdownMenu.Item
+1 -1
View File
@@ -1,4 +1,4 @@
@use "../css/variables.module" as *; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
&.excalidraw-modal-container { &.excalidraw-modal-container {
+1
View File
@@ -49,6 +49,7 @@ export const Modal: React.FC<{
aria-modal="true" aria-modal="true"
onKeyDown={handleKeydown} onKeyDown={handleKeydown}
aria-labelledby={props.labelledBy} aria-labelledby={props.labelledBy}
data-prevent-outside-click
> >
<div <div
className="Modal__background" className="Modal__background"

Some files were not shown because too many files have changed in this diff Show More