merge with master
This commit is contained in:
commit
7b012b1cad
@ -8,3 +8,4 @@ public/workbox
|
||||
packages/excalidraw/types
|
||||
examples/**/public
|
||||
dev-dist
|
||||
coverage
|
||||
|
@ -20,7 +20,7 @@ exportToCanvas({<br/>
|
||||
getDimensions,<br/>
|
||||
files,<br/>
|
||||
exportPadding?: number;<br/>
|
||||
}: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L21">ExportOpts</a>
|
||||
}: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/utils/export.ts#L24">ExportOpts</a>
|
||||
</pre>
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|
@ -40,7 +40,7 @@ import type {
|
||||
} from "@excalidraw/excalidraw/dist/excalidraw/element/types";
|
||||
import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types";
|
||||
|
||||
import "./App.scss";
|
||||
import "./ExampleApp.scss";
|
||||
|
||||
type Comment = {
|
||||
x: number;
|
||||
@ -73,7 +73,7 @@ export interface AppProps {
|
||||
excalidrawLib: typeof TExcalidraw;
|
||||
}
|
||||
|
||||
export default function App({
|
||||
export default function ExampleApp({
|
||||
appTitle,
|
||||
useCustom,
|
||||
customArgs,
|
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import * as excalidrawLib from "@excalidraw/excalidraw";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import App from "../../components/App";
|
||||
import App from "../../components/ExampleApp";
|
||||
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import App from "../components/App";
|
||||
import App from "../components/ExampleApp";
|
||||
import React, { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
|
@ -649,7 +649,12 @@ const ExcalidrawWrapper = () => {
|
||||
|
||||
// Render the debug scene if the debug canvas is available
|
||||
if (debugCanvasRef.current && excalidrawAPI) {
|
||||
debugRenderer(debugCanvasRef.current, appState, window.devicePixelRatio);
|
||||
debugRenderer(
|
||||
debugCanvasRef.current,
|
||||
appState,
|
||||
window.devicePixelRatio,
|
||||
() => forceRefresh((prev) => !prev),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -31,6 +31,7 @@ export const AppMainMenu: React.FC<{
|
||||
/>
|
||||
)}
|
||||
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
|
||||
<MainMenu.DefaultItems.SearchMenu />
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
|
@ -68,12 +68,17 @@ const _debugRenderer = (
|
||||
canvas: HTMLCanvasElement,
|
||||
appState: AppState,
|
||||
scale: number,
|
||||
refresh: () => void,
|
||||
) => {
|
||||
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
|
||||
canvas,
|
||||
scale,
|
||||
);
|
||||
|
||||
if (appState.height !== canvas.height || appState.width !== canvas.width) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
const context = bootstrapCanvas({
|
||||
canvas,
|
||||
scale,
|
||||
@ -138,8 +143,13 @@ export const saveDebugState = (debug: { enabled: boolean }) => {
|
||||
};
|
||||
|
||||
export const debugRenderer = throttleRAF(
|
||||
(canvas: HTMLCanvasElement, appState: AppState, scale: number) => {
|
||||
_debugRenderer(canvas, appState, scale);
|
||||
(
|
||||
canvas: HTMLCanvasElement,
|
||||
appState: AppState,
|
||||
scale: number,
|
||||
refresh: () => void,
|
||||
) => {
|
||||
_debugRenderer(canvas, appState, scale, refresh);
|
||||
},
|
||||
{ trailing: true },
|
||||
);
|
||||
|
@ -20,6 +20,10 @@ import {
|
||||
get,
|
||||
} from "idb-keyval";
|
||||
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
|
||||
import {
|
||||
CANVAS_SEARCH_TAB,
|
||||
DEFAULT_SIDEBAR,
|
||||
} from "../../packages/excalidraw/constants";
|
||||
import type { LibraryPersistedData } from "../../packages/excalidraw/data/library";
|
||||
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||
@ -66,13 +70,22 @@ const saveDataStateToLocalStorage = (
|
||||
appState: AppState,
|
||||
) => {
|
||||
try {
|
||||
const _appState = clearAppStateForLocalStorage(appState);
|
||||
|
||||
if (
|
||||
_appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
||||
_appState.openSidebar.tab === CANVAS_SEARCH_TAB
|
||||
) {
|
||||
_appState.openSidebar = null;
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
JSON.stringify(clearElementsForLocalStorage(elements)),
|
||||
);
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
JSON.stringify(clearAppStateForLocalStorage(appState)),
|
||||
JSON.stringify(_appState),
|
||||
);
|
||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
||||
} catch (error: any) {
|
||||
|
@ -130,15 +130,6 @@
|
||||
</script>
|
||||
<% } %>
|
||||
|
||||
<!-- For Nunito only preload the latin range, which should be good enough for now -->
|
||||
<link
|
||||
rel="preload"
|
||||
href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
<!-- Register Assistant as the UI font, before the scene inits -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
|
@ -48,6 +48,8 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
// don't auto-inline small assets (i.e. fonts hosted on CDN)
|
||||
assetsInlineLimit: 0,
|
||||
},
|
||||
plugins: [
|
||||
woff2BrowserPlugin(),
|
||||
|
@ -36,7 +36,7 @@
|
||||
"prettier": "2.6.2",
|
||||
"rewire": "6.0.0",
|
||||
"typescript": "4.9.4",
|
||||
"vite": "5.4.2",
|
||||
"vite": "5.0.12",
|
||||
"vite-plugin-checker": "0.7.2",
|
||||
"vite-plugin-ejs": "1.7.0",
|
||||
"vite-plugin-pwa": "0.17.4",
|
||||
|
@ -15,6 +15,8 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
### Features
|
||||
|
||||
- Prefer user defined coordinates and dimensions when creating a frame using [`convertToExcalidrawElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton#converttoexcalidrawelements) [#8517](https://github.com/excalidraw/excalidraw/pull/8517)
|
||||
|
||||
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
|
||||
|
||||
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
|
||||
|
@ -24,7 +24,7 @@ import { CODES, KEYS } from "../keys";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import type { AppState } from "../types";
|
||||
import type { AppState, Offsets } from "../types";
|
||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
@ -38,7 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||
import type { SceneBounds } from "../element/bounds";
|
||||
import { setCursor } from "../cursor";
|
||||
import { StoreAction } from "../store";
|
||||
import { clamp } from "../../math";
|
||||
import { clamp, roundToStep } from "../../math";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
@ -259,70 +259,69 @@ const zoomValueToFitBoundsOnViewport = (
|
||||
const adjustedZoomValue =
|
||||
smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
|
||||
|
||||
const zoomAdjustedToSteps =
|
||||
Math.floor(adjustedZoomValue / ZOOM_STEP) * ZOOM_STEP;
|
||||
|
||||
return getNormalizedZoom(Math.min(zoomAdjustedToSteps, 1));
|
||||
return Math.min(adjustedZoomValue, 1);
|
||||
};
|
||||
|
||||
export const zoomToFitBounds = ({
|
||||
bounds,
|
||||
appState,
|
||||
canvasOffsets,
|
||||
fitToViewport = false,
|
||||
viewportZoomFactor = 1,
|
||||
minZoom = -Infinity,
|
||||
maxZoom = Infinity,
|
||||
}: {
|
||||
bounds: SceneBounds;
|
||||
canvasOffsets?: Offsets;
|
||||
appState: Readonly<AppState>;
|
||||
/** whether to fit content to viewport (beyond >100%) */
|
||||
fitToViewport: boolean;
|
||||
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||
viewportZoomFactor?: number;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
}) => {
|
||||
viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
|
||||
|
||||
const [x1, y1, x2, y2] = bounds;
|
||||
const centerX = (x1 + x2) / 2;
|
||||
const centerY = (y1 + y2) / 2;
|
||||
|
||||
let newZoomValue;
|
||||
let scrollX;
|
||||
let scrollY;
|
||||
const canvasOffsetLeft = canvasOffsets?.left ?? 0;
|
||||
const canvasOffsetTop = canvasOffsets?.top ?? 0;
|
||||
const canvasOffsetRight = canvasOffsets?.right ?? 0;
|
||||
const canvasOffsetBottom = canvasOffsets?.bottom ?? 0;
|
||||
|
||||
const effectiveCanvasWidth =
|
||||
appState.width - canvasOffsetLeft - canvasOffsetRight;
|
||||
const effectiveCanvasHeight =
|
||||
appState.height - canvasOffsetTop - canvasOffsetBottom;
|
||||
|
||||
let adjustedZoomValue;
|
||||
|
||||
if (fitToViewport) {
|
||||
const commonBoundsWidth = x2 - x1;
|
||||
const commonBoundsHeight = y2 - y1;
|
||||
|
||||
newZoomValue =
|
||||
adjustedZoomValue =
|
||||
Math.min(
|
||||
appState.width / commonBoundsWidth,
|
||||
appState.height / commonBoundsHeight,
|
||||
) * clamp(viewportZoomFactor, 0.1, 1);
|
||||
|
||||
newZoomValue = getNormalizedZoom(newZoomValue);
|
||||
|
||||
let appStateWidth = appState.width;
|
||||
|
||||
if (appState.openSidebar) {
|
||||
const sidebarDOMElem = document.querySelector(
|
||||
".sidebar",
|
||||
) as HTMLElement | null;
|
||||
const sidebarWidth = sidebarDOMElem?.offsetWidth ?? 0;
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
appStateWidth = !isRTL
|
||||
? appState.width - sidebarWidth
|
||||
: appState.width + sidebarWidth;
|
||||
}
|
||||
|
||||
scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
|
||||
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
|
||||
effectiveCanvasWidth / commonBoundsWidth,
|
||||
effectiveCanvasHeight / commonBoundsHeight,
|
||||
) * viewportZoomFactor;
|
||||
} else {
|
||||
newZoomValue = zoomValueToFitBoundsOnViewport(
|
||||
adjustedZoomValue = zoomValueToFitBoundsOnViewport(
|
||||
bounds,
|
||||
{
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
width: effectiveCanvasWidth,
|
||||
height: effectiveCanvasHeight,
|
||||
},
|
||||
viewportZoomFactor,
|
||||
);
|
||||
}
|
||||
|
||||
const newZoomValue = getNormalizedZoom(
|
||||
clamp(roundToStep(adjustedZoomValue, ZOOM_STEP, "floor"), minZoom, maxZoom),
|
||||
);
|
||||
|
||||
const centerScroll = centerScrollOn({
|
||||
scenePoint: { x: centerX, y: centerY },
|
||||
@ -330,18 +329,15 @@ export const zoomToFitBounds = ({
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
},
|
||||
offsets: canvasOffsets,
|
||||
zoom: { value: newZoomValue },
|
||||
});
|
||||
|
||||
scrollX = centerScroll.scrollX;
|
||||
scrollY = centerScroll.scrollY;
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
scrollX,
|
||||
scrollY,
|
||||
scrollX: centerScroll.scrollX,
|
||||
scrollY: centerScroll.scrollY,
|
||||
zoom: { value: newZoomValue },
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
@ -349,25 +345,34 @@ export const zoomToFitBounds = ({
|
||||
};
|
||||
|
||||
export const zoomToFit = ({
|
||||
canvasOffsets,
|
||||
targetElements,
|
||||
appState,
|
||||
fitToViewport,
|
||||
viewportZoomFactor,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
}: {
|
||||
canvasOffsets?: Offsets;
|
||||
targetElements: readonly ExcalidrawElement[];
|
||||
appState: Readonly<AppState>;
|
||||
/** whether to fit content to viewport (beyond >100%) */
|
||||
fitToViewport: boolean;
|
||||
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||
viewportZoomFactor?: number;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
}) => {
|
||||
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
|
||||
|
||||
return zoomToFitBounds({
|
||||
canvasOffsets,
|
||||
bounds: commonBounds,
|
||||
appState,
|
||||
fitToViewport,
|
||||
viewportZoomFactor,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
});
|
||||
};
|
||||
|
||||
@ -388,6 +393,7 @@ export const actionZoomToFitSelectionInViewport = register({
|
||||
userToFollow: null,
|
||||
},
|
||||
fitToViewport: false,
|
||||
canvasOffsets: app.getEditorUIOffsets(),
|
||||
});
|
||||
},
|
||||
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
|
||||
@ -413,7 +419,7 @@ export const actionZoomToFitSelection = register({
|
||||
userToFollow: null,
|
||||
},
|
||||
fitToViewport: true,
|
||||
viewportZoomFactor: 0.7,
|
||||
canvasOffsets: app.getEditorUIOffsets(),
|
||||
});
|
||||
},
|
||||
// NOTE this action should use shift-2 per figma, alas
|
||||
@ -430,7 +436,7 @@ export const actionZoomToFit = register({
|
||||
icon: zoomAreaIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) =>
|
||||
perform: (elements, appState, _, app) =>
|
||||
zoomToFit({
|
||||
targetElements: elements,
|
||||
appState: {
|
||||
@ -438,6 +444,7 @@ export const actionZoomToFit = register({
|
||||
userToFollow: null,
|
||||
},
|
||||
fitToViewport: false,
|
||||
canvasOffsets: app.getEditorUIOffsets(),
|
||||
}),
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.ONE &&
|
||||
|
@ -15,7 +15,7 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||
import type { AppState } from "../types";
|
||||
import { resetCursor } from "../cursor";
|
||||
import { StoreAction } from "../store";
|
||||
import { point } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
import { isPathALoop } from "../shapes";
|
||||
|
||||
export const actionFinalize = register({
|
||||
@ -115,7 +115,7 @@ export const actionFinalize = register({
|
||||
mutateElement(multiPointElement, {
|
||||
points: linePoints.map((p, index) =>
|
||||
index === linePoints.length - 1
|
||||
? point(firstPoint[0], firstPoint[1])
|
||||
? pointFrom(firstPoint[0], firstPoint[1])
|
||||
: p,
|
||||
),
|
||||
});
|
||||
@ -217,6 +217,7 @@ export const actionFinalize = register({
|
||||
onClick={updateData}
|
||||
visible={appState.multiElement != null}
|
||||
size={data?.size || "medium"}
|
||||
style={{ pointerEvents: "all" }}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
211
packages/excalidraw/actions/actionFlip.test.tsx
Normal file
211
packages/excalidraw/actions/actionFlip.test.tsx
Normal file
@ -0,0 +1,211 @@
|
||||
import React from "react";
|
||||
import { Excalidraw } from "../index";
|
||||
import { render } from "../tests/test-utils";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { pointFrom } from "../../math";
|
||||
import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("flipping re-centers selection", () => {
|
||||
it("elbow arrow touches group selection side yet it remains in place after multiple moves", async () => {
|
||||
const elements = [
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rec1",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
boundElements: [{ id: "arr", type: "arrow" }],
|
||||
}),
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rec2",
|
||||
x: 220,
|
||||
y: 250,
|
||||
width: 100,
|
||||
height: 100,
|
||||
boundElements: [{ id: "arr", type: "arrow" }],
|
||||
}),
|
||||
API.createElement({
|
||||
type: "arrow",
|
||||
id: "arr",
|
||||
x: 149.9,
|
||||
y: 95,
|
||||
width: 156,
|
||||
height: 239.9,
|
||||
startBinding: {
|
||||
elementId: "rec1",
|
||||
focus: 0,
|
||||
gap: 5,
|
||||
fixedPoint: [0.49, -0.05],
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "rec2",
|
||||
focus: 0,
|
||||
gap: 5,
|
||||
fixedPoint: [-0.05, 0.49],
|
||||
},
|
||||
startArrowhead: null,
|
||||
endArrowhead: "arrow",
|
||||
points: [
|
||||
pointFrom(0, 0),
|
||||
pointFrom(0, -35),
|
||||
pointFrom(-90.9, -35),
|
||||
pointFrom(-90.9, 204.9),
|
||||
pointFrom(65.1, 204.9),
|
||||
],
|
||||
elbowed: true,
|
||||
}),
|
||||
];
|
||||
await render(<Excalidraw initialData={{ elements }} />);
|
||||
|
||||
API.setSelectedElements(elements);
|
||||
|
||||
expect(Object.keys(h.state.selectedElementIds).length).toBe(3);
|
||||
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
|
||||
const rec1 = h.elements.find((el) => el.id === "rec1");
|
||||
expect(rec1?.x).toBeCloseTo(100);
|
||||
expect(rec1?.y).toBeCloseTo(100);
|
||||
|
||||
const rec2 = h.elements.find((el) => el.id === "rec2");
|
||||
expect(rec2?.x).toBeCloseTo(220);
|
||||
expect(rec2?.y).toBeCloseTo(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flipping arrowheads", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
it("flipping bound arrow should flip arrowheads only", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
boundElements: [{ type: "arrow", id: "arrow1" }],
|
||||
});
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
startArrowhead: "arrow",
|
||||
endArrowhead: null,
|
||||
endBinding: {
|
||||
elementId: rect.id,
|
||||
focus: 0.5,
|
||||
gap: 5,
|
||||
},
|
||||
});
|
||||
|
||||
API.setElements([rect, arrow]);
|
||||
API.setSelectedElements([arrow]);
|
||||
|
||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
||||
expect(API.getElement(arrow).endArrowhead).toBe(null);
|
||||
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
expect(API.getElement(arrow).startArrowhead).toBe(null);
|
||||
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
|
||||
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
||||
expect(API.getElement(arrow).endArrowhead).toBe(null);
|
||||
|
||||
API.executeAction(actionFlipVertical);
|
||||
expect(API.getElement(arrow).startArrowhead).toBe(null);
|
||||
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
|
||||
});
|
||||
|
||||
it("flipping bound arrow should flip arrowheads only 2", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
boundElements: [{ type: "arrow", id: "arrow1" }],
|
||||
});
|
||||
const rect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
boundElements: [{ type: "arrow", id: "arrow1" }],
|
||||
});
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
startArrowhead: "arrow",
|
||||
endArrowhead: "circle",
|
||||
startBinding: {
|
||||
elementId: rect.id,
|
||||
focus: 0.5,
|
||||
gap: 5,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: rect2.id,
|
||||
focus: 0.5,
|
||||
gap: 5,
|
||||
},
|
||||
});
|
||||
|
||||
API.setElements([rect, rect2, arrow]);
|
||||
API.setSelectedElements([arrow]);
|
||||
|
||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
||||
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
||||
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
expect(API.getElement(arrow).startArrowhead).toBe("circle");
|
||||
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
|
||||
|
||||
API.executeAction(actionFlipVertical);
|
||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
||||
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
||||
});
|
||||
|
||||
it("flipping unbound arrow shouldn't flip arrowheads", () => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
startArrowhead: "arrow",
|
||||
endArrowhead: "circle",
|
||||
});
|
||||
|
||||
API.setElements([arrow]);
|
||||
API.setSelectedElements([arrow]);
|
||||
|
||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
||||
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
||||
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
||||
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
||||
});
|
||||
|
||||
it("flipping bound arrow shouldn't flip arrowheads if selected alongside non-arrow eleemnt", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
boundElements: [{ type: "arrow", id: "arrow1" }],
|
||||
});
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
startArrowhead: "arrow",
|
||||
endArrowhead: null,
|
||||
endBinding: {
|
||||
elementId: rect.id,
|
||||
focus: 0.5,
|
||||
gap: 5,
|
||||
},
|
||||
});
|
||||
|
||||
API.setElements([rect, arrow]);
|
||||
API.setSelectedElements([rect, arrow]);
|
||||
|
||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
||||
expect(API.getElement(arrow).endArrowhead).toBe(null);
|
||||
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
||||
expect(API.getElement(arrow).endArrowhead).toBe(null);
|
||||
});
|
||||
});
|
@ -2,6 +2,8 @@ import { register } from "./register";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawElement,
|
||||
NonDeleted,
|
||||
NonDeletedSceneElementsMap,
|
||||
@ -18,7 +20,13 @@ import {
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import {
|
||||
isArrowElement,
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
} from "../element/typeChecks";
|
||||
import { mutateElbowArrow } from "../element/routing";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
|
||||
export const actionFlipHorizontal = register({
|
||||
name: "flipHorizontal",
|
||||
@ -109,7 +117,23 @@ const flipElements = (
|
||||
flipDirection: "horizontal" | "vertical",
|
||||
app: AppClassProperties,
|
||||
): ExcalidrawElement[] => {
|
||||
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
|
||||
if (
|
||||
selectedElements.every(
|
||||
(element) =>
|
||||
isArrowElement(element) && (element.startBinding || element.endBinding),
|
||||
)
|
||||
) {
|
||||
return selectedElements.map((element) => {
|
||||
const _element = element as ExcalidrawArrowElement;
|
||||
return newElementWith(_element, {
|
||||
startArrowhead: _element.endArrowhead,
|
||||
endArrowhead: _element.startArrowhead,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const { minX, minY, maxX, maxY, midX, midY } =
|
||||
getCommonBoundingBox(selectedElements);
|
||||
|
||||
resizeMultipleElements(
|
||||
elementsMap,
|
||||
@ -131,5 +155,48 @@ const flipElements = (
|
||||
[],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// flipping arrow elements (and potentially other) makes the selection group
|
||||
// "move" across the canvas because of how arrows can bump against the "wall"
|
||||
// of the selection, so we need to center the group back to the original
|
||||
// position so that repeated flips don't accumulate the offset
|
||||
|
||||
const { elbowArrows, otherElements } = selectedElements.reduce(
|
||||
(
|
||||
acc: {
|
||||
elbowArrows: ExcalidrawElbowArrowElement[];
|
||||
otherElements: ExcalidrawElement[];
|
||||
},
|
||||
element,
|
||||
) =>
|
||||
isElbowArrow(element)
|
||||
? { ...acc, elbowArrows: acc.elbowArrows.concat(element) }
|
||||
: { ...acc, otherElements: acc.otherElements.concat(element) },
|
||||
{ elbowArrows: [], otherElements: [] },
|
||||
);
|
||||
|
||||
const { midX: newMidX, midY: newMidY } =
|
||||
getCommonBoundingBox(selectedElements);
|
||||
const [diffX, diffY] = [midX - newMidX, midY - newMidY];
|
||||
otherElements.forEach((element) =>
|
||||
mutateElement(element, {
|
||||
x: element.x + diffX,
|
||||
y: element.y + diffY,
|
||||
}),
|
||||
);
|
||||
elbowArrows.forEach((element) =>
|
||||
mutateElbowArrow(
|
||||
element,
|
||||
elementsMap,
|
||||
element.points,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
informMutation: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return selectedElements;
|
||||
};
|
||||
|
@ -116,7 +116,7 @@ import {
|
||||
import { mutateElbowArrow } from "../element/routing";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import type { LocalPoint } from "../../math";
|
||||
import { point, vector } from "../../math";
|
||||
import { pointFrom, vector } from "../../math";
|
||||
|
||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||
|
||||
@ -1651,7 +1651,7 @@ export const actionChangeArrowType = register({
|
||||
elementsMap,
|
||||
[finalStartPoint, finalEndPoint].map(
|
||||
(p): LocalPoint =>
|
||||
point(p[0] - newElement.x, p[1] - newElement.y),
|
||||
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
|
||||
),
|
||||
vector(0, 0),
|
||||
{
|
||||
@ -1685,19 +1685,6 @@ export const actionChangeArrowType = register({
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
startBinding: newElement.startBinding
|
||||
? { ...newElement.startBinding, fixedPoint: null }
|
||||
: null,
|
||||
endBinding: newElement.endBinding
|
||||
? { ...newElement.endBinding, fixedPoint: null }
|
||||
: null,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
return newElement;
|
||||
|
55
packages/excalidraw/actions/actionToggleSearchMenu.ts
Normal file
55
packages/excalidraw/actions/actionToggleSearchMenu.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import type { AppState } from "../types";
|
||||
import { searchIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants";
|
||||
|
||||
export const actionToggleSearchMenu = register({
|
||||
name: "searchMenu",
|
||||
icon: searchIcon,
|
||||
keywords: ["search", "find"],
|
||||
label: "search.title",
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "search_menu",
|
||||
action: "toggle",
|
||||
predicate: (appState) => appState.gridModeEnabled,
|
||||
},
|
||||
perform(elements, appState, _, app) {
|
||||
if (
|
||||
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
||||
appState.openSidebar.tab === CANVAS_SEARCH_TAB
|
||||
) {
|
||||
const searchInput =
|
||||
app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
|
||||
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
|
||||
);
|
||||
|
||||
if (searchInput?.matches(":focus")) {
|
||||
return {
|
||||
appState: { ...appState, openSidebar: null },
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
}
|
||||
|
||||
searchInput?.focus();
|
||||
searchInput?.select();
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: CANVAS_SEARCH_TAB },
|
||||
openDialog: null,
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState: AppState) => appState.gridModeEnabled,
|
||||
predicate: (element, appState, props) => {
|
||||
return props.gridModeEnabled === undefined;
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F,
|
||||
});
|
@ -86,3 +86,5 @@ export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
export { actionLink } from "./actionLink";
|
||||
export { actionToggleElementLock } from "./actionElementLock";
|
||||
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
||||
|
||||
export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
|
||||
|
@ -51,7 +51,8 @@ export type ShortcutName =
|
||||
>
|
||||
| "saveScene"
|
||||
| "imageExport"
|
||||
| "commandPalette";
|
||||
| "commandPalette"
|
||||
| "searchMenu";
|
||||
|
||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
toggleTheme: [getShortcutKey("Shift+Alt+D")],
|
||||
@ -112,6 +113,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
|
||||
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
||||
toggleShortcuts: [getShortcutKey("?")],
|
||||
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
||||
|
@ -137,7 +137,8 @@ export type ActionName =
|
||||
| "wrapTextInContainer"
|
||||
| "commandPalette"
|
||||
| "autoResize"
|
||||
| "elementStats";
|
||||
| "elementStats"
|
||||
| "searchMenu";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
@ -191,7 +192,8 @@ export interface Action {
|
||||
| "history"
|
||||
| "menu"
|
||||
| "collab"
|
||||
| "hyperlink";
|
||||
| "hyperlink"
|
||||
| "search_menu";
|
||||
action?: string;
|
||||
predicate?: (
|
||||
appState: Readonly<AppState>,
|
||||
|
@ -118,6 +118,7 @@ export const getDefaultAppState = (): Omit<
|
||||
followedBy: new Set(),
|
||||
isCropping: false,
|
||||
croppingElement: null,
|
||||
searchMatches: [],
|
||||
};
|
||||
};
|
||||
|
||||
@ -240,6 +241,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
followedBy: { browser: false, export: false, server: false },
|
||||
isCropping: { browser: false, export: false, server: false },
|
||||
croppingElement: { browser: false, export: false, server: false },
|
||||
searchMatches: { browser: false, export: false, server: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
|
@ -1,5 +1,5 @@
|
||||
import type { Radians } from "../math";
|
||||
import { point } from "../math";
|
||||
import { pointFrom } from "../math";
|
||||
import {
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_CHART_COLOR_INDEX,
|
||||
@ -260,7 +260,7 @@ const chartLines = (
|
||||
x,
|
||||
y,
|
||||
width: chartWidth,
|
||||
points: [point(0, 0), point(chartWidth, 0)],
|
||||
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
|
||||
});
|
||||
|
||||
const yLine = newLinearElement({
|
||||
@ -271,7 +271,7 @@ const chartLines = (
|
||||
x,
|
||||
y,
|
||||
height: chartHeight,
|
||||
points: [point(0, 0), point(0, -chartHeight)],
|
||||
points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
|
||||
});
|
||||
|
||||
const maxLine = newLinearElement({
|
||||
@ -284,7 +284,7 @@ const chartLines = (
|
||||
strokeStyle: "dotted",
|
||||
width: chartWidth,
|
||||
opacity: GRID_OPACITY,
|
||||
points: [point(0, 0), point(chartWidth, 0)],
|
||||
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
|
||||
});
|
||||
|
||||
return [xLine, yLine, maxLine];
|
||||
@ -441,7 +441,7 @@ const chartTypeLine = (
|
||||
height: cy,
|
||||
strokeStyle: "dotted",
|
||||
opacity: GRID_OPACITY,
|
||||
points: [point(0, 0), point(0, cy)],
|
||||
points: [pointFrom(0, 0), pointFrom(0, cy)],
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -185,6 +185,7 @@ import type {
|
||||
MagicGenerationData,
|
||||
ExcalidrawNonSelectionElement,
|
||||
ExcalidrawArrowElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../element/types";
|
||||
import { getCenter, getDistance } from "../gesture";
|
||||
import {
|
||||
@ -259,6 +260,7 @@ import type {
|
||||
ElementsPendingErasure,
|
||||
GenerateDiagramToCode,
|
||||
NullableGridSize,
|
||||
Offsets,
|
||||
} from "../types";
|
||||
import {
|
||||
debounce,
|
||||
@ -286,6 +288,7 @@ import {
|
||||
getDateTime,
|
||||
isShallowEqual,
|
||||
arrayToMap,
|
||||
toBrandedType,
|
||||
} from "../utils";
|
||||
import {
|
||||
createSrcDoc,
|
||||
@ -434,14 +437,15 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize";
|
||||
import { getVisibleSceneBounds } from "../element/bounds";
|
||||
import { isMaybeMermaidDefinition } from "../mermaid";
|
||||
import NewElementCanvas from "./canvases/NewElementCanvas";
|
||||
import { mutateElbowArrow } from "../element/routing";
|
||||
import { mutateElbowArrow, updateElbowArrow } from "../element/routing";
|
||||
import {
|
||||
FlowChartCreator,
|
||||
FlowChartNavigator,
|
||||
getLinkDirectionFromKey,
|
||||
} from "../element/flowchart";
|
||||
import { searchItemInFocusAtom } from "./SearchMenu";
|
||||
import type { LocalPoint, Radians } from "../../math";
|
||||
import { clamp, point, pointDistance, vector } from "../../math";
|
||||
import { clamp, pointFrom, pointDistance, vector } from "../../math";
|
||||
import { cropElement } from "../element/cropElement";
|
||||
|
||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||
@ -549,6 +553,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
public scene: Scene;
|
||||
public fonts: Fonts;
|
||||
public renderer: Renderer;
|
||||
public visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
private resizeObserver: ResizeObserver | undefined;
|
||||
private nearestScrollableContainer: HTMLElement | Document | undefined;
|
||||
public library: AppClassProperties["library"];
|
||||
@ -556,7 +561,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
public id: string;
|
||||
private store: Store;
|
||||
private history: History;
|
||||
private excalidrawContainerValue: {
|
||||
public excalidrawContainerValue: {
|
||||
container: HTMLDivElement | null;
|
||||
id: string;
|
||||
};
|
||||
@ -684,6 +689,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.canvas = document.createElement("canvas");
|
||||
this.rc = rough.canvas(this.canvas);
|
||||
this.renderer = new Renderer(this.scene);
|
||||
this.visibleElements = [];
|
||||
|
||||
this.store = new Store();
|
||||
this.history = new History();
|
||||
@ -1482,6 +1488,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
newElementId: this.state.newElement?.id,
|
||||
pendingImageElementId: this.state.pendingImageElementId,
|
||||
});
|
||||
this.visibleElements = visibleElements;
|
||||
|
||||
const allElementsMap = this.scene.getNonDeletedElementsMap();
|
||||
|
||||
@ -2297,6 +2304,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
|
||||
// clear the shape and image cache so that any images in initialData
|
||||
// can be loaded fresh
|
||||
this.clearImageShapeCache();
|
||||
// FontFaceSet loadingdone event we listen on may not always
|
||||
// fire (looking at you Safari), so on init we manually load all
|
||||
// fonts and rerender scene text elements once done. This also
|
||||
@ -2362,6 +2372,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return false;
|
||||
};
|
||||
|
||||
private clearImageShapeCache(filesMap?: BinaryFiles) {
|
||||
const files = filesMap ?? this.files;
|
||||
this.scene.getNonDeletedElements().forEach((element) => {
|
||||
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||
this.imageCache.delete(element.fileId);
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async componentDidMount() {
|
||||
this.unmounted = false;
|
||||
this.excalidrawContainerValue.container =
|
||||
@ -3093,7 +3113,45 @@ class App extends React.Component<AppProps, AppState> {
|
||||
retainSeed?: boolean;
|
||||
fitToContent?: boolean;
|
||||
}) => {
|
||||
const elements = restoreElements(opts.elements, null, undefined);
|
||||
let elements = opts.elements.map((el, _, elements) => {
|
||||
if (isElbowArrow(el)) {
|
||||
const startEndElements = [
|
||||
el.startBinding &&
|
||||
elements.find((l) => l.id === el.startBinding?.elementId),
|
||||
el.endBinding &&
|
||||
elements.find((l) => l.id === el.endBinding?.elementId),
|
||||
];
|
||||
const startBinding = startEndElements[0] ? el.startBinding : null;
|
||||
const endBinding = startEndElements[1] ? el.endBinding : null;
|
||||
return {
|
||||
...el,
|
||||
...updateElbowArrow(
|
||||
{
|
||||
...el,
|
||||
startBinding,
|
||||
endBinding,
|
||||
},
|
||||
toBrandedType<NonDeletedSceneElementsMap>(
|
||||
new Map(
|
||||
startEndElements
|
||||
.filter((x) => x != null)
|
||||
.map(
|
||||
(el) =>
|
||||
[el!.id, el] as [
|
||||
string,
|
||||
Ordered<NonDeletedExcalidrawElement>,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
[el.points[0], el.points[el.points.length - 1]],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return el;
|
||||
});
|
||||
elements = restoreElements(elements, null, undefined);
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
|
||||
const elementsCenterX = distance(minX, maxX) / 2;
|
||||
@ -3217,6 +3275,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (opts.fitToContent) {
|
||||
this.scrollToContent(newElements, {
|
||||
fitToContent: true,
|
||||
canvasOffsets: this.getEditorUIOffsets(),
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -3529,7 +3588,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
target:
|
||||
| ExcalidrawElement
|
||||
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
|
||||
opts?:
|
||||
opts?: (
|
||||
| {
|
||||
fitToContent?: boolean;
|
||||
fitToViewport?: never;
|
||||
@ -3546,6 +3605,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
viewportZoomFactor?: number;
|
||||
animate?: boolean;
|
||||
duration?: number;
|
||||
}
|
||||
) & {
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
canvasOffsets?: Offsets;
|
||||
},
|
||||
) => {
|
||||
this.cancelInProgressAnimation?.();
|
||||
@ -3559,10 +3623,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
if (opts?.fitToContent || opts?.fitToViewport) {
|
||||
const { appState } = zoomToFit({
|
||||
canvasOffsets: opts.canvasOffsets,
|
||||
targetElements,
|
||||
appState: this.state,
|
||||
fitToViewport: !!opts?.fitToViewport,
|
||||
viewportZoomFactor: opts?.viewportZoomFactor,
|
||||
minZoom: opts?.minZoom,
|
||||
maxZoom: opts?.maxZoom,
|
||||
});
|
||||
zoom = appState.zoom;
|
||||
scrollX = appState.scrollX;
|
||||
@ -3676,15 +3743,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
this.files = { ...this.files, ...Object.fromEntries(filesMap) };
|
||||
|
||||
this.scene.getNonDeletedElements().forEach((element) => {
|
||||
if (
|
||||
isInitializedImageElement(element) &&
|
||||
filesMap.has(element.fileId)
|
||||
) {
|
||||
this.imageCache.delete(element.fileId);
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
});
|
||||
this.clearImageShapeCache(Object.fromEntries(filesMap));
|
||||
this.scene.triggerUpdate();
|
||||
|
||||
this.addNewImagesToImageCache();
|
||||
@ -3798,40 +3857,42 @@ class App extends React.Component<AppProps, AppState> {
|
||||
},
|
||||
);
|
||||
|
||||
private getEditorUIOffsets = (): {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
} => {
|
||||
public getEditorUIOffsets = (): Offsets => {
|
||||
const toolbarBottom =
|
||||
this.excalidrawContainerRef?.current
|
||||
?.querySelector(".App-toolbar")
|
||||
?.getBoundingClientRect()?.bottom ?? 0;
|
||||
const sidebarWidth = Math.max(
|
||||
this.excalidrawContainerRef?.current
|
||||
?.querySelector(".default-sidebar")
|
||||
?.getBoundingClientRect()?.width ?? 0,
|
||||
);
|
||||
const propertiesPanelWidth = Math.max(
|
||||
this.excalidrawContainerRef?.current
|
||||
const sidebarRect = this.excalidrawContainerRef?.current
|
||||
?.querySelector(".sidebar")
|
||||
?.getBoundingClientRect();
|
||||
const propertiesPanelRect = this.excalidrawContainerRef?.current
|
||||
?.querySelector(".App-menu__left")
|
||||
?.getBoundingClientRect()?.width ?? 0,
|
||||
0,
|
||||
);
|
||||
?.getBoundingClientRect();
|
||||
|
||||
const PADDING = 16;
|
||||
|
||||
return getLanguage().rtl
|
||||
? {
|
||||
top: toolbarBottom,
|
||||
right: propertiesPanelWidth,
|
||||
bottom: 0,
|
||||
left: sidebarWidth,
|
||||
top: toolbarBottom + PADDING,
|
||||
right:
|
||||
Math.max(
|
||||
this.state.width -
|
||||
(propertiesPanelRect?.left ?? this.state.width),
|
||||
0,
|
||||
) + PADDING,
|
||||
bottom: PADDING,
|
||||
left: Math.max(sidebarRect?.right ?? 0, 0) + PADDING,
|
||||
}
|
||||
: {
|
||||
top: toolbarBottom,
|
||||
right: sidebarWidth,
|
||||
bottom: 0,
|
||||
left: propertiesPanelWidth,
|
||||
top: toolbarBottom + PADDING,
|
||||
right: Math.max(
|
||||
this.state.width -
|
||||
(sidebarRect?.left ?? this.state.width) +
|
||||
PADDING,
|
||||
0,
|
||||
),
|
||||
bottom: PADDING,
|
||||
left: Math.max(propertiesPanelRect?.right ?? 0, 0) + PADDING,
|
||||
};
|
||||
};
|
||||
|
||||
@ -3938,7 +3999,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
animate: true,
|
||||
duration: 300,
|
||||
fitToContent: true,
|
||||
viewportZoomFactor: 0.8,
|
||||
canvasOffsets: this.getEditorUIOffsets(),
|
||||
});
|
||||
}
|
||||
|
||||
@ -3994,6 +4055,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scrollToContent(nextNode, {
|
||||
animate: true,
|
||||
duration: 300,
|
||||
canvasOffsets: this.getEditorUIOffsets(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -4426,6 +4488,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scrollToContent(firstNode, {
|
||||
animate: true,
|
||||
duration: 300,
|
||||
canvasOffsets: this.getEditorUIOffsets(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -4871,7 +4934,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.getElementHitThreshold(),
|
||||
);
|
||||
|
||||
return isPointInShape(point(x, y), selectionShape);
|
||||
return isPointInShape(pointFrom(x, y), selectionShape);
|
||||
}
|
||||
|
||||
// take bound text element into consideration for hit collision as well
|
||||
@ -5247,7 +5310,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
element,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.state,
|
||||
point(scenePointer.x, scenePointer.y),
|
||||
pointFrom(scenePointer.x, scenePointer.y),
|
||||
this.device.editor.isMobile,
|
||||
)
|
||||
);
|
||||
@ -5259,11 +5322,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
isTouchScreen: boolean,
|
||||
) => {
|
||||
const draggedDistance = pointDistance(
|
||||
point(
|
||||
pointFrom(
|
||||
this.lastPointerDownEvent!.clientX,
|
||||
this.lastPointerDownEvent!.clientY,
|
||||
),
|
||||
point(this.lastPointerUpEvent!.clientX, this.lastPointerUpEvent!.clientY),
|
||||
pointFrom(
|
||||
this.lastPointerUpEvent!.clientX,
|
||||
this.lastPointerUpEvent!.clientY,
|
||||
),
|
||||
);
|
||||
if (
|
||||
!this.hitLinkElement ||
|
||||
@ -5282,7 +5348,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.hitLinkElement,
|
||||
elementsMap,
|
||||
this.state,
|
||||
point(lastPointerDownCoords.x, lastPointerDownCoords.y),
|
||||
pointFrom(lastPointerDownCoords.x, lastPointerDownCoords.y),
|
||||
this.device.editor.isMobile,
|
||||
);
|
||||
const lastPointerUpCoords = viewportCoordsToSceneCoords(
|
||||
@ -5293,7 +5359,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.hitLinkElement,
|
||||
elementsMap,
|
||||
this.state,
|
||||
point(lastPointerUpCoords.x, lastPointerUpCoords.y),
|
||||
pointFrom(lastPointerUpCoords.x, lastPointerUpCoords.y),
|
||||
this.device.editor.isMobile,
|
||||
);
|
||||
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
|
||||
@ -5543,7 +5609,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// threshold, add a point
|
||||
if (
|
||||
pointDistance(
|
||||
point(scenePointerX - rx, scenePointerY - ry),
|
||||
pointFrom(scenePointerX - rx, scenePointerY - ry),
|
||||
lastPoint,
|
||||
) >= LINE_CONFIRM_THRESHOLD
|
||||
) {
|
||||
@ -5552,7 +5618,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
{
|
||||
points: [
|
||||
...points,
|
||||
point<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
|
||||
pointFrom<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
|
||||
],
|
||||
},
|
||||
false,
|
||||
@ -5566,7 +5632,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
points.length > 2 &&
|
||||
lastCommittedPoint &&
|
||||
pointDistance(
|
||||
point(scenePointerX - rx, scenePointerY - ry),
|
||||
pointFrom(scenePointerX - rx, scenePointerY - ry),
|
||||
lastCommittedPoint,
|
||||
) < LINE_CONFIRM_THRESHOLD
|
||||
) {
|
||||
@ -5614,7 +5680,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
[
|
||||
...points.slice(0, -1),
|
||||
point<LocalPoint>(
|
||||
pointFrom<LocalPoint>(
|
||||
lastCommittedX + dxFromLastCommitted,
|
||||
lastCommittedY + dyFromLastCommitted,
|
||||
),
|
||||
@ -5633,7 +5699,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
{
|
||||
points: [
|
||||
...points.slice(0, -1),
|
||||
point<LocalPoint>(
|
||||
pointFrom<LocalPoint>(
|
||||
lastCommittedX + dxFromLastCommitted,
|
||||
lastCommittedY + dyFromLastCommitted,
|
||||
),
|
||||
@ -5862,8 +5928,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
|
||||
const distance = pointDistance(
|
||||
point(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y),
|
||||
point(scenePointer.x, scenePointer.y),
|
||||
pointFrom(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y),
|
||||
pointFrom(scenePointer.x, scenePointer.y),
|
||||
);
|
||||
const threshold = this.getElementHitThreshold();
|
||||
const p = { ...pointerDownState.lastCoords };
|
||||
@ -6010,6 +6076,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
|
||||
this.maybeUnfollowRemoteUser();
|
||||
|
||||
if (this.state.searchMatches) {
|
||||
this.setState((state) => ({
|
||||
searchMatches: state.searchMatches.map((searchMatch) => ({
|
||||
...searchMatch,
|
||||
focus: false,
|
||||
})),
|
||||
}));
|
||||
jotaiStore.set(searchItemInFocusAtom, null);
|
||||
}
|
||||
|
||||
// since contextMenu options are potentially evaluated on each render,
|
||||
// and an contextMenu action may depend on selection state, we must
|
||||
// close the contextMenu before we update the selection on pointerDown
|
||||
@ -6365,7 +6441,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.hitLinkElement,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.state,
|
||||
point(scenePointer.x, scenePointer.y),
|
||||
pointFrom(scenePointer.x, scenePointer.y),
|
||||
)
|
||||
) {
|
||||
this.handleEmbeddableCenterClick(this.hitLinkElement);
|
||||
@ -6438,8 +6514,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
isPanning = true;
|
||||
|
||||
if (!this.state.editingTextElement) {
|
||||
// due to event.preventDefault below, container wouldn't get focus
|
||||
// automatically
|
||||
this.focusContainer();
|
||||
|
||||
// preventing defualt while text editing messes with cursor/focus
|
||||
if (!this.state.editingTextElement) {
|
||||
// necessary to prevent browser from scrolling the page if excalidraw
|
||||
// not full-page #4489
|
||||
//
|
||||
// as such, the above is broken when panning canvas while in wysiwyg
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
@ -7068,7 +7152,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
simulatePressure,
|
||||
locked: false,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
points: [point<LocalPoint>(0, 0)],
|
||||
points: [pointFrom<LocalPoint>(0, 0)],
|
||||
pressures: simulatePressure ? [] : [event.pressure],
|
||||
});
|
||||
|
||||
@ -7277,7 +7361,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
multiElement.points.length > 1 &&
|
||||
lastCommittedPoint &&
|
||||
pointDistance(
|
||||
point(pointerDownState.origin.x - rx, pointerDownState.origin.y - ry),
|
||||
pointFrom(
|
||||
pointerDownState.origin.x - rx,
|
||||
pointerDownState.origin.y - ry,
|
||||
),
|
||||
lastCommittedPoint,
|
||||
) < LINE_CONFIRM_THRESHOLD
|
||||
) {
|
||||
@ -7379,7 +7466,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
});
|
||||
mutateElement(element, {
|
||||
points: [...element.points, point<LocalPoint>(0, 0)],
|
||||
points: [...element.points, pointFrom<LocalPoint>(0, 0)],
|
||||
});
|
||||
const boundElement = getHoveredElementForBinding(
|
||||
pointerDownState.origin,
|
||||
@ -7635,8 +7722,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
) {
|
||||
if (
|
||||
pointDistance(
|
||||
point(pointerCoords.x, pointerCoords.y),
|
||||
point(pointerDownState.origin.x, pointerDownState.origin.y),
|
||||
pointFrom(pointerCoords.x, pointerCoords.y),
|
||||
pointFrom(pointerDownState.origin.x, pointerDownState.origin.y),
|
||||
) < DRAGGING_THRESHOLD
|
||||
) {
|
||||
return;
|
||||
@ -8031,7 +8118,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
points: [...points, point<LocalPoint>(dx, dy)],
|
||||
points: [...points, pointFrom<LocalPoint>(dx, dy)],
|
||||
pressures,
|
||||
},
|
||||
false,
|
||||
@ -8060,7 +8147,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
points: [...points, point<LocalPoint>(dx, dy)],
|
||||
points: [...points, pointFrom<LocalPoint>(dx, dy)],
|
||||
},
|
||||
false,
|
||||
);
|
||||
@ -8068,7 +8155,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElbowArrow(
|
||||
newElement,
|
||||
elementsMap,
|
||||
[...points.slice(0, -1), point<LocalPoint>(dx, dy)],
|
||||
[...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
|
||||
vector(0, 0),
|
||||
undefined,
|
||||
{
|
||||
@ -8080,7 +8167,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
points: [...points.slice(0, -1), point<LocalPoint>(dx, dy)],
|
||||
points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
|
||||
},
|
||||
false,
|
||||
);
|
||||
@ -8394,9 +8481,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
: [...newElement.pressures, childEvent.pressure];
|
||||
|
||||
mutateElement(newElement, {
|
||||
points: [...points, point<LocalPoint>(dx, dy)],
|
||||
points: [...points, pointFrom<LocalPoint>(dx, dy)],
|
||||
pressures,
|
||||
lastCommittedPoint: point<LocalPoint>(dx, dy),
|
||||
lastCommittedPoint: pointFrom<LocalPoint>(dx, dy),
|
||||
});
|
||||
|
||||
this.actionManager.executeAction(actionFinalize);
|
||||
@ -8443,7 +8530,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElement(newElement, {
|
||||
points: [
|
||||
...newElement.points,
|
||||
point<LocalPoint>(
|
||||
pointFrom<LocalPoint>(
|
||||
pointerCoords.x - newElement.x,
|
||||
pointerCoords.y - newElement.y,
|
||||
),
|
||||
@ -8771,8 +8858,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.eraserTrail.endPath();
|
||||
|
||||
const draggedDistance = pointDistance(
|
||||
point(pointerStart.clientX, pointerStart.clientY),
|
||||
point(pointerEnd.clientX, pointerEnd.clientY),
|
||||
pointFrom(pointerStart.clientX, pointerStart.clientY),
|
||||
pointFrom(pointerEnd.clientX, pointerEnd.clientY),
|
||||
);
|
||||
|
||||
if (draggedDistance === 0) {
|
||||
|
@ -43,7 +43,11 @@ import { InlineIcon } from "../InlineIcon";
|
||||
import { SHAPES } from "../../shapes";
|
||||
import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
|
||||
import { useStableCallback } from "../../hooks/useStableCallback";
|
||||
import { actionClearCanvas, actionLink } from "../../actions";
|
||||
import {
|
||||
actionClearCanvas,
|
||||
actionLink,
|
||||
actionToggleSearchMenu,
|
||||
} from "../../actions";
|
||||
import { jotaiStore } from "../../jotai";
|
||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||
import type { CommandPaletteItem } from "./types";
|
||||
@ -382,6 +386,15 @@ function CommandPaletteInner({
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("search.title"),
|
||||
category: DEFAULT_CATEGORIES.app,
|
||||
icon: searchIcon,
|
||||
viewMode: true,
|
||||
perform: () => {
|
||||
actionManager.executeAction(actionToggleSearchMenu);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("labels.changeStroke"),
|
||||
keywords: ["color", "outline"],
|
||||
|
@ -1,8 +1,11 @@
|
||||
import clsx from "clsx";
|
||||
import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants";
|
||||
import {
|
||||
CANVAS_SEARCH_TAB,
|
||||
DEFAULT_SIDEBAR,
|
||||
LIBRARY_SIDEBAR_TAB,
|
||||
} from "../constants";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { useUIAppState } from "../context/ui-appState";
|
||||
import { t } from "../i18n";
|
||||
import type { MarkOptional, Merge } from "../utility-types";
|
||||
import { composeEventHandlers } from "../utils";
|
||||
import { useExcalidrawSetAppState } from "./App";
|
||||
@ -10,6 +13,9 @@ import { withInternalFallback } from "./hoc/withInternalFallback";
|
||||
import { LibraryMenu } from "./LibraryMenu";
|
||||
import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
|
||||
import { Sidebar } from "./Sidebar/Sidebar";
|
||||
import "../components/dropdownMenu/DropdownMenu.scss";
|
||||
import { SearchMenu } from "./SearchMenu";
|
||||
import { LibraryIcon, searchIcon } from "./icons";
|
||||
|
||||
const DefaultSidebarTrigger = withInternalFallback(
|
||||
"DefaultSidebarTrigger",
|
||||
@ -31,14 +37,11 @@ const DefaultSidebarTrigger = withInternalFallback(
|
||||
);
|
||||
DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
|
||||
|
||||
const DefaultTabTriggers = ({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
|
||||
const DefaultTabTriggers = ({ children }: { children: React.ReactNode }) => {
|
||||
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
|
||||
return (
|
||||
<DefaultSidebarTabTriggersTunnel.In>
|
||||
<Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers>
|
||||
{children}
|
||||
</DefaultSidebarTabTriggersTunnel.In>
|
||||
);
|
||||
};
|
||||
@ -65,17 +68,21 @@ export const DefaultSidebar = Object.assign(
|
||||
|
||||
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
|
||||
|
||||
const isForceDocked = appState.openSidebar?.tab === CANVAS_SEARCH_TAB;
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
{...rest}
|
||||
name="default"
|
||||
key="default"
|
||||
className={clsx("default-sidebar", className)}
|
||||
docked={docked ?? appState.defaultSidebarDockedPreference}
|
||||
docked={
|
||||
isForceDocked || (docked ?? appState.defaultSidebarDockedPreference)
|
||||
}
|
||||
onDock={
|
||||
// `onDock=false` disables docking.
|
||||
// if `docked` passed, but no onDock passed, disable manual docking.
|
||||
onDock === false || (!onDock && docked != null)
|
||||
isForceDocked || onDock === false || (!onDock && docked != null)
|
||||
? undefined
|
||||
: // compose to allow the host app to listen on default behavior
|
||||
composeEventHandlers(onDock, (docked) => {
|
||||
@ -85,26 +92,22 @@ export const DefaultSidebar = Object.assign(
|
||||
>
|
||||
<Sidebar.Tabs>
|
||||
<Sidebar.Header>
|
||||
{rest.__fallback && (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--color-primary)",
|
||||
fontSize: "1.2em",
|
||||
fontWeight: "bold",
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
paddingRight: "1em",
|
||||
}}
|
||||
>
|
||||
{t("toolBar.library")}
|
||||
</div>
|
||||
)}
|
||||
<Sidebar.TabTriggers>
|
||||
<Sidebar.TabTrigger tab={CANVAS_SEARCH_TAB}>
|
||||
{searchIcon}
|
||||
</Sidebar.TabTrigger>
|
||||
<Sidebar.TabTrigger tab={LIBRARY_SIDEBAR_TAB}>
|
||||
{LibraryIcon}
|
||||
</Sidebar.TabTrigger>
|
||||
<DefaultSidebarTabTriggersTunnel.Out />
|
||||
</Sidebar.TabTriggers>
|
||||
</Sidebar.Header>
|
||||
<Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
|
||||
<LibraryMenu />
|
||||
</Sidebar.Tab>
|
||||
<Sidebar.Tab tab={CANVAS_SEARCH_TAB}>
|
||||
<SearchMenu />
|
||||
</Sidebar.Tab>
|
||||
{children}
|
||||
</Sidebar.Tabs>
|
||||
</Sidebar>
|
||||
|
@ -288,6 +288,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
label={t("stats.fullTitle")}
|
||||
shortcuts={[getShortcutKey("Alt+/")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("search.title")}
|
||||
shortcuts={[getShortcutFromShortcutName("searchMenu")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("commandPalette.title")}
|
||||
shortcuts={
|
||||
|
@ -13,6 +13,7 @@ import { isEraserActive } from "../appState";
|
||||
import "./HintViewer.scss";
|
||||
import { isNodeInFlowchart } from "../element/flowchart";
|
||||
import { isGridModeEnabled } from "../snapping";
|
||||
import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "../constants";
|
||||
|
||||
interface HintViewerProps {
|
||||
appState: UIAppState;
|
||||
@ -30,6 +31,14 @@ const getHints = ({
|
||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const multiMode = appState.multiElement !== null;
|
||||
|
||||
if (
|
||||
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
||||
appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
|
||||
appState.searchMatches?.length
|
||||
) {
|
||||
return t("hints.dismissSearch");
|
||||
}
|
||||
|
||||
if (appState.openSidebar && !device.editor.canFitSidebar) {
|
||||
return null;
|
||||
}
|
||||
|
@ -53,9 +53,6 @@ import { LibraryIcon } from "./icons";
|
||||
import { UIAppStateContext } from "../context/ui-appState";
|
||||
import { DefaultSidebar } from "./DefaultSidebar";
|
||||
import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
|
||||
|
||||
import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import Scene from "../scene/Scene";
|
||||
@ -64,6 +61,9 @@ import { TTDDialog } from "./TTDDialog/TTDDialog";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions";
|
||||
|
||||
import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
appState: UIAppState;
|
||||
@ -99,6 +99,7 @@ const DefaultMainMenu: React.FC<{
|
||||
{UIOptions.canvasActions.saveAsImage && (
|
||||
<MainMenu.DefaultItems.SaveAsImage />
|
||||
)}
|
||||
<MainMenu.DefaultItems.SearchMenu />
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
|
110
packages/excalidraw/components/SearchMenu.scss
Normal file
110
packages/excalidraw/components/SearchMenu.scss
Normal file
@ -0,0 +1,110 @@
|
||||
@import "open-color/open-color";
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__search {
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 0 0 0;
|
||||
}
|
||||
|
||||
.layer-ui__search-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 0.75rem;
|
||||
.ExcTextField {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.ExcTextField__input {
|
||||
background-color: #f5f5f9;
|
||||
@at-root .excalidraw.theme--dark#{&} {
|
||||
background-color: #31303b;
|
||||
}
|
||||
|
||||
border-radius: var(--border-radius-md);
|
||||
border: 0;
|
||||
|
||||
input::placeholder {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__search-count {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 8px 0 8px;
|
||||
margin: 0 0.75rem 0.25rem 0.75rem;
|
||||
font-size: 0.8em;
|
||||
|
||||
.result-nav {
|
||||
display: flex;
|
||||
|
||||
.result-nav-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
--button-border: transparent;
|
||||
|
||||
&:active {
|
||||
background-color: var(--color-surface-high);
|
||||
}
|
||||
|
||||
&:first {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__search-result-container {
|
||||
overflow-y: auto;
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.layer-ui__result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 2rem;
|
||||
flex: 0 0 auto;
|
||||
padding: 0.25rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
outline: none;
|
||||
|
||||
margin: 0 0.75rem;
|
||||
border-radius: var(--border-radius-md);
|
||||
|
||||
.text-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
flex: 1;
|
||||
max-height: 48px;
|
||||
line-height: 24px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-surface-high);
|
||||
}
|
||||
&:active {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--color-surface-high);
|
||||
}
|
||||
}
|
||||
}
|
718
packages/excalidraw/components/SearchMenu.tsx
Normal file
718
packages/excalidraw/components/SearchMenu.tsx
Normal file
@ -0,0 +1,718 @@
|
||||
import { Fragment, memo, useEffect, useRef, useState } from "react";
|
||||
import { collapseDownIcon, upIcon, searchIcon } from "./icons";
|
||||
import { TextField } from "./TextField";
|
||||
import { Button } from "./Button";
|
||||
import { useApp, useExcalidrawSetAppState } from "./App";
|
||||
import { debounce } from "lodash";
|
||||
import type { AppClassProperties } from "../types";
|
||||
import { isTextElement, newTextElement } from "../element";
|
||||
import type { ExcalidrawTextElement } from "../element/types";
|
||||
import { measureText } from "../element/textElement";
|
||||
import { addEventListener, getFontString } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
import clsx from "clsx";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { t } from "../i18n";
|
||||
import { isElementCompletelyInViewport } from "../element/sizeHelpers";
|
||||
import { randomInteger } from "../random";
|
||||
import { CLASSES, EVENT } from "../constants";
|
||||
import { useStable } from "../hooks/useStable";
|
||||
|
||||
import "./SearchMenu.scss";
|
||||
import { round } from "../../math";
|
||||
|
||||
const searchQueryAtom = atom<string>("");
|
||||
export const searchItemInFocusAtom = atom<number | null>(null);
|
||||
|
||||
const SEARCH_DEBOUNCE = 350;
|
||||
|
||||
type SearchMatchItem = {
|
||||
textElement: ExcalidrawTextElement;
|
||||
searchQuery: SearchQuery;
|
||||
index: number;
|
||||
preview: {
|
||||
indexInSearchQuery: number;
|
||||
previewText: string;
|
||||
moreBefore: boolean;
|
||||
moreAfter: boolean;
|
||||
};
|
||||
matchedLines: {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
type SearchMatches = {
|
||||
nonce: number | null;
|
||||
items: SearchMatchItem[];
|
||||
};
|
||||
|
||||
type SearchQuery = string & { _brand: "SearchQuery" };
|
||||
|
||||
export const SearchMenu = () => {
|
||||
const app = useApp();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope);
|
||||
const searchQuery = inputValue.trim() as SearchQuery;
|
||||
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const [searchMatches, setSearchMatches] = useState<SearchMatches>({
|
||||
nonce: null,
|
||||
items: [],
|
||||
});
|
||||
const searchedQueryRef = useRef<SearchQuery | null>(null);
|
||||
const lastSceneNonceRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const [focusIndex, setFocusIndex] = useAtom(
|
||||
searchItemInFocusAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (isSearching) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
searchQuery !== searchedQueryRef.current ||
|
||||
app.scene.getSceneNonce() !== lastSceneNonceRef.current
|
||||
) {
|
||||
searchedQueryRef.current = null;
|
||||
handleSearch(searchQuery, app, (matchItems, index) => {
|
||||
setSearchMatches({
|
||||
nonce: randomInteger(),
|
||||
items: matchItems,
|
||||
});
|
||||
searchedQueryRef.current = searchQuery;
|
||||
lastSceneNonceRef.current = app.scene.getSceneNonce();
|
||||
setAppState({
|
||||
searchMatches: matchItems.map((searchMatch) => ({
|
||||
id: searchMatch.textElement.id,
|
||||
focus: false,
|
||||
matchedLines: searchMatch.matchedLines,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [
|
||||
isSearching,
|
||||
searchQuery,
|
||||
elementsMap,
|
||||
app,
|
||||
setAppState,
|
||||
setFocusIndex,
|
||||
lastSceneNonceRef,
|
||||
]);
|
||||
|
||||
const goToNextItem = () => {
|
||||
if (searchMatches.items.length > 0) {
|
||||
setFocusIndex((focusIndex) => {
|
||||
if (focusIndex === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (focusIndex + 1) % searchMatches.items.length;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const goToPreviousItem = () => {
|
||||
if (searchMatches.items.length > 0) {
|
||||
setFocusIndex((focusIndex) => {
|
||||
if (focusIndex === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return focusIndex - 1 < 0
|
||||
? searchMatches.items.length - 1
|
||||
: focusIndex - 1;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setAppState((state) => {
|
||||
return {
|
||||
searchMatches: state.searchMatches.map((match, index) => {
|
||||
if (index === focusIndex) {
|
||||
return { ...match, focus: true };
|
||||
}
|
||||
return { ...match, focus: false };
|
||||
}),
|
||||
};
|
||||
});
|
||||
}, [focusIndex, setAppState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchMatches.items.length > 0 && focusIndex !== null) {
|
||||
const match = searchMatches.items[focusIndex];
|
||||
|
||||
if (match) {
|
||||
const zoomValue = app.state.zoom.value;
|
||||
|
||||
const matchAsElement = newTextElement({
|
||||
text: match.searchQuery,
|
||||
x: match.textElement.x + (match.matchedLines[0]?.offsetX ?? 0),
|
||||
y: match.textElement.y + (match.matchedLines[0]?.offsetY ?? 0),
|
||||
width: match.matchedLines[0]?.width,
|
||||
height: match.matchedLines[0]?.height,
|
||||
fontSize: match.textElement.fontSize,
|
||||
fontFamily: match.textElement.fontFamily,
|
||||
});
|
||||
|
||||
const FONT_SIZE_LEGIBILITY_THRESHOLD = 14;
|
||||
|
||||
const fontSize = match.textElement.fontSize;
|
||||
const isTextTiny =
|
||||
fontSize * zoomValue < FONT_SIZE_LEGIBILITY_THRESHOLD;
|
||||
|
||||
if (
|
||||
!isElementCompletelyInViewport(
|
||||
[matchAsElement],
|
||||
app.canvas.width / window.devicePixelRatio,
|
||||
app.canvas.height / window.devicePixelRatio,
|
||||
{
|
||||
offsetLeft: app.state.offsetLeft,
|
||||
offsetTop: app.state.offsetTop,
|
||||
scrollX: app.state.scrollX,
|
||||
scrollY: app.state.scrollY,
|
||||
zoom: app.state.zoom,
|
||||
},
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
app.getEditorUIOffsets(),
|
||||
) ||
|
||||
isTextTiny
|
||||
) {
|
||||
let zoomOptions: Parameters<AppClassProperties["scrollToContent"]>[1];
|
||||
|
||||
if (isTextTiny) {
|
||||
if (fontSize >= FONT_SIZE_LEGIBILITY_THRESHOLD) {
|
||||
zoomOptions = { fitToContent: true };
|
||||
} else {
|
||||
zoomOptions = {
|
||||
fitToViewport: true,
|
||||
// calculate zoom level to make the fontSize ~equal to FONT_SIZE_THRESHOLD, rounded to nearest 10%
|
||||
maxZoom: round(FONT_SIZE_LEGIBILITY_THRESHOLD / fontSize, 1),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
zoomOptions = { fitToContent: true };
|
||||
}
|
||||
|
||||
app.scrollToContent(matchAsElement, {
|
||||
animate: true,
|
||||
duration: 300,
|
||||
...zoomOptions,
|
||||
canvasOffsets: app.getEditorUIOffsets(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [focusIndex, searchMatches, app]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setFocusIndex(null);
|
||||
searchedQueryRef.current = null;
|
||||
lastSceneNonceRef.current = undefined;
|
||||
setAppState({
|
||||
searchMatches: [],
|
||||
});
|
||||
setIsSearching(false);
|
||||
};
|
||||
}, [setAppState, setFocusIndex]);
|
||||
|
||||
const stableState = useStable({
|
||||
goToNextItem,
|
||||
goToPreviousItem,
|
||||
searchMatches,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const eventHandler = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === KEYS.ESCAPE &&
|
||||
!app.state.openDialog &&
|
||||
!app.state.openPopup
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setAppState({
|
||||
openSidebar: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!searchInputRef.current?.matches(":focus")) {
|
||||
if (app.state.openDialog) {
|
||||
setAppState({
|
||||
openDialog: null,
|
||||
});
|
||||
}
|
||||
searchInputRef.current?.focus();
|
||||
searchInputRef.current?.select();
|
||||
} else {
|
||||
setAppState({
|
||||
openSidebar: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.target instanceof HTMLElement &&
|
||||
event.target.closest(".layer-ui__search")
|
||||
) {
|
||||
if (stableState.searchMatches.items.length) {
|
||||
if (event.key === KEYS.ENTER) {
|
||||
event.stopPropagation();
|
||||
stableState.goToNextItem();
|
||||
}
|
||||
|
||||
if (event.key === KEYS.ARROW_UP) {
|
||||
event.stopPropagation();
|
||||
stableState.goToPreviousItem();
|
||||
} else if (event.key === KEYS.ARROW_DOWN) {
|
||||
event.stopPropagation();
|
||||
stableState.goToNextItem();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// `capture` needed to prevent firing on initial open from App.tsx,
|
||||
// as well as to handle events before App ones
|
||||
return addEventListener(window, EVENT.KEYDOWN, eventHandler, {
|
||||
capture: true,
|
||||
});
|
||||
}, [setAppState, stableState, app]);
|
||||
|
||||
const matchCount = `${searchMatches.items.length} ${
|
||||
searchMatches.items.length === 1
|
||||
? t("search.singleResult")
|
||||
: t("search.multipleResults")
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="layer-ui__search">
|
||||
<div className="layer-ui__search-header">
|
||||
<TextField
|
||||
className={CLASSES.SEARCH_MENU_INPUT_WRAPPER}
|
||||
value={inputValue}
|
||||
ref={searchInputRef}
|
||||
placeholder={t("search.placeholder")}
|
||||
icon={searchIcon}
|
||||
onChange={(value) => {
|
||||
setInputValue(value);
|
||||
setIsSearching(true);
|
||||
const searchQuery = value.trim() as SearchQuery;
|
||||
handleSearch(searchQuery, app, (matchItems, index) => {
|
||||
setSearchMatches({
|
||||
nonce: randomInteger(),
|
||||
items: matchItems,
|
||||
});
|
||||
setFocusIndex(index);
|
||||
searchedQueryRef.current = searchQuery;
|
||||
lastSceneNonceRef.current = app.scene.getSceneNonce();
|
||||
setAppState({
|
||||
searchMatches: matchItems.map((searchMatch) => ({
|
||||
id: searchMatch.textElement.id,
|
||||
focus: false,
|
||||
matchedLines: searchMatch.matchedLines,
|
||||
})),
|
||||
});
|
||||
|
||||
setIsSearching(false);
|
||||
});
|
||||
}}
|
||||
selectOnRender
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="layer-ui__search-count">
|
||||
{searchMatches.items.length > 0 && (
|
||||
<>
|
||||
{focusIndex !== null && focusIndex > -1 ? (
|
||||
<div>
|
||||
{focusIndex + 1} / {matchCount}
|
||||
</div>
|
||||
) : (
|
||||
<div>{matchCount}</div>
|
||||
)}
|
||||
<div className="result-nav">
|
||||
<Button
|
||||
onSelect={() => {
|
||||
goToNextItem();
|
||||
}}
|
||||
className="result-nav-btn"
|
||||
>
|
||||
{collapseDownIcon}
|
||||
</Button>
|
||||
<Button
|
||||
onSelect={() => {
|
||||
goToPreviousItem();
|
||||
}}
|
||||
className="result-nav-btn"
|
||||
>
|
||||
{upIcon}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{searchMatches.items.length === 0 &&
|
||||
searchQuery &&
|
||||
searchedQueryRef.current && (
|
||||
<div style={{ margin: "1rem auto" }}>{t("search.noMatch")}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MatchList
|
||||
matches={searchMatches}
|
||||
onItemClick={setFocusIndex}
|
||||
focusIndex={focusIndex}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ListItem = (props: {
|
||||
preview: SearchMatchItem["preview"];
|
||||
searchQuery: SearchQuery;
|
||||
highlighted: boolean;
|
||||
onClick?: () => void;
|
||||
}) => {
|
||||
const preview = [
|
||||
props.preview.moreBefore ? "..." : "",
|
||||
props.preview.previewText.slice(0, props.preview.indexInSearchQuery),
|
||||
props.preview.previewText.slice(
|
||||
props.preview.indexInSearchQuery,
|
||||
props.preview.indexInSearchQuery + props.searchQuery.length,
|
||||
),
|
||||
props.preview.previewText.slice(
|
||||
props.preview.indexInSearchQuery + props.searchQuery.length,
|
||||
),
|
||||
props.preview.moreAfter ? "..." : "",
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={-1}
|
||||
className={clsx("layer-ui__result-item", {
|
||||
active: props.highlighted,
|
||||
})}
|
||||
onClick={props.onClick}
|
||||
ref={(ref) => {
|
||||
if (props.highlighted) {
|
||||
ref?.scrollIntoView({ behavior: "auto", block: "nearest" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="preview-text">
|
||||
{preview.flatMap((text, idx) => (
|
||||
<Fragment key={idx}>{idx === 2 ? <b>{text}</b> : text}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface MatchListProps {
|
||||
matches: SearchMatches;
|
||||
onItemClick: (index: number) => void;
|
||||
focusIndex: number | null;
|
||||
searchQuery: SearchQuery;
|
||||
}
|
||||
|
||||
const MatchListBase = (props: MatchListProps) => {
|
||||
return (
|
||||
<div className="layer-ui__search-result-container">
|
||||
{props.matches.items.map((searchMatch, index) => (
|
||||
<ListItem
|
||||
key={searchMatch.textElement.id + searchMatch.index}
|
||||
searchQuery={props.searchQuery}
|
||||
preview={searchMatch.preview}
|
||||
highlighted={index === props.focusIndex}
|
||||
onClick={() => props.onItemClick(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const areEqual = (prevProps: MatchListProps, nextProps: MatchListProps) => {
|
||||
return (
|
||||
prevProps.matches.nonce === nextProps.matches.nonce &&
|
||||
prevProps.focusIndex === nextProps.focusIndex
|
||||
);
|
||||
};
|
||||
|
||||
const MatchList = memo(MatchListBase, areEqual);
|
||||
|
||||
const getMatchPreview = (
|
||||
text: string,
|
||||
index: number,
|
||||
searchQuery: SearchQuery,
|
||||
) => {
|
||||
const WORDS_BEFORE = 2;
|
||||
const WORDS_AFTER = 5;
|
||||
|
||||
const substrBeforeQuery = text.slice(0, index);
|
||||
const wordsBeforeQuery = substrBeforeQuery.split(/\s+/);
|
||||
// text = "small", query = "mall", not complete before
|
||||
// text = "small", query = "smal", complete before
|
||||
const isQueryCompleteBefore = substrBeforeQuery.endsWith(" ");
|
||||
const startWordIndex =
|
||||
wordsBeforeQuery.length -
|
||||
WORDS_BEFORE -
|
||||
1 -
|
||||
(isQueryCompleteBefore ? 0 : 1);
|
||||
let wordsBeforeAsString =
|
||||
wordsBeforeQuery.slice(startWordIndex <= 0 ? 0 : startWordIndex).join(" ") +
|
||||
(isQueryCompleteBefore ? " " : "");
|
||||
|
||||
const MAX_ALLOWED_CHARS = 20;
|
||||
|
||||
wordsBeforeAsString =
|
||||
wordsBeforeAsString.length > MAX_ALLOWED_CHARS
|
||||
? wordsBeforeAsString.slice(-MAX_ALLOWED_CHARS)
|
||||
: wordsBeforeAsString;
|
||||
|
||||
const substrAfterQuery = text.slice(index + searchQuery.length);
|
||||
const wordsAfter = substrAfterQuery.split(/\s+/);
|
||||
// text = "small", query = "mall", complete after
|
||||
// text = "small", query = "smal", not complete after
|
||||
const isQueryCompleteAfter = !substrAfterQuery.startsWith(" ");
|
||||
const numberOfWordsToTake = isQueryCompleteAfter
|
||||
? WORDS_AFTER + 1
|
||||
: WORDS_AFTER;
|
||||
const wordsAfterAsString =
|
||||
(isQueryCompleteAfter ? "" : " ") +
|
||||
wordsAfter.slice(0, numberOfWordsToTake).join(" ");
|
||||
|
||||
return {
|
||||
indexInSearchQuery: wordsBeforeAsString.length,
|
||||
previewText: wordsBeforeAsString + searchQuery + wordsAfterAsString,
|
||||
moreBefore: startWordIndex > 0,
|
||||
moreAfter: wordsAfter.length > numberOfWordsToTake,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeWrappedText = (
|
||||
wrappedText: string,
|
||||
originalText: string,
|
||||
): string => {
|
||||
const wrappedLines = wrappedText.split("\n");
|
||||
const normalizedLines: string[] = [];
|
||||
let originalIndex = 0;
|
||||
|
||||
for (let i = 0; i < wrappedLines.length; i++) {
|
||||
let currentLine = wrappedLines[i];
|
||||
const nextLine = wrappedLines[i + 1];
|
||||
|
||||
if (nextLine) {
|
||||
const nextLineIndexInOriginal = originalText.indexOf(
|
||||
nextLine,
|
||||
originalIndex,
|
||||
);
|
||||
|
||||
if (nextLineIndexInOriginal > currentLine.length + originalIndex) {
|
||||
let j = nextLineIndexInOriginal - (currentLine.length + originalIndex);
|
||||
|
||||
while (j > 0) {
|
||||
currentLine += " ";
|
||||
j--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalizedLines.push(currentLine);
|
||||
originalIndex = originalIndex + currentLine.length;
|
||||
}
|
||||
|
||||
return normalizedLines.join("\n");
|
||||
};
|
||||
|
||||
const getMatchedLines = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
searchQuery: SearchQuery,
|
||||
index: number,
|
||||
) => {
|
||||
const normalizedText = normalizeWrappedText(
|
||||
textElement.text,
|
||||
textElement.originalText,
|
||||
);
|
||||
|
||||
const lines = normalizedText.split("\n");
|
||||
|
||||
const lineIndexRanges = [];
|
||||
let currentIndex = 0;
|
||||
let lineNumber = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const startIndex = currentIndex;
|
||||
const endIndex = startIndex + line.length - 1;
|
||||
|
||||
lineIndexRanges.push({
|
||||
line,
|
||||
startIndex,
|
||||
endIndex,
|
||||
lineNumber,
|
||||
});
|
||||
|
||||
// Move to the next line's start index
|
||||
currentIndex = endIndex + 1;
|
||||
lineNumber++;
|
||||
}
|
||||
|
||||
let startIndex = index;
|
||||
let remainingQuery = textElement.originalText.slice(
|
||||
index,
|
||||
index + searchQuery.length,
|
||||
);
|
||||
const matchedLines: {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}[] = [];
|
||||
|
||||
for (const lineIndexRange of lineIndexRanges) {
|
||||
if (remainingQuery === "") {
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
startIndex >= lineIndexRange.startIndex &&
|
||||
startIndex <= lineIndexRange.endIndex
|
||||
) {
|
||||
const matchCapacity = lineIndexRange.endIndex + 1 - startIndex;
|
||||
const textToStart = lineIndexRange.line.slice(
|
||||
0,
|
||||
startIndex - lineIndexRange.startIndex,
|
||||
);
|
||||
|
||||
const matchedWord = remainingQuery.slice(0, matchCapacity);
|
||||
remainingQuery = remainingQuery.slice(matchCapacity);
|
||||
|
||||
const offset = measureText(
|
||||
textToStart,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
true,
|
||||
);
|
||||
|
||||
// measureText returns a non-zero width for the empty string
|
||||
// which is not what we're after here, hence the check and the correction
|
||||
if (textToStart === "") {
|
||||
offset.width = 0;
|
||||
}
|
||||
|
||||
if (textElement.textAlign !== "left" && lineIndexRange.line.length > 0) {
|
||||
const lineLength = measureText(
|
||||
lineIndexRange.line,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
true,
|
||||
);
|
||||
|
||||
const spaceToStart =
|
||||
textElement.textAlign === "center"
|
||||
? (textElement.width - lineLength.width) / 2
|
||||
: textElement.width - lineLength.width;
|
||||
offset.width += spaceToStart;
|
||||
}
|
||||
|
||||
const { width, height } = measureText(
|
||||
matchedWord,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
);
|
||||
|
||||
const offsetX = offset.width;
|
||||
const offsetY = lineIndexRange.lineNumber * offset.height;
|
||||
|
||||
matchedLines.push({
|
||||
offsetX,
|
||||
offsetY,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
startIndex += matchCapacity;
|
||||
}
|
||||
}
|
||||
|
||||
return matchedLines;
|
||||
};
|
||||
|
||||
const escapeSpecialCharacters = (string: string) => {
|
||||
return string.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
|
||||
};
|
||||
|
||||
const handleSearch = debounce(
|
||||
(
|
||||
searchQuery: SearchQuery,
|
||||
app: AppClassProperties,
|
||||
cb: (matchItems: SearchMatchItem[], focusIndex: number | null) => void,
|
||||
) => {
|
||||
if (!searchQuery || searchQuery === "") {
|
||||
cb([], null);
|
||||
return;
|
||||
}
|
||||
|
||||
const elements = app.scene.getNonDeletedElements();
|
||||
const texts = elements.filter((el) =>
|
||||
isTextElement(el),
|
||||
) as ExcalidrawTextElement[];
|
||||
|
||||
texts.sort((a, b) => a.y - b.y);
|
||||
|
||||
const matchItems: SearchMatchItem[] = [];
|
||||
|
||||
const regex = new RegExp(escapeSpecialCharacters(searchQuery), "gi");
|
||||
|
||||
for (const textEl of texts) {
|
||||
let match = null;
|
||||
const text = textEl.originalText;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const preview = getMatchPreview(text, match.index, searchQuery);
|
||||
const matchedLines = getMatchedLines(textEl, searchQuery, match.index);
|
||||
|
||||
if (matchedLines.length > 0) {
|
||||
matchItems.push({
|
||||
textElement: textEl,
|
||||
searchQuery,
|
||||
preview,
|
||||
index: match.index,
|
||||
matchedLines,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const visibleIds = new Set(
|
||||
app.visibleElements.map((visibleElement) => visibleElement.id),
|
||||
);
|
||||
|
||||
const focusIndex =
|
||||
matchItems.findIndex((matchItem) =>
|
||||
visibleIds.has(matchItem.textElement.id),
|
||||
) ?? null;
|
||||
|
||||
cb(matchItems, focusIndex);
|
||||
},
|
||||
SEARCH_DEBOUNCE,
|
||||
);
|
@ -20,7 +20,7 @@ import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
import { getElementsInAtomicUnit, resizeElement } from "./utils";
|
||||
import type { AtomicUnit } from "./utils";
|
||||
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
||||
import { point, type GlobalPoint } from "../../../math";
|
||||
import { pointFrom, type GlobalPoint } from "../../../math";
|
||||
|
||||
interface MultiDimensionProps {
|
||||
property: "width" | "height";
|
||||
@ -182,7 +182,7 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
nextHeight,
|
||||
initialHeight,
|
||||
aspectRatio,
|
||||
point(x1, y1),
|
||||
pointFrom(x1, y1),
|
||||
property,
|
||||
latestElements,
|
||||
originalElements,
|
||||
@ -287,7 +287,7 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
nextHeight,
|
||||
initialHeight,
|
||||
aspectRatio,
|
||||
point(x1, y1),
|
||||
pointFrom(x1, y1),
|
||||
property,
|
||||
latestElements,
|
||||
originalElements,
|
||||
|
@ -13,7 +13,7 @@ import { useMemo } from "react";
|
||||
import { getElementsInAtomicUnit, moveElement } from "./utils";
|
||||
import type { AtomicUnit } from "./utils";
|
||||
import type { AppState } from "../../types";
|
||||
import { point, pointRotateRads } from "../../../math";
|
||||
import { pointFrom, pointRotateRads } from "../../../math";
|
||||
|
||||
interface MultiPositionProps {
|
||||
property: "x" | "y";
|
||||
@ -44,8 +44,8 @@ const moveElements = (
|
||||
origElement.y + origElement.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(origElement.x, origElement.y),
|
||||
point(cx, cy),
|
||||
pointFrom(origElement.x, origElement.y),
|
||||
pointFrom(cx, cy),
|
||||
origElement.angle,
|
||||
);
|
||||
|
||||
@ -97,8 +97,8 @@ const moveGroupTo = (
|
||||
];
|
||||
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(latestElement.x, latestElement.y),
|
||||
point(cx, cy),
|
||||
pointFrom(latestElement.x, latestElement.y),
|
||||
pointFrom(cx, cy),
|
||||
latestElement.angle,
|
||||
);
|
||||
|
||||
@ -171,8 +171,8 @@ const handlePositionChange: DragInputCallbackType<
|
||||
origElement.y + origElement.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(origElement.x, origElement.y),
|
||||
point(cx, cy),
|
||||
pointFrom(origElement.x, origElement.y),
|
||||
pointFrom(cx, cy),
|
||||
origElement.angle,
|
||||
);
|
||||
|
||||
@ -241,8 +241,8 @@ const MultiPosition = ({
|
||||
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
|
||||
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(el.x, el.y),
|
||||
point(cx, cy),
|
||||
pointFrom(el.x, el.y),
|
||||
pointFrom(cx, cy),
|
||||
el.angle,
|
||||
);
|
||||
|
||||
|
@ -4,7 +4,7 @@ import type { DragInputCallbackType } from "./DragInput";
|
||||
import { getStepSizedValue, moveElement } from "./utils";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import type { AppState } from "../../types";
|
||||
import { point, pointRotateRads } from "../../../math";
|
||||
import { pointFrom, pointRotateRads } from "../../../math";
|
||||
|
||||
interface PositionProps {
|
||||
property: "x" | "y";
|
||||
@ -33,8 +33,8 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
|
||||
origElement.y + origElement.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(origElement.x, origElement.y),
|
||||
point(cx, cy),
|
||||
pointFrom(origElement.x, origElement.y),
|
||||
pointFrom(cx, cy),
|
||||
origElement.angle,
|
||||
);
|
||||
|
||||
@ -93,8 +93,8 @@ const Position = ({
|
||||
appState,
|
||||
}: PositionProps) => {
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(element.x, element.y),
|
||||
point(element.x + element.width / 2, element.y + element.height / 2),
|
||||
pointFrom(element.x, element.y),
|
||||
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
|
||||
element.angle,
|
||||
);
|
||||
const value =
|
||||
|
@ -25,7 +25,7 @@ import { API } from "../../tests/helpers/api";
|
||||
import { actionGroup } from "../../actions";
|
||||
import { isInGroup } from "../../groups";
|
||||
import type { Degrees } from "../../../math";
|
||||
import { degreesToRadians, point, pointRotateRads } from "../../../math";
|
||||
import { degreesToRadians, pointFrom, pointRotateRads } from "../../../math";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
@ -264,8 +264,8 @@ describe("stats for a generic element", () => {
|
||||
rectangle.y + rectangle.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(rectangle.x, rectangle.y),
|
||||
point(cx, cy),
|
||||
pointFrom(rectangle.x, rectangle.y),
|
||||
pointFrom(cx, cy),
|
||||
rectangle.angle,
|
||||
);
|
||||
|
||||
@ -283,8 +283,8 @@ describe("stats for a generic element", () => {
|
||||
testInputProperty(rectangle, "angle", "A", 0, 45);
|
||||
|
||||
let [newTopLeftX, newTopLeftY] = pointRotateRads(
|
||||
point(rectangle.x, rectangle.y),
|
||||
point(cx, cy),
|
||||
pointFrom(rectangle.x, rectangle.y),
|
||||
pointFrom(cx, cy),
|
||||
rectangle.angle,
|
||||
);
|
||||
|
||||
@ -294,8 +294,8 @@ describe("stats for a generic element", () => {
|
||||
testInputProperty(rectangle, "angle", "A", 45, 66);
|
||||
|
||||
[newTopLeftX, newTopLeftY] = pointRotateRads(
|
||||
point(rectangle.x, rectangle.y),
|
||||
point(cx, cy),
|
||||
pointFrom(rectangle.x, rectangle.y),
|
||||
pointFrom(cx, cy),
|
||||
rectangle.angle,
|
||||
);
|
||||
expect(newTopLeftX.toString()).not.toEqual(xInput.value);
|
||||
@ -311,8 +311,8 @@ describe("stats for a generic element", () => {
|
||||
rectangle.y + rectangle.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(rectangle.x, rectangle.y),
|
||||
point(cx, cy),
|
||||
pointFrom(rectangle.x, rectangle.y),
|
||||
pointFrom(cx, cy),
|
||||
rectangle.angle,
|
||||
);
|
||||
testInputProperty(rectangle, "width", "W", rectangle.width, 400);
|
||||
@ -321,8 +321,8 @@ describe("stats for a generic element", () => {
|
||||
rectangle.y + rectangle.height / 2,
|
||||
];
|
||||
let [currentTopLeftX, currentTopLeftY] = pointRotateRads(
|
||||
point(rectangle.x, rectangle.y),
|
||||
point(cx, cy),
|
||||
pointFrom(rectangle.x, rectangle.y),
|
||||
pointFrom(cx, cy),
|
||||
rectangle.angle,
|
||||
);
|
||||
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
|
||||
@ -334,8 +334,8 @@ describe("stats for a generic element", () => {
|
||||
rectangle.y + rectangle.height / 2,
|
||||
];
|
||||
[currentTopLeftX, currentTopLeftY] = pointRotateRads(
|
||||
point(rectangle.x, rectangle.y),
|
||||
point(cx, cy),
|
||||
pointFrom(rectangle.x, rectangle.y),
|
||||
pointFrom(cx, cy),
|
||||
rectangle.angle,
|
||||
);
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import type { Radians } from "../../../math";
|
||||
import { point, pointRotateRads } from "../../../math";
|
||||
import { pointFrom, pointRotateRads } from "../../../math";
|
||||
import {
|
||||
bindOrUnbindLinearElements,
|
||||
updateBoundElements,
|
||||
@ -231,8 +231,8 @@ export const moveElement = (
|
||||
originalElement.y + originalElement.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(originalElement.x, originalElement.y),
|
||||
point(cx, cy),
|
||||
pointFrom(originalElement.x, originalElement.y),
|
||||
pointFrom(cx, cy),
|
||||
originalElement.angle,
|
||||
);
|
||||
|
||||
@ -240,8 +240,8 @@ export const moveElement = (
|
||||
const changeInY = newTopLeftY - topLeftY;
|
||||
|
||||
const [x, y] = pointRotateRads(
|
||||
point(newTopLeftX, newTopLeftY),
|
||||
point(cx + changeInX, cy + changeInY),
|
||||
pointFrom(newTopLeftX, newTopLeftY),
|
||||
pointFrom(cx + changeInX, cy + changeInY),
|
||||
-originalElement.angle as Radians,
|
||||
);
|
||||
|
||||
|
@ -3,16 +3,29 @@
|
||||
.excalidraw {
|
||||
--ExcTextField--color: var(--color-on-surface);
|
||||
--ExcTextField--label-color: var(--color-on-surface);
|
||||
--ExcTextField--background: transparent;
|
||||
--ExcTextField--background: var(--color-surface-low);
|
||||
--ExcTextField--readonly--background: var(--color-surface-high);
|
||||
--ExcTextField--readonly--color: var(--color-on-surface);
|
||||
--ExcTextField--border: var(--color-border-outline);
|
||||
--ExcTextField--border: var(--color-gray-20);
|
||||
--ExcTextField--readonly--border: var(--color-border-outline-variant);
|
||||
--ExcTextField--border-hover: var(--color-brand-hover);
|
||||
--ExcTextField--border-active: var(--color-brand-active);
|
||||
--ExcTextField--placeholder: var(--color-border-outline-variant);
|
||||
|
||||
.ExcTextField {
|
||||
position: relative;
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
top: 50%; // 50% is not exactly in the center of the input
|
||||
transform: translateY(-50%);
|
||||
left: 0.75rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: var(--color-gray-40);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&--fullWidth {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
@ -37,7 +50,6 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
|
||||
height: 3rem;
|
||||
|
||||
@ -45,6 +57,8 @@
|
||||
border: 1px solid var(--ExcTextField--border);
|
||||
border-radius: 0.5rem;
|
||||
|
||||
padding: 0 0.75rem;
|
||||
|
||||
&:not(&--readonly) {
|
||||
&:hover {
|
||||
border-color: var(--ExcTextField--border-hover);
|
||||
@ -80,10 +94,6 @@
|
||||
|
||||
width: 100%;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--ExcTextField--placeholder);
|
||||
}
|
||||
|
||||
&:not(:focus) {
|
||||
&:hover {
|
||||
background-color: initial;
|
||||
@ -105,5 +115,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--hasIcon .ExcTextField__input {
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -21,7 +21,9 @@ type TextFieldProps = {
|
||||
fullWidth?: boolean;
|
||||
selectOnRender?: boolean;
|
||||
|
||||
icon?: React.ReactNode;
|
||||
label?: string;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
isRedacted?: boolean;
|
||||
} & ({ value: string } | { defaultValue: string });
|
||||
@ -37,6 +39,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
selectOnRender,
|
||||
onKeyDown,
|
||||
isRedacted = false,
|
||||
icon,
|
||||
className,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
@ -47,6 +51,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (selectOnRender) {
|
||||
// focusing first is needed because vitest/jsdom
|
||||
innerRef.current?.focus();
|
||||
innerRef.current?.select();
|
||||
}
|
||||
}, [selectOnRender]);
|
||||
@ -56,14 +62,16 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("ExcTextField", {
|
||||
className={clsx("ExcTextField", className, {
|
||||
"ExcTextField--fullWidth": fullWidth,
|
||||
"ExcTextField--hasIcon": !!icon,
|
||||
})}
|
||||
onClick={() => {
|
||||
innerRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div className="ExcTextField__label">{label}</div>
|
||||
{icon}
|
||||
{label && <div className="ExcTextField__label">{label}</div>}
|
||||
<div
|
||||
className={clsx("ExcTextField__input", {
|
||||
"ExcTextField__input--readonly": readonly,
|
||||
|
@ -205,6 +205,7 @@ const getRelevantAppStateProps = (
|
||||
editingTextElement: appState.editingTextElement,
|
||||
isCropping: appState.isCropping,
|
||||
croppingElement: appState.croppingElement,
|
||||
searchMatches: appState.searchMatches,
|
||||
});
|
||||
|
||||
const areEqual = (
|
||||
|
@ -36,7 +36,7 @@ import { trackEvent } from "../../analytics";
|
||||
import { useAppProps, useExcalidrawAppState } from "../App";
|
||||
import { isEmbeddableElement } from "../../element/typeChecks";
|
||||
import { getLinkHandleFromCoords } from "./helpers";
|
||||
import { point, type GlobalPoint } from "../../../math";
|
||||
import { pointFrom, type GlobalPoint } from "../../../math";
|
||||
|
||||
const CONTAINER_WIDTH = 320;
|
||||
const SPACE_BOTTOM = 85;
|
||||
@ -181,7 +181,7 @@ export const Hyperlink = ({
|
||||
element,
|
||||
elementsMap,
|
||||
appState,
|
||||
point(event.clientX, event.clientY),
|
||||
pointFrom(event.clientX, event.clientY),
|
||||
) as boolean;
|
||||
if (shouldHide) {
|
||||
timeoutId = window.setTimeout(() => {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import type { GlobalPoint, Radians } from "../../../math";
|
||||
import { point, pointRotateRads } from "../../../math";
|
||||
import { pointFrom, pointRotateRads } from "../../../math";
|
||||
import { MIME_TYPES } from "../../constants";
|
||||
import type { Bounds } from "../../element/bounds";
|
||||
import { getElementAbsoluteCoords } from "../../element/bounds";
|
||||
@ -35,8 +35,8 @@ export const getLinkHandleFromCoords = (
|
||||
const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
|
||||
|
||||
const [rotatedX, rotatedY] = pointRotateRads(
|
||||
point(x + linkWidth / 2, y + linkHeight / 2),
|
||||
point(centerX, centerY),
|
||||
pointFrom(x + linkWidth / 2, y + linkHeight / 2),
|
||||
pointFrom(centerX, centerY),
|
||||
angle,
|
||||
);
|
||||
return [
|
||||
@ -85,5 +85,10 @@ export const isPointHittingLink = (
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return isPointHittingLinkIcon(element, elementsMap, appState, point(x, y));
|
||||
return isPointHittingLinkIcon(
|
||||
element,
|
||||
elementsMap,
|
||||
appState,
|
||||
pointFrom(x, y),
|
||||
);
|
||||
};
|
||||
|
@ -2139,3 +2139,11 @@ export const collapseUpIcon = createIcon(
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const upIcon = createIcon(
|
||||
<g>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M6 15l6 -6l6 6" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
@ -15,6 +15,7 @@ import {
|
||||
LoadIcon,
|
||||
MoonIcon,
|
||||
save,
|
||||
searchIcon,
|
||||
SunIcon,
|
||||
TrashIcon,
|
||||
usersIcon,
|
||||
@ -27,6 +28,7 @@ import {
|
||||
actionLoadScene,
|
||||
actionSaveToActiveFile,
|
||||
actionShortcuts,
|
||||
actionToggleSearchMenu,
|
||||
actionToggleTheme,
|
||||
} from "../../actions";
|
||||
import clsx from "clsx";
|
||||
@ -40,7 +42,6 @@ import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemConten
|
||||
import { THEME } from "../../constants";
|
||||
import type { Theme } from "../../element/types";
|
||||
import { trackEvent } from "../../analytics";
|
||||
|
||||
import "./DefaultItems.scss";
|
||||
|
||||
export const LoadScene = () => {
|
||||
@ -145,6 +146,27 @@ export const CommandPalette = (opts?: { className?: string }) => {
|
||||
};
|
||||
CommandPalette.displayName = "CommandPalette";
|
||||
|
||||
export const SearchMenu = (opts?: { className?: string }) => {
|
||||
const { t } = useI18n();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
icon={searchIcon}
|
||||
data-testid="search-menu-button"
|
||||
onSelect={() => {
|
||||
actionManager.executeAction(actionToggleSearchMenu);
|
||||
}}
|
||||
shortcut={getShortcutFromShortcutName("searchMenu")}
|
||||
aria-label={t("search.title")}
|
||||
className={opts?.className}
|
||||
>
|
||||
{t("search.title")}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
SearchMenu.displayName = "SearchMenu";
|
||||
|
||||
export const Help = () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
|
@ -113,6 +113,7 @@ export const ENV = {
|
||||
export const CLASSES = {
|
||||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||
ZOOM_ACTIONS: "zoom-actions",
|
||||
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
||||
};
|
||||
|
||||
/**
|
||||
@ -376,6 +377,7 @@ export const DEFAULT_ELEMENT_PROPS: {
|
||||
};
|
||||
|
||||
export const LIBRARY_SIDEBAR_TAB = "library";
|
||||
export const CANVAS_SEARCH_TAB = "search";
|
||||
|
||||
export const DEFAULT_SIDEBAR = {
|
||||
name: "default",
|
||||
|
@ -144,9 +144,9 @@
|
||||
--border-radius-md: 0.375rem;
|
||||
--border-radius-lg: 0.5rem;
|
||||
|
||||
--color-surface-high: hsl(244, 100%, 97%);
|
||||
--color-surface-mid: hsl(240 25% 96%);
|
||||
--color-surface-low: hsl(240 25% 94%);
|
||||
--color-surface-high: #f1f0ff;
|
||||
--color-surface-mid: #f2f2f7;
|
||||
--color-surface-low: #ececf4;
|
||||
--color-surface-lowest: #ffffff;
|
||||
--color-on-surface: #1b1b1f;
|
||||
--color-brand-hover: #5753d0;
|
||||
|
@ -6,11 +6,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"backgroundColor": "#d8f5a2",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id45",
|
||||
"id": "id47",
|
||||
"type": "arrow",
|
||||
},
|
||||
{
|
||||
"id": "id46",
|
||||
"id": "id48",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@ -47,7 +47,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id46",
|
||||
"id": "id48",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@ -118,7 +118,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"seed": Any<Number>,
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id47",
|
||||
"elementId": "id49",
|
||||
"fixedPoint": null,
|
||||
"focus": -0.08139534883720931,
|
||||
"gap": 1,
|
||||
@ -200,7 +200,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id45",
|
||||
"id": "id47",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@ -238,7 +238,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id48",
|
||||
"id": "id50",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@ -284,7 +284,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id48",
|
||||
"id": "id50",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@ -329,7 +329,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id49",
|
||||
"id": "id51",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
@ -392,7 +392,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id48",
|
||||
"containerId": "id50",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@ -433,7 +433,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id38",
|
||||
"id": "id40",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
@ -441,7 +441,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "id40",
|
||||
"elementId": "id42",
|
||||
"fixedPoint": null,
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
@ -472,7 +472,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"seed": Any<Number>,
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id39",
|
||||
"elementId": "id41",
|
||||
"fixedPoint": null,
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
@ -496,7 +496,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id37",
|
||||
"containerId": "id39",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@ -537,7 +537,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id37",
|
||||
"id": "id39",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@ -574,7 +574,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id37",
|
||||
"id": "id39",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@ -611,7 +611,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id42",
|
||||
"id": "id44",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
@ -619,7 +619,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "id44",
|
||||
"elementId": "id46",
|
||||
"fixedPoint": null,
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
@ -650,7 +650,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"seed": Any<Number>,
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id43",
|
||||
"elementId": "id45",
|
||||
"fixedPoint": null,
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
@ -674,7 +674,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id41",
|
||||
"containerId": "id43",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@ -716,7 +716,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id41",
|
||||
"id": "id43",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@ -762,7 +762,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id41",
|
||||
"id": "id43",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@ -1303,7 +1303,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id54",
|
||||
"id": "id56",
|
||||
"type": "text",
|
||||
},
|
||||
{
|
||||
@ -1346,7 +1346,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id55",
|
||||
"id": "id57",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
@ -1385,7 +1385,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id56",
|
||||
"id": "id58",
|
||||
"type": "text",
|
||||
},
|
||||
{
|
||||
@ -1428,7 +1428,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id57",
|
||||
"id": "id59",
|
||||
"type": "text",
|
||||
},
|
||||
{
|
||||
@ -1475,7 +1475,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id58",
|
||||
"id": "id60",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
@ -1540,7 +1540,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id59",
|
||||
"id": "id61",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
|
@ -57,6 +57,15 @@ export const base64ToString = async (base64: string, isByteString = false) => {
|
||||
: byteStringToString(window.atob(base64));
|
||||
};
|
||||
|
||||
export const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
|
||||
if (typeof Buffer !== "undefined") {
|
||||
// Node.js environment
|
||||
return Buffer.from(base64, "base64").buffer;
|
||||
}
|
||||
// Browser environment
|
||||
return byteStringToArrayBuffer(atob(base64));
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// text encoding
|
||||
// -----------------------------------------------------------------------------
|
||||
|
@ -5,6 +5,7 @@ import type {
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawTextElement,
|
||||
FixedPointBinding,
|
||||
FontFamilyValues,
|
||||
OrderedExcalidrawElement,
|
||||
PointBinding,
|
||||
@ -21,6 +22,7 @@ import {
|
||||
import {
|
||||
isArrowElement,
|
||||
isElbowArrow,
|
||||
isFixedPointBinding,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
@ -55,7 +57,7 @@ import {
|
||||
getNormalizedZoom,
|
||||
} from "../scene";
|
||||
import type { LocalPoint, Radians } from "../../math";
|
||||
import { isFiniteNumber, point } from "../../math";
|
||||
import { isFiniteNumber, pointFrom } from "../../math";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
@ -101,8 +103,8 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
||||
|
||||
const repairBinding = (
|
||||
element: ExcalidrawLinearElement,
|
||||
binding: PointBinding | null,
|
||||
): PointBinding | null => {
|
||||
binding: PointBinding | FixedPointBinding | null,
|
||||
): PointBinding | FixedPointBinding | null => {
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
@ -110,9 +112,11 @@ const repairBinding = (
|
||||
return {
|
||||
...binding,
|
||||
focus: binding.focus || 0,
|
||||
fixedPoint: isElbowArrow(element)
|
||||
? normalizeFixedPoint(binding.fixedPoint ?? [0, 0])
|
||||
: null,
|
||||
...(isElbowArrow(element) && isFixedPointBinding(binding)
|
||||
? {
|
||||
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
@ -265,7 +269,7 @@ const restoreElement = (
|
||||
let y = element.y;
|
||||
let points = // migrate old arrow model to new one
|
||||
!Array.isArray(element.points) || element.points.length < 2
|
||||
? [point(0, 0), point(element.width, element.height)]
|
||||
? [pointFrom(0, 0), pointFrom(element.width, element.height)]
|
||||
: element.points;
|
||||
|
||||
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
||||
@ -293,7 +297,7 @@ const restoreElement = (
|
||||
let y: number | undefined = element.y;
|
||||
let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
|
||||
!Array.isArray(element.points) || element.points.length < 2
|
||||
? [point(0, 0), point(element.width, element.height)]
|
||||
? [pointFrom(0, 0), pointFrom(element.width, element.height)]
|
||||
: element.points;
|
||||
|
||||
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
||||
|
@ -2,7 +2,7 @@ import { vi } from "vitest";
|
||||
import type { ExcalidrawElementSkeleton } from "./transform";
|
||||
import { convertToExcalidrawElements } from "./transform";
|
||||
import type { ExcalidrawArrowElement } from "../element/types";
|
||||
import { point } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
|
||||
const opts = { regenerateIds: false };
|
||||
|
||||
@ -309,8 +309,7 @@ describe("Test Transform", () => {
|
||||
});
|
||||
|
||||
describe("Test Frames", () => {
|
||||
it("should transform frames and update frame ids when regenerated", () => {
|
||||
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
|
||||
const elements: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
@ -331,6 +330,11 @@ describe("Test Transform", () => {
|
||||
},
|
||||
id: "2",
|
||||
},
|
||||
];
|
||||
|
||||
it("should transform frames and update frame ids when regenerated", () => {
|
||||
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
|
||||
...elements,
|
||||
{
|
||||
type: "frame",
|
||||
children: ["1", "2"],
|
||||
@ -352,28 +356,9 @@ describe("Test Transform", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should consider max of calculated and frame dimensions when provided", () => {
|
||||
it("should consider user defined frame dimensions over calculated when provided", () => {
|
||||
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
strokeWidth: 2,
|
||||
id: "1",
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 120,
|
||||
y: 20,
|
||||
backgroundColor: "#fff3bf",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "HELLO EXCALIDRAW",
|
||||
strokeColor: "#099268",
|
||||
fontSize: 30,
|
||||
},
|
||||
id: "2",
|
||||
},
|
||||
...elements,
|
||||
{
|
||||
type: "frame",
|
||||
children: ["1", "2"],
|
||||
@ -388,7 +373,27 @@ describe("Test Transform", () => {
|
||||
);
|
||||
const frame = excalidrawElements.find((ele) => ele.type === "frame")!;
|
||||
expect(frame.width).toBe(800);
|
||||
expect(frame.height).toBe(126);
|
||||
expect(frame.height).toBe(100);
|
||||
});
|
||||
|
||||
it("should consider user defined frame coordinates calculated when provided", () => {
|
||||
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
|
||||
...elements,
|
||||
{
|
||||
type: "frame",
|
||||
children: ["1", "2"],
|
||||
name: "My frame",
|
||||
x: 100,
|
||||
y: 300,
|
||||
},
|
||||
];
|
||||
const excalidrawElements = convertToExcalidrawElements(
|
||||
elementsSkeleton,
|
||||
opts,
|
||||
);
|
||||
const frame = excalidrawElements.find((ele) => ele.type === "frame")!;
|
||||
expect(frame.x).toBe(100);
|
||||
expect(frame.y).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
@ -912,7 +917,7 @@ describe("Test Transform", () => {
|
||||
x: 111.262,
|
||||
y: 57,
|
||||
strokeWidth: 2,
|
||||
points: [point(0, 0), point(272.985, 0)],
|
||||
points: [pointFrom(0, 0), pointFrom(272.985, 0)],
|
||||
label: {
|
||||
text: "How are you?",
|
||||
fontSize: 20,
|
||||
@ -935,7 +940,7 @@ describe("Test Transform", () => {
|
||||
x: 77.017,
|
||||
y: 79,
|
||||
strokeWidth: 2,
|
||||
points: [point(0, 0)],
|
||||
points: [pointFrom(0, 0)],
|
||||
label: {
|
||||
text: "Friendship",
|
||||
fontSize: 20,
|
||||
|
@ -46,6 +46,7 @@ import {
|
||||
assertNever,
|
||||
cloneJSON,
|
||||
getFontString,
|
||||
isDevEnv,
|
||||
toBrandedType,
|
||||
} from "../utils";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
@ -53,7 +54,7 @@ import { randomId } from "../random";
|
||||
import { syncInvalidIndices } from "../fractionalIndex";
|
||||
import { getLineHeight } from "../fonts";
|
||||
import { isArrowElement } from "../element/typeChecks";
|
||||
import { point, type LocalPoint } from "../../math";
|
||||
import { pointFrom, type LocalPoint } from "../../math";
|
||||
|
||||
export type ValidLinearElement = {
|
||||
type: "arrow" | "line";
|
||||
@ -536,7 +537,7 @@ export const convertToExcalidrawElements = (
|
||||
excalidrawElement = newLinearElement({
|
||||
width,
|
||||
height,
|
||||
points: [point(0, 0), point(width, height)],
|
||||
points: [pointFrom(0, 0), pointFrom(width, height)],
|
||||
...element,
|
||||
});
|
||||
|
||||
@ -549,7 +550,7 @@ export const convertToExcalidrawElements = (
|
||||
width,
|
||||
height,
|
||||
endArrowhead: "arrow",
|
||||
points: [point(0, 0), point(width, height)],
|
||||
points: [pointFrom(0, 0), pointFrom(width, height)],
|
||||
...element,
|
||||
type: "arrow",
|
||||
});
|
||||
@ -717,7 +718,7 @@ export const convertToExcalidrawElements = (
|
||||
}
|
||||
|
||||
// Once all the excalidraw elements are created, we can add frames since we
|
||||
// need to calculate coordinates and dimensions of frame which is possibe after all
|
||||
// need to calculate coordinates and dimensions of frame which is possible after all
|
||||
// frame children are processed.
|
||||
for (const [id, element] of elementsWithIds) {
|
||||
if (element.type !== "frame" && element.type !== "magicframe") {
|
||||
@ -764,10 +765,26 @@ export const convertToExcalidrawElements = (
|
||||
maxX = maxX + PADDING;
|
||||
maxY = maxY + PADDING;
|
||||
|
||||
// Take the max of calculated and provided frame dimensions, whichever is higher
|
||||
const width = Math.max(frame?.width, maxX - minX);
|
||||
const height = Math.max(frame?.height, maxY - minY);
|
||||
Object.assign(frame, { x: minX, y: minY, width, height });
|
||||
const frameX = frame?.x || minX;
|
||||
const frameY = frame?.y || minY;
|
||||
const frameWidth = frame?.width || maxX - minX;
|
||||
const frameHeight = frame?.height || maxY - minY;
|
||||
|
||||
Object.assign(frame, {
|
||||
x: frameX,
|
||||
y: frameY,
|
||||
width: frameWidth,
|
||||
height: frameHeight,
|
||||
});
|
||||
if (
|
||||
isDevEnv() &&
|
||||
element.children.length &&
|
||||
(frame?.x || frame?.y || frame?.width || frame?.height)
|
||||
) {
|
||||
console.info(
|
||||
"User provided frame attributes are being considered, if you find this inaccurate, please remove any of the attributes - x, y, width and height so frame coordinates and dimensions are calculated automatically",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return elementStore.getElements();
|
||||
|
@ -39,6 +39,7 @@ import {
|
||||
isBindingElement,
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFixedPointBinding,
|
||||
isFrameLikeElement,
|
||||
isLinearElement,
|
||||
isRectangularElement,
|
||||
@ -65,7 +66,7 @@ import {
|
||||
import type { LocalPoint, Radians } from "../../math";
|
||||
import {
|
||||
lineSegment,
|
||||
point,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
type GlobalPoint,
|
||||
vectorFromPoint,
|
||||
@ -719,7 +720,7 @@ export const getHeadingForElbowArrowSnap = (
|
||||
return vectorToHeading(
|
||||
vectorFromPoint(
|
||||
p,
|
||||
point<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(
|
||||
bindableElement.x + bindableElement.width / 2,
|
||||
bindableElement.y + bindableElement.height / 2,
|
||||
),
|
||||
@ -765,15 +766,15 @@ export const bindPointToSnapToElementOutline = (
|
||||
const intersections = [
|
||||
...(intersectElementWithLine(
|
||||
bindableElement,
|
||||
point(p[0], p[1] - 2 * bindableElement.height),
|
||||
point(p[0], p[1] + 2 * bindableElement.height),
|
||||
pointFrom(p[0], p[1] - 2 * bindableElement.height),
|
||||
pointFrom(p[0], p[1] + 2 * bindableElement.height),
|
||||
FIXED_BINDING_DISTANCE,
|
||||
elementsMap,
|
||||
) ?? []),
|
||||
...(intersectElementWithLine(
|
||||
bindableElement,
|
||||
point(p[0] - 2 * bindableElement.width, p[1]),
|
||||
point(p[0] + 2 * bindableElement.width, p[1]),
|
||||
pointFrom(p[0] - 2 * bindableElement.width, p[1]),
|
||||
pointFrom(p[0] + 2 * bindableElement.width, p[1]),
|
||||
FIXED_BINDING_DISTANCE,
|
||||
elementsMap,
|
||||
) ?? []),
|
||||
@ -797,7 +798,7 @@ export const bindPointToSnapToElementOutline = (
|
||||
isVertical
|
||||
? Math.abs(p[1] - i[1]) < 0.1
|
||||
: Math.abs(p[0] - i[0]) < 0.1,
|
||||
)[0] ?? point;
|
||||
)[0] ?? p;
|
||||
}
|
||||
|
||||
return p;
|
||||
@ -814,25 +815,25 @@ const headingToMidBindPoint = (
|
||||
switch (true) {
|
||||
case compareHeading(heading, HEADING_UP):
|
||||
return pointRotateRads(
|
||||
point((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
|
||||
pointFrom((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
|
||||
center,
|
||||
bindableElement.angle,
|
||||
);
|
||||
case compareHeading(heading, HEADING_RIGHT):
|
||||
return pointRotateRads(
|
||||
point(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
|
||||
pointFrom(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
|
||||
center,
|
||||
bindableElement.angle,
|
||||
);
|
||||
case compareHeading(heading, HEADING_DOWN):
|
||||
return pointRotateRads(
|
||||
point((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
|
||||
pointFrom((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
|
||||
center,
|
||||
bindableElement.angle,
|
||||
);
|
||||
default:
|
||||
return pointRotateRads(
|
||||
point(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
|
||||
pointFrom(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
|
||||
center,
|
||||
bindableElement.angle,
|
||||
);
|
||||
@ -843,7 +844,7 @@ export const avoidRectangularCorner = (
|
||||
element: ExcalidrawBindableElement,
|
||||
p: GlobalPoint,
|
||||
): GlobalPoint => {
|
||||
const center = point<GlobalPoint>(
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
@ -853,13 +854,13 @@ export const avoidRectangularCorner = (
|
||||
// Top left
|
||||
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
|
||||
return pointRotateRads<GlobalPoint>(
|
||||
point(element.x - FIXED_BINDING_DISTANCE, element.y),
|
||||
pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
}
|
||||
return pointRotateRads(
|
||||
point(element.x, element.y - FIXED_BINDING_DISTANCE),
|
||||
pointFrom(element.x, element.y - FIXED_BINDING_DISTANCE),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
@ -870,13 +871,16 @@ export const avoidRectangularCorner = (
|
||||
// Bottom left
|
||||
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
|
||||
return pointRotateRads(
|
||||
point(element.x, element.y + element.height + FIXED_BINDING_DISTANCE),
|
||||
pointFrom(
|
||||
element.x,
|
||||
element.y + element.height + FIXED_BINDING_DISTANCE,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
}
|
||||
return pointRotateRads(
|
||||
point(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
|
||||
pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
@ -890,7 +894,7 @@ export const avoidRectangularCorner = (
|
||||
element.width + FIXED_BINDING_DISTANCE
|
||||
) {
|
||||
return pointRotateRads(
|
||||
point(
|
||||
pointFrom(
|
||||
element.x + element.width,
|
||||
element.y + element.height + FIXED_BINDING_DISTANCE,
|
||||
),
|
||||
@ -899,7 +903,7 @@ export const avoidRectangularCorner = (
|
||||
);
|
||||
}
|
||||
return pointRotateRads(
|
||||
point(
|
||||
pointFrom(
|
||||
element.x + element.width + FIXED_BINDING_DISTANCE,
|
||||
element.y + element.height,
|
||||
),
|
||||
@ -916,13 +920,16 @@ export const avoidRectangularCorner = (
|
||||
element.width + FIXED_BINDING_DISTANCE
|
||||
) {
|
||||
return pointRotateRads(
|
||||
point(element.x + element.width, element.y - FIXED_BINDING_DISTANCE),
|
||||
pointFrom(
|
||||
element.x + element.width,
|
||||
element.y - FIXED_BINDING_DISTANCE,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
}
|
||||
return pointRotateRads(
|
||||
point(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
|
||||
pointFrom(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
@ -937,7 +944,10 @@ export const snapToMid = (
|
||||
tolerance: number = 0.05,
|
||||
): GlobalPoint => {
|
||||
const { x, y, width, height, angle } = element;
|
||||
const center = point<GlobalPoint>(x + width / 2 - 0.1, y + height / 2 - 0.1);
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
x + width / 2 - 0.1,
|
||||
y + height / 2 - 0.1,
|
||||
);
|
||||
const nonRotated = pointRotateRads(p, center, -angle as Radians);
|
||||
|
||||
// snap-to-center point is adaptive to element size, but we don't want to go
|
||||
@ -952,7 +962,7 @@ export const snapToMid = (
|
||||
) {
|
||||
// LEFT
|
||||
return pointRotateRads(
|
||||
point(x - FIXED_BINDING_DISTANCE, center[1]),
|
||||
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
@ -963,7 +973,7 @@ export const snapToMid = (
|
||||
) {
|
||||
// TOP
|
||||
return pointRotateRads(
|
||||
point(center[0], y - FIXED_BINDING_DISTANCE),
|
||||
pointFrom(center[0], y - FIXED_BINDING_DISTANCE),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
@ -974,7 +984,7 @@ export const snapToMid = (
|
||||
) {
|
||||
// RIGHT
|
||||
return pointRotateRads(
|
||||
point(x + width + FIXED_BINDING_DISTANCE, center[1]),
|
||||
pointFrom(x + width + FIXED_BINDING_DISTANCE, center[1]),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
@ -985,7 +995,7 @@ export const snapToMid = (
|
||||
) {
|
||||
// DOWN
|
||||
return pointRotateRads(
|
||||
point(center[0], y + height + FIXED_BINDING_DISTANCE),
|
||||
pointFrom(center[0], y + height + FIXED_BINDING_DISTANCE),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
@ -1013,7 +1023,7 @@ const updateBoundPoint = (
|
||||
const direction = startOrEnd === "startBinding" ? -1 : 1;
|
||||
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
|
||||
|
||||
if (isElbowArrow(linearElement)) {
|
||||
if (isElbowArrow(linearElement) && isFixedPointBinding(binding)) {
|
||||
const fixedPoint =
|
||||
normalizeFixedPoint(binding.fixedPoint) ??
|
||||
calculateFixedPointForElbowArrowBinding(
|
||||
@ -1022,11 +1032,11 @@ const updateBoundPoint = (
|
||||
startOrEnd === "startBinding" ? "start" : "end",
|
||||
elementsMap,
|
||||
).fixedPoint;
|
||||
const globalMidPoint = point<GlobalPoint>(
|
||||
const globalMidPoint = pointFrom<GlobalPoint>(
|
||||
bindableElement.x + bindableElement.width / 2,
|
||||
bindableElement.y + bindableElement.height / 2,
|
||||
);
|
||||
const global = point<GlobalPoint>(
|
||||
const global = pointFrom<GlobalPoint>(
|
||||
bindableElement.x + fixedPoint[0] * bindableElement.width,
|
||||
bindableElement.y + fixedPoint[1] * bindableElement.height,
|
||||
);
|
||||
@ -1117,7 +1127,7 @@ export const calculateFixedPointForElbowArrowBinding = (
|
||||
hoveredElement,
|
||||
elementsMap,
|
||||
);
|
||||
const globalMidPoint = point(
|
||||
const globalMidPoint = pointFrom(
|
||||
bounds[0] + (bounds[2] - bounds[0]) / 2,
|
||||
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
||||
);
|
||||
@ -1336,9 +1346,9 @@ export const bindingBorderTest = (
|
||||
const threshold = maxBindingGap(element, element.width, element.height);
|
||||
const shape = getElementShape(element, elementsMap);
|
||||
return (
|
||||
isPointOnShape(point(x, y), shape, threshold) ||
|
||||
isPointOnShape(pointFrom(x, y), shape, threshold) ||
|
||||
(fullShape === true &&
|
||||
pointInsideBounds(point(x, y), aabbForElement(element)))
|
||||
pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
|
||||
);
|
||||
};
|
||||
|
||||
@ -2196,11 +2206,11 @@ export const getGlobalFixedPointForBindableElement = (
|
||||
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
|
||||
|
||||
return pointRotateRads(
|
||||
point(
|
||||
pointFrom(
|
||||
element.x + element.width * fixedX,
|
||||
element.y + element.height * fixedY,
|
||||
),
|
||||
point<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
),
|
||||
@ -2228,7 +2238,7 @@ const getGlobalFixedPoints = (
|
||||
arrow.startBinding.fixedPoint,
|
||||
startElement as ExcalidrawBindableElement,
|
||||
)
|
||||
: point<GlobalPoint>(
|
||||
: pointFrom<GlobalPoint>(
|
||||
arrow.x + arrow.points[0][0],
|
||||
arrow.y + arrow.points[0][1],
|
||||
);
|
||||
@ -2238,7 +2248,7 @@ const getGlobalFixedPoints = (
|
||||
arrow.endBinding.fixedPoint,
|
||||
endElement as ExcalidrawBindableElement,
|
||||
)
|
||||
: point<GlobalPoint>(
|
||||
: pointFrom<GlobalPoint>(
|
||||
arrow.x + arrow.points[arrow.points.length - 1][0],
|
||||
arrow.y + arrow.points[arrow.points.length - 1][1],
|
||||
);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import type { LocalPoint } from "../../math";
|
||||
import { point } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
import { ROUNDNESS } from "../constants";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||
@ -125,9 +125,9 @@ describe("getElementBounds", () => {
|
||||
a: 0.6447741904932416,
|
||||
}),
|
||||
points: [
|
||||
point<LocalPoint>(0, 0),
|
||||
point<LocalPoint>(67.33984375, 92.48828125),
|
||||
point<LocalPoint>(-102.7890625, 52.15625),
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(67.33984375, 92.48828125),
|
||||
pointFrom<LocalPoint>(-102.7890625, 52.15625),
|
||||
],
|
||||
} as ExcalidrawLinearElement;
|
||||
|
||||
|
@ -34,7 +34,7 @@ import type {
|
||||
import {
|
||||
degreesToRadians,
|
||||
lineSegment,
|
||||
point,
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
pointFromArray,
|
||||
pointRotateRads,
|
||||
@ -113,8 +113,8 @@ export class ElementBounds {
|
||||
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
|
||||
element.points.map(([x, y]) =>
|
||||
pointRotateRads(
|
||||
point(x, y),
|
||||
point(cx - element.x, cy - element.y),
|
||||
pointFrom(x, y),
|
||||
pointFrom(cx - element.x, cy - element.y),
|
||||
element.angle,
|
||||
),
|
||||
),
|
||||
@ -130,23 +130,23 @@ export class ElementBounds {
|
||||
bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
|
||||
} else if (element.type === "diamond") {
|
||||
const [x11, y11] = pointRotateRads(
|
||||
point(cx, y1),
|
||||
point(cx, cy),
|
||||
pointFrom(cx, y1),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const [x12, y12] = pointRotateRads(
|
||||
point(cx, y2),
|
||||
point(cx, cy),
|
||||
pointFrom(cx, y2),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const [x22, y22] = pointRotateRads(
|
||||
point(x1, cy),
|
||||
point(cx, cy),
|
||||
pointFrom(x1, cy),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const [x21, y21] = pointRotateRads(
|
||||
point(x2, cy),
|
||||
point(cx, cy),
|
||||
pointFrom(x2, cy),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
@ -164,23 +164,23 @@ export class ElementBounds {
|
||||
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
|
||||
} else {
|
||||
const [x11, y11] = pointRotateRads(
|
||||
point(x1, y1),
|
||||
point(cx, cy),
|
||||
pointFrom(x1, y1),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const [x12, y12] = pointRotateRads(
|
||||
point(x1, y2),
|
||||
point(cx, cy),
|
||||
pointFrom(x1, y2),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const [x22, y22] = pointRotateRads(
|
||||
point(x2, y2),
|
||||
point(cx, cy),
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const [x21, y21] = pointRotateRads(
|
||||
point(x2, y1),
|
||||
point(cx, cy),
|
||||
pointFrom(x2, y1),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
@ -255,7 +255,7 @@ export const getElementLineSegments = (
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const center: GlobalPoint = point(cx, cy);
|
||||
const center: GlobalPoint = pointFrom(cx, cy);
|
||||
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
const segments: LineSegment<GlobalPoint>[] = [];
|
||||
@ -266,7 +266,7 @@ export const getElementLineSegments = (
|
||||
segments.push(
|
||||
lineSegment(
|
||||
pointRotateRads(
|
||||
point(
|
||||
pointFrom(
|
||||
element.points[i][0] + element.x,
|
||||
element.points[i][1] + element.y,
|
||||
),
|
||||
@ -274,7 +274,7 @@ export const getElementLineSegments = (
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
point(
|
||||
pointFrom(
|
||||
element.points[i + 1][0] + element.x,
|
||||
element.points[i + 1][1] + element.y,
|
||||
),
|
||||
@ -470,7 +470,7 @@ export const getMinMaxXYFromCurvePathOps = (
|
||||
ops: Op[],
|
||||
transformXY?: (p: GlobalPoint) => GlobalPoint,
|
||||
): Bounds => {
|
||||
let currentP: GlobalPoint = point(0, 0);
|
||||
let currentP: GlobalPoint = pointFrom(0, 0);
|
||||
|
||||
const { minX, minY, maxX, maxY } = ops.reduce(
|
||||
(limits, { op, data }) => {
|
||||
@ -484,9 +484,9 @@ export const getMinMaxXYFromCurvePathOps = (
|
||||
// move operation does not draw anything; so, it always
|
||||
// returns false
|
||||
} else if (op === "bcurveTo") {
|
||||
const _p1 = point<GlobalPoint>(data[0], data[1]);
|
||||
const _p2 = point<GlobalPoint>(data[2], data[3]);
|
||||
const _p3 = point<GlobalPoint>(data[4], data[5]);
|
||||
const _p1 = pointFrom<GlobalPoint>(data[0], data[1]);
|
||||
const _p2 = pointFrom<GlobalPoint>(data[2], data[3]);
|
||||
const _p3 = pointFrom<GlobalPoint>(data[4], data[5]);
|
||||
|
||||
const p1 = transformXY ? transformXY(_p1) : _p1;
|
||||
const p2 = transformXY ? transformXY(_p2) : _p2;
|
||||
@ -591,21 +591,21 @@ export const getArrowheadPoints = (
|
||||
|
||||
invariant(data.length === 6, "Op data length is not 6");
|
||||
|
||||
const p3 = point(data[4], data[5]);
|
||||
const p2 = point(data[2], data[3]);
|
||||
const p1 = point(data[0], data[1]);
|
||||
const p3 = pointFrom(data[4], data[5]);
|
||||
const p2 = pointFrom(data[2], data[3]);
|
||||
const p1 = pointFrom(data[0], data[1]);
|
||||
|
||||
// We need to find p0 of the bezier curve.
|
||||
// It is typically the last point of the previous
|
||||
// curve; it can also be the position of moveTo operation.
|
||||
const prevOp = ops[index - 1];
|
||||
let p0 = point(0, 0);
|
||||
let p0 = pointFrom(0, 0);
|
||||
if (prevOp.op === "move") {
|
||||
const p = pointFromArray(prevOp.data);
|
||||
invariant(p != null, "Op data is not a point");
|
||||
p0 = p;
|
||||
} else if (prevOp.op === "bcurveTo") {
|
||||
p0 = point(prevOp.data[4], prevOp.data[5]);
|
||||
p0 = pointFrom(prevOp.data[4], prevOp.data[5]);
|
||||
}
|
||||
|
||||
// B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
|
||||
@ -671,13 +671,13 @@ export const getArrowheadPoints = (
|
||||
|
||||
// Return points
|
||||
const [x3, y3] = pointRotateRads(
|
||||
point(xs, ys),
|
||||
point(x2, y2),
|
||||
pointFrom(xs, ys),
|
||||
pointFrom(x2, y2),
|
||||
((-angle * Math.PI) / 180) as Radians,
|
||||
);
|
||||
const [x4, y4] = pointRotateRads(
|
||||
point(xs, ys),
|
||||
point(x2, y2),
|
||||
pointFrom(xs, ys),
|
||||
pointFrom(x2, y2),
|
||||
degreesToRadians(angle),
|
||||
);
|
||||
|
||||
@ -690,8 +690,8 @@ export const getArrowheadPoints = (
|
||||
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
|
||||
|
||||
[ox, oy] = pointRotateRads(
|
||||
point(x2 + minSize * 2, y2),
|
||||
point(x2, y2),
|
||||
pointFrom(x2 + minSize * 2, y2),
|
||||
pointFrom(x2, y2),
|
||||
Math.atan2(py - y2, px - x2) as Radians,
|
||||
);
|
||||
} else {
|
||||
@ -701,8 +701,8 @@ export const getArrowheadPoints = (
|
||||
: [0, 0];
|
||||
|
||||
[ox, oy] = pointRotateRads(
|
||||
point(x2 - minSize * 2, y2),
|
||||
point(x2, y2),
|
||||
pointFrom(x2 - minSize * 2, y2),
|
||||
pointFrom(x2, y2),
|
||||
Math.atan2(y2 - py, x2 - px) as Radians,
|
||||
);
|
||||
}
|
||||
@ -746,8 +746,8 @@ const getLinearElementRotatedBounds = (
|
||||
if (element.points.length < 2) {
|
||||
const [pointX, pointY] = element.points[0];
|
||||
const [x, y] = pointRotateRads(
|
||||
point(element.x + pointX, element.y + pointY),
|
||||
point(cx, cy),
|
||||
pointFrom(element.x + pointX, element.y + pointY),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
@ -775,8 +775,8 @@ const getLinearElementRotatedBounds = (
|
||||
const ops = getCurvePathOps(shape);
|
||||
const transformXY = ([x, y]: GlobalPoint) =>
|
||||
pointRotateRads<GlobalPoint>(
|
||||
point(element.x + x, element.y + y),
|
||||
point(cx, cy),
|
||||
pointFrom(element.x + x, element.y + y),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
|
||||
@ -931,8 +931,8 @@ export const getClosestElementBounds = (
|
||||
elements.forEach((element) => {
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||
const distance = pointDistance(
|
||||
point((x1 + x2) / 2, (y1 + y2) / 2),
|
||||
point(from.x, from.y),
|
||||
pointFrom((x1 + x2) / 2, (y1 + y2) / 2),
|
||||
pointFrom(from.x, from.y),
|
||||
);
|
||||
|
||||
if (distance < minDistance) {
|
||||
@ -990,7 +990,7 @@ export const getVisibleSceneBounds = ({
|
||||
};
|
||||
|
||||
export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
|
||||
point(
|
||||
pointFrom(
|
||||
bounds[0] + (bounds[2] - bounds[0]) / 2,
|
||||
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
||||
);
|
||||
|
@ -17,7 +17,7 @@ import {
|
||||
} from "./typeChecks";
|
||||
import { getBoundTextShape, isPathALoop } from "../shapes";
|
||||
import type { GlobalPoint, LocalPoint, Polygon } from "../../math";
|
||||
import { isPointWithinBounds, point } from "../../math";
|
||||
import { isPointWithinBounds, pointFrom } from "../../math";
|
||||
|
||||
export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||
if (element.type === "arrow") {
|
||||
@ -61,13 +61,13 @@ export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
|
||||
let hit = shouldTestInside(element)
|
||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
||||
// we would need `onShape` as well to include the "borders"
|
||||
isPointInShape(point(x, y), shape) ||
|
||||
isPointOnShape(point(x, y), shape, threshold)
|
||||
: isPointOnShape(point(x, y), shape, threshold);
|
||||
isPointInShape(pointFrom(x, y), shape) ||
|
||||
isPointOnShape(pointFrom(x, y), shape, threshold)
|
||||
: isPointOnShape(pointFrom(x, y), shape, threshold);
|
||||
|
||||
// hit test against a frame's name
|
||||
if (!hit && frameNameBound) {
|
||||
hit = isPointInShape(point(x, y), {
|
||||
hit = isPointInShape(pointFrom(x, y), {
|
||||
type: "polygon",
|
||||
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
|
||||
.data as Polygon<Point>,
|
||||
@ -89,7 +89,11 @@ export const hitElementBoundingBox = (
|
||||
y1 -= tolerance;
|
||||
x2 += tolerance;
|
||||
y2 += tolerance;
|
||||
return isPointWithinBounds(point(x1, y1), point(x, y), point(x2, y2));
|
||||
return isPointWithinBounds(
|
||||
pointFrom(x1, y1),
|
||||
pointFrom(x, y),
|
||||
pointFrom(x2, y2),
|
||||
);
|
||||
};
|
||||
|
||||
export const hitElementBoundingBoxOnly = <
|
||||
@ -115,5 +119,5 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
||||
y: number,
|
||||
textShape: GeometricShape<Point> | null,
|
||||
): boolean => {
|
||||
return !!textShape && isPointInShape(point(x, y), textShape);
|
||||
return !!textShape && isPointInShape(pointFrom(x, y), textShape);
|
||||
};
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { type Point } from "points-on-curve";
|
||||
import {
|
||||
type Radians,
|
||||
point,
|
||||
pointFrom,
|
||||
pointCenter,
|
||||
pointRotateRads,
|
||||
vectorFromPoint,
|
||||
@ -64,8 +64,8 @@ const _cropElement = (
|
||||
*/
|
||||
|
||||
const rotatedPointer = pointRotateRads(
|
||||
point(pointerX, pointerY),
|
||||
point(element.x + element.width / 2, element.y + element.height / 2),
|
||||
pointFrom(pointerX, pointerY),
|
||||
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
@ -199,8 +199,8 @@ const recomputeOrigin = (
|
||||
stateAtCropStart.height,
|
||||
true,
|
||||
);
|
||||
const startTopLeft = point(x1, y1);
|
||||
const startBottomRight = point(x2, y2);
|
||||
const startTopLeft = pointFrom(x1, y1);
|
||||
const startBottomRight = pointFrom(x2, y2);
|
||||
const startCenter: any = pointCenter(startTopLeft, startBottomRight);
|
||||
|
||||
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
|
||||
@ -267,16 +267,16 @@ export const getUncroppedImageElement = (
|
||||
);
|
||||
|
||||
const topLeftVector = vectorFromPoint(
|
||||
pointRotateRads(point(x1, y1), point(cx, cy), element.angle),
|
||||
pointRotateRads(pointFrom(x1, y1), pointFrom(cx, cy), element.angle),
|
||||
);
|
||||
const topRightVector = vectorFromPoint(
|
||||
pointRotateRads(point(x2, y1), point(cx, cy), element.angle),
|
||||
pointRotateRads(pointFrom(x2, y1), pointFrom(cx, cy), element.angle),
|
||||
);
|
||||
const topEdgeNormalized = vectorNormalize(
|
||||
vectorSubtract(topRightVector, topLeftVector),
|
||||
);
|
||||
const bottomLeftVector = vectorFromPoint(
|
||||
pointRotateRads(point(x1, y2), point(cx, cy), element.angle),
|
||||
pointRotateRads(pointFrom(x1, y2), pointFrom(cx, cy), element.angle),
|
||||
);
|
||||
const leftEdgeVector = vectorSubtract(bottomLeftVector, topLeftVector);
|
||||
const leftEdgeNormalized = vectorNormalize(leftEdgeVector);
|
||||
|
@ -36,7 +36,6 @@ export const dragSelectedElements = (
|
||||
) => {
|
||||
if (
|
||||
_selectedElements.length === 1 &&
|
||||
isArrowElement(_selectedElements[0]) &&
|
||||
isElbowArrow(_selectedElements[0]) &&
|
||||
(_selectedElements[0].startBinding || _selectedElements[0].endBinding)
|
||||
) {
|
||||
@ -44,13 +43,7 @@ export const dragSelectedElements = (
|
||||
}
|
||||
|
||||
const selectedElements = _selectedElements.filter(
|
||||
(el) =>
|
||||
!(
|
||||
isArrowElement(el) &&
|
||||
isElbowArrow(el) &&
|
||||
el.startBinding &&
|
||||
el.endBinding
|
||||
),
|
||||
(el) => !(isElbowArrow(el) && el.startBinding && el.endBinding),
|
||||
);
|
||||
|
||||
// we do not want a frame and its elements to be selected at the same time
|
||||
|
@ -45,6 +45,12 @@ const RE_GENERIC_EMBED =
|
||||
const RE_GIPHY =
|
||||
/giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/;
|
||||
|
||||
const RE_REDDIT =
|
||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?reddit\.com\/r\/([a-zA-Z0-9_]+)\/comments\/([a-zA-Z0-9_]+)\/([a-zA-Z0-9_]+)\/?(?:\?[^#\s]*)?(?:#[^\s]*)?$/;
|
||||
|
||||
const RE_REDDIT_EMBED =
|
||||
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
|
||||
|
||||
const ALLOWED_DOMAINS = new Set([
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
@ -59,6 +65,7 @@ const ALLOWED_DOMAINS = new Set([
|
||||
"stackblitz.com",
|
||||
"val.town",
|
||||
"giphy.com",
|
||||
"reddit.com",
|
||||
]);
|
||||
|
||||
const ALLOW_SAME_ORIGIN = new Set([
|
||||
@ -71,6 +78,7 @@ const ALLOW_SAME_ORIGIN = new Set([
|
||||
"x.com",
|
||||
"*.simplepdf.eu",
|
||||
"stackblitz.com",
|
||||
"reddit.com",
|
||||
]);
|
||||
|
||||
export const createSrcDoc = (body: string) => {
|
||||
@ -218,6 +226,24 @@ export const getEmbedLink = (
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (RE_REDDIT.test(link)) {
|
||||
const [, page, postId, title] = link.match(RE_REDDIT)!;
|
||||
const safeURL = sanitizeHTMLAttribute(
|
||||
`https://reddit.com/r/${page}/comments/${postId}/${title}`,
|
||||
);
|
||||
const ret: IframeDataWithSandbox = {
|
||||
type: "document",
|
||||
srcdoc: (theme: string) =>
|
||||
createSrcDoc(
|
||||
`<blockquote class="reddit-embed-bq" data-embed-theme="${theme}"><a href="${safeURL}"></a><br></blockquote><script async="" src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script>`,
|
||||
),
|
||||
intrinsicSize: { w: 480, h: 480 },
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
embeddedLinkCache.set(originalLink, ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (RE_GH_GIST.test(link)) {
|
||||
const [, user, gistId] = link.match(RE_GH_GIST)!;
|
||||
const safeURL = sanitizeHTMLAttribute(
|
||||
@ -361,6 +387,11 @@ export const maybeParseEmbedSrc = (str: string): string => {
|
||||
return twitterMatch[1];
|
||||
}
|
||||
|
||||
const redditMatch = str.match(RE_REDDIT_EMBED);
|
||||
if (redditMatch && redditMatch.length === 2) {
|
||||
return redditMatch[1];
|
||||
}
|
||||
|
||||
const gistMatch = str.match(RE_GH_GIST_EMBED);
|
||||
if (gistMatch && gistMatch.length === 2) {
|
||||
return gistMatch[1];
|
||||
|
@ -29,7 +29,7 @@ import {
|
||||
isFlowchartNodeElement,
|
||||
} from "./typeChecks";
|
||||
import { invariant } from "../utils";
|
||||
import { point, type LocalPoint } from "../../math";
|
||||
import { pointFrom, type LocalPoint } from "../../math";
|
||||
import { aabbForElement } from "../shapes";
|
||||
|
||||
type LinkDirection = "up" | "right" | "down" | "left";
|
||||
@ -421,7 +421,7 @@ const createBindingArrow = (
|
||||
strokeColor: appState.currentItemStrokeColor,
|
||||
strokeStyle: appState.currentItemStrokeStyle,
|
||||
strokeWidth: appState.currentItemStrokeWidth,
|
||||
points: [point(0, 0), point(endX, endY)],
|
||||
points: [pointFrom(0, 0), pointFrom(endX, endY)],
|
||||
elbowed: true,
|
||||
});
|
||||
|
||||
|
@ -6,7 +6,7 @@ import type {
|
||||
Radians,
|
||||
} from "../../math";
|
||||
import {
|
||||
point,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
pointScaleFromOrigin,
|
||||
radiansToDegrees,
|
||||
@ -82,7 +82,7 @@ export const headingForPointFromElement = <
|
||||
|
||||
const top = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
point(element.x + element.width / 2, element.y),
|
||||
pointFrom(element.x + element.width / 2, element.y),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
@ -91,7 +91,7 @@ export const headingForPointFromElement = <
|
||||
);
|
||||
const right = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
point(element.x + element.width, element.y + element.height / 2),
|
||||
pointFrom(element.x + element.width, element.y + element.height / 2),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
@ -100,7 +100,7 @@ export const headingForPointFromElement = <
|
||||
);
|
||||
const bottom = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
point(element.x + element.width / 2, element.y + element.height),
|
||||
pointFrom(element.x + element.width / 2, element.y + element.height),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
@ -109,7 +109,7 @@ export const headingForPointFromElement = <
|
||||
);
|
||||
const left = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
point(element.x, element.y + element.height / 2),
|
||||
pointFrom(element.x, element.y + element.height / 2),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
@ -133,22 +133,22 @@ export const headingForPointFromElement = <
|
||||
}
|
||||
|
||||
const topLeft = pointScaleFromOrigin(
|
||||
point(aabb[0], aabb[1]),
|
||||
pointFrom(aabb[0], aabb[1]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
) as Point;
|
||||
const topRight = pointScaleFromOrigin(
|
||||
point(aabb[2], aabb[1]),
|
||||
pointFrom(aabb[2], aabb[1]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
) as Point;
|
||||
const bottomLeft = pointScaleFromOrigin(
|
||||
point(aabb[0], aabb[3]),
|
||||
pointFrom(aabb[0], aabb[3]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
) as Point;
|
||||
const bottomRight = pointScaleFromOrigin(
|
||||
point(aabb[2], aabb[3]),
|
||||
pointFrom(aabb[2], aabb[3]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
) as Point;
|
||||
|
@ -49,7 +49,7 @@ import type Scene from "../scene/Scene";
|
||||
import type { Radians } from "../../math";
|
||||
import {
|
||||
pointCenter,
|
||||
point,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
pointsEqual,
|
||||
vector,
|
||||
@ -102,12 +102,13 @@ export class LinearElementEditor {
|
||||
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||
public readonly hoverPointIndex: number;
|
||||
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
||||
public readonly elbowed: boolean;
|
||||
|
||||
constructor(element: NonDeleted<ExcalidrawLinearElement>) {
|
||||
this.elementId = element.id as string & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
};
|
||||
if (!pointsEqual(element.points[0], point(0, 0))) {
|
||||
if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
|
||||
console.error("Linear element is not normalized", Error().stack);
|
||||
}
|
||||
|
||||
@ -131,6 +132,7 @@ export class LinearElementEditor {
|
||||
};
|
||||
this.hoverPointIndex = -1;
|
||||
this.segmentMidPointHoveredCoords = null;
|
||||
this.elbowed = isElbowArrow(element) && element.elbowed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -285,7 +287,7 @@ export class LinearElementEditor {
|
||||
element,
|
||||
elementsMap,
|
||||
referencePoint,
|
||||
point(scenePointerX, scenePointerY),
|
||||
pointFrom(scenePointerX, scenePointerY),
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
);
|
||||
|
||||
@ -294,7 +296,7 @@ export class LinearElementEditor {
|
||||
[
|
||||
{
|
||||
index: selectedIndex,
|
||||
point: point(
|
||||
point: pointFrom(
|
||||
width + referencePoint[0],
|
||||
height + referencePoint[1],
|
||||
),
|
||||
@ -327,7 +329,7 @@ export class LinearElementEditor {
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
)
|
||||
: point(
|
||||
: pointFrom(
|
||||
element.points[pointIndex][0] + deltaX,
|
||||
element.points[pointIndex][1] + deltaY,
|
||||
);
|
||||
@ -588,11 +590,11 @@ export class LinearElementEditor {
|
||||
linearElementEditor.segmentMidPointHoveredCoords;
|
||||
if (existingSegmentMidpointHitCoords) {
|
||||
const distance = pointDistance(
|
||||
point(
|
||||
pointFrom(
|
||||
existingSegmentMidpointHitCoords[0],
|
||||
existingSegmentMidpointHitCoords[1],
|
||||
),
|
||||
point(scenePointer.x, scenePointer.y),
|
||||
pointFrom(scenePointer.x, scenePointer.y),
|
||||
);
|
||||
if (distance <= threshold) {
|
||||
return existingSegmentMidpointHitCoords;
|
||||
@ -604,8 +606,8 @@ export class LinearElementEditor {
|
||||
while (index < midPoints.length) {
|
||||
if (midPoints[index] !== null) {
|
||||
const distance = pointDistance(
|
||||
point(midPoints[index]![0], midPoints[index]![1]),
|
||||
point(scenePointer.x, scenePointer.y),
|
||||
pointFrom(midPoints[index]![0], midPoints[index]![1]),
|
||||
pointFrom(scenePointer.x, scenePointer.y),
|
||||
);
|
||||
if (distance <= threshold) {
|
||||
return midPoints[index];
|
||||
@ -624,8 +626,8 @@ export class LinearElementEditor {
|
||||
zoom: AppState["zoom"],
|
||||
) {
|
||||
let distance = pointDistance(
|
||||
point(startPoint[0], startPoint[1]),
|
||||
point(endPoint[0], endPoint[1]),
|
||||
pointFrom(startPoint[0], startPoint[1]),
|
||||
pointFrom(endPoint[0], endPoint[1]),
|
||||
);
|
||||
if (element.points.length > 2 && element.roundness) {
|
||||
distance = getBezierCurveLength(element, endPoint);
|
||||
@ -827,11 +829,11 @@ export class LinearElementEditor {
|
||||
const targetPoint =
|
||||
clickedPointIndex > -1 &&
|
||||
pointRotateRads(
|
||||
point(
|
||||
pointFrom(
|
||||
element.x + element.points[clickedPointIndex][0],
|
||||
element.y + element.points[clickedPointIndex][1],
|
||||
),
|
||||
point(cx, cy),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
@ -926,11 +928,11 @@ export class LinearElementEditor {
|
||||
element,
|
||||
elementsMap,
|
||||
lastCommittedPoint,
|
||||
point(scenePointerX, scenePointerY),
|
||||
pointFrom(scenePointerX, scenePointerY),
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
);
|
||||
|
||||
newPoint = point(
|
||||
newPoint = pointFrom(
|
||||
width + lastCommittedPoint[0],
|
||||
height + lastCommittedPoint[1],
|
||||
);
|
||||
@ -982,8 +984,8 @@ export class LinearElementEditor {
|
||||
|
||||
const { x, y } = element;
|
||||
return pointRotateRads(
|
||||
point(x + p[0], y + p[1]),
|
||||
point(cx, cy),
|
||||
pointFrom(x + p[0], y + p[1]),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
}
|
||||
@ -999,8 +1001,8 @@ export class LinearElementEditor {
|
||||
return element.points.map((p) => {
|
||||
const { x, y } = element;
|
||||
return pointRotateRads(
|
||||
point(x + p[0], y + p[1]),
|
||||
point(cx, cy),
|
||||
pointFrom(x + p[0], y + p[1]),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
});
|
||||
@ -1023,8 +1025,12 @@ export class LinearElementEditor {
|
||||
const { x, y } = element;
|
||||
|
||||
return p
|
||||
? pointRotateRads(point(x + p[0], y + p[1]), point(cx, cy), element.angle)
|
||||
: pointRotateRads(point(x, y), point(cx, cy), element.angle);
|
||||
? pointRotateRads(
|
||||
pointFrom(x + p[0], y + p[1]),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
)
|
||||
: pointRotateRads(pointFrom(x, y), pointFrom(cx, cy), element.angle);
|
||||
}
|
||||
|
||||
static pointFromAbsoluteCoords(
|
||||
@ -1034,7 +1040,7 @@ export class LinearElementEditor {
|
||||
): LocalPoint {
|
||||
if (isElbowArrow(element)) {
|
||||
// No rotation for elbow arrows
|
||||
return point(
|
||||
return pointFrom(
|
||||
absoluteCoords[0] - element.x,
|
||||
absoluteCoords[1] - element.y,
|
||||
);
|
||||
@ -1044,11 +1050,11 @@ export class LinearElementEditor {
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const [x, y] = pointRotateRads(
|
||||
point(absoluteCoords[0], absoluteCoords[1]),
|
||||
point(cx, cy),
|
||||
pointFrom(absoluteCoords[0], absoluteCoords[1]),
|
||||
pointFrom(cx, cy),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
return point(x - element.x, y - element.y);
|
||||
return pointFrom(x - element.x, y - element.y);
|
||||
}
|
||||
|
||||
static getPointIndexUnderCursor(
|
||||
@ -1069,7 +1075,7 @@ export class LinearElementEditor {
|
||||
while (--idx > -1) {
|
||||
const p = pointHandles[idx];
|
||||
if (
|
||||
pointDistance(point(x, y), point(p[0], p[1])) * zoom.value <
|
||||
pointDistance(pointFrom(x, y), pointFrom(p[0], p[1])) * zoom.value <
|
||||
// +1px to account for outline stroke
|
||||
LinearElementEditor.POINT_HANDLE_SIZE + 1
|
||||
) {
|
||||
@ -1091,12 +1097,12 @@ export class LinearElementEditor {
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const [rotatedX, rotatedY] = pointRotateRads(
|
||||
point(pointerOnGrid[0], pointerOnGrid[1]),
|
||||
point(cx, cy),
|
||||
pointFrom(pointerOnGrid[0], pointerOnGrid[1]),
|
||||
pointFrom(cx, cy),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
return point(rotatedX - element.x, rotatedY - element.y);
|
||||
return pointFrom(rotatedX - element.x, rotatedY - element.y);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1116,7 +1122,7 @@ export class LinearElementEditor {
|
||||
|
||||
return {
|
||||
points: points.map((p) => {
|
||||
return point(p[0] - offsetX, p[1] - offsetY);
|
||||
return pointFrom(p[0] - offsetX, p[1] - offsetY);
|
||||
}),
|
||||
x: element.x + offsetX,
|
||||
y: element.y + offsetY,
|
||||
@ -1170,8 +1176,8 @@ export class LinearElementEditor {
|
||||
}
|
||||
acc.push(
|
||||
nextPoint
|
||||
? point((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
|
||||
: point(p[0], p[1]),
|
||||
? pointFrom((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
|
||||
: pointFrom(p[0], p[1]),
|
||||
);
|
||||
|
||||
nextSelectedIndices.push(indexCursor + 1);
|
||||
@ -1192,7 +1198,7 @@ export class LinearElementEditor {
|
||||
[
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: point(lastPoint[0] + 30, lastPoint[1] + 30),
|
||||
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
|
||||
},
|
||||
],
|
||||
elementsMap,
|
||||
@ -1233,7 +1239,9 @@ export class LinearElementEditor {
|
||||
const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
|
||||
if (!pointIndices.includes(idx)) {
|
||||
acc.push(
|
||||
!acc.length ? point(0, 0) : point(p[0] - offsetX, p[1] - offsetY),
|
||||
!acc.length
|
||||
? pointFrom(0, 0)
|
||||
: pointFrom(p[0] - offsetX, p[1] - offsetY),
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
@ -1310,9 +1318,9 @@ export class LinearElementEditor {
|
||||
const deltaY =
|
||||
selectedPointData.point[1] - points[selectedPointData.index][1];
|
||||
|
||||
return point(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
|
||||
return pointFrom(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
|
||||
}
|
||||
return offsetX || offsetY ? point(p[0] - offsetX, p[1] - offsetY) : p;
|
||||
return offsetX || offsetY ? pointFrom(p[0] - offsetX, p[1] - offsetY) : p;
|
||||
});
|
||||
|
||||
LinearElementEditor._updatePoints(
|
||||
@ -1366,8 +1374,8 @@ export class LinearElementEditor {
|
||||
|
||||
const origin = linearElementEditor.pointerDownState.origin!;
|
||||
const dist = pointDistance(
|
||||
point(origin.x, origin.y),
|
||||
point(pointerCoords.x, pointerCoords.y),
|
||||
pointFrom(origin.x, origin.y),
|
||||
pointFrom(pointerCoords.x, pointerCoords.y),
|
||||
);
|
||||
if (
|
||||
!appState.editingLinearElement &&
|
||||
@ -1477,7 +1485,9 @@ export class LinearElementEditor {
|
||||
nextPoints,
|
||||
vector(offsetX, offsetY),
|
||||
bindings,
|
||||
options,
|
||||
{
|
||||
isDragging: options?.isDragging,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const nextCoords = getElementPointsCoords(element, nextPoints);
|
||||
@ -1489,8 +1499,8 @@ export class LinearElementEditor {
|
||||
const dX = prevCenterX - nextCenterX;
|
||||
const dY = prevCenterY - nextCenterY;
|
||||
const rotated = pointRotateRads(
|
||||
point(offsetX, offsetY),
|
||||
point(dX, dY),
|
||||
pointFrom(offsetX, offsetY),
|
||||
pointFrom(dX, dY),
|
||||
element.angle,
|
||||
);
|
||||
mutateElement(element, {
|
||||
@ -1536,8 +1546,8 @@ export class LinearElementEditor {
|
||||
);
|
||||
|
||||
return pointRotateRads(
|
||||
point(width, height),
|
||||
point(0, 0),
|
||||
pointFrom(width, height),
|
||||
pointFrom(0, 0),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
}
|
||||
@ -1607,36 +1617,36 @@ export class LinearElementEditor {
|
||||
);
|
||||
const boundTextX2 = boundTextX1 + boundTextElement.width;
|
||||
const boundTextY2 = boundTextY1 + boundTextElement.height;
|
||||
const centerPoint = point(cx, cy);
|
||||
const centerPoint = pointFrom(cx, cy);
|
||||
|
||||
const topLeftRotatedPoint = pointRotateRads(
|
||||
point(x1, y1),
|
||||
pointFrom(x1, y1),
|
||||
centerPoint,
|
||||
element.angle,
|
||||
);
|
||||
const topRightRotatedPoint = pointRotateRads(
|
||||
point(x2, y1),
|
||||
pointFrom(x2, y1),
|
||||
centerPoint,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
const counterRotateBoundTextTopLeft = pointRotateRads(
|
||||
point(boundTextX1, boundTextY1),
|
||||
pointFrom(boundTextX1, boundTextY1),
|
||||
centerPoint,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const counterRotateBoundTextTopRight = pointRotateRads(
|
||||
point(boundTextX2, boundTextY1),
|
||||
pointFrom(boundTextX2, boundTextY1),
|
||||
centerPoint,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const counterRotateBoundTextBottomLeft = pointRotateRads(
|
||||
point(boundTextX1, boundTextY2),
|
||||
pointFrom(boundTextX1, boundTextY2),
|
||||
centerPoint,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const counterRotateBoundTextBottomRight = pointRotateRads(
|
||||
point(boundTextX2, boundTextY2),
|
||||
pointFrom(boundTextX2, boundTextY2),
|
||||
centerPoint,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
@ -5,7 +5,7 @@ import { FONT_FAMILY, ROUNDNESS } from "../constants";
|
||||
import { isPrimitive } from "../utils";
|
||||
import type { ExcalidrawLinearElement } from "./types";
|
||||
import type { LocalPoint } from "../../math";
|
||||
import { point } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
|
||||
const assertCloneObjects = (source: any, clone: any) => {
|
||||
for (const key in clone) {
|
||||
@ -38,7 +38,7 @@ describe("duplicating single elements", () => {
|
||||
element.__proto__ = { hello: "world" };
|
||||
|
||||
mutateElement(element, {
|
||||
points: [point<LocalPoint>(1, 2), point<LocalPoint>(3, 4)],
|
||||
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
|
||||
});
|
||||
|
||||
const copy = duplicateElement(null, new Map(), element);
|
||||
|
@ -223,7 +223,6 @@ export const newTextElement = (
|
||||
verticalAlign?: VerticalAlign;
|
||||
containerId?: ExcalidrawTextContainer["id"] | null;
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
|
||||
autoResize?: ExcalidrawTextElement["autoResize"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
|
@ -9,6 +9,7 @@ import type {
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ExcalidrawImageElement,
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
SceneElementsMap,
|
||||
} from "./types";
|
||||
@ -57,7 +58,7 @@ import type { GlobalPoint } from "../../math";
|
||||
import {
|
||||
pointCenter,
|
||||
normalizeRadians,
|
||||
point,
|
||||
pointFrom,
|
||||
pointFromPair,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
@ -239,8 +240,8 @@ const resizeSingleTextElement = (
|
||||
);
|
||||
// rotation pointer with reverse angle
|
||||
const [rotatedX, rotatedY] = pointRotateRads(
|
||||
point(pointerX, pointerY),
|
||||
point(cx, cy),
|
||||
pointFrom(pointerX, pointerY),
|
||||
pointFrom(cx, cy),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
let scaleX = 0;
|
||||
@ -275,23 +276,23 @@ const resizeSingleTextElement = (
|
||||
const startBottomRight = [x2, y2];
|
||||
const startCenter = [cx, cy];
|
||||
|
||||
let newTopLeft = point<GlobalPoint>(x1, y1);
|
||||
let newTopLeft = pointFrom<GlobalPoint>(x1, y1);
|
||||
if (["n", "w", "nw"].includes(transformHandleType)) {
|
||||
newTopLeft = point<GlobalPoint>(
|
||||
newTopLeft = pointFrom<GlobalPoint>(
|
||||
startBottomRight[0] - Math.abs(nextWidth),
|
||||
startBottomRight[1] - Math.abs(nextHeight),
|
||||
);
|
||||
}
|
||||
if (transformHandleType === "ne") {
|
||||
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
|
||||
newTopLeft = point<GlobalPoint>(
|
||||
newTopLeft = pointFrom<GlobalPoint>(
|
||||
bottomLeft[0],
|
||||
bottomLeft[1] - Math.abs(nextHeight),
|
||||
);
|
||||
}
|
||||
if (transformHandleType === "sw") {
|
||||
const topRight = [startBottomRight[0], startTopLeft[1]];
|
||||
newTopLeft = point<GlobalPoint>(
|
||||
newTopLeft = pointFrom<GlobalPoint>(
|
||||
topRight[0] - Math.abs(nextWidth),
|
||||
topRight[1],
|
||||
);
|
||||
@ -310,12 +311,20 @@ const resizeSingleTextElement = (
|
||||
}
|
||||
|
||||
const angle = element.angle;
|
||||
const rotatedTopLeft = pointRotateRads(newTopLeft, point(cx, cy), angle);
|
||||
const newCenter = point<GlobalPoint>(
|
||||
const rotatedTopLeft = pointRotateRads(
|
||||
newTopLeft,
|
||||
pointFrom(cx, cy),
|
||||
angle,
|
||||
);
|
||||
const newCenter = pointFrom<GlobalPoint>(
|
||||
newTopLeft[0] + Math.abs(nextWidth) / 2,
|
||||
newTopLeft[1] + Math.abs(nextHeight) / 2,
|
||||
);
|
||||
const rotatedNewCenter = pointRotateRads(newCenter, point(cx, cy), angle);
|
||||
const rotatedNewCenter = pointRotateRads(
|
||||
newCenter,
|
||||
pointFrom(cx, cy),
|
||||
angle,
|
||||
);
|
||||
newTopLeft = pointRotateRads(
|
||||
rotatedTopLeft,
|
||||
rotatedNewCenter,
|
||||
@ -340,12 +349,12 @@ const resizeSingleTextElement = (
|
||||
stateAtResizeStart.height,
|
||||
true,
|
||||
);
|
||||
const startTopLeft = point<GlobalPoint>(x1, y1);
|
||||
const startBottomRight = point<GlobalPoint>(x2, y2);
|
||||
const startTopLeft = pointFrom<GlobalPoint>(x1, y1);
|
||||
const startBottomRight = pointFrom<GlobalPoint>(x2, y2);
|
||||
const startCenter = pointCenter(startTopLeft, startBottomRight);
|
||||
|
||||
const rotatedPointer = pointRotateRads(
|
||||
point(pointerX, pointerY),
|
||||
pointFrom(pointerX, pointerY),
|
||||
startCenter,
|
||||
-stateAtResizeStart.angle as Radians,
|
||||
);
|
||||
@ -418,7 +427,7 @@ const resizeSingleTextElement = (
|
||||
startCenter,
|
||||
angle,
|
||||
);
|
||||
const newCenter = point(
|
||||
const newCenter = pointFrom(
|
||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
||||
);
|
||||
@ -460,13 +469,13 @@ export const resizeSingleElement = (
|
||||
stateAtResizeStart.height,
|
||||
true,
|
||||
);
|
||||
const startTopLeft = point(x1, y1);
|
||||
const startBottomRight = point(x2, y2);
|
||||
const startTopLeft = pointFrom(x1, y1);
|
||||
const startBottomRight = pointFrom(x2, y2);
|
||||
const startCenter = pointCenter(startTopLeft, startBottomRight);
|
||||
|
||||
// Calculate new dimensions based on cursor position
|
||||
const rotatedPointer = pointRotateRads(
|
||||
point(pointerX, pointerY),
|
||||
pointFrom(pointerX, pointerY),
|
||||
startCenter,
|
||||
-stateAtResizeStart.angle as Radians,
|
||||
);
|
||||
@ -647,7 +656,7 @@ export const resizeSingleElement = (
|
||||
startCenter,
|
||||
angle,
|
||||
);
|
||||
const newCenter = point(
|
||||
const newCenter = pointFrom(
|
||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
||||
);
|
||||
@ -816,20 +825,20 @@ export const resizeMultipleElements = (
|
||||
const direction = transformHandleType;
|
||||
|
||||
const anchorsMap: Record<TransformHandleDirection, GlobalPoint> = {
|
||||
ne: point(minX, maxY),
|
||||
se: point(minX, minY),
|
||||
sw: point(maxX, minY),
|
||||
nw: point(maxX, maxY),
|
||||
e: point(minX, minY + height / 2),
|
||||
w: point(maxX, minY + height / 2),
|
||||
n: point(minX + width / 2, maxY),
|
||||
s: point(minX + width / 2, minY),
|
||||
ne: pointFrom(minX, maxY),
|
||||
se: pointFrom(minX, minY),
|
||||
sw: pointFrom(maxX, minY),
|
||||
nw: pointFrom(maxX, maxY),
|
||||
e: pointFrom(minX, minY + height / 2),
|
||||
w: pointFrom(maxX, minY + height / 2),
|
||||
n: pointFrom(minX + width / 2, maxY),
|
||||
s: pointFrom(minX + width / 2, minY),
|
||||
};
|
||||
|
||||
// anchor point must be on the opposite side of the dragged selection handle
|
||||
// or be the center of the selection if shouldResizeFromCenter
|
||||
const [anchorX, anchorY] = shouldResizeFromCenter
|
||||
? point(midX, midY)
|
||||
? pointFrom(midX, midY)
|
||||
: anchorsMap[direction];
|
||||
|
||||
const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
|
||||
@ -909,6 +918,8 @@ export const resizeMultipleElements = (
|
||||
fontSize?: ExcalidrawTextElement["fontSize"];
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
|
||||
startBinding?: ExcalidrawArrowElement["startBinding"];
|
||||
endBinding?: ExcalidrawArrowElement["endBinding"];
|
||||
};
|
||||
}[] = [];
|
||||
|
||||
@ -993,19 +1004,6 @@ export const resizeMultipleElements = (
|
||||
|
||||
mutateElement(element, update, false);
|
||||
|
||||
if (isArrowElement(element) && isElbowArrow(element)) {
|
||||
mutateElbowArrow(
|
||||
element,
|
||||
elementsMap,
|
||||
element.points,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
informMutation: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
updateBoundElements(element, elementsMap, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
oldSize: { width: oldWidth, height: oldHeight },
|
||||
@ -1054,12 +1052,12 @@ const rotateMultipleElements = (
|
||||
const origAngle =
|
||||
originalElements.get(element.id)?.angle ?? element.angle;
|
||||
const [rotatedCX, rotatedCY] = pointRotateRads(
|
||||
point(cx, cy),
|
||||
point(centerX, centerY),
|
||||
pointFrom(cx, cy),
|
||||
pointFrom(centerX, centerY),
|
||||
(centerAngle + origAngle - element.angle) as Radians,
|
||||
);
|
||||
|
||||
if (isArrowElement(element) && isElbowArrow(element)) {
|
||||
if (isElbowArrow(element)) {
|
||||
const points = getArrowLocalFixedPoints(element, elementsMap);
|
||||
mutateElbowArrow(element, elementsMap, points);
|
||||
} else {
|
||||
@ -1111,40 +1109,44 @@ export const getResizeOffsetXY = (
|
||||
const angle = (
|
||||
selectedElements.length === 1 ? selectedElements[0].angle : 0
|
||||
) as Radians;
|
||||
[x, y] = pointRotateRads(point(x, y), point(cx, cy), -angle as Radians);
|
||||
[x, y] = pointRotateRads(
|
||||
pointFrom(x, y),
|
||||
pointFrom(cx, cy),
|
||||
-angle as Radians,
|
||||
);
|
||||
switch (transformHandleType) {
|
||||
case "n":
|
||||
return pointRotateRads(
|
||||
point(x - (x1 + x2) / 2, y - y1),
|
||||
point(0, 0),
|
||||
pointFrom(x - (x1 + x2) / 2, y - y1),
|
||||
pointFrom(0, 0),
|
||||
angle,
|
||||
);
|
||||
case "s":
|
||||
return pointRotateRads(
|
||||
point(x - (x1 + x2) / 2, y - y2),
|
||||
point(0, 0),
|
||||
pointFrom(x - (x1 + x2) / 2, y - y2),
|
||||
pointFrom(0, 0),
|
||||
angle,
|
||||
);
|
||||
case "w":
|
||||
return pointRotateRads(
|
||||
point(x - x1, y - (y1 + y2) / 2),
|
||||
point(0, 0),
|
||||
pointFrom(x - x1, y - (y1 + y2) / 2),
|
||||
pointFrom(0, 0),
|
||||
angle,
|
||||
);
|
||||
case "e":
|
||||
return pointRotateRads(
|
||||
point(x - x2, y - (y1 + y2) / 2),
|
||||
point(0, 0),
|
||||
pointFrom(x - x2, y - (y1 + y2) / 2),
|
||||
pointFrom(0, 0),
|
||||
angle,
|
||||
);
|
||||
case "nw":
|
||||
return pointRotateRads(point(x - x1, y - y1), point(0, 0), angle);
|
||||
return pointRotateRads(pointFrom(x - x1, y - y1), pointFrom(0, 0), angle);
|
||||
case "ne":
|
||||
return pointRotateRads(point(x - x2, y - y1), point(0, 0), angle);
|
||||
return pointRotateRads(pointFrom(x - x2, y - y1), pointFrom(0, 0), angle);
|
||||
case "sw":
|
||||
return pointRotateRads(point(x - x1, y - y2), point(0, 0), angle);
|
||||
return pointRotateRads(pointFrom(x - x1, y - y2), pointFrom(0, 0), angle);
|
||||
case "se":
|
||||
return pointRotateRads(point(x - x2, y - y2), point(0, 0), angle);
|
||||
return pointRotateRads(pointFrom(x - x2, y - y2), pointFrom(0, 0), angle);
|
||||
default:
|
||||
return [0, 0];
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ import { SIDE_RESIZING_THRESHOLD } from "../constants";
|
||||
import { isLinearElement } from "./typeChecks";
|
||||
import type { GlobalPoint, LineSegment, LocalPoint } from "../../math";
|
||||
import {
|
||||
point,
|
||||
pointFrom,
|
||||
pointOnLineSegment,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
@ -92,16 +92,20 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
||||
if (!(isLinearElement(element) && element.points.length <= 2)) {
|
||||
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||
const sides = getSelectionBorders(
|
||||
point(x1 - SPACING, y1 - SPACING),
|
||||
point(x2 + SPACING, y2 + SPACING),
|
||||
point(cx, cy),
|
||||
pointFrom(x1 - SPACING, y1 - SPACING),
|
||||
pointFrom(x2 + SPACING, y2 + SPACING),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
for (const [dir, side] of Object.entries(sides)) {
|
||||
// test to see if x, y are on the line segment
|
||||
if (
|
||||
pointOnLineSegment(point(x, y), side as LineSegment<Point>, SPACING)
|
||||
pointOnLineSegment(
|
||||
pointFrom(x, y),
|
||||
side as LineSegment<Point>,
|
||||
SPACING,
|
||||
)
|
||||
) {
|
||||
return dir as TransformHandleType;
|
||||
}
|
||||
@ -178,9 +182,9 @@ export const getTransformHandleTypeFromCoords = <
|
||||
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||
|
||||
const sides = getSelectionBorders(
|
||||
point(x1 - SPACING, y1 - SPACING),
|
||||
point(x2 + SPACING, y2 + SPACING),
|
||||
point(cx, cy),
|
||||
pointFrom(x1 - SPACING, y1 - SPACING),
|
||||
pointFrom(x2 + SPACING, y2 + SPACING),
|
||||
pointFrom(cx, cy),
|
||||
0 as Radians,
|
||||
);
|
||||
|
||||
@ -188,7 +192,7 @@ export const getTransformHandleTypeFromCoords = <
|
||||
// test to see if x, y are on the line segment
|
||||
if (
|
||||
pointOnLineSegment(
|
||||
point(scenePointerX, scenePointerY),
|
||||
pointFrom(scenePointerX, scenePointerY),
|
||||
side as LineSegment<Point>,
|
||||
SPACING,
|
||||
)
|
||||
@ -265,10 +269,10 @@ const getSelectionBorders = <Point extends LocalPoint | GlobalPoint>(
|
||||
center: Point,
|
||||
angle: Radians,
|
||||
) => {
|
||||
const topLeft = pointRotateRads(point(x1, y1), center, angle);
|
||||
const topRight = pointRotateRads(point(x2, y1), center, angle);
|
||||
const bottomLeft = pointRotateRads(point(x1, y2), center, angle);
|
||||
const bottomRight = pointRotateRads(point(x2, y2), center, angle);
|
||||
const topLeft = pointRotateRads(pointFrom(x1, y1), center, angle);
|
||||
const topRight = pointRotateRads(pointFrom(x2, y1), center, angle);
|
||||
const bottomLeft = pointRotateRads(pointFrom(x1, y2), center, angle);
|
||||
const bottomRight = pointRotateRads(pointFrom(x2, y2), center, angle);
|
||||
|
||||
return {
|
||||
n: [topLeft, topRight],
|
||||
|
@ -17,7 +17,7 @@ import type {
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
import { ARROW_TYPE } from "../constants";
|
||||
import { point } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
@ -32,8 +32,8 @@ describe("elbow arrow routing", () => {
|
||||
}) as ExcalidrawElbowArrowElement;
|
||||
scene.insertElement(arrow);
|
||||
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
|
||||
point(-45 - arrow.x, -100.1 - arrow.y),
|
||||
point(45 - arrow.x, 99.9 - arrow.y),
|
||||
pointFrom(-45 - arrow.x, -100.1 - arrow.y),
|
||||
pointFrom(45 - arrow.x, 99.9 - arrow.y),
|
||||
]);
|
||||
expect(arrow.points).toEqual([
|
||||
[0, 0],
|
||||
@ -69,7 +69,7 @@ describe("elbow arrow routing", () => {
|
||||
y: -100.1,
|
||||
width: 90,
|
||||
height: 200,
|
||||
points: [point(0, 0), point(90, 200)],
|
||||
points: [pointFrom(0, 0), pointFrom(90, 200)],
|
||||
}) as ExcalidrawElbowArrowElement;
|
||||
scene.insertElement(rectangle1);
|
||||
scene.insertElement(rectangle2);
|
||||
@ -81,7 +81,7 @@ describe("elbow arrow routing", () => {
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
mutateElbowArrow(arrow, elementsMap, [point(0, 0), point(90, 200)]);
|
||||
mutateElbowArrow(arrow, elementsMap, [pointFrom(0, 0), pointFrom(90, 200)]);
|
||||
|
||||
expect(arrow.points).toEqual([
|
||||
[0, 0],
|
||||
@ -94,7 +94,16 @@ describe("elbow arrow routing", () => {
|
||||
|
||||
describe("elbow arrow ui", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
||||
});
|
||||
|
||||
it("can follow bound shapes", async () => {
|
||||
@ -130,8 +139,8 @@ describe("elbow arrow ui", () => {
|
||||
expect(arrow.elbowed).toBe(true);
|
||||
expect(arrow.points).toEqual([
|
||||
[0, 0],
|
||||
[35, 0],
|
||||
[35, 200],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
]);
|
||||
});
|
||||
@ -163,14 +172,6 @@ describe("elbow arrow ui", () => {
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
||||
|
||||
mouse.click(51, 51);
|
||||
|
||||
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
|
||||
@ -182,8 +183,8 @@ describe("elbow arrow ui", () => {
|
||||
[0, 0],
|
||||
[35, 0],
|
||||
[35, 90],
|
||||
[25, 90],
|
||||
[25, 165],
|
||||
[35, 90], // Note that coordinates are rounded above!
|
||||
[35, 165],
|
||||
[103, 165],
|
||||
]);
|
||||
});
|
||||
|
@ -1,6 +1,6 @@
|
||||
import type { Radians } from "../../math";
|
||||
import {
|
||||
point,
|
||||
pointFrom,
|
||||
pointScaleFromOrigin,
|
||||
pointTranslate,
|
||||
vector,
|
||||
@ -36,11 +36,11 @@ import {
|
||||
HEADING_UP,
|
||||
vectorToHeading,
|
||||
} from "./heading";
|
||||
import type { ElementUpdate } from "./mutateElement";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { isBindableElement, isRectanguloidElement } from "./typeChecks";
|
||||
import type {
|
||||
ExcalidrawElbowArrowElement,
|
||||
FixedPointBinding,
|
||||
NonDeletedSceneElementsMap,
|
||||
SceneElementsMap,
|
||||
} from "./types";
|
||||
@ -72,16 +72,48 @@ export const mutateElbowArrow = (
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
nextPoints: readonly LocalPoint[],
|
||||
offset?: Vector,
|
||||
otherUpdates?: {
|
||||
startBinding?: FixedPointBinding | null;
|
||||
endBinding?: FixedPointBinding | null;
|
||||
otherUpdates?: Omit<
|
||||
ElementUpdate<ExcalidrawElbowArrowElement>,
|
||||
"angle" | "x" | "y" | "width" | "height" | "elbowed" | "points"
|
||||
>,
|
||||
options?: {
|
||||
isDragging?: boolean;
|
||||
informMutation?: boolean;
|
||||
},
|
||||
) => {
|
||||
const update = updateElbowArrow(
|
||||
arrow,
|
||||
elementsMap,
|
||||
nextPoints,
|
||||
offset,
|
||||
options,
|
||||
);
|
||||
if (update) {
|
||||
mutateElement(
|
||||
arrow,
|
||||
{
|
||||
...otherUpdates,
|
||||
...update,
|
||||
angle: 0 as Radians,
|
||||
},
|
||||
options?.informMutation,
|
||||
);
|
||||
} else {
|
||||
console.error("Elbow arrow cannot find a route");
|
||||
}
|
||||
};
|
||||
|
||||
export const updateElbowArrow = (
|
||||
arrow: ExcalidrawElbowArrowElement,
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
nextPoints: readonly LocalPoint[],
|
||||
offset?: Vector,
|
||||
options?: {
|
||||
isDragging?: boolean;
|
||||
disableBinding?: boolean;
|
||||
informMutation?: boolean;
|
||||
},
|
||||
) => {
|
||||
): ElementUpdate<ExcalidrawElbowArrowElement> | null => {
|
||||
const origStartGlobalPoint: GlobalPoint = pointTranslate(
|
||||
pointTranslate<LocalPoint, GlobalPoint>(
|
||||
nextPoints[0],
|
||||
@ -235,6 +267,8 @@ export const mutateElbowArrow = (
|
||||
BASE_PADDING,
|
||||
),
|
||||
boundsOverlap,
|
||||
hoveredStartElement && aabbForElement(hoveredStartElement),
|
||||
hoveredEndElement && aabbForElement(hoveredEndElement),
|
||||
);
|
||||
const startDonglePosition = getDonglePosition(
|
||||
dynamicAABBs[0],
|
||||
@ -295,18 +329,10 @@ export const mutateElbowArrow = (
|
||||
startDongle && points.unshift(startGlobalPoint);
|
||||
endDongle && points.push(endGlobalPoint);
|
||||
|
||||
mutateElement(
|
||||
arrow,
|
||||
{
|
||||
...otherUpdates,
|
||||
...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0),
|
||||
angle: 0 as Radians,
|
||||
},
|
||||
options?.informMutation,
|
||||
);
|
||||
} else {
|
||||
console.error("Elbow arrow cannot find a route");
|
||||
return normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const offsetFromHeading = (
|
||||
@ -475,7 +501,11 @@ const generateDynamicAABBs = (
|
||||
startDifference?: [number, number, number, number],
|
||||
endDifference?: [number, number, number, number],
|
||||
disableSideHack?: boolean,
|
||||
startElementBounds?: Bounds | null,
|
||||
endElementBounds?: Bounds | null,
|
||||
): Bounds[] => {
|
||||
const startEl = startElementBounds ?? a;
|
||||
const endEl = endElementBounds ?? b;
|
||||
const [startUp, startRight, startDown, startLeft] = startDifference ?? [
|
||||
0, 0, 0, 0,
|
||||
];
|
||||
@ -484,29 +514,29 @@ const generateDynamicAABBs = (
|
||||
const first = [
|
||||
a[0] > b[2]
|
||||
? a[1] > b[3] || a[3] < b[1]
|
||||
? Math.min((a[0] + b[2]) / 2, a[0] - startLeft)
|
||||
: (a[0] + b[2]) / 2
|
||||
? Math.min((startEl[0] + endEl[2]) / 2, a[0] - startLeft)
|
||||
: (startEl[0] + endEl[2]) / 2
|
||||
: a[0] > b[0]
|
||||
? a[0] - startLeft
|
||||
: common[0] - startLeft,
|
||||
a[1] > b[3]
|
||||
? a[0] > b[2] || a[2] < b[0]
|
||||
? Math.min((a[1] + b[3]) / 2, a[1] - startUp)
|
||||
: (a[1] + b[3]) / 2
|
||||
? Math.min((startEl[1] + endEl[3]) / 2, a[1] - startUp)
|
||||
: (startEl[1] + endEl[3]) / 2
|
||||
: a[1] > b[1]
|
||||
? a[1] - startUp
|
||||
: common[1] - startUp,
|
||||
a[2] < b[0]
|
||||
? a[1] > b[3] || a[3] < b[1]
|
||||
? Math.max((a[2] + b[0]) / 2, a[2] + startRight)
|
||||
: (a[2] + b[0]) / 2
|
||||
? Math.max((startEl[2] + endEl[0]) / 2, a[2] + startRight)
|
||||
: (startEl[2] + endEl[0]) / 2
|
||||
: a[2] < b[2]
|
||||
? a[2] + startRight
|
||||
: common[2] + startRight,
|
||||
a[3] < b[1]
|
||||
? a[0] > b[2] || a[2] < b[0]
|
||||
? Math.max((a[3] + b[1]) / 2, a[3] + startDown)
|
||||
: (a[3] + b[1]) / 2
|
||||
? Math.max((startEl[3] + endEl[1]) / 2, a[3] + startDown)
|
||||
: (startEl[3] + endEl[1]) / 2
|
||||
: a[3] < b[3]
|
||||
? a[3] + startDown
|
||||
: common[3] + startDown,
|
||||
@ -514,29 +544,29 @@ const generateDynamicAABBs = (
|
||||
const second = [
|
||||
b[0] > a[2]
|
||||
? b[1] > a[3] || b[3] < a[1]
|
||||
? Math.min((b[0] + a[2]) / 2, b[0] - endLeft)
|
||||
: (b[0] + a[2]) / 2
|
||||
? Math.min((endEl[0] + startEl[2]) / 2, b[0] - endLeft)
|
||||
: (endEl[0] + startEl[2]) / 2
|
||||
: b[0] > a[0]
|
||||
? b[0] - endLeft
|
||||
: common[0] - endLeft,
|
||||
b[1] > a[3]
|
||||
? b[0] > a[2] || b[2] < a[0]
|
||||
? Math.min((b[1] + a[3]) / 2, b[1] - endUp)
|
||||
: (b[1] + a[3]) / 2
|
||||
? Math.min((endEl[1] + startEl[3]) / 2, b[1] - endUp)
|
||||
: (endEl[1] + startEl[3]) / 2
|
||||
: b[1] > a[1]
|
||||
? b[1] - endUp
|
||||
: common[1] - endUp,
|
||||
b[2] < a[0]
|
||||
? b[1] > a[3] || b[3] < a[1]
|
||||
? Math.max((b[2] + a[0]) / 2, b[2] + endRight)
|
||||
: (b[2] + a[0]) / 2
|
||||
? Math.max((endEl[2] + startEl[0]) / 2, b[2] + endRight)
|
||||
: (endEl[2] + startEl[0]) / 2
|
||||
: b[2] < a[2]
|
||||
? b[2] + endRight
|
||||
: common[2] + endRight,
|
||||
b[3] < a[1]
|
||||
? b[0] > a[2] || b[2] < a[0]
|
||||
? Math.max((b[3] + a[1]) / 2, b[3] + endDown)
|
||||
: (b[3] + a[1]) / 2
|
||||
? Math.max((endEl[3] + startEl[1]) / 2, b[3] + endDown)
|
||||
: (endEl[3] + startEl[1]) / 2
|
||||
: b[3] < a[3]
|
||||
? b[3] + endDown
|
||||
: common[3] + endDown,
|
||||
@ -713,13 +743,13 @@ const getDonglePosition = (
|
||||
): GlobalPoint => {
|
||||
switch (heading) {
|
||||
case HEADING_UP:
|
||||
return point(p[0], bounds[1]);
|
||||
return pointFrom(p[0], bounds[1]);
|
||||
case HEADING_RIGHT:
|
||||
return point(bounds[2], p[1]);
|
||||
return pointFrom(bounds[2], p[1]);
|
||||
case HEADING_DOWN:
|
||||
return point(p[0], bounds[3]);
|
||||
return pointFrom(p[0], bounds[3]);
|
||||
}
|
||||
return point(bounds[0], p[1]);
|
||||
return pointFrom(bounds[0], p[1]);
|
||||
};
|
||||
|
||||
const estimateSegmentCount = (
|
||||
|
@ -2,7 +2,7 @@ import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
||||
import { SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import type { AppState, Zoom } from "../types";
|
||||
import type { AppState, Offsets, Zoom } from "../types";
|
||||
import { getCommonBounds, getElementBounds } from "./bounds";
|
||||
import { viewportCoordsToSceneCoords } from "../utils";
|
||||
|
||||
@ -67,12 +67,7 @@ export const isElementCompletelyInViewport = (
|
||||
scrollY: number;
|
||||
},
|
||||
elementsMap: ElementsMap,
|
||||
padding?: Partial<{
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
}>,
|
||||
padding?: Offsets,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates
|
||||
const topLeftSceneCoords = viewportCoordsToSceneCoords(
|
||||
|
@ -284,16 +284,17 @@ export const measureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
forceAdvanceWidth?: true,
|
||||
) => {
|
||||
text = text
|
||||
const _text = text
|
||||
.split("\n")
|
||||
// replace empty lines with single space because leading/trailing empty
|
||||
// lines would be stripped from computation
|
||||
.map((x) => x || " ")
|
||||
.join("\n");
|
||||
const fontSize = parseFloat(font);
|
||||
const height = getTextHeight(text, fontSize, lineHeight);
|
||||
const width = getTextWidth(text, font);
|
||||
const height = getTextHeight(_text, fontSize, lineHeight);
|
||||
const width = getTextWidth(_text, font, forceAdvanceWidth);
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
|
@ -19,7 +19,7 @@ import type {
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { getOriginalContainerHeightFromCache } from "./containerCache";
|
||||
import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
|
||||
import { point } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
@ -42,7 +42,7 @@ describe("textWysiwyg", () => {
|
||||
type: "line",
|
||||
width: 100,
|
||||
height: 0,
|
||||
points: [point(0, 0), point(100, 0)],
|
||||
points: [pointFrom(0, 0), pointFrom(100, 0)],
|
||||
});
|
||||
const textSize = 20;
|
||||
const text = API.createElement({
|
||||
|
@ -247,7 +247,7 @@ export const textWysiwyg = ({
|
||||
|
||||
// adding left and right padding buffer, so that browser does not cut the glyphs (does not work in Safari)
|
||||
const padding = !isSafari
|
||||
? Math.ceil(updatedTextElement.fontSize / 2)
|
||||
? Math.ceil(updatedTextElement.fontSize / appState.zoom.value / 2)
|
||||
: 0;
|
||||
|
||||
// Make sure text editor height doesn't go beyond viewport
|
||||
|
@ -19,7 +19,7 @@ import {
|
||||
isIOS,
|
||||
} from "../constants";
|
||||
import type { Radians } from "../../math";
|
||||
import { point, pointRotateRads } from "../../math";
|
||||
import { pointFrom, pointRotateRads } from "../../math";
|
||||
|
||||
export type TransformHandleDirection =
|
||||
| "n"
|
||||
@ -95,8 +95,8 @@ const generateTransformHandle = (
|
||||
angle: Radians,
|
||||
): TransformHandle => {
|
||||
const [xx, yy] = pointRotateRads(
|
||||
point(x + width / 2, y + height / 2),
|
||||
point(cx, cy),
|
||||
pointFrom(x + width / 2, y + height / 2),
|
||||
pointFrom(cx, cy),
|
||||
angle,
|
||||
);
|
||||
return [xx - width / 2, yy - height / 2, width, height];
|
||||
|
@ -320,9 +320,12 @@ export const getDefaultRoundnessTypeForElement = (
|
||||
};
|
||||
|
||||
export const isFixedPointBinding = (
|
||||
binding: PointBinding,
|
||||
binding: PointBinding | FixedPointBinding,
|
||||
): binding is FixedPointBinding => {
|
||||
return binding.fixedPoint != null;
|
||||
return (
|
||||
Object.hasOwn(binding, "fixedPoint") &&
|
||||
(binding as FixedPointBinding).fixedPoint != null
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: Move this to @excalidraw/math
|
||||
|
@ -202,6 +202,7 @@ export type ExcalidrawElement =
|
||||
| ExcalidrawGenericElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawLinearElement
|
||||
| ExcalidrawArrowElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement
|
||||
@ -277,15 +278,19 @@ export type PointBinding = {
|
||||
elementId: ExcalidrawBindableElement["id"];
|
||||
focus: number;
|
||||
gap: number;
|
||||
};
|
||||
|
||||
export type FixedPointBinding = Merge<
|
||||
PointBinding,
|
||||
{
|
||||
// Represents the fixed point binding information in form of a vertical and
|
||||
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
|
||||
// gives the user selected fixed point by multiplying the bound element width
|
||||
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
|
||||
// bound element-local point coordinate.
|
||||
fixedPoint: FixedPoint | null;
|
||||
};
|
||||
|
||||
export type FixedPointBinding = Merge<PointBinding, { fixedPoint: FixedPoint }>;
|
||||
fixedPoint: FixedPoint;
|
||||
}
|
||||
>;
|
||||
|
||||
export type Arrowhead =
|
||||
| "arrow"
|
||||
|
@ -1,4 +1,8 @@
|
||||
import { stringToBase64, toByteString } from "../data/encode";
|
||||
import {
|
||||
base64ToArrayBuffer,
|
||||
stringToBase64,
|
||||
toByteString,
|
||||
} from "../data/encode";
|
||||
import { LOCAL_FONT_PROTOCOL } from "./metadata";
|
||||
import loadWoff2 from "./wasm/woff2.loader";
|
||||
import loadHbSubset from "./wasm/hb-subset.loader";
|
||||
@ -49,10 +53,7 @@ export class ExcalidrawFont implements Font {
|
||||
|
||||
// it's dataurl (server), the font is inlined as base64, no need to fetch
|
||||
if (url.protocol === "data:") {
|
||||
const arrayBuffer = Buffer.from(
|
||||
url.toString().split(",")[1],
|
||||
"base64",
|
||||
).buffer;
|
||||
const arrayBuffer = base64ToArrayBuffer(url.toString().split(",")[1]);
|
||||
|
||||
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
|
||||
arrayBuffer,
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -24,14 +24,14 @@ import Cascadia from "./assets/CascadiaCode-Regular.woff2";
|
||||
import ComicShanns from "./assets/ComicShanns-Regular.woff2";
|
||||
import LiberationSans from "./assets/LiberationSans-Regular.woff2";
|
||||
|
||||
import LilitaLatin from "https://fonts.gstatic.com/s/lilitaone/v15/i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2";
|
||||
import LilitaLatinExt from "https://fonts.gstatic.com/s/lilitaone/v15/i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2";
|
||||
import LilitaLatin from "./assets/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2";
|
||||
import LilitaLatinExt from "./assets/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2";
|
||||
|
||||
import NunitoLatin from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2";
|
||||
import NunitoLatinExt from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2";
|
||||
import NunitoCyrilic from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2";
|
||||
import NunitoCyrilicExt from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2";
|
||||
import NunitoVietnamese from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2";
|
||||
import NunitoLatin from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2";
|
||||
import NunitoLatinExt from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2";
|
||||
import NunitoCyrilic from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2";
|
||||
import NunitoCyrilicExt from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2";
|
||||
import NunitoVietnamese from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2";
|
||||
|
||||
export class Fonts {
|
||||
// it's ok to track fonts across multiple instances only once, so let's use
|
||||
|
@ -29,7 +29,7 @@ import { getElementLineSegments } from "./element/bounds";
|
||||
import { doLineSegmentsIntersect, elementsOverlappingBBox } from "../utils/";
|
||||
import { isFrameElement, isFrameLikeElement } from "./element/typeChecks";
|
||||
import type { ReadonlySetLike } from "./utility-types";
|
||||
import { isPointWithinBounds, point } from "../math";
|
||||
import { isPointWithinBounds, pointFrom } from "../math";
|
||||
|
||||
// --------------------------- Frame State ------------------------------------
|
||||
export const bindElementsToFramesAfterDuplication = (
|
||||
@ -159,9 +159,9 @@ export const isCursorInFrame = (
|
||||
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap);
|
||||
|
||||
return isPointWithinBounds(
|
||||
point(fx1, fy1),
|
||||
point(cursorCoords.x, cursorCoords.y),
|
||||
point(fx2, fy2),
|
||||
pointFrom(fx1, fy1),
|
||||
pointFrom(cursorCoords.x, cursorCoords.y),
|
||||
pointFrom(fx2, fy2),
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -162,6 +162,13 @@
|
||||
"hint_emptyLibrary": "Select an item on canvas to add it here, or install a library from the public repository, below.",
|
||||
"hint_emptyPrivateLibrary": "Select an item on canvas to add it here."
|
||||
},
|
||||
"search": {
|
||||
"title": "Find on canvas",
|
||||
"noMatch": "No matches found...",
|
||||
"singleResult": "result",
|
||||
"multipleResults": "results",
|
||||
"placeholder": "Find text on canvas..."
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Reset the canvas",
|
||||
"exportJSON": "Export to file",
|
||||
@ -297,6 +304,7 @@
|
||||
"shapes": "Shapes"
|
||||
},
|
||||
"hints": {
|
||||
"dismissSearch": "Escape to dismiss search",
|
||||
"canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging, or use the hand tool",
|
||||
"linearElement": "Click to start multiple points, drag for single line",
|
||||
"arrowTool": "Click to start multiple points, drag for single line. Press {{arrowShortcut}} again to change arrow type.",
|
||||
|
@ -30,8 +30,12 @@ import {
|
||||
shouldShowBoundingBox,
|
||||
} from "../element/transformHandles";
|
||||
import { arrayToMap, throttleRAF } from "../utils";
|
||||
import type { InteractiveCanvasAppState } from "../types";
|
||||
import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants";
|
||||
import {
|
||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||
FRAME_STYLE,
|
||||
THEME,
|
||||
} from "../constants";
|
||||
import { type InteractiveCanvasAppState } from "../types";
|
||||
|
||||
import { renderSnaps } from "../renderer/renderSnaps";
|
||||
|
||||
@ -48,7 +52,6 @@ import {
|
||||
} from "./helpers";
|
||||
import oc from "open-color";
|
||||
import {
|
||||
isArrowElement,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
isLinearElement,
|
||||
@ -901,7 +904,6 @@ const _renderInteractiveScene = ({
|
||||
// Elbow arrow elements cannot be selected when bound on either end
|
||||
(
|
||||
isSingleLinearElementSelected &&
|
||||
isArrowElement(element) &&
|
||||
isElbowArrow(element) &&
|
||||
(element.startBinding || element.endBinding)
|
||||
)
|
||||
@ -1066,9 +1068,48 @@ const _renderInteractiveScene = ({
|
||||
context.restore();
|
||||
}
|
||||
|
||||
appState.searchMatches.forEach(({ id, focus, matchedLines }) => {
|
||||
const element = elementsMap.get(id);
|
||||
|
||||
if (element && isTextElement(element)) {
|
||||
const [elementX1, elementY1, , , cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
elementsMap,
|
||||
true,
|
||||
);
|
||||
|
||||
context.save();
|
||||
if (appState.theme === THEME.LIGHT) {
|
||||
if (focus) {
|
||||
context.fillStyle = "rgba(255, 124, 0, 0.4)";
|
||||
} else {
|
||||
context.fillStyle = "rgba(255, 226, 0, 0.4)";
|
||||
}
|
||||
} else if (focus) {
|
||||
context.fillStyle = "rgba(229, 82, 0, 0.4)";
|
||||
} else {
|
||||
context.fillStyle = "rgba(99, 52, 0, 0.4)";
|
||||
}
|
||||
|
||||
context.translate(appState.scrollX, appState.scrollY);
|
||||
context.translate(cx, cy);
|
||||
context.rotate(element.angle);
|
||||
|
||||
matchedLines.forEach((matchedLine) => {
|
||||
context.fillRect(
|
||||
elementX1 + matchedLine.offsetX - cx,
|
||||
elementY1 + matchedLine.offsetY - cy,
|
||||
matchedLine.width,
|
||||
matchedLine.height,
|
||||
);
|
||||
});
|
||||
|
||||
context.restore();
|
||||
}
|
||||
});
|
||||
|
||||
renderSnaps(context, appState);
|
||||
|
||||
// Reset zoom
|
||||
context.restore();
|
||||
|
||||
renderRemoteCursors({
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { point, type GlobalPoint, type LocalPoint } from "../../math";
|
||||
import { pointFrom, type GlobalPoint, type LocalPoint } from "../../math";
|
||||
import { THEME } from "../constants";
|
||||
import type { PointSnapLine, PointerSnapLine } from "../snapping";
|
||||
import type { InteractiveCanvasAppState } from "../types";
|
||||
@ -140,27 +140,31 @@ const drawGapLine = <Point extends LocalPoint | GlobalPoint>(
|
||||
// (1)
|
||||
if (!appState.zenModeEnabled) {
|
||||
drawLine(
|
||||
point(from[0], from[1] - FULL),
|
||||
point(from[0], from[1] + FULL),
|
||||
pointFrom(from[0], from[1] - FULL),
|
||||
pointFrom(from[0], from[1] + FULL),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// (3)
|
||||
drawLine(
|
||||
point(halfPoint[0] - QUARTER, halfPoint[1] - HALF),
|
||||
point(halfPoint[0] - QUARTER, halfPoint[1] + HALF),
|
||||
pointFrom(halfPoint[0] - QUARTER, halfPoint[1] - HALF),
|
||||
pointFrom(halfPoint[0] - QUARTER, halfPoint[1] + HALF),
|
||||
context,
|
||||
);
|
||||
drawLine(
|
||||
point(halfPoint[0] + QUARTER, halfPoint[1] - HALF),
|
||||
point(halfPoint[0] + QUARTER, halfPoint[1] + HALF),
|
||||
pointFrom(halfPoint[0] + QUARTER, halfPoint[1] - HALF),
|
||||
pointFrom(halfPoint[0] + QUARTER, halfPoint[1] + HALF),
|
||||
context,
|
||||
);
|
||||
|
||||
if (!appState.zenModeEnabled) {
|
||||
// (4)
|
||||
drawLine(point(to[0], to[1] - FULL), point(to[0], to[1] + FULL), context);
|
||||
drawLine(
|
||||
pointFrom(to[0], to[1] - FULL),
|
||||
pointFrom(to[0], to[1] + FULL),
|
||||
context,
|
||||
);
|
||||
|
||||
// (2)
|
||||
drawLine(from, to, context);
|
||||
@ -170,27 +174,31 @@ const drawGapLine = <Point extends LocalPoint | GlobalPoint>(
|
||||
// (1)
|
||||
if (!appState.zenModeEnabled) {
|
||||
drawLine(
|
||||
point(from[0] - FULL, from[1]),
|
||||
point(from[0] + FULL, from[1]),
|
||||
pointFrom(from[0] - FULL, from[1]),
|
||||
pointFrom(from[0] + FULL, from[1]),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// (3)
|
||||
drawLine(
|
||||
point(halfPoint[0] - HALF, halfPoint[1] - QUARTER),
|
||||
point(halfPoint[0] + HALF, halfPoint[1] - QUARTER),
|
||||
pointFrom(halfPoint[0] - HALF, halfPoint[1] - QUARTER),
|
||||
pointFrom(halfPoint[0] + HALF, halfPoint[1] - QUARTER),
|
||||
context,
|
||||
);
|
||||
drawLine(
|
||||
point(halfPoint[0] - HALF, halfPoint[1] + QUARTER),
|
||||
point(halfPoint[0] + HALF, halfPoint[1] + QUARTER),
|
||||
pointFrom(halfPoint[0] - HALF, halfPoint[1] + QUARTER),
|
||||
pointFrom(halfPoint[0] + HALF, halfPoint[1] + QUARTER),
|
||||
context,
|
||||
);
|
||||
|
||||
if (!appState.zenModeEnabled) {
|
||||
// (4)
|
||||
drawLine(point(to[0] - FULL, to[1]), point(to[0] + FULL, to[1]), context);
|
||||
drawLine(
|
||||
pointFrom(to[0] - FULL, to[1]),
|
||||
pointFrom(to[0] + FULL, to[1]),
|
||||
context,
|
||||
);
|
||||
|
||||
// (2)
|
||||
drawLine(from, to, context);
|
||||
|
@ -421,6 +421,7 @@ const renderElementToSvg = (
|
||||
image.setAttribute("width", "100%");
|
||||
image.setAttribute("height", "100%");
|
||||
image.setAttribute("href", fileData.dataURL);
|
||||
image.setAttribute("preserveAspectRatio", "none");
|
||||
|
||||
symbol.appendChild(image);
|
||||
|
||||
|
@ -24,7 +24,7 @@ import {
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
import type { EmbedsValidationStatus } from "../types";
|
||||
import {
|
||||
point,
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
@ -408,7 +408,7 @@ export const _generateElementShape = (
|
||||
// initial position to it
|
||||
const points = element.points.length
|
||||
? element.points
|
||||
: [point<LocalPoint>(0, 0)];
|
||||
: [pointFrom<LocalPoint>(0, 0)];
|
||||
|
||||
if (isElbowArrow(element)) {
|
||||
shape = [
|
||||
|
@ -185,6 +185,11 @@ export const exportToCanvas = async (
|
||||
exportingFrame ?? null,
|
||||
appState.frameRendering ?? null,
|
||||
);
|
||||
// for canvas export, don't clip if exporting a specific frame as it would
|
||||
// clip the corners of the content
|
||||
if (exportingFrame) {
|
||||
frameRendering.clip = false;
|
||||
}
|
||||
|
||||
const elementsForRender = prepareElementsForRender({
|
||||
elements,
|
||||
@ -351,6 +356,11 @@ export const exportToSvg = async (
|
||||
}) rotate(${frame.angle} ${cx} ${cy})"
|
||||
width="${frame.width}"
|
||||
height="${frame.height}"
|
||||
${
|
||||
exportingFrame
|
||||
? ""
|
||||
: `rx=${FRAME_STYLE.radius} ry=${FRAME_STYLE.radius}`
|
||||
}
|
||||
>
|
||||
</rect>
|
||||
</clipPath>`;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import type { AppState, PointerCoords, Zoom } from "../types";
|
||||
import type { AppState, Offsets, PointerCoords, Zoom } from "../types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import {
|
||||
getCommonBounds,
|
||||
@ -31,14 +31,28 @@ export const centerScrollOn = ({
|
||||
scenePoint,
|
||||
viewportDimensions,
|
||||
zoom,
|
||||
offsets,
|
||||
}: {
|
||||
scenePoint: PointerCoords;
|
||||
viewportDimensions: { height: number; width: number };
|
||||
zoom: Zoom;
|
||||
offsets?: Offsets;
|
||||
}) => {
|
||||
let scrollX =
|
||||
(viewportDimensions.width - (offsets?.right ?? 0)) / 2 / zoom.value -
|
||||
scenePoint.x;
|
||||
|
||||
scrollX += (offsets?.left ?? 0) / 2 / zoom.value;
|
||||
|
||||
let scrollY =
|
||||
(viewportDimensions.height - (offsets?.bottom ?? 0)) / 2 / zoom.value -
|
||||
scenePoint.y;
|
||||
|
||||
scrollY += (offsets?.top ?? 0) / 2 / zoom.value;
|
||||
|
||||
return {
|
||||
scrollX: viewportDimensions.width / 2 / zoom.value - scenePoint.x,
|
||||
scrollY: viewportDimensions.height / 2 / zoom.value - scenePoint.y,
|
||||
scrollX,
|
||||
scrollY,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {
|
||||
isPoint,
|
||||
point,
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
pointFromPair,
|
||||
pointRotateRads,
|
||||
@ -167,15 +167,15 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
? getClosedCurveShape<Point>(
|
||||
element,
|
||||
roughShape,
|
||||
point<Point>(element.x, element.y),
|
||||
pointFrom<Point>(element.x, element.y),
|
||||
element.angle,
|
||||
point(cx, cy),
|
||||
pointFrom(cx, cy),
|
||||
)
|
||||
: getCurveShape<Point>(
|
||||
roughShape,
|
||||
point<Point>(element.x, element.y),
|
||||
pointFrom<Point>(element.x, element.y),
|
||||
element.angle,
|
||||
point(cx, cy),
|
||||
pointFrom(cx, cy),
|
||||
);
|
||||
}
|
||||
|
||||
@ -186,7 +186,7 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||
return getFreedrawShape(
|
||||
element,
|
||||
point(cx, cy),
|
||||
pointFrom(cx, cy),
|
||||
shouldTestInside(element),
|
||||
);
|
||||
}
|
||||
@ -233,7 +233,7 @@ export const getControlPointsForBezierCurve = <
|
||||
}
|
||||
|
||||
const ops = getCurvePathOps(shape[0]);
|
||||
let currentP = point<P>(0, 0);
|
||||
let currentP = pointFrom<P>(0, 0);
|
||||
let index = 0;
|
||||
let minDistance = Infinity;
|
||||
let controlPoints: P[] | null = null;
|
||||
@ -249,9 +249,9 @@ export const getControlPointsForBezierCurve = <
|
||||
}
|
||||
if (op === "bcurveTo") {
|
||||
const p0 = currentP;
|
||||
const p1 = point<P>(data[0], data[1]);
|
||||
const p2 = point<P>(data[2], data[3]);
|
||||
const p3 = point<P>(data[4], data[5]);
|
||||
const p1 = pointFrom<P>(data[0], data[1]);
|
||||
const p2 = pointFrom<P>(data[2], data[3]);
|
||||
const p3 = pointFrom<P>(data[4], data[5]);
|
||||
const distance = pointDistance(p3, endPoint);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
@ -279,7 +279,7 @@ export const getBezierXY = <P extends GlobalPoint | LocalPoint>(
|
||||
p0[idx] * Math.pow(t, 3);
|
||||
const tx = equation(t, 0);
|
||||
const ty = equation(t, 1);
|
||||
return point(tx, ty);
|
||||
return pointFrom(tx, ty);
|
||||
};
|
||||
|
||||
const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>(
|
||||
@ -301,12 +301,12 @@ const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>(
|
||||
controlPoints[3],
|
||||
t,
|
||||
);
|
||||
pointsOnCurve.push(point(p[0], p[1]));
|
||||
pointsOnCurve.push(pointFrom(p[0], p[1]));
|
||||
t -= 0.05;
|
||||
}
|
||||
if (pointsOnCurve.length) {
|
||||
if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
|
||||
pointsOnCurve.push(point(endPoint[0], endPoint[1]));
|
||||
pointsOnCurve.push(pointFrom(endPoint[0], endPoint[1]));
|
||||
}
|
||||
}
|
||||
return pointsOnCurve;
|
||||
@ -393,24 +393,24 @@ export const aabbForElement = (
|
||||
midY: element.y + element.height / 2,
|
||||
};
|
||||
|
||||
const center = point(bbox.midX, bbox.midY);
|
||||
const center = pointFrom(bbox.midX, bbox.midY);
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(bbox.minX, bbox.minY),
|
||||
pointFrom(bbox.minX, bbox.minY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [topRightX, topRightY] = pointRotateRads(
|
||||
point(bbox.maxX, bbox.minY),
|
||||
pointFrom(bbox.maxX, bbox.minY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [bottomRightX, bottomRightY] = pointRotateRads(
|
||||
point(bbox.maxX, bbox.maxY),
|
||||
pointFrom(bbox.maxX, bbox.maxY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [bottomLeftX, bottomLeftY] = pointRotateRads(
|
||||
point(bbox.minX, bbox.maxY),
|
||||
pointFrom(bbox.minX, bbox.maxY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
@ -442,14 +442,14 @@ export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
|
||||
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
|
||||
|
||||
export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
|
||||
pointInsideBounds(point(a[0], a[1]), b) ||
|
||||
pointInsideBounds(point(a[2], a[1]), b) ||
|
||||
pointInsideBounds(point(a[2], a[3]), b) ||
|
||||
pointInsideBounds(point(a[0], a[3]), b) ||
|
||||
pointInsideBounds(point(b[0], b[1]), a) ||
|
||||
pointInsideBounds(point(b[2], b[1]), a) ||
|
||||
pointInsideBounds(point(b[2], b[3]), a) ||
|
||||
pointInsideBounds(point(b[0], b[3]), a);
|
||||
pointInsideBounds(pointFrom(a[0], a[1]), b) ||
|
||||
pointInsideBounds(pointFrom(a[2], a[1]), b) ||
|
||||
pointInsideBounds(pointFrom(a[2], a[3]), b) ||
|
||||
pointInsideBounds(pointFrom(a[0], a[3]), b) ||
|
||||
pointInsideBounds(pointFrom(b[0], b[1]), a) ||
|
||||
pointInsideBounds(pointFrom(b[2], b[1]), a) ||
|
||||
pointInsideBounds(pointFrom(b[2], b[3]), a) ||
|
||||
pointInsideBounds(pointFrom(b[0], b[3]), a);
|
||||
|
||||
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
|
||||
if (
|
||||
|
@ -1,6 +1,6 @@
|
||||
import type { InclusiveRange } from "../math";
|
||||
import {
|
||||
point,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
rangeInclusive,
|
||||
rangeIntersection,
|
||||
@ -228,52 +228,52 @@ export const getElementsCorners = (
|
||||
!boundingBoxCorners
|
||||
) {
|
||||
const leftMid = pointRotateRads<GlobalPoint>(
|
||||
point(x1, y1 + halfHeight),
|
||||
point(cx, cy),
|
||||
pointFrom(x1, y1 + halfHeight),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const topMid = pointRotateRads<GlobalPoint>(
|
||||
point(x1 + halfWidth, y1),
|
||||
point(cx, cy),
|
||||
pointFrom(x1 + halfWidth, y1),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const rightMid = pointRotateRads<GlobalPoint>(
|
||||
point(x2, y1 + halfHeight),
|
||||
point(cx, cy),
|
||||
pointFrom(x2, y1 + halfHeight),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const bottomMid = pointRotateRads<GlobalPoint>(
|
||||
point(x1 + halfWidth, y2),
|
||||
point(cx, cy),
|
||||
pointFrom(x1 + halfWidth, y2),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const center = point<GlobalPoint>(cx, cy);
|
||||
const center = pointFrom<GlobalPoint>(cx, cy);
|
||||
|
||||
result = omitCenter
|
||||
? [leftMid, topMid, rightMid, bottomMid]
|
||||
: [leftMid, topMid, rightMid, bottomMid, center];
|
||||
} else {
|
||||
const topLeft = pointRotateRads<GlobalPoint>(
|
||||
point(x1, y1),
|
||||
point(cx, cy),
|
||||
pointFrom(x1, y1),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const topRight = pointRotateRads<GlobalPoint>(
|
||||
point(x2, y1),
|
||||
point(cx, cy),
|
||||
pointFrom(x2, y1),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const bottomLeft = pointRotateRads<GlobalPoint>(
|
||||
point(x1, y2),
|
||||
point(cx, cy),
|
||||
pointFrom(x1, y2),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const bottomRight = pointRotateRads<GlobalPoint>(
|
||||
point(x2, y2),
|
||||
point(cx, cy),
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const center = point<GlobalPoint>(cx, cy);
|
||||
const center = pointFrom<GlobalPoint>(cx, cy);
|
||||
|
||||
result = omitCenter
|
||||
? [topLeft, topRight, bottomLeft, bottomRight]
|
||||
@ -287,18 +287,18 @@ export const getElementsCorners = (
|
||||
const width = maxX - minX;
|
||||
const height = maxY - minY;
|
||||
|
||||
const topLeft = point<GlobalPoint>(minX, minY);
|
||||
const topRight = point<GlobalPoint>(maxX, minY);
|
||||
const bottomLeft = point<GlobalPoint>(minX, maxY);
|
||||
const bottomRight = point<GlobalPoint>(maxX, maxY);
|
||||
const center = point<GlobalPoint>(minX + width / 2, minY + height / 2);
|
||||
const topLeft = pointFrom<GlobalPoint>(minX, minY);
|
||||
const topRight = pointFrom<GlobalPoint>(maxX, minY);
|
||||
const bottomLeft = pointFrom<GlobalPoint>(minX, maxY);
|
||||
const bottomRight = pointFrom<GlobalPoint>(maxX, maxY);
|
||||
const center = pointFrom<GlobalPoint>(minX + width / 2, minY + height / 2);
|
||||
|
||||
result = omitCenter
|
||||
? [topLeft, topRight, bottomLeft, bottomRight]
|
||||
: [topLeft, topRight, bottomLeft, bottomRight, center];
|
||||
}
|
||||
|
||||
return result.map((p) => point(round(p[0]), round(p[1])));
|
||||
return result.map((p) => pointFrom(round(p[0]), round(p[1])));
|
||||
};
|
||||
|
||||
const getReferenceElements = (
|
||||
@ -375,8 +375,11 @@ export const getVisibleGaps = (
|
||||
horizontalGaps.push({
|
||||
startBounds,
|
||||
endBounds,
|
||||
startSide: [point(startMaxX, startMinY), point(startMaxX, startMaxY)],
|
||||
endSide: [point(endMinX, endMinY), point(endMinX, endMaxY)],
|
||||
startSide: [
|
||||
pointFrom(startMaxX, startMinY),
|
||||
pointFrom(startMaxX, startMaxY),
|
||||
],
|
||||
endSide: [pointFrom(endMinX, endMinY), pointFrom(endMinX, endMaxY)],
|
||||
length: endMinX - startMaxX,
|
||||
overlap: rangeIntersection(
|
||||
rangeInclusive(startMinY, startMaxY),
|
||||
@ -415,8 +418,11 @@ export const getVisibleGaps = (
|
||||
verticalGaps.push({
|
||||
startBounds,
|
||||
endBounds,
|
||||
startSide: [point(startMinX, startMaxY), point(startMaxX, startMaxY)],
|
||||
endSide: [point(endMinX, endMinY), point(endMaxX, endMinY)],
|
||||
startSide: [
|
||||
pointFrom(startMinX, startMaxY),
|
||||
pointFrom(startMaxX, startMaxY),
|
||||
],
|
||||
endSide: [pointFrom(endMinX, endMinY), pointFrom(endMaxX, endMinY)],
|
||||
length: endMinY - startMaxY,
|
||||
overlap: rangeIntersection(
|
||||
rangeInclusive(startMinX, startMaxX),
|
||||
@ -832,7 +838,7 @@ const createPointSnapLines = (
|
||||
}
|
||||
snapsX[key].push(
|
||||
...snap.points.map((p) =>
|
||||
point<GlobalPoint>(round(p[0]), round(p[1])),
|
||||
pointFrom<GlobalPoint>(round(p[0]), round(p[1])),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -849,7 +855,7 @@ const createPointSnapLines = (
|
||||
}
|
||||
snapsY[key].push(
|
||||
...snap.points.map((p) =>
|
||||
point<GlobalPoint>(round(p[0]), round(p[1])),
|
||||
pointFrom<GlobalPoint>(round(p[0]), round(p[1])),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -863,7 +869,7 @@ const createPointSnapLines = (
|
||||
points: dedupePoints(
|
||||
points
|
||||
.map((p) => {
|
||||
return point<GlobalPoint>(Number(key), p[1]);
|
||||
return pointFrom<GlobalPoint>(Number(key), p[1]);
|
||||
})
|
||||
.sort((a, b) => a[1] - b[1]),
|
||||
),
|
||||
@ -876,7 +882,7 @@ const createPointSnapLines = (
|
||||
points: dedupePoints(
|
||||
points
|
||||
.map((p) => {
|
||||
return point<GlobalPoint>(p[0], Number(key));
|
||||
return pointFrom<GlobalPoint>(p[0], Number(key));
|
||||
})
|
||||
.sort((a, b) => a[0] - b[0]),
|
||||
),
|
||||
@ -940,16 +946,16 @@ const createGapSnapLines = (
|
||||
type: "gap",
|
||||
direction: "horizontal",
|
||||
points: [
|
||||
point(gapSnap.gap.startSide[0][0], gapLineY),
|
||||
point(minX, gapLineY),
|
||||
pointFrom(gapSnap.gap.startSide[0][0], gapLineY),
|
||||
pointFrom(minX, gapLineY),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "gap",
|
||||
direction: "horizontal",
|
||||
points: [
|
||||
point(maxX, gapLineY),
|
||||
point(gapSnap.gap.endSide[0][0], gapLineY),
|
||||
pointFrom(maxX, gapLineY),
|
||||
pointFrom(gapSnap.gap.endSide[0][0], gapLineY),
|
||||
],
|
||||
},
|
||||
);
|
||||
@ -966,16 +972,16 @@ const createGapSnapLines = (
|
||||
type: "gap",
|
||||
direction: "vertical",
|
||||
points: [
|
||||
point(gapLineX, gapSnap.gap.startSide[0][1]),
|
||||
point(gapLineX, minY),
|
||||
pointFrom(gapLineX, gapSnap.gap.startSide[0][1]),
|
||||
pointFrom(gapLineX, minY),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "gap",
|
||||
direction: "vertical",
|
||||
points: [
|
||||
point(gapLineX, maxY),
|
||||
point(gapLineX, gapSnap.gap.endSide[0][1]),
|
||||
pointFrom(gapLineX, maxY),
|
||||
pointFrom(gapLineX, gapSnap.gap.endSide[0][1]),
|
||||
],
|
||||
},
|
||||
);
|
||||
@ -991,12 +997,15 @@ const createGapSnapLines = (
|
||||
{
|
||||
type: "gap",
|
||||
direction: "horizontal",
|
||||
points: [point(startMaxX, gapLineY), point(endMinX, gapLineY)],
|
||||
points: [
|
||||
pointFrom(startMaxX, gapLineY),
|
||||
pointFrom(endMinX, gapLineY),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "gap",
|
||||
direction: "horizontal",
|
||||
points: [point(endMaxX, gapLineY), point(minX, gapLineY)],
|
||||
points: [pointFrom(endMaxX, gapLineY), pointFrom(minX, gapLineY)],
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -1011,12 +1020,18 @@ const createGapSnapLines = (
|
||||
{
|
||||
type: "gap",
|
||||
direction: "horizontal",
|
||||
points: [point(maxX, gapLineY), point(startMinX, gapLineY)],
|
||||
points: [
|
||||
pointFrom(maxX, gapLineY),
|
||||
pointFrom(startMinX, gapLineY),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "gap",
|
||||
direction: "horizontal",
|
||||
points: [point(startMaxX, gapLineY), point(endMinX, gapLineY)],
|
||||
points: [
|
||||
pointFrom(startMaxX, gapLineY),
|
||||
pointFrom(endMinX, gapLineY),
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -1031,12 +1046,18 @@ const createGapSnapLines = (
|
||||
{
|
||||
type: "gap",
|
||||
direction: "vertical",
|
||||
points: [point(gapLineX, maxY), point(gapLineX, startMinY)],
|
||||
points: [
|
||||
pointFrom(gapLineX, maxY),
|
||||
pointFrom(gapLineX, startMinY),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "gap",
|
||||
direction: "vertical",
|
||||
points: [point(gapLineX, startMaxY), point(gapLineX, endMinY)],
|
||||
points: [
|
||||
pointFrom(gapLineX, startMaxY),
|
||||
pointFrom(gapLineX, endMinY),
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -1051,12 +1072,15 @@ const createGapSnapLines = (
|
||||
{
|
||||
type: "gap",
|
||||
direction: "vertical",
|
||||
points: [point(gapLineX, startMaxY), point(gapLineX, endMinY)],
|
||||
points: [
|
||||
pointFrom(gapLineX, startMaxY),
|
||||
pointFrom(gapLineX, endMinY),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "gap",
|
||||
direction: "vertical",
|
||||
points: [point(gapLineX, endMaxY), point(gapLineX, minY)],
|
||||
points: [pointFrom(gapLineX, endMaxY), pointFrom(gapLineX, minY)],
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -1070,7 +1094,7 @@ const createGapSnapLines = (
|
||||
return {
|
||||
...gapSnapLine,
|
||||
points: gapSnapLine.points.map((p) =>
|
||||
point(round(p[0]), round(p[1])),
|
||||
pointFrom(round(p[0]), round(p[1])),
|
||||
) as PointPair,
|
||||
};
|
||||
}),
|
||||
@ -1120,35 +1144,35 @@ export const snapResizingElements = (
|
||||
if (transformHandle) {
|
||||
switch (transformHandle) {
|
||||
case "e": {
|
||||
selectionSnapPoints.push(point(maxX, minY), point(maxX, maxY));
|
||||
selectionSnapPoints.push(pointFrom(maxX, minY), pointFrom(maxX, maxY));
|
||||
break;
|
||||
}
|
||||
case "w": {
|
||||
selectionSnapPoints.push(point(minX, minY), point(minX, maxY));
|
||||
selectionSnapPoints.push(pointFrom(minX, minY), pointFrom(minX, maxY));
|
||||
break;
|
||||
}
|
||||
case "n": {
|
||||
selectionSnapPoints.push(point(minX, minY), point(maxX, minY));
|
||||
selectionSnapPoints.push(pointFrom(minX, minY), pointFrom(maxX, minY));
|
||||
break;
|
||||
}
|
||||
case "s": {
|
||||
selectionSnapPoints.push(point(minX, maxY), point(maxX, maxY));
|
||||
selectionSnapPoints.push(pointFrom(minX, maxY), pointFrom(maxX, maxY));
|
||||
break;
|
||||
}
|
||||
case "ne": {
|
||||
selectionSnapPoints.push(point(maxX, minY));
|
||||
selectionSnapPoints.push(pointFrom(maxX, minY));
|
||||
break;
|
||||
}
|
||||
case "nw": {
|
||||
selectionSnapPoints.push(point(minX, minY));
|
||||
selectionSnapPoints.push(pointFrom(minX, minY));
|
||||
break;
|
||||
}
|
||||
case "se": {
|
||||
selectionSnapPoints.push(point(maxX, maxY));
|
||||
selectionSnapPoints.push(pointFrom(maxX, maxY));
|
||||
break;
|
||||
}
|
||||
case "sw": {
|
||||
selectionSnapPoints.push(point(minX, maxY));
|
||||
selectionSnapPoints.push(pointFrom(minX, maxY));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -1191,10 +1215,10 @@ export const snapResizingElements = (
|
||||
);
|
||||
|
||||
const corners: GlobalPoint[] = [
|
||||
point(x1, y1),
|
||||
point(x1, y2),
|
||||
point(x2, y1),
|
||||
point(x2, y2),
|
||||
pointFrom(x1, y1),
|
||||
pointFrom(x1, y2),
|
||||
pointFrom(x2, y1),
|
||||
pointFrom(x2, y2),
|
||||
];
|
||||
|
||||
getPointSnaps(
|
||||
@ -1231,7 +1255,7 @@ export const snapNewElement = (
|
||||
}
|
||||
|
||||
const selectionSnapPoints: GlobalPoint[] = [
|
||||
point(origin.x + dragOffset.x, origin.y + dragOffset.y),
|
||||
pointFrom(origin.x + dragOffset.x, origin.y + dragOffset.y),
|
||||
];
|
||||
|
||||
const snapDistance = getSnapDistance(app.state.zoom.value);
|
||||
@ -1331,7 +1355,7 @@ export const getSnapLinesAtPointer = (
|
||||
|
||||
verticalSnapLines.push({
|
||||
type: "pointer",
|
||||
points: [corner, point(corner[0], pointer.y)],
|
||||
points: [corner, pointFrom(corner[0], pointer.y)],
|
||||
direction: "vertical",
|
||||
});
|
||||
|
||||
@ -1347,7 +1371,7 @@ export const getSnapLinesAtPointer = (
|
||||
|
||||
horizontalSnapLines.push({
|
||||
type: "pointer",
|
||||
points: [corner, point(pointer.x, corner[1])],
|
||||
points: [corner, pointFrom(pointer.x, corner[1])],
|
||||
direction: "horizontal",
|
||||
});
|
||||
|
||||
|
@ -794,6 +794,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"left": 30,
|
||||
"top": 40,
|
||||
},
|
||||
"croppingElement": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
@ -836,6 +837,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
@ -866,6 +868,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id1": true,
|
||||
@ -999,6 +1002,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
},
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElement": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
@ -1041,6 +1045,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
@ -1068,6 +1073,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@ -1214,6 +1220,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
},
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElement": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
@ -1256,6 +1263,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
@ -1283,6 +1291,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@ -1544,6 +1553,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
},
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElement": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
@ -1586,6 +1596,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
@ -1613,6 +1624,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@ -1874,6 +1886,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
},
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElement": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
@ -1916,6 +1929,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
@ -1943,6 +1957,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@ -2089,6 +2104,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
},
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElement": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
@ -2131,6 +2147,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
@ -2158,6 +2175,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@ -2328,6 +2346,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
},
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElement": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
@ -2370,6 +2389,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
@ -2397,6 +2417,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0_copy": true,
|
||||
},
|
||||
@ -2628,6 +2649,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
},
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElement": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
@ -2670,6 +2692,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
@ -2699,6 +2722,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id1": true,
|
||||
@ -2996,6 +3020,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
},
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElement": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
@ -3038,6 +3063,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
@ -3065,6 +3091,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@ -3470,6 +3497,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
},
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElement": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
@ -3512,6 +3540,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
@ -3539,6 +3568,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id1": true,
|
||||
},
|
||||
@ -3792,6 +3822,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
},
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElement": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
@ -3834,6 +3865,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
@ -3861,6 +3893,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id1": true,
|
||||
},
|
||||
@ -4114,6 +4147,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
},
|
||||
"collaborators": Map {},
|
||||
"contextMenu": null,
|
||||
"croppingElement": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
@ -4156,6 +4190,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
@ -4185,6 +4220,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id1": true,
|
||||
@ -5299,6 +5335,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"left": -17,
|
||||
"top": -7,
|
||||
},
|
||||
"croppingElement": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
@ -5341,6 +5378,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
@ -5370,6 +5408,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id1": true,
|
||||
@ -6425,6 +6464,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"left": -17,
|
||||
"top": -7,
|
||||
},
|
||||
"croppingElement": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
@ -6467,6 +6507,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
@ -6496,6 +6537,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
"id1": true,
|
||||
@ -7359,6 +7401,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
"left": -19,
|
||||
"top": -9,
|
||||
},
|
||||
"croppingElement": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
@ -7401,6 +7444,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
@ -7431,6 +7475,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
@ -8270,6 +8315,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"left": -17,
|
||||
"top": -7,
|
||||
},
|
||||
"croppingElement": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
@ -8312,6 +8358,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
@ -8339,6 +8386,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
@ -9163,6 +9211,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"left": 80,
|
||||
"top": 90,
|
||||
},
|
||||
"croppingElement": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
@ -9205,6 +9254,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"gridStep": 5,
|
||||
"height": 100,
|
||||
"isBindingEnabled": true,
|
||||
"isCropping": false,
|
||||
"isLoading": false,
|
||||
"isResizing": false,
|
||||
"isRotating": false,
|
||||
@ -9235,6 +9285,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"scrollX": 0,
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"searchMatches": [],
|
||||
"selectedElementIds": {
|
||||
"id1": true,
|
||||
},
|
||||
|
@ -239,6 +239,55 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
Ctrl+Shift+E
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Find on canvas"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="search-menu-button"
|
||||
title="Find on canvas"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<path
|
||||
d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"
|
||||
/>
|
||||
<path
|
||||
d="M21 21l-6 -6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Find on canvas
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Ctrl+F
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Help"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,7 @@ import { API } from "./helpers/api";
|
||||
import { KEYS } from "../keys";
|
||||
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { point } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
@ -32,7 +32,12 @@ describe("element binding", () => {
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 1,
|
||||
points: [point(0, 0), point(0, 0), point(100, 0), point(100, 0)],
|
||||
points: [
|
||||
pointFrom(0, 0),
|
||||
pointFrom(0, 0),
|
||||
pointFrom(100, 0),
|
||||
pointFrom(100, 0),
|
||||
],
|
||||
});
|
||||
API.setElements([rect, arrow]);
|
||||
expect(arrow.startBinding).toBe(null);
|
||||
@ -310,7 +315,7 @@ describe("element binding", () => {
|
||||
const arrow1 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
points: [point(0, 0), point(0, -87.45777932247563)],
|
||||
points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)],
|
||||
startBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
@ -328,7 +333,7 @@ describe("element binding", () => {
|
||||
const arrow2 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow2",
|
||||
points: [point(0, 0), point(0, -87.45777932247563)],
|
||||
points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)],
|
||||
startBinding: {
|
||||
elementId: "text1",
|
||||
focus: 0.2,
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user