mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-04-14 16:40:58 -04:00
Merge remote-tracking branch 'origin/master' into aakansha-create-text-containers-programmatically
This commit is contained in:
commit
2ff0528a4f
186 changed files with 8275 additions and 1805 deletions
|
@ -20,14 +20,10 @@ REACT_APP_DEV_ENABLE_SW=
|
|||
# whether to disable live reload / HMR. Usuaully what you want to do when
|
||||
# debugging Service Workers.
|
||||
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
|
||||
REACT_APP_DISABLE_TRACKING=true
|
||||
|
||||
FAST_REFRESH=false
|
||||
|
||||
# MATOMO
|
||||
REACT_APP_MATOMO_URL=
|
||||
REACT_APP_CDN_MATOMO_TRACKER_URL=
|
||||
REACT_APP_MATOMO_SITE_ID=
|
||||
|
||||
#Debug flags
|
||||
|
||||
# To enable bounding box for text containers
|
||||
|
|
|
@ -11,14 +11,5 @@ REACT_APP_WS_SERVER_URL=
|
|||
|
||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||
|
||||
# production-only vars
|
||||
# GOOGLE ANALYTICS
|
||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
||||
# MATOMO
|
||||
REACT_APP_MATOMO_URL=https://excalidraw.matomo.cloud/
|
||||
REACT_APP_CDN_MATOMO_TRACKER_URL=//cdn.matomo.cloud/excalidraw.matomo.cloud/matomo.js
|
||||
REACT_APP_MATOMO_SITE_ID=1
|
||||
|
||||
|
||||
|
||||
REACT_APP_PLUS_APP=https://app.excalidraw.com
|
||||
REACT_APP_DISABLE_TRACKING=
|
||||
|
|
|
@ -306,30 +306,32 @@ This is the history API. history.clear() will clear the history.
|
|||
|
||||
## scrollToContent
|
||||
|
||||
<pre>
|
||||
(<br />
|
||||
{" "}
|
||||
target?:{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
|
||||
ExcalidrawElement
|
||||
</a>{" "}
|
||||
|{" "}
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
|
||||
ExcalidrawElement
|
||||
</a>
|
||||
[],
|
||||
<br />
|
||||
{" "}opts?: { fitToContent?: boolean; animate?: boolean; duration?: number
|
||||
}
|
||||
<br />) => void
|
||||
</pre>
|
||||
```tsx
|
||||
(
|
||||
target?: ExcalidrawElement | ExcalidrawElement[],
|
||||
opts?:
|
||||
| {
|
||||
fitToContent?: boolean;
|
||||
animate?: boolean;
|
||||
duration?: number;
|
||||
}
|
||||
| {
|
||||
fitToViewport?: boolean;
|
||||
viewportZoomFactor?: number;
|
||||
animate?: boolean;
|
||||
duration?: number;
|
||||
}
|
||||
) => void
|
||||
```
|
||||
|
||||
Scroll the nearest element out of the elements supplied to the center of the viewport. Defaults to the elements on the scene.
|
||||
|
||||
| Attribute | type | default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| target | <code>ExcalidrawElement | ExcalidrawElement[]</code> | All scene elements | The element(s) to scroll to. |
|
||||
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. |
|
||||
| target | [ExcalidrawElement](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) | [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) | All scene elements | The element(s) to scroll to. |
|
||||
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. Note that the zoom range is between 10%-100%. |
|
||||
| opts.fitToViewport | boolean | false | Similar to fitToContent but the zoom range is not limited. If elements are smaller than the viewport, zoom will go above 100%. |
|
||||
| opts.viewportZoomFactor | number | 0.7 | when fitToViewport=true, how much screen should the content cover, between 0.1 (10%) and 1 (100%) |
|
||||
| opts.animate | boolean | false | Whether to animate between starting and ending position. Note that for larger scenes the animation may not be smooth due to performance issues. |
|
||||
| opts.duration | number | 500 | Duration of the animation if `opts.animate` is `true`. |
|
||||
|
||||
|
|
|
@ -2,6 +2,11 @@
|
|||
|
||||
Pull requests are welcome. For major changes, please [open an issue](https://github.com/excalidraw/excalidraw/issues/new) first to discuss what you would like to change.
|
||||
|
||||
We have a [roadmap](https://github.com/orgs/excalidraw/projects/3) which we strongly recommend to go through and check if something interests you.
|
||||
For new contributors we would recommend to start with *Easy* tasks.
|
||||
|
||||
In case you want to pick up something from the roadmap, comment on that issue and one of the project maintainers will assign it to you, post which you can discuss in the issue and start working on it.
|
||||
|
||||
## Setup
|
||||
|
||||
### Option 1 - Manual
|
||||
|
|
|
@ -19,6 +19,8 @@
|
|||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "6.0.2",
|
||||
"@excalidraw/random-username": "1.0.0",
|
||||
"@radix-ui/react-popover": "1.0.3",
|
||||
"@radix-ui/react-tabs": "1.0.2",
|
||||
"@sentry/browser": "6.2.5",
|
||||
|
@ -27,6 +29,7 @@
|
|||
"@testing-library/react": "12.1.5",
|
||||
"@tldraw/vec": "1.7.1",
|
||||
"browser-fs-access": "0.29.1",
|
||||
"canvas-roundrect-polyfill": "0.0.1",
|
||||
"clsx": "1.1.1",
|
||||
"cross-env": "7.0.3",
|
||||
"fake-indexeddb": "3.1.7",
|
||||
|
@ -106,7 +109,7 @@
|
|||
"<rootDir>/src/packages/excalidraw/example"
|
||||
],
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
|
||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access|canvas-roundrect-polyfill)/)"
|
||||
],
|
||||
"resetMocks": false
|
||||
},
|
||||
|
|
|
@ -148,33 +148,6 @@
|
|||
// setting this so that libraries installation reuses this window tab.
|
||||
window.name = "_excalidraw";
|
||||
</script>
|
||||
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
|
||||
|
||||
<!-- Fathom - privacy-friendly analytics -->
|
||||
<script
|
||||
src="https://cdn.usefathom.com/script.js"
|
||||
data-site="VMSBUEYA"
|
||||
defer
|
||||
></script>
|
||||
<!-- / Fathom -->
|
||||
|
||||
<!-- LEGACY GOOGLE ANALYTICS -->
|
||||
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
|
||||
></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
|
||||
</script>
|
||||
<% } %>
|
||||
<!-- end LEGACY GOOGLE ANALYTICS -->
|
||||
<% } %>
|
||||
|
||||
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
|
||||
<style>
|
||||
|
@ -227,17 +200,39 @@
|
|||
<h1 class="visually-hidden">Excalidraw</h1>
|
||||
</header>
|
||||
<div id="root"></div>
|
||||
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
|
||||
<!-- 100% privacy friendly analytics -->
|
||||
<script
|
||||
async
|
||||
defer
|
||||
src="https://scripts.simpleanalyticscdn.com/latest.js"
|
||||
></script>
|
||||
<noscript
|
||||
><img
|
||||
src="https://queue.simpleanalyticscdn.com/noscript.gif"
|
||||
alt=""
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
/></noscript>
|
||||
<script>
|
||||
// need to load this script dynamically bcs. of iframe embed tracking
|
||||
var scriptEle = document.createElement("script");
|
||||
scriptEle.setAttribute(
|
||||
"src",
|
||||
"https://scripts.simpleanalyticscdn.com/latest.js",
|
||||
);
|
||||
scriptEle.setAttribute("type", "text/javascript");
|
||||
scriptEle.setAttribute("defer", true);
|
||||
scriptEle.setAttribute("async", true);
|
||||
// if iframe
|
||||
if (window.self !== window.top) {
|
||||
scriptEle.setAttribute("data-auto-collect", true);
|
||||
}
|
||||
|
||||
document.body.appendChild(scriptEle);
|
||||
|
||||
// if iframe
|
||||
if (window.self !== window.top) {
|
||||
scriptEle.addEventListener("load", () => {
|
||||
if (window.sa_pageview) {
|
||||
window.window.sa_event(action, {
|
||||
category: "iframe",
|
||||
label: "embed",
|
||||
value: window.location.pathname,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<!-- end LEGACY GOOGLE ANALYTICS -->
|
||||
<% } %>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -12,7 +12,10 @@ export const actionAddToLibrary = register({
|
|||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
},
|
||||
);
|
||||
if (selectedElements.some((element) => element.type === "image")) {
|
||||
return {
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { ToolButton } from "../components/ToolButton";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
|
@ -17,10 +18,20 @@ import { AppState } from "../types";
|
|||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const enableActionGroup = (
|
||||
const alignActionsPredicate = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
return (
|
||||
selectedElements.length > 1 &&
|
||||
// TODO enable aligning frames when implemented properly
|
||||
!selectedElements.some((el) => el.type === "frame")
|
||||
);
|
||||
};
|
||||
|
||||
const alignSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
|
@ -36,14 +47,16 @@ const alignSelectedElements = (
|
|||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
return elements.map(
|
||||
(element) => updatedElementsMap.get(element.id) || element,
|
||||
return updateFrameMembershipOfSelectedElements(
|
||||
elements.map((element) => updatedElementsMap.get(element.id) || element),
|
||||
appState,
|
||||
);
|
||||
};
|
||||
|
||||
export const actionAlignTop = register({
|
||||
name: "alignTop",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
|
@ -58,7 +71,7 @@ export const actionAlignTop = register({
|
|||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
type="button"
|
||||
icon={AlignTopIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
@ -74,6 +87,7 @@ export const actionAlignTop = register({
|
|||
export const actionAlignBottom = register({
|
||||
name: "alignBottom",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
|
@ -88,7 +102,7 @@ export const actionAlignBottom = register({
|
|||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
type="button"
|
||||
icon={AlignBottomIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
@ -104,6 +118,7 @@ export const actionAlignBottom = register({
|
|||
export const actionAlignLeft = register({
|
||||
name: "alignLeft",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
|
@ -118,7 +133,7 @@ export const actionAlignLeft = register({
|
|||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
type="button"
|
||||
icon={AlignLeftIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
@ -134,7 +149,7 @@ export const actionAlignLeft = register({
|
|||
export const actionAlignRight = register({
|
||||
name: "alignRight",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
|
@ -149,7 +164,7 @@ export const actionAlignRight = register({
|
|||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
type="button"
|
||||
icon={AlignRightIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
@ -165,7 +180,7 @@ export const actionAlignRight = register({
|
|||
export const actionAlignVerticallyCentered = register({
|
||||
name: "alignVerticallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
|
@ -178,7 +193,7 @@ export const actionAlignVerticallyCentered = register({
|
|||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
type="button"
|
||||
icon={CenterVerticallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
@ -192,6 +207,7 @@ export const actionAlignVerticallyCentered = register({
|
|||
export const actionAlignHorizontallyCentered = register({
|
||||
name: "alignHorizontallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
|
@ -204,7 +220,7 @@ export const actionAlignHorizontallyCentered = register({
|
|||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
type="button"
|
||||
icon={CenterHorizontallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
|
|
@ -31,6 +31,7 @@ import {
|
|||
} from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -211,7 +212,7 @@ export const actionWrapTextInContainer = register({
|
|||
appState,
|
||||
);
|
||||
let updatedElements: readonly ExcalidrawElement[] = elements.slice();
|
||||
const containerIds: AppState["selectedElementIds"] = {};
|
||||
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
|
||||
|
||||
for (const textElement of selectedElements) {
|
||||
if (isTextElement(textElement)) {
|
||||
|
@ -249,6 +250,7 @@ export const actionWrapTextInContainer = register({
|
|||
"rectangle",
|
||||
),
|
||||
groupIds: textElement.groupIds,
|
||||
frameId: textElement.frameId,
|
||||
});
|
||||
|
||||
// update bindings
|
||||
|
|
|
@ -20,6 +20,7 @@ import {
|
|||
isHandToolActive,
|
||||
} from "../appState";
|
||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||
import { Bounds } from "../element/bounds";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
|
@ -206,7 +207,7 @@ export const actionResetZoom = register({
|
|||
});
|
||||
|
||||
const zoomValueToFitBoundsOnViewport = (
|
||||
bounds: [number, number, number, number],
|
||||
bounds: Bounds,
|
||||
viewportDimensions: { width: number; height: number },
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = bounds;
|
||||
|
@ -224,50 +225,96 @@ const zoomValueToFitBoundsOnViewport = (
|
|||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||
};
|
||||
|
||||
export const zoomToFitElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
zoomToSelection: boolean,
|
||||
) => {
|
||||
const nonDeletedElements = getNonDeletedElements(elements);
|
||||
const selectedElements = getSelectedElements(nonDeletedElements, appState);
|
||||
|
||||
const commonBounds =
|
||||
zoomToSelection && selectedElements.length > 0
|
||||
? getCommonBounds(selectedElements)
|
||||
: getCommonBounds(nonDeletedElements);
|
||||
|
||||
const newZoom = {
|
||||
value: zoomValueToFitBoundsOnViewport(commonBounds, {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
}),
|
||||
};
|
||||
export const zoomToFit = ({
|
||||
targetElements,
|
||||
appState,
|
||||
fitToViewport = false,
|
||||
viewportZoomFactor = 0.7,
|
||||
}: {
|
||||
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;
|
||||
}) => {
|
||||
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
|
||||
|
||||
const [x1, y1, x2, y2] = commonBounds;
|
||||
const centerX = (x1 + x2) / 2;
|
||||
const centerY = (y1 + y2) / 2;
|
||||
|
||||
let newZoomValue;
|
||||
let scrollX;
|
||||
let scrollY;
|
||||
|
||||
if (fitToViewport) {
|
||||
const commonBoundsWidth = x2 - x1;
|
||||
const commonBoundsHeight = y2 - y1;
|
||||
|
||||
newZoomValue =
|
||||
Math.min(
|
||||
appState.width / commonBoundsWidth,
|
||||
appState.height / commonBoundsHeight,
|
||||
) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
|
||||
|
||||
// Apply clamping to newZoomValue to be between 10% and 3000%
|
||||
newZoomValue = Math.min(
|
||||
Math.max(newZoomValue, 0.1),
|
||||
30.0,
|
||||
) as NormalizedZoomValue;
|
||||
|
||||
scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
|
||||
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
|
||||
} else {
|
||||
newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
});
|
||||
|
||||
const centerScroll = centerScrollOn({
|
||||
scenePoint: { x: centerX, y: centerY },
|
||||
viewportDimensions: {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
},
|
||||
zoom: { value: newZoomValue },
|
||||
});
|
||||
|
||||
scrollX = centerScroll.scrollX;
|
||||
scrollY = centerScroll.scrollY;
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...centerScrollOn({
|
||||
scenePoint: { x: centerX, y: centerY },
|
||||
viewportDimensions: {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
},
|
||||
zoom: newZoom,
|
||||
}),
|
||||
zoom: newZoom,
|
||||
scrollX,
|
||||
scrollY,
|
||||
zoom: { value: newZoomValue },
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const actionZoomToSelected = register({
|
||||
name: "zoomToSelection",
|
||||
// Note, this action differs from actionZoomToFitSelection in that it doesn't
|
||||
// zoom beyond 100%. In other words, if the content is smaller than viewport
|
||||
// size, it won't be zoomed in.
|
||||
export const actionZoomToFitSelectionInViewport = register({
|
||||
name: "zoomToFitSelectionInViewport",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
return zoomToFit({
|
||||
targetElements: selectedElements.length ? selectedElements : elements,
|
||||
appState,
|
||||
fitToViewport: false,
|
||||
});
|
||||
},
|
||||
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
|
||||
// TBD on how proceed
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.TWO &&
|
||||
event.shiftKey &&
|
||||
|
@ -275,11 +322,34 @@ export const actionZoomToSelected = register({
|
|||
!event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionZoomToFitSelection = register({
|
||||
name: "zoomToFitSelection",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
return zoomToFit({
|
||||
targetElements: selectedElements.length ? selectedElements : elements,
|
||||
appState,
|
||||
fitToViewport: true,
|
||||
});
|
||||
},
|
||||
// NOTE this action should use shift-2 per figma, alas
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.THREE &&
|
||||
event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionZoomToFit = register({
|
||||
name: "zoomToFit",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
|
||||
perform: (elements, appState) =>
|
||||
zoomToFit({ targetElements: elements, appState, fitToViewport: false }),
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.ONE &&
|
||||
event.shiftKey &&
|
||||
|
|
|
@ -16,9 +16,12 @@ export const actionCopy = register({
|
|||
name: "copy",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
const elementsToCopy = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
||||
copyToClipboard(selectedElements, app.files);
|
||||
copyToClipboard(elementsToCopy, app.files);
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
|
@ -75,7 +78,10 @@ export const actionCopyAsSvg = register({
|
|||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
},
|
||||
);
|
||||
try {
|
||||
await exportCanvas(
|
||||
|
@ -119,7 +125,10 @@ export const actionCopyAsPng = register({
|
|||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
},
|
||||
);
|
||||
try {
|
||||
await exportCanvas(
|
||||
|
@ -172,7 +181,9 @@ export const copyText = register({
|
|||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
);
|
||||
|
||||
const text = selectedElements
|
||||
|
@ -191,7 +202,9 @@ export const copyText = register({
|
|||
predicate: (elements, appState) => {
|
||||
return (
|
||||
probablySupportsClipboardWriteText &&
|
||||
getSelectedElements(elements, appState, true).some(isTextElement)
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
}).some(isTextElement)
|
||||
);
|
||||
},
|
||||
contextItemLabel: "labels.copyText",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { isSomeElementSelected } from "../scene";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { KEYS } from "../keys";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
|
@ -18,11 +18,23 @@ const deleteSelectedElements = (
|
|||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const framesToBeDeleted = new Set(
|
||||
getSelectedElements(
|
||||
elements.filter((el) => el.type === "frame"),
|
||||
appState,
|
||||
).map((el) => el.id),
|
||||
);
|
||||
|
||||
return {
|
||||
elements: elements.map((el) => {
|
||||
if (appState.selectedElementIds[el.id]) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
|
||||
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
|
||||
if (
|
||||
isBoundToContainer(el) &&
|
||||
appState.selectedElementIds[el.containerId]
|
||||
|
|
|
@ -6,6 +6,7 @@ import { ToolButton } from "../components/ToolButton";
|
|||
import { distributeElements, Distribution } from "../distribute";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { t } from "../i18n";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
|
@ -16,7 +17,17 @@ import { register } from "./register";
|
|||
const enableActionGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
return (
|
||||
selectedElements.length > 1 &&
|
||||
// TODO enable distributing frames when implemented properly
|
||||
!selectedElements.some((el) => el.type === "frame")
|
||||
);
|
||||
};
|
||||
|
||||
const distributeSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
|
@ -32,8 +43,9 @@ const distributeSelectedElements = (
|
|||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
return elements.map(
|
||||
(element) => updatedElementsMap.get(element.id) || element,
|
||||
return updateFrameMembershipOfSelectedElements(
|
||||
elements.map((element) => updatedElementsMap.get(element.id) || element),
|
||||
appState,
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import { KEYS } from "../keys";
|
|||
import { register } from "./register";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { duplicateElement, getNonDeletedElements } from "../element";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
|
@ -20,9 +20,17 @@ import {
|
|||
bindTextToShapeAfterDuplication,
|
||||
getBoundTextElement,
|
||||
} from "../element/textElement";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import { isBoundToContainer, isFrameElement } from "../element/typeChecks";
|
||||
import { normalizeElementOrder } from "../element/sortElements";
|
||||
import { DuplicateIcon } from "../components/icons";
|
||||
import {
|
||||
bindElementsToFramesAfterDuplication,
|
||||
getFrameElements,
|
||||
} from "../frame";
|
||||
import {
|
||||
excludeElementsInFramesFromSelection,
|
||||
getSelectedElements,
|
||||
} from "../scene/selection";
|
||||
|
||||
export const actionDuplicateSelection = register({
|
||||
name: "duplicateSelection",
|
||||
|
@ -94,8 +102,11 @@ const duplicateElements = (
|
|||
return newElement;
|
||||
};
|
||||
|
||||
const selectedElementIds = arrayToMap(
|
||||
getSelectedElements(sortedElements, appState, true),
|
||||
const idsOfElementsToDuplicate = arrayToMap(
|
||||
getSelectedElements(sortedElements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Ids of elements that have already been processed so we don't push them
|
||||
|
@ -129,12 +140,25 @@ const duplicateElements = (
|
|||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (selectedElementIds.get(element.id)) {
|
||||
// if a group or a container/bound-text, duplicate atomically
|
||||
if (element.groupIds.length || boundTextElement) {
|
||||
const isElementAFrame = isFrameElement(element);
|
||||
|
||||
if (idsOfElementsToDuplicate.get(element.id)) {
|
||||
// if a group or a container/bound-text or frame, duplicate atomically
|
||||
if (element.groupIds.length || boundTextElement || isElementAFrame) {
|
||||
const groupId = getSelectedGroupForElement(appState, element);
|
||||
if (groupId) {
|
||||
const groupElements = getElementsInGroup(sortedElements, groupId);
|
||||
// TODO:
|
||||
// remove `.flatMap...`
|
||||
// if the elements in a frame are grouped when the frame is grouped
|
||||
const groupElements = getElementsInGroup(
|
||||
sortedElements,
|
||||
groupId,
|
||||
).flatMap((element) =>
|
||||
isFrameElement(element)
|
||||
? [...getFrameElements(elements, element.id), element]
|
||||
: [element],
|
||||
);
|
||||
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([
|
||||
...groupElements,
|
||||
|
@ -156,10 +180,34 @@ const duplicateElements = (
|
|||
);
|
||||
continue;
|
||||
}
|
||||
if (isElementAFrame) {
|
||||
const elementsInFrame = getFrameElements(sortedElements, element.id);
|
||||
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([
|
||||
...elementsInFrame,
|
||||
element,
|
||||
...elementsInFrame.map((e) => duplicateAndOffsetElement(e)),
|
||||
duplicateAndOffsetElement(element),
|
||||
]),
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// since elements in frames have a lower z-index than the frame itself,
|
||||
// they will be looped first and if their frames are selected as well,
|
||||
// they will have been copied along with the frame atomically in the
|
||||
// above branch, so we must skip those elements here
|
||||
//
|
||||
// now, for elements do not belong any frames or elements whose frames
|
||||
// are selected (or elements that are left out from the above
|
||||
// steps for whatever reason) we (should at least) duplicate them here
|
||||
if (!element.frameId || !idsOfElementsToDuplicate.has(element.frameId)) {
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
|
||||
);
|
||||
}
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
|
||||
);
|
||||
} else {
|
||||
elementsWithClones.push(...markAsProcessed([element]));
|
||||
}
|
||||
|
@ -200,6 +248,14 @@ const duplicateElements = (
|
|||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
bindElementsToFramesAfterDuplication(
|
||||
finalElements,
|
||||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
|
||||
const nextElementsToSelect =
|
||||
excludeElementsInFramesFromSelection(newElements);
|
||||
|
||||
return {
|
||||
elements: finalElements,
|
||||
|
@ -207,7 +263,7 @@ const duplicateElements = (
|
|||
{
|
||||
...appState,
|
||||
selectedGroupIds: {},
|
||||
selectedElementIds: newElements.reduce(
|
||||
selectedElementIds: nextElementsToSelect.reduce(
|
||||
(acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (!isBoundToContainer(element)) {
|
||||
acc[element.id] = true;
|
||||
|
@ -218,6 +274,7 @@ const duplicateElements = (
|
|||
),
|
||||
},
|
||||
getNonDeletedElements(finalElements),
|
||||
appState,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -11,8 +11,17 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
|||
export const actionToggleElementLock = register({
|
||||
name: "toggleElementLock",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return !selectedElements.some(
|
||||
(element) => element.locked && element.frameId,
|
||||
);
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
const selectedElements = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
||||
if (!selectedElements.length) {
|
||||
return false;
|
||||
|
@ -38,8 +47,10 @@ export const actionToggleElementLock = register({
|
|||
};
|
||||
},
|
||||
contextItemLabel: (elements, appState) => {
|
||||
const selected = getSelectedElements(elements, appState, false);
|
||||
if (selected.length === 1) {
|
||||
const selected = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: false,
|
||||
});
|
||||
if (selected.length === 1 && selected[0].type !== "frame") {
|
||||
return selected[0].locked
|
||||
? "labels.elementLock.unlock"
|
||||
: "labels.elementLock.lock";
|
||||
|
@ -54,7 +65,9 @@ export const actionToggleElementLock = register({
|
|||
event.key.toLocaleLowerCase() === KEYS.L &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
getSelectedElements(elements, appState, false).length > 0
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: false,
|
||||
}).length > 0
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -125,13 +125,6 @@ export const actionFinalize = register({
|
|||
{ x, y },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw"
|
||||
) {
|
||||
appState.selectedElementIds[multiPointElement.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
|
|
@ -12,13 +12,17 @@ import {
|
|||
isBindingEnabled,
|
||||
unbindLinearElements,
|
||||
} from "../element/binding";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
|
||||
export const actionFlipHorizontal = register({
|
||||
name: "flipHorizontal",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: flipSelectedElements(elements, appState, "horizontal"),
|
||||
elements: updateFrameMembershipOfSelectedElements(
|
||||
flipSelectedElements(elements, appState, "horizontal"),
|
||||
appState,
|
||||
),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
|
@ -32,7 +36,10 @@ export const actionFlipVertical = register({
|
|||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: flipSelectedElements(elements, appState, "vertical"),
|
||||
elements: updateFrameMembershipOfSelectedElements(
|
||||
flipSelectedElements(elements, appState, "vertical"),
|
||||
appState,
|
||||
),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
|
@ -50,6 +57,9 @@ const flipSelectedElements = (
|
|||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
{
|
||||
includeElementsInFrames: true,
|
||||
},
|
||||
);
|
||||
|
||||
const updatedElements = flipElements(
|
||||
|
|
143
src/actions/actionFrame.ts
Normal file
143
src/actions/actionFrame.ts
Normal file
|
@ -0,0 +1,143 @@
|
|||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { removeAllElementsFromFrame } from "../frame";
|
||||
import { getFrameElements } from "../frame";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { setCursorForShape, updateActiveTool } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const isSingleFrameSelected = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
|
||||
return selectedElements.length === 1 && selectedElements[0].type === "frame";
|
||||
};
|
||||
|
||||
export const actionSelectAllElementsInFrame = register({
|
||||
name: "selectAllElementsInFrame",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedFrame = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
)[0];
|
||||
|
||||
if (selectedFrame && selectedFrame.type === "frame") {
|
||||
const elementsInFrame = getFrameElements(
|
||||
getNonDeletedElements(elements),
|
||||
selectedFrame.id,
|
||||
).filter((element) => !(element.type === "text" && element.containerId));
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: elementsInFrame.reduce((acc, element) => {
|
||||
acc[element.id] = true;
|
||||
return acc;
|
||||
}, {} as Record<ExcalidrawElement["id"], true>),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.selectAllElementsInFrame",
|
||||
predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
|
||||
});
|
||||
|
||||
export const actionRemoveAllElementsFromFrame = register({
|
||||
name: "removeAllElementsFromFrame",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedFrame = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
)[0];
|
||||
|
||||
if (selectedFrame && selectedFrame.type === "frame") {
|
||||
return {
|
||||
elements: removeAllElementsFromFrame(elements, selectedFrame, appState),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {
|
||||
[selectedFrame.id]: true,
|
||||
},
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.removeAllElementsFromFrame",
|
||||
predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
|
||||
});
|
||||
|
||||
export const actionupdateFrameRendering = register({
|
||||
name: "updateFrameRendering",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
frameRendering: {
|
||||
...appState.frameRendering,
|
||||
enabled: !appState.frameRendering.enabled,
|
||||
},
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.updateFrameRendering",
|
||||
checked: (appState: AppState) => appState.frameRendering.enabled,
|
||||
});
|
||||
|
||||
export const actionSetFrameAsActiveTool = register({
|
||||
name: "setFrameAsActiveTool",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
setCursorForShape(app.canvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
activeTool: updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
}),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey &&
|
||||
event.key.toLocaleLowerCase() === KEYS.F,
|
||||
});
|
|
@ -17,9 +17,19 @@ import {
|
|||
import { getNonDeletedElements } from "../element";
|
||||
import { randomId } from "../random";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import {
|
||||
getElementsInResizingFrame,
|
||||
groupByFrames,
|
||||
removeElementsFromFrame,
|
||||
replaceAllElementsInFrame,
|
||||
} from "../frame";
|
||||
|
||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||
if (elements.length >= 2) {
|
||||
|
@ -45,7 +55,9 @@ const enableActionGroup = (
|
|||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
);
|
||||
return (
|
||||
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
||||
|
@ -55,11 +67,13 @@ const enableActionGroup = (
|
|||
export const actionGroup = register({
|
||||
name: "group",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
);
|
||||
if (selectedElements.length < 2) {
|
||||
// nothing to group
|
||||
|
@ -86,9 +100,31 @@ export const actionGroup = register({
|
|||
return { appState, elements, commitToHistory: false };
|
||||
}
|
||||
}
|
||||
|
||||
let nextElements = [...elements];
|
||||
|
||||
// this includes the case where we are grouping elements inside a frame
|
||||
// and elements outside that frame
|
||||
const groupingElementsFromDifferentFrames =
|
||||
new Set(selectedElements.map((element) => element.frameId)).size > 1;
|
||||
// when it happens, we want to remove elements that are in the frame
|
||||
// and are going to be grouped from the frame (mouthful, I know)
|
||||
if (groupingElementsFromDifferentFrames) {
|
||||
const frameElementsMap = groupByFrames(selectedElements);
|
||||
|
||||
frameElementsMap.forEach((elementsInFrame, frameId) => {
|
||||
nextElements = removeElementsFromFrame(
|
||||
nextElements,
|
||||
elementsInFrame,
|
||||
appState,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const newGroupId = randomId();
|
||||
const selectElementIds = arrayToMap(selectedElements);
|
||||
const updatedElements = elements.map((element) => {
|
||||
|
||||
nextElements = nextElements.map((element) => {
|
||||
if (!selectElementIds.get(element.id)) {
|
||||
return element;
|
||||
}
|
||||
|
@ -102,17 +138,16 @@ export const actionGroup = register({
|
|||
});
|
||||
// keep the z order within the group the same, but move them
|
||||
// to the z order of the highest element in the layer stack
|
||||
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
|
||||
const elementsInGroup = getElementsInGroup(nextElements, newGroupId);
|
||||
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
||||
const lastGroupElementIndex =
|
||||
updatedElements.lastIndexOf(lastElementInGroup);
|
||||
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
||||
const elementsBeforeGroup = updatedElements
|
||||
const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup);
|
||||
const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
|
||||
const elementsBeforeGroup = nextElements
|
||||
.slice(0, lastGroupElementIndex)
|
||||
.filter(
|
||||
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
|
||||
);
|
||||
const updatedElementsInOrder = [
|
||||
nextElements = [
|
||||
...elementsBeforeGroup,
|
||||
...elementsInGroup,
|
||||
...elementsAfterGroup,
|
||||
|
@ -122,9 +157,9 @@ export const actionGroup = register({
|
|||
appState: selectGroup(
|
||||
newGroupId,
|
||||
{ ...appState, selectedGroupIds: {} },
|
||||
getNonDeletedElements(updatedElementsInOrder),
|
||||
getNonDeletedElements(nextElements),
|
||||
),
|
||||
elements: updatedElementsInOrder,
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
|
@ -148,14 +183,23 @@ export const actionGroup = register({
|
|||
export const actionUngroup = register({
|
||||
name: "ungroup",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
const groupIds = getSelectedGroupIds(appState);
|
||||
if (groupIds.length === 0) {
|
||||
return { appState, elements, commitToHistory: false };
|
||||
}
|
||||
|
||||
let nextElements = [...elements];
|
||||
|
||||
const selectedElements = getSelectedElements(nextElements, appState);
|
||||
const frames = selectedElements
|
||||
.filter((element) => element.frameId)
|
||||
.map((element) =>
|
||||
app.scene.getElement(element.frameId!),
|
||||
) as ExcalidrawFrameElement[];
|
||||
|
||||
const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
|
||||
const nextElements = elements.map((element) => {
|
||||
nextElements = nextElements.map((element) => {
|
||||
if (isBoundToContainer(element)) {
|
||||
boundTextElementIds.push(element.id);
|
||||
}
|
||||
|
@ -174,15 +218,35 @@ export const actionUngroup = register({
|
|||
const updateAppState = selectGroupsForSelectedElements(
|
||||
{ ...appState, selectedGroupIds: {} },
|
||||
getNonDeletedElements(nextElements),
|
||||
appState,
|
||||
);
|
||||
|
||||
frames.forEach((frame) => {
|
||||
if (frame) {
|
||||
nextElements = replaceAllElementsInFrame(
|
||||
nextElements,
|
||||
getElementsInResizingFrame(nextElements, frame, appState),
|
||||
frame,
|
||||
appState,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// remove binded text elements from selection
|
||||
boundTextElementIds.forEach(
|
||||
(id) => (updateAppState.selectedElementIds[id] = false),
|
||||
updateAppState.selectedElementIds = Object.entries(
|
||||
updateAppState.selectedElementIds,
|
||||
).reduce(
|
||||
(acc: { [key: ExcalidrawElement["id"]]: true }, [id, selected]) => {
|
||||
if (selected && !boundTextElementIds.includes(id)) {
|
||||
acc[id] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return {
|
||||
appState: updateAppState,
|
||||
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
};
|
||||
|
|
|
@ -21,7 +21,9 @@ export const actionToggleLinearEditor = register({
|
|||
const selectedElement = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
)[0] as ExcalidrawLinearElement;
|
||||
|
||||
const editingLinearElement =
|
||||
|
@ -40,7 +42,9 @@ export const actionToggleLinearEditor = register({
|
|||
const selectedElement = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
)[0] as ExcalidrawLinearElement;
|
||||
return appState.editingLinearElement?.elementId === selectedElement.id
|
||||
? "labels.lineEditor.exit"
|
||||
|
|
|
@ -67,7 +67,6 @@ export const actionFullScreen = register({
|
|||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.F && !event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionShortcuts = register({
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { getClientColors } from "../clients";
|
||||
import { getClientColor } from "../clients";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { Collaborator } from "../types";
|
||||
|
@ -31,15 +31,14 @@ export const actionGoToCollaborator = register({
|
|||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, data }) => {
|
||||
PanelComponent: ({ updateData, data }) => {
|
||||
const [clientId, collaborator] = data as [string, Collaborator];
|
||||
|
||||
const { background, stroke } = getClientColors(clientId, appState);
|
||||
const background = getClientColor(clientId);
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
color={background}
|
||||
border={stroke}
|
||||
onClick={() => updateData(collaborator.pointer)}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
|
|
|
@ -102,8 +102,11 @@ const changeProperty = (
|
|||
includeBoundText = false,
|
||||
) => {
|
||||
const selectedElementIds = arrayToMap(
|
||||
getSelectedElements(elements, appState, includeBoundText),
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: includeBoundText,
|
||||
}),
|
||||
);
|
||||
|
||||
return elements.map((element) => {
|
||||
if (
|
||||
selectedElementIds.get(element.id) ||
|
||||
|
|
|
@ -5,6 +5,7 @@ import { getNonDeletedElements, isTextElement } from "../element";
|
|||
import { ExcalidrawElement } from "../element/types";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
|
@ -13,19 +14,18 @@ export const actionSelectAll = register({
|
|||
if (appState.editingLinearElement) {
|
||||
return false;
|
||||
}
|
||||
const selectedElementIds = elements.reduce(
|
||||
(map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (
|
||||
|
||||
const selectedElementIds = excludeElementsInFramesFromSelection(
|
||||
elements.filter(
|
||||
(element) =>
|
||||
!element.isDeleted &&
|
||||
!(isTextElement(element) && element.containerId) &&
|
||||
!element.locked
|
||||
) {
|
||||
map[element.id] = true;
|
||||
}
|
||||
return map;
|
||||
},
|
||||
{},
|
||||
);
|
||||
!element.locked,
|
||||
),
|
||||
).reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
map[element.id] = true;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
appState: selectGroupsForSelectedElements(
|
||||
|
@ -41,6 +41,7 @@ export const actionSelectAll = register({
|
|||
selectedElementIds,
|
||||
},
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
),
|
||||
commitToHistory: true,
|
||||
};
|
||||
|
|
|
@ -20,6 +20,7 @@ import {
|
|||
hasBoundTextElement,
|
||||
canApplyRoundnessTypeToElement,
|
||||
getDefaultRoundnessTypeForElement,
|
||||
isFrameElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
|
@ -64,7 +65,9 @@ export const actionPasteStyles = register({
|
|||
return { elements, commitToHistory: false };
|
||||
}
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
const selectedElements = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
const selectedElementIds = selectedElements.map((element) => element.id);
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
|
@ -127,6 +130,13 @@ export const actionPasteStyles = register({
|
|||
});
|
||||
}
|
||||
|
||||
if (isFrameElement(element)) {
|
||||
newElement = newElementWith(newElement, {
|
||||
roundness: null,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
}
|
||||
|
||||
return newElement;
|
||||
}
|
||||
return element;
|
||||
|
|
|
@ -82,7 +82,8 @@ export type ActionName =
|
|||
| "zoomOut"
|
||||
| "resetZoom"
|
||||
| "zoomToFit"
|
||||
| "zoomToSelection"
|
||||
| "zoomToFitSelection"
|
||||
| "zoomToFitSelectionInViewport"
|
||||
| "changeFontFamily"
|
||||
| "changeTextAlign"
|
||||
| "changeVerticalAlign"
|
||||
|
@ -116,6 +117,11 @@ export type ActionName =
|
|||
| "toggleLinearEditor"
|
||||
| "toggleEraserTool"
|
||||
| "toggleHandTool"
|
||||
| "selectAllElementsInFrame"
|
||||
| "removeAllElementsFromFrame"
|
||||
| "updateFrameRendering"
|
||||
| "setFrameAsActiveTool"
|
||||
| "createContainerFromText"
|
||||
| "wrapTextInContainer";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { ExcalidrawElement } from "./element/types";
|
||||
import { newElementWith } from "./element/mutateElement";
|
||||
import { Box, getCommonBoundingBox } from "./element/bounds";
|
||||
import { BoundingBox, getCommonBoundingBox } from "./element/bounds";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
|
||||
export interface Alignment {
|
||||
|
@ -33,7 +33,7 @@ export const alignElements = (
|
|||
|
||||
const calculateTranslation = (
|
||||
group: ExcalidrawElement[],
|
||||
selectionBoundingBox: Box,
|
||||
selectionBoundingBox: BoundingBox,
|
||||
{ axis, position }: Alignment,
|
||||
): { x: number; y: number } => {
|
||||
const groupBoundingBox = getCommonBoundingBox(group);
|
||||
|
|
|
@ -5,6 +5,9 @@ export const trackEvent = (
|
|||
value?: number,
|
||||
) => {
|
||||
try {
|
||||
// place here categories that you want to track as events
|
||||
// KEEP IN MIND THE PRICING
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = [] as string[];
|
||||
// Uncomment the next line to track locally
|
||||
// console.log("Track Event", { category, action, label, value });
|
||||
|
||||
|
@ -12,12 +15,8 @@ export const trackEvent = (
|
|||
return;
|
||||
}
|
||||
|
||||
if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID && window.gtag) {
|
||||
window.gtag("event", action, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value,
|
||||
});
|
||||
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.sa_event) {
|
||||
|
@ -27,14 +26,6 @@ export const trackEvent = (
|
|||
value,
|
||||
});
|
||||
}
|
||||
|
||||
if (window.fathom) {
|
||||
window.fathom.trackEvent(action, {
|
||||
category,
|
||||
label,
|
||||
value,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("error during analytics", error);
|
||||
}
|
||||
|
|
|
@ -78,11 +78,16 @@ export const getDefaultAppState = (): Omit<
|
|||
scrollY: 0,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
selectedElementsAreBeingDragged: false,
|
||||
selectionElement: null,
|
||||
shouldCacheIgnoreZoom: false,
|
||||
showStats: false,
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
frameRendering: { enabled: true, clip: true, name: true, outline: true },
|
||||
frameToHighlight: null,
|
||||
editingFrame: null,
|
||||
elementsToHighlight: null,
|
||||
toast: null,
|
||||
viewBackgroundColor: COLOR_PALETTE.white,
|
||||
zenModeEnabled: false,
|
||||
|
@ -176,11 +181,20 @@ const APP_STATE_STORAGE_CONF = (<
|
|||
scrollY: { browser: true, export: false, server: false },
|
||||
selectedElementIds: { browser: true, export: false, server: false },
|
||||
selectedGroupIds: { browser: true, export: false, server: false },
|
||||
selectedElementsAreBeingDragged: {
|
||||
browser: false,
|
||||
export: false,
|
||||
server: false,
|
||||
},
|
||||
selectionElement: { browser: false, export: false, server: false },
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||
showStats: { browser: true, export: false, server: false },
|
||||
startBoundElement: { browser: false, export: false, server: false },
|
||||
suggestedBindings: { browser: false, export: false, server: false },
|
||||
frameRendering: { browser: false, export: false, server: false },
|
||||
frameToHighlight: { browser: false, export: false, server: false },
|
||||
editingFrame: { browser: false, export: false, server: false },
|
||||
elementsToHighlight: { browser: false, export: false, server: false },
|
||||
toast: { browser: false, export: false, server: false },
|
||||
viewBackgroundColor: { browser: true, export: true, server: true },
|
||||
width: { browser: false, export: false, server: false },
|
||||
|
|
|
@ -180,7 +180,7 @@ const commonProps = {
|
|||
locked: false,
|
||||
} as const;
|
||||
|
||||
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
||||
const getChartDimensions = (spreadsheet: Spreadsheet) => {
|
||||
const chartWidth =
|
||||
(BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
|
||||
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
|
||||
|
@ -250,7 +250,7 @@ const chartLines = (
|
|||
groupId: string,
|
||||
backgroundColor: string,
|
||||
): ChartElements => {
|
||||
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
|
||||
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
|
||||
const xLine = newLinearElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
|
@ -313,7 +313,7 @@ const chartBaseElements = (
|
|||
backgroundColor: string,
|
||||
debug?: boolean,
|
||||
): ChartElements => {
|
||||
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
|
||||
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
|
||||
|
||||
const title = spreadsheet.title
|
||||
? newTextElement({
|
||||
|
|
|
@ -1,31 +1,31 @@
|
|||
import {
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
||||
getAllColorsSpecificShade,
|
||||
} from "./colors";
|
||||
import { AppState } from "./types";
|
||||
|
||||
const BG_COLORS = getAllColorsSpecificShade(
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||
);
|
||||
const STROKE_COLORS = getAllColorsSpecificShade(
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
||||
);
|
||||
|
||||
export const getClientColors = (clientId: string, appState: AppState) => {
|
||||
if (appState?.collaborators) {
|
||||
const currentUser = appState.collaborators.get(clientId);
|
||||
if (currentUser?.color) {
|
||||
return currentUser.color;
|
||||
}
|
||||
function hashToInteger(id: string) {
|
||||
let hash = 0;
|
||||
if (id.length === 0) {
|
||||
return hash;
|
||||
}
|
||||
// Naive way of getting an integer out of the clientId
|
||||
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
const char = id.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
return {
|
||||
background: BG_COLORS[sum % BG_COLORS.length],
|
||||
stroke: STROKE_COLORS[sum % STROKE_COLORS.length],
|
||||
};
|
||||
export const getClientColor = (
|
||||
/**
|
||||
* any uniquely identifying key, such as user id or socket id
|
||||
*/
|
||||
id: string,
|
||||
) => {
|
||||
// to get more even distribution in case `id` is not uniformly distributed to
|
||||
// begin with, we hash it
|
||||
const hash = Math.abs(hashToInteger(id));
|
||||
// we want to get a multiple of 10 number in the range of 0-360 (in other
|
||||
// words a hue value of step size 10). There are 37 such values including 0.
|
||||
const hue = (hash % 37) * 10;
|
||||
const saturation = 100;
|
||||
const lightness = 83;
|
||||
|
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -7,6 +7,9 @@ import { SVG_EXPORT_TAG } from "./scene/export";
|
|||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||
import { isInitializedImageElement } from "./element/typeChecks";
|
||||
import { deepCopyElement } from "./element/newElement";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import { getContainingFrame } from "./frame";
|
||||
import { isPromiseLike, isTestEnv } from "./utils";
|
||||
|
||||
type ElementsClipboard = {
|
||||
|
@ -57,6 +60,9 @@ export const copyToClipboard = async (
|
|||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
files: BinaryFiles | null,
|
||||
) => {
|
||||
const framesToCopy = new Set(
|
||||
elements.filter((element) => element.type === "frame"),
|
||||
);
|
||||
let foundFile = false;
|
||||
|
||||
const _files = elements.reduce((acc, element) => {
|
||||
|
@ -78,7 +84,20 @@ export const copyToClipboard = async (
|
|||
// select binded text elements when copying
|
||||
const contents: ElementsClipboard = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
elements,
|
||||
elements: elements.map((element) => {
|
||||
if (
|
||||
getContainingFrame(element) &&
|
||||
!framesToCopy.has(getContainingFrame(element)!)
|
||||
) {
|
||||
const copiedElement = deepCopyElement(element);
|
||||
mutateElement(copiedElement, {
|
||||
frameId: null,
|
||||
});
|
||||
return copiedElement;
|
||||
}
|
||||
|
||||
return element;
|
||||
}),
|
||||
files: files ? _files : undefined,
|
||||
};
|
||||
const json = JSON.stringify(contents);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement, PointerType } from "../element/types";
|
||||
|
@ -35,6 +35,9 @@ import {
|
|||
} from "../element/textElement";
|
||||
|
||||
import "./Actions.scss";
|
||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||
import { extraToolsIcon, frameToolIcon } from "./icons";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
|
@ -89,7 +92,8 @@ export const SelectedShapeActions = ({
|
|||
<div>
|
||||
{((hasStrokeColor(appState.activeTool.type) &&
|
||||
appState.activeTool.type !== "image" &&
|
||||
commonSelectedType !== "image") ||
|
||||
commonSelectedType !== "image" &&
|
||||
commonSelectedType !== "frame") ||
|
||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||
renderAction("changeStrokeColor")}
|
||||
</div>
|
||||
|
@ -220,28 +224,78 @@ export const ShapesSwitcher = ({
|
|||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||
appState: UIAppState;
|
||||
}) => (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter =
|
||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||
: `${numericKey}`;
|
||||
return (
|
||||
}) => {
|
||||
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
|
||||
const device = useDevice();
|
||||
return (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter =
|
||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||
: `${numericKey}`;
|
||||
return (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable })}
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={activeTool.type === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={numericKey || letter}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={`toolbar-${value}`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: value,
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(canvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
if (value === "image") {
|
||||
onImageAction({ pointerType });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="App-toolbar__divider" />
|
||||
{/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
|
||||
{device.isMobile ? (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable })}
|
||||
key={value}
|
||||
className={clsx("Shape", { fillable: false })}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={activeTool.type === value}
|
||||
icon={frameToolIcon}
|
||||
checked={activeTool.type === "frame"}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={numericKey || letter}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={`toolbar-${value}`}
|
||||
title={`${capitalizeString(
|
||||
t("toolBar.frame"),
|
||||
)} — ${KEYS.F.toLocaleUpperCase()}`}
|
||||
keyBindingLabel={KEYS.F.toLocaleUpperCase()}
|
||||
aria-label={capitalizeString(t("toolBar.frame"))}
|
||||
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
|
||||
data-testid={`toolbar-frame`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
setAppState({
|
||||
|
@ -251,30 +305,54 @@ export const ShapesSwitcher = ({
|
|||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
trackEvent("toolbar", "frame", "ui");
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: value,
|
||||
type: "frame",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(canvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
if (value === "image") {
|
||||
onImageAction({ pointerType });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
) : (
|
||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||
<DropdownMenu.Trigger
|
||||
className="App-toolbar__extra-tools-trigger"
|
||||
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
|
||||
title={t("toolBar.extraTools")}
|
||||
>
|
||||
{extraToolsIcon}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
|
||||
onSelect={() => setIsExtraToolsMenuOpen(false)}
|
||||
className="App-toolbar__extra-tools-dropdown"
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
}}
|
||||
icon={frameToolIcon}
|
||||
shortcut={KEYS.F.toLocaleUpperCase()}
|
||||
data-testid="toolbar-frame"
|
||||
>
|
||||
{t("toolBar.frame")}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ZoomActions = ({
|
||||
renderAction,
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -10,10 +10,9 @@
|
|||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: $oc-white;
|
||||
cursor: pointer;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
|
||||
&-img {
|
||||
|
|
|
@ -6,7 +6,6 @@ import { getNameInitial } from "../clients";
|
|||
type AvatarProps = {
|
||||
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
color: string;
|
||||
border: string;
|
||||
name: string;
|
||||
src?: string;
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { isTransparent, isWritableElement } from "../../utils";
|
||||
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { AppState } from "../../types";
|
||||
import { TopPicks } from "./TopPicks";
|
||||
|
@ -121,11 +121,14 @@ const ColorPickerPopupContent = ({
|
|||
}
|
||||
}}
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// prevents focusing the trigger
|
||||
e.preventDefault();
|
||||
|
||||
// return focus to excalidraw container
|
||||
if (container) {
|
||||
// return focus to excalidraw container unless
|
||||
// user focuses an interactive element, such as a button, or
|
||||
// enters the text editor by clicking on canvas with the text tool
|
||||
if (container && !isInteractive(document.activeElement)) {
|
||||
container.focus();
|
||||
}
|
||||
|
||||
|
|
|
@ -17,16 +17,34 @@ import { useSetAtom } from "jotai";
|
|||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||
import { jotaiScope } from "../jotai";
|
||||
|
||||
export type DialogSize = number | "small" | "regular" | "wide" | undefined;
|
||||
|
||||
export interface DialogProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
size?: "small" | "regular" | "wide";
|
||||
size?: DialogSize;
|
||||
onCloseRequest(): void;
|
||||
title: React.ReactNode | false;
|
||||
autofocus?: boolean;
|
||||
closeOnClickOutside?: boolean;
|
||||
}
|
||||
|
||||
function getDialogSize(size: DialogSize): number {
|
||||
if (size && typeof size === "number") {
|
||||
return size;
|
||||
}
|
||||
|
||||
switch (size) {
|
||||
case "small":
|
||||
return 550;
|
||||
case "wide":
|
||||
return 1024;
|
||||
case "regular":
|
||||
default:
|
||||
return 800;
|
||||
}
|
||||
}
|
||||
|
||||
export const Dialog = (props: DialogProps) => {
|
||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||
const [lastActiveElement] = useState(document.activeElement);
|
||||
|
@ -85,9 +103,7 @@ export const Dialog = (props: DialogProps) => {
|
|||
<Modal
|
||||
className={clsx("Dialog", props.className)}
|
||||
labelledBy="dialog-title"
|
||||
maxWidth={
|
||||
props.size === "wide" ? 1024 : props.size === "small" ? 550 : 800
|
||||
}
|
||||
maxWidth={getDialogSize(props.size)}
|
||||
onCloseRequest={onClose}
|
||||
closeOnClickOutside={props.closeOnClickOutside}
|
||||
>
|
||||
|
|
|
@ -2,20 +2,140 @@
|
|||
|
||||
.excalidraw {
|
||||
.ExcButton {
|
||||
&--color-primary {
|
||||
color: var(--input-bg-color);
|
||||
--text-color: transparent;
|
||||
--border-color: transparent;
|
||||
--back-color: transparent;
|
||||
|
||||
--accent-color: var(--color-primary);
|
||||
--accent-color-hover: var(--color-primary-darker);
|
||||
--accent-color-active: var(--color-primary-darkest);
|
||||
color: var(--text-color);
|
||||
background-color: var(--back-color);
|
||||
border-color: var(--border-color);
|
||||
|
||||
&--color-primary {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: var(--input-bg-color);
|
||||
--back-color: var(--color-primary);
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-primary-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-primary);
|
||||
--border-color: var(--color-primary);
|
||||
--back-color: var(--input-bg-color);
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-primary-darker);
|
||||
--border-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-primary-darkest);
|
||||
--border-color: var(--color-primary-darkest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--color-danger {
|
||||
color: var(--input-bg-color);
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: var(--color-danger-text);
|
||||
--back-color: var(--color-danger-dark);
|
||||
|
||||
--accent-color: var(--color-danger);
|
||||
--accent-color-hover: #d65550;
|
||||
--accent-color-active: #d1413c;
|
||||
&:hover {
|
||||
--back-color: var(--color-danger-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-danger-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-danger);
|
||||
--border-color: var(--color-danger);
|
||||
--back-color: transparent;
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-danger-darkest);
|
||||
--border-color: var(--color-danger-darkest);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-danger-darker);
|
||||
--border-color: var(--color-danger-darker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--color-muted {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: var(--island-bg-color);
|
||||
--back-color: var(--color-gray-50);
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-gray-60);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-gray-80);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-muted-background);
|
||||
--border-color: var(--color-muted);
|
||||
--back-color: var(--island-bg-color);
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-muted-background-darker);
|
||||
--border-color: var(--color-muted-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-muted-background-darker);
|
||||
--border-color: var(--color-muted-darkest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--color-warning {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: black;
|
||||
--back-color: var(--color-warning-dark);
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-warning-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-warning-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-warning-dark);
|
||||
--border-color: var(--color-warning-dark);
|
||||
--back-color: var(--input-bg-color);
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-warning-darker);
|
||||
--border-color: var(--color-warning-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-warning-darkest);
|
||||
--border-color: var(--color-warning-darkest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
display: flex;
|
||||
|
@ -25,6 +145,8 @@
|
|||
flex-wrap: nowrap;
|
||||
|
||||
border-radius: 0.5rem;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
|
||||
font-family: "Assistant";
|
||||
|
||||
|
@ -33,9 +155,9 @@
|
|||
transition: all 150ms ease-out;
|
||||
|
||||
&--size-large {
|
||||
font-weight: 400;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
height: 3rem;
|
||||
min-height: 3rem;
|
||||
padding: 0.5rem 1.5rem;
|
||||
gap: 0.75rem;
|
||||
|
||||
|
@ -45,48 +167,22 @@
|
|||
&--size-medium {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
height: 2.5rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
gap: 0.5rem;
|
||||
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
&--variant-filled {
|
||||
background: var(--accent-color);
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-color-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accent-color-active);
|
||||
}
|
||||
}
|
||||
|
||||
&--variant-outlined,
|
||||
&--variant-icon {
|
||||
border: 1px solid var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid var(--accent-color-hover);
|
||||
color: var(--accent-color-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
border: 1px solid var(--accent-color-active);
|
||||
color: var(--accent-color-active);
|
||||
}
|
||||
}
|
||||
|
||||
&--variant-icon {
|
||||
padding: 0.5rem 0.75rem;
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
&--fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
|
|
|
@ -4,7 +4,7 @@ import clsx from "clsx";
|
|||
import "./FilledButton.scss";
|
||||
|
||||
export type ButtonVariant = "filled" | "outlined" | "icon";
|
||||
export type ButtonColor = "primary" | "danger";
|
||||
export type ButtonColor = "primary" | "danger" | "warning" | "muted";
|
||||
export type ButtonSize = "medium" | "large";
|
||||
|
||||
export type FilledButtonProps = {
|
||||
|
@ -17,6 +17,7 @@ export type FilledButtonProps = {
|
|||
color?: ButtonColor;
|
||||
size?: ButtonSize;
|
||||
className?: string;
|
||||
fullWidth?: boolean;
|
||||
|
||||
startIcon?: React.ReactNode;
|
||||
};
|
||||
|
@ -31,6 +32,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
|||
variant = "filled",
|
||||
color = "primary",
|
||||
size = "medium",
|
||||
fullWidth,
|
||||
className,
|
||||
},
|
||||
ref,
|
||||
|
@ -42,6 +44,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
|||
`ExcButton--color-${color}`,
|
||||
`ExcButton--variant-${variant}`,
|
||||
`ExcButton--size-${size}`,
|
||||
{ "ExcButton--fullWidth": fullWidth },
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
|
|
|
@ -12,7 +12,7 @@ const Header = () => (
|
|||
<div className="HelpDialog__header">
|
||||
<a
|
||||
className="HelpDialog__btn"
|
||||
href="https://github.com/excalidraw/excalidraw#documentation"
|
||||
href="https://docs.excalidraw.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
@ -164,6 +164,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||
label={t("toolBar.eraser")}
|
||||
shortcuts={[KEYS.E, KEYS["0"]]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
|
||||
<Shortcut
|
||||
label={t("labels.eyeDropper")}
|
||||
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
|
||||
|
|
|
@ -84,7 +84,10 @@ const ImageExportModal = ({
|
|||
const [renderError, setRenderError] = useState<Error | null>(null);
|
||||
|
||||
const exportedElements = exportSelected
|
||||
? getSelectedElements(elements, appState, true)
|
||||
? getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
})
|
||||
: elements;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -41,6 +41,7 @@ import { jotaiScope } from "../jotai";
|
|||
import { Provider, useAtom, useAtomValue } from "jotai";
|
||||
import MainMenu from "./main-menu/MainMenu";
|
||||
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
|
||||
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
|
||||
import { HandButton } from "./HandButton";
|
||||
import { isHandToolActive } from "../appState";
|
||||
import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
|
||||
|
@ -99,6 +100,15 @@ const DefaultMainMenu: React.FC<{
|
|||
);
|
||||
};
|
||||
|
||||
const DefaultOverwriteConfirmDialog = () => {
|
||||
return (
|
||||
<OverwriteConfirmDialog __fallback>
|
||||
<OverwriteConfirmDialog.Actions.SaveToDisk />
|
||||
<OverwriteConfirmDialog.Actions.ExportToImage />
|
||||
</OverwriteConfirmDialog>
|
||||
);
|
||||
};
|
||||
|
||||
const LayerUI = ({
|
||||
actionManager,
|
||||
appState,
|
||||
|
@ -204,12 +214,7 @@ const LayerUI = ({
|
|||
return (
|
||||
<FixedSideContainer side="top">
|
||||
<div className="App-menu App-menu_top">
|
||||
<Stack.Col
|
||||
gap={6}
|
||||
className={clsx("App-menu_top__left", {
|
||||
"disable-pointerEvents": appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<Stack.Col gap={6} className={clsx("App-menu_top__left")}>
|
||||
{renderCanvasActions()}
|
||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||
</Stack.Col>
|
||||
|
@ -254,7 +259,7 @@ const LayerUI = ({
|
|||
title={t("toolBar.lock")}
|
||||
/>
|
||||
|
||||
<div className="App-toolbar__divider"></div>
|
||||
<div className="App-toolbar__divider" />
|
||||
|
||||
<HandButton
|
||||
checked={isHandToolActive(appState)}
|
||||
|
@ -348,6 +353,7 @@ const LayerUI = ({
|
|||
>
|
||||
{t("toolBar.library")}
|
||||
</DefaultSidebar.Trigger>
|
||||
<DefaultOverwriteConfirmDialog />
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
|
||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||
|
@ -379,6 +385,7 @@ const LayerUI = ({
|
|||
/>
|
||||
)}
|
||||
<ActiveConfirmDialog />
|
||||
<tunnels.OverwriteConfirmDialogTunnel.Out />
|
||||
{renderImageExportDialog()}
|
||||
{renderJSONExportDialog()}
|
||||
{appState.pasteDialog.shown && (
|
||||
|
@ -392,7 +399,7 @@ const LayerUI = ({
|
|||
}
|
||||
/>
|
||||
)}
|
||||
{device.isMobile && !eyeDropperState && (
|
||||
{device.isMobile && (
|
||||
<MobileMenu
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
|
|
|
@ -148,7 +148,11 @@ const usePendingElementsMemo = (
|
|||
appState: UIAppState,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
) => {
|
||||
const create = () => getSelectedElements(elements, appState, true);
|
||||
const create = () =>
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
const val = useRef(create());
|
||||
const prevAppState = useRef<UIAppState>(appState);
|
||||
const prevElements = useRef(elements);
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
.excalidraw {
|
||||
&.excalidraw-modal-container {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
z-index: var(--zIndex-modal);
|
||||
}
|
||||
|
||||
.Modal {
|
||||
|
|
126
src/components/OverwriteConfirm/OverwriteConfirm.scss
Normal file
126
src/components/OverwriteConfirm/OverwriteConfirm.scss
Normal file
|
@ -0,0 +1,126 @@
|
|||
@import "../../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.OverwriteConfirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
isolation: isolate;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
|
||||
font-weight: 700;
|
||||
font-size: 1.3125rem;
|
||||
line-height: 130%;
|
||||
align-self: flex-start;
|
||||
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
&__Description {
|
||||
box-sizing: border-box;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
|
||||
@include isMobile {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
padding: 2.5rem;
|
||||
|
||||
background: var(--color-danger-background);
|
||||
border-radius: 0.5rem;
|
||||
|
||||
font-family: "Assistant";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
line-height: 150%;
|
||||
|
||||
color: var(--color-danger-color);
|
||||
|
||||
&__spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 2.5rem;
|
||||
background: var(--color-danger-icon-background);
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
|
||||
padding: 0.75rem;
|
||||
|
||||
svg {
|
||||
color: var(--color-danger-icon-color);
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.OverwriteConfirm__Description--color-warning {
|
||||
background: var(--color-warning-background);
|
||||
color: var(--color-warning-color);
|
||||
|
||||
.OverwriteConfirm__Description__icon {
|
||||
background: var(--color-warning-icon-background);
|
||||
flex: 0 0 auto;
|
||||
|
||||
svg {
|
||||
color: var(--color-warning-icon-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__Actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
justify-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
|
||||
@include isMobile {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__Action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
gap: 0.75rem;
|
||||
flex-basis: 50%;
|
||||
flex-grow: 0;
|
||||
|
||||
&__content {
|
||||
height: 100%;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
line-height: 130%;
|
||||
|
||||
margin: 0;
|
||||
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
76
src/components/OverwriteConfirm/OverwriteConfirm.tsx
Normal file
76
src/components/OverwriteConfirm/OverwriteConfirm.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
import React from "react";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import { Dialog } from "../Dialog";
|
||||
import { withInternalFallback } from "../hoc/withInternalFallback";
|
||||
import { overwriteConfirmStateAtom } from "./OverwriteConfirmState";
|
||||
|
||||
import { FilledButton } from "../FilledButton";
|
||||
import { alertTriangleIcon } from "../icons";
|
||||
import { Actions, Action } from "./OverwriteConfirmActions";
|
||||
import "./OverwriteConfirm.scss";
|
||||
|
||||
export type OverwriteConfirmDialogProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const OverwriteConfirmDialog = Object.assign(
|
||||
withInternalFallback(
|
||||
"OverwriteConfirmDialog",
|
||||
({ children }: OverwriteConfirmDialogProps) => {
|
||||
const { OverwriteConfirmDialogTunnel } = useTunnels();
|
||||
const [overwriteConfirmState, setState] = useAtom(
|
||||
overwriteConfirmStateAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
|
||||
if (!overwriteConfirmState.active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
overwriteConfirmState.onClose();
|
||||
setState((state) => ({ ...state, active: false }));
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
overwriteConfirmState.onConfirm();
|
||||
setState((state) => ({ ...state, active: false }));
|
||||
};
|
||||
|
||||
return (
|
||||
<OverwriteConfirmDialogTunnel.In>
|
||||
<Dialog onCloseRequest={handleClose} title={false} size={916}>
|
||||
<div className="OverwriteConfirm">
|
||||
<h3>{overwriteConfirmState.title}</h3>
|
||||
<div
|
||||
className={`OverwriteConfirm__Description OverwriteConfirm__Description--color-${overwriteConfirmState.color}`}
|
||||
>
|
||||
<div className="OverwriteConfirm__Description__icon">
|
||||
{alertTriangleIcon}
|
||||
</div>
|
||||
<div>{overwriteConfirmState.description}</div>
|
||||
<div className="OverwriteConfirm__Description__spacer"></div>
|
||||
<FilledButton
|
||||
color={overwriteConfirmState.color}
|
||||
size="large"
|
||||
label={overwriteConfirmState.actionLabel}
|
||||
onClick={handleConfirm}
|
||||
/>
|
||||
</div>
|
||||
<Actions>{children}</Actions>
|
||||
</div>
|
||||
</Dialog>
|
||||
</OverwriteConfirmDialogTunnel.In>
|
||||
);
|
||||
},
|
||||
),
|
||||
{
|
||||
Actions,
|
||||
Action,
|
||||
},
|
||||
);
|
||||
|
||||
export { OverwriteConfirmDialog };
|
85
src/components/OverwriteConfirm/OverwriteConfirmActions.tsx
Normal file
85
src/components/OverwriteConfirm/OverwriteConfirmActions.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import React from "react";
|
||||
import { FilledButton } from "../FilledButton";
|
||||
import { useExcalidrawActionManager, useExcalidrawSetAppState } from "../App";
|
||||
import { actionSaveFileToDisk } from "../../actions";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { actionChangeExportEmbedScene } from "../../actions/actionExport";
|
||||
|
||||
export type ActionProps = {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
actionLabel: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export const Action = ({
|
||||
title,
|
||||
children,
|
||||
actionLabel,
|
||||
onClick,
|
||||
}: ActionProps) => {
|
||||
return (
|
||||
<div className="OverwriteConfirm__Actions__Action">
|
||||
<h4>{title}</h4>
|
||||
<div className="OverwriteConfirm__Actions__Action__content">
|
||||
{children}
|
||||
</div>
|
||||
<FilledButton
|
||||
variant="outlined"
|
||||
color="muted"
|
||||
label={actionLabel}
|
||||
size="large"
|
||||
fullWidth
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ExportToImage = () => {
|
||||
const { t } = useI18n();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
return (
|
||||
<Action
|
||||
title={t("overwriteConfirm.action.exportToImage.title")}
|
||||
actionLabel={t("overwriteConfirm.action.exportToImage.button")}
|
||||
onClick={() => {
|
||||
actionManager.executeAction(actionChangeExportEmbedScene, "ui", true);
|
||||
setAppState({ openDialog: "imageExport" });
|
||||
}}
|
||||
>
|
||||
{t("overwriteConfirm.action.exportToImage.description")}
|
||||
</Action>
|
||||
);
|
||||
};
|
||||
|
||||
export const SaveToDisk = () => {
|
||||
const { t } = useI18n();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
return (
|
||||
<Action
|
||||
title={t("overwriteConfirm.action.saveToDisk.title")}
|
||||
actionLabel={t("overwriteConfirm.action.saveToDisk.button")}
|
||||
onClick={() => {
|
||||
actionManager.executeAction(actionSaveFileToDisk, "ui");
|
||||
}}
|
||||
>
|
||||
{t("overwriteConfirm.action.saveToDisk.description")}
|
||||
</Action>
|
||||
);
|
||||
};
|
||||
|
||||
const Actions = Object.assign(
|
||||
({ children }: { children: React.ReactNode }) => {
|
||||
return <div className="OverwriteConfirm__Actions">{children}</div>;
|
||||
},
|
||||
{
|
||||
ExportToImage,
|
||||
SaveToDisk,
|
||||
},
|
||||
);
|
||||
|
||||
export { Actions };
|
46
src/components/OverwriteConfirm/OverwriteConfirmState.ts
Normal file
46
src/components/OverwriteConfirm/OverwriteConfirmState.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { atom } from "jotai";
|
||||
import { jotaiStore } from "../../jotai";
|
||||
import React from "react";
|
||||
|
||||
export type OverwriteConfirmState =
|
||||
| {
|
||||
active: true;
|
||||
title: string;
|
||||
description: React.ReactNode;
|
||||
actionLabel: string;
|
||||
color: "danger" | "warning";
|
||||
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onReject: () => void;
|
||||
}
|
||||
| { active: false };
|
||||
|
||||
export const overwriteConfirmStateAtom = atom<OverwriteConfirmState>({
|
||||
active: false,
|
||||
});
|
||||
|
||||
export async function openConfirmModal({
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
color,
|
||||
}: {
|
||||
title: string;
|
||||
description: React.ReactNode;
|
||||
actionLabel: string;
|
||||
color: "danger" | "warning";
|
||||
}) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
jotaiStore.set(overwriteConfirmStateAtom, {
|
||||
active: true,
|
||||
onConfirm: () => resolve(true),
|
||||
onClose: () => resolve(false),
|
||||
onReject: () => resolve(false),
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
color,
|
||||
});
|
||||
});
|
||||
}
|
91
src/components/ShareableLinkDialog.scss
Normal file
91
src/components/ShareableLinkDialog.scss
Normal file
|
@ -0,0 +1,91 @@
|
|||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.ShareableLinkDialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
|
||||
color: var(--text-primary-color);
|
||||
|
||||
::selection {
|
||||
background: var(--color-primary-light-darker);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: "Assistant";
|
||||
font-weight: 700;
|
||||
font-size: 1.313rem;
|
||||
line-height: 130%;
|
||||
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__popover {
|
||||
@keyframes RoomDialog__popover__scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
box-sizing: border-box;
|
||||
z-index: 100;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 0.125rem 0.5rem;
|
||||
gap: 0.125rem;
|
||||
|
||||
height: 1.125rem;
|
||||
|
||||
border: none;
|
||||
border-radius: 0.6875rem;
|
||||
|
||||
font-family: "Assistant";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
line-height: 110%;
|
||||
|
||||
background: var(--color-success-lighter);
|
||||
color: var(--color-success);
|
||||
|
||||
& > svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
transform-origin: var(--radix-popover-content-transform-origin);
|
||||
animation: RoomDialog__popover__scaleIn 150ms ease-out;
|
||||
}
|
||||
|
||||
&__linkRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
&__description {
|
||||
border-top: 1px solid var(--color-gray-20);
|
||||
|
||||
padding: 0.5rem 0.5rem 0;
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
line-height: 150%;
|
||||
|
||||
& p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& p + p {
|
||||
margin-top: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
91
src/components/ShareableLinkDialog.tsx
Normal file
91
src/components/ShareableLinkDialog.tsx
Normal file
|
@ -0,0 +1,91 @@
|
|||
import { useRef, useState } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import { copyTextToSystemClipboard } from "../clipboard";
|
||||
import { useI18n } from "../i18n";
|
||||
|
||||
import { Dialog } from "./Dialog";
|
||||
import { TextField } from "./TextField";
|
||||
import { FilledButton } from "./FilledButton";
|
||||
import { copyIcon, tablerCheckIcon } from "./icons";
|
||||
|
||||
import "./ShareableLinkDialog.scss";
|
||||
|
||||
export type ShareableLinkDialogProps = {
|
||||
link: string;
|
||||
|
||||
onCloseRequest: () => void;
|
||||
setErrorMessage: (error: string) => void;
|
||||
};
|
||||
|
||||
export const ShareableLinkDialog = ({
|
||||
link,
|
||||
onCloseRequest,
|
||||
setErrorMessage,
|
||||
}: ShareableLinkDialogProps) => {
|
||||
const { t } = useI18n();
|
||||
const [justCopied, setJustCopied] = useState(false);
|
||||
const timerRef = useRef<number>(0);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
const copyRoomLink = async () => {
|
||||
try {
|
||||
await copyTextToSystemClipboard(link);
|
||||
|
||||
setJustCopied(true);
|
||||
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setJustCopied(false);
|
||||
}, 3000);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error.message);
|
||||
}
|
||||
|
||||
ref.current?.select();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog onCloseRequest={onCloseRequest} title={false} size="small">
|
||||
<div className="ShareableLinkDialog">
|
||||
<h3>Shareable link</h3>
|
||||
<div className="ShareableLinkDialog__linkRow">
|
||||
<TextField
|
||||
ref={ref}
|
||||
label="Link"
|
||||
readonly
|
||||
fullWidth
|
||||
value={link}
|
||||
selectOnRender
|
||||
/>
|
||||
<Popover.Root open={justCopied}>
|
||||
<Popover.Trigger asChild>
|
||||
<FilledButton
|
||||
size="large"
|
||||
label="Copy link"
|
||||
startIcon={copyIcon}
|
||||
onClick={copyRoomLink}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
className="ShareableLinkDialog__popover"
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={5.5}
|
||||
>
|
||||
{tablerCheckIcon} copied
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
<div className="ShareableLinkDialog__description">
|
||||
🔒 {t("alerts.uploadedSecurly")}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
|
@ -1,4 +1,10 @@
|
|||
import { forwardRef, useRef, useImperativeHandle, KeyboardEvent } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useRef,
|
||||
useImperativeHandle,
|
||||
KeyboardEvent,
|
||||
useLayoutEffect,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import "./TextField.scss";
|
||||
|
@ -12,6 +18,7 @@ export type TextFieldProps = {
|
|||
|
||||
readonly?: boolean;
|
||||
fullWidth?: boolean;
|
||||
selectOnRender?: boolean;
|
||||
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
|
@ -19,13 +26,28 @@ export type TextFieldProps = {
|
|||
|
||||
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
(
|
||||
{ value, onChange, label, fullWidth, placeholder, readonly, onKeyDown },
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
fullWidth,
|
||||
placeholder,
|
||||
readonly,
|
||||
selectOnRender,
|
||||
onKeyDown,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const innerRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useImperativeHandle(ref, () => innerRef.current!);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (selectOnRender) {
|
||||
innerRef.current?.select();
|
||||
}
|
||||
}, [selectOnRender]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("ExcTextField", {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import "./ToolIcon.scss";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { CSSProperties, useEffect, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
import { AbortError } from "../errors";
|
||||
|
@ -25,6 +25,7 @@ type ToolButtonBaseProps = {
|
|||
visible?: boolean;
|
||||
selected?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
|
@ -114,6 +115,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||
"ToolIcon--plain": props.type === "icon",
|
||||
},
|
||||
)}
|
||||
style={props.style}
|
||||
data-testid={props["data-testid"]}
|
||||
hidden={props.hidden}
|
||||
title={props.title}
|
||||
|
|
|
@ -15,7 +15,24 @@
|
|||
height: 1.5rem;
|
||||
align-self: center;
|
||||
background-color: var(--default-border-color);
|
||||
margin: 0 0.5rem;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.App-toolbar__extra-tools-trigger {
|
||||
box-shadow: none;
|
||||
border: 0;
|
||||
|
||||
&:active {
|
||||
background-color: var(--button-hover-bg);
|
||||
box-shadow: 0 0 0 1px
|
||||
var(--button-active-border, var(--color-primary-darkest)) inset;
|
||||
}
|
||||
}
|
||||
|
||||
.App-toolbar__extra-tools-dropdown {
|
||||
margin-top: 0.375rem;
|
||||
right: 0;
|
||||
min-width: 11.875rem;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family: var(--ui-font);
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
z-index: var(--zIndex-popup);
|
||||
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
|
|
|
@ -1,23 +1,23 @@
|
|||
import clsx from "clsx";
|
||||
import { useUIAppState } from "../../context/ui-appState";
|
||||
import { useDevice } from "../App";
|
||||
|
||||
const MenuTrigger = ({
|
||||
className = "",
|
||||
children,
|
||||
onToggle,
|
||||
title,
|
||||
...rest
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
onToggle: () => void;
|
||||
}) => {
|
||||
const appState = useUIAppState();
|
||||
title?: string;
|
||||
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||
const device = useDevice();
|
||||
const classNames = clsx(
|
||||
`dropdown-menu-button ${className}`,
|
||||
"zen-mode-transition",
|
||||
{
|
||||
"transition-left": appState.zenModeEnabled,
|
||||
"dropdown-menu-button--mobile": device.isMobile,
|
||||
},
|
||||
).trim();
|
||||
|
@ -28,6 +28,8 @@ const MenuTrigger = ({
|
|||
onClick={onToggle}
|
||||
type="button"
|
||||
data-testid="dropdown-menu-button"
|
||||
title={title}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
|
|
@ -12,17 +12,17 @@ describe("Test internal component fallback rendering", () => {
|
|||
</div>,
|
||||
);
|
||||
|
||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
||||
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
|
||||
|
||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||
".excalidraw-container",
|
||||
);
|
||||
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
|
@ -36,17 +36,17 @@ describe("Test internal component fallback rendering", () => {
|
|||
</div>,
|
||||
);
|
||||
|
||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
||||
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
|
||||
|
||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||
".excalidraw-container",
|
||||
);
|
||||
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
|
@ -62,17 +62,17 @@ describe("Test internal component fallback rendering", () => {
|
|||
</div>,
|
||||
);
|
||||
|
||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
||||
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
|
||||
|
||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||
".excalidraw-container",
|
||||
);
|
||||
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
|
@ -84,17 +84,17 @@ describe("Test internal component fallback rendering", () => {
|
|||
</div>,
|
||||
);
|
||||
|
||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
||||
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
|
||||
|
||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||
".excalidraw-container",
|
||||
);
|
||||
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1608,6 +1608,16 @@ export const tablerCheckIcon = createIcon(
|
|||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const alertTriangleIcon = createIcon(
|
||||
<>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M10.24 3.957l-8.422 14.06a1.989 1.989 0 0 0 1.7 2.983h16.845a1.989 1.989 0 0 0 1.7 -2.983l-8.423 -14.06a1.989 1.989 0 0 0 -3.4 0z" />
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" />
|
||||
</>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const eyeDropperIcon = createIcon(
|
||||
<g strokeWidth={1.25}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
|
@ -1616,3 +1626,24 @@ export const eyeDropperIcon = createIcon(
|
|||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const extraToolsIcon = createIcon(
|
||||
<g strokeWidth={1.5}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M12 3l-4 7h8z"></path>
|
||||
<path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
||||
<path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"></path>
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const frameToolIcon = createIcon(
|
||||
<g strokeWidth={1.5}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M4 7l16 0"></path>
|
||||
<path d="M4 17l16 0"></path>
|
||||
<path d="M7 4l0 16"></path>
|
||||
<path d="M17 4l0 16"></path>
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useExcalidrawSetAppState, useExcalidrawActionManager } from "../App";
|
||||
import {
|
||||
useExcalidrawSetAppState,
|
||||
useExcalidrawActionManager,
|
||||
useExcalidrawElements,
|
||||
} from "../App";
|
||||
import {
|
||||
ExportIcon,
|
||||
ExportImageIcon,
|
||||
|
@ -29,19 +33,42 @@ import { useSetAtom } from "jotai";
|
|||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import { useUIAppState } from "../../context/ui-appState";
|
||||
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
||||
import Trans from "../Trans";
|
||||
|
||||
export const LoadScene = () => {
|
||||
const { t } = useI18n();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
const elements = useExcalidrawElements();
|
||||
|
||||
if (!actionManager.isActionEnabled(actionLoadScene)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSelect = async () => {
|
||||
if (
|
||||
!elements.length ||
|
||||
(await openConfirmModal({
|
||||
title: t("overwriteConfirm.modal.loadFromFile.title"),
|
||||
actionLabel: t("overwriteConfirm.modal.loadFromFile.button"),
|
||||
color: "warning",
|
||||
description: (
|
||||
<Trans
|
||||
i18nKey="overwriteConfirm.modal.loadFromFile.description"
|
||||
bold={(text) => <strong>{text}</strong>}
|
||||
br={() => <br />}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
) {
|
||||
actionManager.executeAction(actionLoadScene);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
icon={LoadIcon}
|
||||
onSelect={() => actionManager.executeAction(actionLoadScene)}
|
||||
onSelect={handleSelect}
|
||||
data-testid="load-button"
|
||||
shortcut={getShortcutFromShortcutName("loadScene")}
|
||||
aria-label={t("buttons.load")}
|
||||
|
|
|
@ -42,6 +42,7 @@ const MainMenu = Object.assign(
|
|||
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
||||
});
|
||||
}}
|
||||
data-testid="main-menu-trigger"
|
||||
>
|
||||
{HamburgerMenuIcon}
|
||||
</DropdownMenu.Trigger>
|
||||
|
|
|
@ -94,6 +94,17 @@ export const THEME = {
|
|||
DARK: "dark",
|
||||
};
|
||||
|
||||
export const FRAME_STYLE = {
|
||||
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
|
||||
strokeWidth: 1 as ExcalidrawElement["strokeWidth"],
|
||||
strokeStyle: "solid" as ExcalidrawElement["strokeStyle"],
|
||||
fillStyle: "solid" as ExcalidrawElement["fillStyle"],
|
||||
roughness: 0 as ExcalidrawElement["roughness"],
|
||||
roundness: null as ExcalidrawElement["roundness"],
|
||||
backgroundColor: "transparent" as ExcalidrawElement["backgroundColor"],
|
||||
radius: 8,
|
||||
};
|
||||
|
||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||
|
||||
export const DEFAULT_FONT_SIZE = 20;
|
||||
|
|
|
@ -12,6 +12,7 @@ type TunnelsContextValue = {
|
|||
FooterCenterTunnel: Tunnel;
|
||||
DefaultSidebarTriggerTunnel: Tunnel;
|
||||
DefaultSidebarTabTriggersTunnel: Tunnel;
|
||||
OverwriteConfirmDialogTunnel: Tunnel;
|
||||
jotaiScope: symbol;
|
||||
};
|
||||
|
||||
|
@ -30,6 +31,7 @@ export const useInitializeTunnels = () => {
|
|||
FooterCenterTunnel: tunnel(),
|
||||
DefaultSidebarTriggerTunnel: tunnel(),
|
||||
DefaultSidebarTabTriggersTunnel: tunnel(),
|
||||
OverwriteConfirmDialogTunnel: tunnel(),
|
||||
jotaiScope: Symbol(),
|
||||
};
|
||||
}, []);
|
||||
|
|
|
@ -5,6 +5,15 @@
|
|||
--zIndex-canvas: 1;
|
||||
--zIndex-wysiwyg: 2;
|
||||
--zIndex-layerUI: 3;
|
||||
|
||||
--zIndex-modal: 1000;
|
||||
--zIndex-popup: 1001;
|
||||
--zIndex-toast: 999999;
|
||||
|
||||
--sab: env(safe-area-inset-bottom);
|
||||
--sal: env(safe-area-inset-left);
|
||||
--sar: env(safe-area-inset-right);
|
||||
--sat: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
|
|
|
@ -27,10 +27,6 @@
|
|||
--popup-secondary-bg-color: #{$oc-gray-1};
|
||||
--popup-text-color: #{$oc-black};
|
||||
--popup-text-inverted-color: #{$oc-white};
|
||||
--sab: env(safe-area-inset-bottom);
|
||||
--sal: env(safe-area-inset-left);
|
||||
--sar: env(safe-area-inset-right);
|
||||
--sat: env(safe-area-inset-top);
|
||||
--select-highlight-color: #{$oc-blue-5};
|
||||
--shadow-island: 0px 7px 14px rgba(0, 0, 0, 0.05),
|
||||
0px 0px 3.12708px rgba(0, 0, 0, 0.0798),
|
||||
|
@ -99,9 +95,33 @@
|
|||
--color-gray-100: #121212;
|
||||
|
||||
--color-warning: #fceeca;
|
||||
--color-warning-dark: #f5c354;
|
||||
--color-warning-darker: #f3ab2c;
|
||||
--color-warning-darkest: #ec8b14;
|
||||
--color-text-warning: var(--text-primary-color);
|
||||
|
||||
--color-danger: #db6965;
|
||||
--color-danger-dark: #db6965;
|
||||
--color-danger-darker: #d65550;
|
||||
--color-danger-darkest: #d1413c;
|
||||
--color-danger-text: black;
|
||||
|
||||
--color-danger-background: #fff0f0;
|
||||
--color-danger-icon-background: #ffdad6;
|
||||
--color-danger-color: #700000;
|
||||
--color-danger-icon-color: #700000;
|
||||
|
||||
--color-warning-background: var(--color-warning);
|
||||
--color-warning-icon-background: var(--color-warning-dark);
|
||||
--color-warning-color: var(--text-primary-color);
|
||||
--color-warning-icon-color: var(--text-primary-color);
|
||||
|
||||
--color-muted: var(--color-gray-30);
|
||||
--color-muted-darker: var(--color-gray-60);
|
||||
--color-muted-darkest: var(--color-gray-100);
|
||||
--color-muted-background: var(--color-gray-80);
|
||||
--color-muted-background-darker: var(--color-gray-100);
|
||||
|
||||
--color-promo: #e70078;
|
||||
--color-success: #268029;
|
||||
--color-success-lighter: #cafccc;
|
||||
|
@ -177,6 +197,27 @@
|
|||
--color-text-warning: var(--color-gray-80);
|
||||
|
||||
--color-danger: #ffa8a5;
|
||||
--color-danger-dark: #672120;
|
||||
--color-danger-darker: #8f2625;
|
||||
--color-danger-darkest: #ac2b29;
|
||||
--color-danger-text: #fbcbcc;
|
||||
|
||||
--color-danger-background: #fbcbcc;
|
||||
--color-danger-icon-background: #672120;
|
||||
--color-danger-color: #261919;
|
||||
--color-danger-icon-color: #fbcbcc;
|
||||
|
||||
--color-warning-background: var(--color-warning);
|
||||
--color-warning-icon-background: var(--color-warning-dark);
|
||||
--color-warning-color: var(--color-gray-80);
|
||||
--color-warning-icon-color: var(--color-gray-80);
|
||||
|
||||
--color-muted: var(--color-gray-80);
|
||||
--color-muted-darker: var(--color-gray-60);
|
||||
--color-muted-darkest: var(--color-gray-20);
|
||||
--color-muted-background: var(--color-gray-40);
|
||||
--color-muted-background-darker: var(--color-gray-20);
|
||||
|
||||
--color-promo: #d297ff;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -45,6 +45,7 @@ import {
|
|||
ExcalidrawProgrammaticAPI,
|
||||
convertToExcalidrawElements,
|
||||
} from "../data/transform";
|
||||
import { normalizeLink } from "./url";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
|
@ -66,6 +67,7 @@ export const AllowedExcalidrawActiveTools: Record<
|
|||
freedraw: true,
|
||||
eraser: false,
|
||||
custom: true,
|
||||
frame: true,
|
||||
hand: true,
|
||||
};
|
||||
|
||||
|
@ -131,6 +133,7 @@ export const restoreElementWithProperties = <
|
|||
height: element.height || 0,
|
||||
seed: element.seed ?? 1,
|
||||
groupIds: element.groupIds ?? [],
|
||||
frameId: element.frameId ?? null,
|
||||
roundness: element.roundness
|
||||
? element.roundness
|
||||
: element.strokeSharpness === "round"
|
||||
|
@ -146,7 +149,7 @@ export const restoreElementWithProperties = <
|
|||
? element.boundElementIds.map((id) => ({ type: "arrow", id }))
|
||||
: element.boundElements ?? [],
|
||||
updated: element.updated ?? getUpdatedTimestamp(),
|
||||
link: element.link ?? null,
|
||||
link: element.link ? normalizeLink(element.link) : null,
|
||||
locked: element.locked ?? false,
|
||||
};
|
||||
|
||||
|
@ -276,6 +279,10 @@ const restoreElement = (
|
|||
return restoreElementWithProperties(element, {});
|
||||
case "diamond":
|
||||
return restoreElementWithProperties(element, {});
|
||||
case "frame":
|
||||
return restoreElementWithProperties(element, {
|
||||
name: element.name ?? null,
|
||||
});
|
||||
|
||||
// Don't use default case so as to catch a missing an element type case.
|
||||
// We also don't want to throw, but instead return void so we filter
|
||||
|
@ -368,6 +375,24 @@ const repairBoundElement = (
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove an element's frameId if its containing frame is non-existent
|
||||
*
|
||||
* NOTE mutates elements.
|
||||
*/
|
||||
const repairFrameMembership = (
|
||||
element: Mutable<ExcalidrawElement>,
|
||||
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
|
||||
) => {
|
||||
if (element.frameId) {
|
||||
const containingFrame = elementsMap.get(element.frameId);
|
||||
|
||||
if (!containingFrame) {
|
||||
element.frameId = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const restoreElements = (
|
||||
elements: ExcalidrawProgrammaticAPI["elements"],
|
||||
/** NOTE doesn't serve for reconciliation */
|
||||
|
@ -415,6 +440,10 @@ export const restoreElements = (
|
|||
// repair binding. Mutates elements.
|
||||
const restoredElementsMap = arrayToMap(restoredElements);
|
||||
for (const element of restoredElements) {
|
||||
if (element.frameId) {
|
||||
repairFrameMembership(element, restoredElementsMap);
|
||||
}
|
||||
|
||||
if (isTextElement(element) && element.containerId) {
|
||||
repairBoundElement(element, restoredElementsMap);
|
||||
} else if (element.boundElements) {
|
||||
|
|
30
src/data/url.test.tsx
Normal file
30
src/data/url.test.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { normalizeLink } from "./url";
|
||||
|
||||
describe("normalizeLink", () => {
|
||||
// NOTE not an extensive XSS test suite, just to check if we're not
|
||||
// regressing in sanitization
|
||||
it("should sanitize links", () => {
|
||||
expect(
|
||||
// eslint-disable-next-line no-script-url
|
||||
normalizeLink(`javascript://%0aalert(document.domain)`).startsWith(
|
||||
// eslint-disable-next-line no-script-url
|
||||
`javascript:`,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(normalizeLink("ola")).toBe("ola");
|
||||
expect(normalizeLink(" ola")).toBe("ola");
|
||||
|
||||
expect(normalizeLink("https://www.excalidraw.com")).toBe(
|
||||
"https://www.excalidraw.com",
|
||||
);
|
||||
expect(normalizeLink("www.excalidraw.com")).toBe("www.excalidraw.com");
|
||||
expect(normalizeLink("/ola")).toBe("/ola");
|
||||
expect(normalizeLink("http://test")).toBe("http://test");
|
||||
expect(normalizeLink("ftp://test")).toBe("ftp://test");
|
||||
expect(normalizeLink("file://")).toBe("file://");
|
||||
expect(normalizeLink("file://")).toBe("file://");
|
||||
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
|
||||
expect(normalizeLink("[[test]]")).toBe("[[test]]");
|
||||
expect(normalizeLink("<test>")).toBe("<test>");
|
||||
});
|
||||
});
|
9
src/data/url.ts
Normal file
9
src/data/url.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
|
||||
export const normalizeLink = (link: string) => {
|
||||
return sanitizeUrl(link);
|
||||
};
|
||||
|
||||
export const isLocalLink = (link: string | null) => {
|
||||
return !!(link?.includes(location.origin) || link?.startsWith("/"));
|
||||
};
|
|
@ -29,6 +29,7 @@ import { getTooltipDiv, updateTooltipPosition } from "../components/Tooltip";
|
|||
import { getSelectedElements } from "../scene";
|
||||
import { isPointHittingElementBoundingBox } from "./collision";
|
||||
import { getElementAbsoluteCoords } from "./";
|
||||
import { isLocalLink, normalizeLink } from "../data/url";
|
||||
|
||||
import "./Hyperlink.scss";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
@ -166,7 +167,7 @@ export const Hyperlink = ({
|
|||
/>
|
||||
) : (
|
||||
<a
|
||||
href={element.link || ""}
|
||||
href={normalizeLink(element.link || "")}
|
||||
className={clsx("excalidraw-hyperlinkContainer-link", {
|
||||
"d-none": isEditing,
|
||||
})}
|
||||
|
@ -177,7 +178,13 @@ export const Hyperlink = ({
|
|||
EVENT.EXCALIDRAW_LINK,
|
||||
event.nativeEvent,
|
||||
);
|
||||
onLinkOpen(element, customEvent);
|
||||
onLinkOpen(
|
||||
{
|
||||
...element,
|
||||
link: normalizeLink(element.link),
|
||||
},
|
||||
customEvent,
|
||||
);
|
||||
if (customEvent.defaultPrevented) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
@ -231,21 +238,6 @@ const getCoordsForPopover = (
|
|||
return { x, y };
|
||||
};
|
||||
|
||||
export const normalizeLink = (link: string) => {
|
||||
link = link.trim();
|
||||
if (link) {
|
||||
// prefix with protocol if not fully-qualified
|
||||
if (!link.includes("://") && !/^[[\\/]/.test(link)) {
|
||||
link = `https://${link}`;
|
||||
}
|
||||
}
|
||||
return link;
|
||||
};
|
||||
|
||||
export const isLocalLink = (link: string | null) => {
|
||||
return !!(link?.includes(location.origin) || link?.startsWith("/"));
|
||||
};
|
||||
|
||||
export const actionLink = register({
|
||||
name: "hyperlink",
|
||||
perform: (elements, appState) => {
|
||||
|
@ -344,7 +336,7 @@ export const isPointHittingLinkIcon = (
|
|||
if (
|
||||
!isMobile &&
|
||||
appState.viewModeEnabled &&
|
||||
isPointHittingElementBoundingBox(element, [x, y], threshold)
|
||||
isPointHittingElementBoundingBox(element, [x, y], threshold, null)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
@ -440,7 +432,9 @@ export const shouldHideLinkPopup = (
|
|||
|
||||
const threshold = 15 / appState.zoom.value;
|
||||
// hitbox to prevent hiding when hovered in element bounding box
|
||||
if (isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold)) {
|
||||
if (
|
||||
isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold, null)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const [x1, y1, x2] = getElementAbsoluteCoords(element);
|
||||
|
|
|
@ -39,7 +39,7 @@ export type SuggestedPointBinding = [
|
|||
];
|
||||
|
||||
export const shouldEnableBindingForPointerEvent = (
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
) => {
|
||||
return !event[KEYS.CTRL_OR_CMD];
|
||||
};
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
NonDeleted,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
} from "./types";
|
||||
import { distance2d, rotate } from "../math";
|
||||
import { distance2d, rotate, rotatePoint } from "../math";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import { Drawable, Op } from "roughjs/bin/core";
|
||||
import { Point } from "../types";
|
||||
|
@ -25,10 +25,101 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
|
|||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { Mutable } from "../utility-types";
|
||||
|
||||
// x and y position of top left corner, x and y position of bottom right corner
|
||||
export type Bounds = readonly [number, number, number, number];
|
||||
export type RectangleBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
angle: number;
|
||||
};
|
||||
|
||||
type MaybeQuadraticSolution = [number | null, number | null] | false;
|
||||
|
||||
// x and y position of top left corner, x and y position of bottom right corner
|
||||
export type Bounds = readonly [x1: number, y1: number, x2: number, y2: number];
|
||||
|
||||
export class ElementBounds {
|
||||
private static boundsCache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
{
|
||||
bounds: Bounds;
|
||||
version: ExcalidrawElement["version"];
|
||||
}
|
||||
>();
|
||||
|
||||
static getBounds(element: ExcalidrawElement) {
|
||||
const cachedBounds = ElementBounds.boundsCache.get(element);
|
||||
|
||||
if (cachedBounds?.version && cachedBounds.version === element.version) {
|
||||
return cachedBounds.bounds;
|
||||
}
|
||||
|
||||
const bounds = ElementBounds.calculateBounds(element);
|
||||
|
||||
ElementBounds.boundsCache.set(element, {
|
||||
version: element.version,
|
||||
bounds,
|
||||
});
|
||||
|
||||
return bounds;
|
||||
}
|
||||
|
||||
private static calculateBounds(element: ExcalidrawElement): Bounds {
|
||||
let bounds: [number, number, number, number];
|
||||
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
|
||||
|
||||
if (isFreeDrawElement(element)) {
|
||||
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
|
||||
element.points.map(([x, y]) =>
|
||||
rotate(x, y, cx - element.x, cy - element.y, element.angle),
|
||||
),
|
||||
);
|
||||
|
||||
return [
|
||||
minX + element.x,
|
||||
minY + element.y,
|
||||
maxX + element.x,
|
||||
maxY + element.y,
|
||||
];
|
||||
} else if (isLinearElement(element)) {
|
||||
bounds = getLinearElementRotatedBounds(element, cx, cy);
|
||||
} else if (element.type === "diamond") {
|
||||
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
|
||||
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
|
||||
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
|
||||
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
const minY = Math.min(y11, y12, y22, y21);
|
||||
const maxX = Math.max(x11, x12, x22, x21);
|
||||
const maxY = Math.max(y11, y12, y22, y21);
|
||||
bounds = [minX, minY, maxX, maxY];
|
||||
} else if (element.type === "ellipse") {
|
||||
const w = (x2 - x1) / 2;
|
||||
const h = (y2 - y1) / 2;
|
||||
const cos = Math.cos(element.angle);
|
||||
const sin = Math.sin(element.angle);
|
||||
const ww = Math.hypot(w * cos, h * sin);
|
||||
const hh = Math.hypot(h * cos, w * sin);
|
||||
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
|
||||
} else {
|
||||
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
|
||||
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
|
||||
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
|
||||
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
const minY = Math.min(y11, y12, y22, y21);
|
||||
const maxX = Math.max(x11, x12, x22, x21);
|
||||
const maxY = Math.max(y11, y12, y22, y21);
|
||||
bounds = [minX, minY, maxX, maxY];
|
||||
}
|
||||
|
||||
return bounds;
|
||||
}
|
||||
}
|
||||
|
||||
// Scene -> Scene coords, but in x1,x2,y1,y2 format.
|
||||
//
|
||||
// If the element is created from right to left, the width is going to be negative
|
||||
// This set of functions retrieves the absolute position of the 4 points.
|
||||
export const getElementAbsoluteCoords = (
|
||||
|
@ -69,6 +160,111 @@ export const getElementAbsoluteCoords = (
|
|||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* for a given element, `getElementLineSegments` returns line segments
|
||||
* that can be used for visual collision detection (useful for frames)
|
||||
* as opposed to bounding box collision detection
|
||||
*/
|
||||
export const getElementLineSegments = (
|
||||
element: ExcalidrawElement,
|
||||
): [Point, Point][] => {
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
|
||||
|
||||
const center: Point = [cx, cy];
|
||||
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
const segments: [Point, Point][] = [];
|
||||
|
||||
let i = 0;
|
||||
|
||||
while (i < element.points.length - 1) {
|
||||
segments.push([
|
||||
rotatePoint(
|
||||
[
|
||||
element.points[i][0] + element.x,
|
||||
element.points[i][1] + element.y,
|
||||
] as Point,
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
rotatePoint(
|
||||
[
|
||||
element.points[i + 1][0] + element.x,
|
||||
element.points[i + 1][1] + element.y,
|
||||
] as Point,
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
]);
|
||||
i++;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
const [nw, ne, sw, se, n, s, w, e] = (
|
||||
[
|
||||
[x1, y1],
|
||||
[x2, y1],
|
||||
[x1, y2],
|
||||
[x2, y2],
|
||||
[cx, y1],
|
||||
[cx, y2],
|
||||
[x1, cy],
|
||||
[x2, cy],
|
||||
] as Point[]
|
||||
).map((point) => rotatePoint(point, center, element.angle));
|
||||
|
||||
if (element.type === "diamond") {
|
||||
return [
|
||||
[n, w],
|
||||
[n, e],
|
||||
[s, w],
|
||||
[s, e],
|
||||
];
|
||||
}
|
||||
|
||||
if (element.type === "ellipse") {
|
||||
return [
|
||||
[n, w],
|
||||
[n, e],
|
||||
[s, w],
|
||||
[s, e],
|
||||
[n, w],
|
||||
[n, e],
|
||||
[s, w],
|
||||
[s, e],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
[nw, ne],
|
||||
[sw, se],
|
||||
[nw, sw],
|
||||
[ne, se],
|
||||
[nw, e],
|
||||
[sw, e],
|
||||
[ne, w],
|
||||
[se, w],
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Scene -> Scene coords, but in x1,x2,y1,y2 format.
|
||||
*
|
||||
* Rectangle here means any rectangular frame, not an excalidraw element.
|
||||
*/
|
||||
export const getRectangleBoxAbsoluteCoords = (boxSceneCoords: RectangleBox) => {
|
||||
return [
|
||||
boxSceneCoords.x,
|
||||
boxSceneCoords.y,
|
||||
boxSceneCoords.x + boxSceneCoords.width,
|
||||
boxSceneCoords.y + boxSceneCoords.height,
|
||||
boxSceneCoords.x + boxSceneCoords.width / 2,
|
||||
boxSceneCoords.y + boxSceneCoords.height / 2,
|
||||
];
|
||||
};
|
||||
|
||||
export const pointRelativeTo = (
|
||||
element: ExcalidrawElement,
|
||||
absoluteCoords: Point,
|
||||
|
@ -454,64 +650,12 @@ const getLinearElementRotatedBounds = (
|
|||
return coords;
|
||||
};
|
||||
|
||||
// We could cache this stuff
|
||||
export const getElementBounds = (
|
||||
element: ExcalidrawElement,
|
||||
): [number, number, number, number] => {
|
||||
let bounds: [number, number, number, number];
|
||||
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
|
||||
if (isFreeDrawElement(element)) {
|
||||
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
|
||||
element.points.map(([x, y]) =>
|
||||
rotate(x, y, cx - element.x, cy - element.y, element.angle),
|
||||
),
|
||||
);
|
||||
|
||||
return [
|
||||
minX + element.x,
|
||||
minY + element.y,
|
||||
maxX + element.x,
|
||||
maxY + element.y,
|
||||
];
|
||||
} else if (isLinearElement(element)) {
|
||||
bounds = getLinearElementRotatedBounds(element, cx, cy);
|
||||
} else if (element.type === "diamond") {
|
||||
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
|
||||
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
|
||||
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
|
||||
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
const minY = Math.min(y11, y12, y22, y21);
|
||||
const maxX = Math.max(x11, x12, x22, x21);
|
||||
const maxY = Math.max(y11, y12, y22, y21);
|
||||
bounds = [minX, minY, maxX, maxY];
|
||||
} else if (element.type === "ellipse") {
|
||||
const w = (x2 - x1) / 2;
|
||||
const h = (y2 - y1) / 2;
|
||||
const cos = Math.cos(element.angle);
|
||||
const sin = Math.sin(element.angle);
|
||||
const ww = Math.hypot(w * cos, h * sin);
|
||||
const hh = Math.hypot(h * cos, w * sin);
|
||||
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
|
||||
} else {
|
||||
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
|
||||
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
|
||||
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
|
||||
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
const minY = Math.min(y11, y12, y22, y21);
|
||||
const maxX = Math.max(x11, x12, x22, x21);
|
||||
const maxY = Math.max(y11, y12, y22, y21);
|
||||
bounds = [minX, minY, maxX, maxY];
|
||||
}
|
||||
|
||||
return bounds;
|
||||
export const getElementBounds = (element: ExcalidrawElement): Bounds => {
|
||||
return ElementBounds.getBounds(element);
|
||||
};
|
||||
|
||||
export const getCommonBounds = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): [number, number, number, number] => {
|
||||
): Bounds => {
|
||||
if (!elements.length) {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
|
@ -608,7 +752,7 @@ export const getElementPointsCoords = (
|
|||
export const getClosestElementBounds = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
from: { x: number; y: number },
|
||||
): [number, number, number, number] => {
|
||||
): Bounds => {
|
||||
if (!elements.length) {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
|
@ -629,7 +773,7 @@ export const getClosestElementBounds = (
|
|||
return getElementBounds(closestElement);
|
||||
};
|
||||
|
||||
export interface Box {
|
||||
export interface BoundingBox {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
|
@ -642,7 +786,7 @@ export interface Box {
|
|||
|
||||
export const getCommonBoundingBox = (
|
||||
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
|
||||
): Box => {
|
||||
): BoundingBox => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
return {
|
||||
minX,
|
||||
|
|
|
@ -26,10 +26,16 @@ import {
|
|||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
StrokeRoundness,
|
||||
ExcalidrawFrameElement,
|
||||
} from "./types";
|
||||
|
||||
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
||||
import { Point } from "../types";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getCurvePathOps,
|
||||
getRectangleBoxAbsoluteCoords,
|
||||
RectangleBox,
|
||||
} from "./bounds";
|
||||
import { FrameNameBoundsCache, Point } from "../types";
|
||||
import { Drawable } from "roughjs/bin/core";
|
||||
import { AppState } from "../types";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
|
@ -61,6 +67,7 @@ const isElementDraggableFromInside = (
|
|||
export const hitTest = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
frameNameBoundsCache: FrameNameBoundsCache,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean => {
|
||||
|
@ -72,22 +79,39 @@ export const hitTest = (
|
|||
isElementSelected(appState, element) &&
|
||||
shouldShowBoundingBox([element], appState)
|
||||
) {
|
||||
return isPointHittingElementBoundingBox(element, point, threshold);
|
||||
return isPointHittingElementBoundingBox(
|
||||
element,
|
||||
point,
|
||||
threshold,
|
||||
frameNameBoundsCache,
|
||||
);
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const isHittingBoundTextElement = hitTest(boundTextElement, appState, x, y);
|
||||
const isHittingBoundTextElement = hitTest(
|
||||
boundTextElement,
|
||||
appState,
|
||||
frameNameBoundsCache,
|
||||
x,
|
||||
y,
|
||||
);
|
||||
if (isHittingBoundTextElement) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return isHittingElementNotConsideringBoundingBox(element, appState, point);
|
||||
return isHittingElementNotConsideringBoundingBox(
|
||||
element,
|
||||
appState,
|
||||
frameNameBoundsCache,
|
||||
point,
|
||||
);
|
||||
};
|
||||
|
||||
export const isHittingElementBoundingBoxWithoutHittingElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
frameNameBoundsCache: FrameNameBoundsCache,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean => {
|
||||
|
@ -96,19 +120,33 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
|
|||
// So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element
|
||||
// eg for linear elements text can be outside the element bounding box
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement && hitTest(boundTextElement, appState, x, y)) {
|
||||
if (
|
||||
boundTextElement &&
|
||||
hitTest(boundTextElement, appState, frameNameBoundsCache, x, y)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) &&
|
||||
isPointHittingElementBoundingBox(element, [x, y], threshold)
|
||||
!isHittingElementNotConsideringBoundingBox(
|
||||
element,
|
||||
appState,
|
||||
frameNameBoundsCache,
|
||||
[x, y],
|
||||
) &&
|
||||
isPointHittingElementBoundingBox(
|
||||
element,
|
||||
[x, y],
|
||||
threshold,
|
||||
frameNameBoundsCache,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const isHittingElementNotConsideringBoundingBox = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
frameNameBoundsCache: FrameNameBoundsCache | null,
|
||||
point: Point,
|
||||
): boolean => {
|
||||
const threshold = 10 / appState.zoom.value;
|
||||
|
@ -117,7 +155,13 @@ export const isHittingElementNotConsideringBoundingBox = (
|
|||
: isElementDraggableFromInside(element)
|
||||
? isInsideCheck
|
||||
: isNearCheck;
|
||||
return hitTestPointAgainstElement({ element, point, threshold, check });
|
||||
return hitTestPointAgainstElement({
|
||||
element,
|
||||
point,
|
||||
threshold,
|
||||
check,
|
||||
frameNameBoundsCache,
|
||||
});
|
||||
};
|
||||
|
||||
const isElementSelected = (
|
||||
|
@ -129,7 +173,22 @@ export const isPointHittingElementBoundingBox = (
|
|||
element: NonDeleted<ExcalidrawElement>,
|
||||
[x, y]: Point,
|
||||
threshold: number,
|
||||
frameNameBoundsCache: FrameNameBoundsCache | null,
|
||||
) => {
|
||||
// frames needs be checked differently so as to be able to drag it
|
||||
// by its frame, whether it has been selected or not
|
||||
// this logic here is not ideal
|
||||
// TODO: refactor it later...
|
||||
if (element.type === "frame") {
|
||||
return hitTestPointAgainstElement({
|
||||
element,
|
||||
point: [x, y],
|
||||
threshold,
|
||||
check: isInsideCheck,
|
||||
frameNameBoundsCache,
|
||||
});
|
||||
}
|
||||
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const elementCenterX = (x1 + x2) / 2;
|
||||
const elementCenterY = (y1 + y2) / 2;
|
||||
|
@ -157,7 +216,13 @@ export const bindingBorderTest = (
|
|||
const threshold = maxBindingGap(element, element.width, element.height);
|
||||
const check = isOutsideCheck;
|
||||
const point: Point = [x, y];
|
||||
return hitTestPointAgainstElement({ element, point, threshold, check });
|
||||
return hitTestPointAgainstElement({
|
||||
element,
|
||||
point,
|
||||
threshold,
|
||||
check,
|
||||
frameNameBoundsCache: null,
|
||||
});
|
||||
};
|
||||
|
||||
export const maxBindingGap = (
|
||||
|
@ -177,6 +242,7 @@ type HitTestArgs = {
|
|||
point: Point;
|
||||
threshold: number;
|
||||
check: (distance: number, threshold: number) => boolean;
|
||||
frameNameBoundsCache: FrameNameBoundsCache | null;
|
||||
};
|
||||
|
||||
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
||||
|
@ -208,6 +274,27 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
|||
"This should not happen, we need to investigate why it does.",
|
||||
);
|
||||
return false;
|
||||
case "frame": {
|
||||
// check distance to frame element first
|
||||
if (
|
||||
args.check(
|
||||
distanceToBindableElement(args.element, args.point),
|
||||
args.threshold,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const frameNameBounds = args.frameNameBoundsCache?.get(args.element);
|
||||
|
||||
if (frameNameBounds) {
|
||||
return args.check(
|
||||
distanceToRectangleBox(frameNameBounds, args.point),
|
||||
args.threshold,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -219,6 +306,7 @@ export const distanceToBindableElement = (
|
|||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "frame":
|
||||
return distanceToRectangle(element, point);
|
||||
case "diamond":
|
||||
return distanceToDiamond(element, point);
|
||||
|
@ -248,7 +336,8 @@ const distanceToRectangle = (
|
|||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement,
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement,
|
||||
point: Point,
|
||||
): number => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
||||
|
@ -258,6 +347,14 @@ const distanceToRectangle = (
|
|||
);
|
||||
};
|
||||
|
||||
const distanceToRectangleBox = (box: RectangleBox, point: Point): number => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToDivElement(point, box);
|
||||
return Math.max(
|
||||
GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)),
|
||||
GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)),
|
||||
);
|
||||
};
|
||||
|
||||
const distanceToDiamond = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
point: Point,
|
||||
|
@ -457,8 +554,7 @@ const pointRelativeToElement = (
|
|||
): [GA.Point, GA.Point, number, number] => {
|
||||
const point = GAPoint.from(pointTuple);
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const elementCoords = getElementAbsoluteCoords(element);
|
||||
const center = coordsCenter([x1, y1, x2, y2]);
|
||||
const center = coordsCenter(x1, y1, x2, y2);
|
||||
// GA has angle orientation opposite to `rotate`
|
||||
const rotate = GATransform.rotation(center, element.angle);
|
||||
const pointRotated = GATransform.apply(rotate, point);
|
||||
|
@ -466,9 +562,26 @@ const pointRelativeToElement = (
|
|||
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
|
||||
const elementPos = GA.offset(element.x, element.y);
|
||||
const pointRelToPos = GA.sub(pointRotated, elementPos);
|
||||
const [ax, ay, bx, by] = elementCoords;
|
||||
const halfWidth = (bx - ax) / 2;
|
||||
const halfHeight = (by - ay) / 2;
|
||||
const halfWidth = (x2 - x1) / 2;
|
||||
const halfHeight = (y2 - y1) / 2;
|
||||
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
|
||||
};
|
||||
|
||||
const pointRelativeToDivElement = (
|
||||
pointTuple: Point,
|
||||
rectangle: RectangleBox,
|
||||
): [GA.Point, GA.Point, number, number] => {
|
||||
const point = GAPoint.from(pointTuple);
|
||||
const [x1, y1, x2, y2] = getRectangleBoxAbsoluteCoords(rectangle);
|
||||
const center = coordsCenter(x1, y1, x2, y2);
|
||||
const rotate = GATransform.rotation(center, rectangle.angle);
|
||||
const pointRotated = GATransform.apply(rotate, point);
|
||||
const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center));
|
||||
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
|
||||
const elementPos = GA.offset(rectangle.x, rectangle.y);
|
||||
const pointRelToPos = GA.sub(pointRotated, elementPos);
|
||||
const halfWidth = (x2 - x1) / 2;
|
||||
const halfHeight = (y2 - y1) / 2;
|
||||
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
|
||||
};
|
||||
|
||||
|
@ -490,7 +603,7 @@ const relativizationToElementCenter = (
|
|||
element: ExcalidrawElement,
|
||||
): GA.Transform => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const center = coordsCenter([x1, y1, x2, y2]);
|
||||
const center = coordsCenter(x1, y1, x2, y2);
|
||||
// GA has angle orientation opposite to `rotate`
|
||||
const rotate = GATransform.rotation(center, element.angle);
|
||||
const translate = GA.reverse(
|
||||
|
@ -499,8 +612,13 @@ const relativizationToElementCenter = (
|
|||
return GATransform.compose(rotate, translate);
|
||||
};
|
||||
|
||||
const coordsCenter = ([ax, ay, bx, by]: Bounds): GA.Point => {
|
||||
return GA.point((ax + bx) / 2, (ay + by) / 2);
|
||||
const coordsCenter = (
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
): GA.Point => {
|
||||
return GA.point((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
};
|
||||
|
||||
// The focus distance is the oriented ratio between the size of
|
||||
|
@ -531,6 +649,7 @@ export const determineFocusDistance = (
|
|||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "frame":
|
||||
return c / (hwidth * (nabs + q * mabs));
|
||||
case "diamond":
|
||||
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
|
||||
|
@ -548,7 +667,7 @@ export const determineFocusPoint = (
|
|||
): Point => {
|
||||
if (focus === 0) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const center = coordsCenter([x1, y1, x2, y2]);
|
||||
const center = coordsCenter(x1, y1, x2, y2);
|
||||
return GAPoint.toTuple(center);
|
||||
}
|
||||
const relateToCenter = relativizationToElementCenter(element);
|
||||
|
@ -563,6 +682,7 @@ export const determineFocusPoint = (
|
|||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "frame":
|
||||
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
|
||||
break;
|
||||
case "ellipse":
|
||||
|
@ -613,6 +733,7 @@ const getSortedElementLineIntersections = (
|
|||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "frame":
|
||||
const corners = getCorners(element);
|
||||
intersections = corners
|
||||
.flatMap((point, i) => {
|
||||
|
@ -646,7 +767,8 @@ const getCorners = (
|
|||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement,
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFrameElement,
|
||||
scale: number = 1,
|
||||
): GA.Point[] => {
|
||||
const hx = (scale * element.width) / 2;
|
||||
|
@ -655,6 +777,7 @@ const getCorners = (
|
|||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "frame":
|
||||
return [
|
||||
GA.point(hx, hy),
|
||||
GA.point(hx, -hy),
|
||||
|
@ -802,7 +925,8 @@ export const findFocusPointForRectangulars = (
|
|||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement,
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFrameElement,
|
||||
// Between -1 and 1 for how far away should the focus point be relative
|
||||
// to the size of the element. Sign determines orientation.
|
||||
relativeDistance: number,
|
||||
|
|
|
@ -6,6 +6,8 @@ import { NonDeletedExcalidrawElement } from "./types";
|
|||
import { AppState, PointerDownState } from "../types";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { isSelectedViaGroup } from "../groups";
|
||||
import Scene from "../scene/Scene";
|
||||
import { isFrameElement } from "./typeChecks";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
|
@ -16,10 +18,31 @@ export const dragSelectedElements = (
|
|||
distanceX: number = 0,
|
||||
distanceY: number = 0,
|
||||
appState: AppState,
|
||||
scene: Scene,
|
||||
) => {
|
||||
const [x1, y1] = getCommonBounds(selectedElements);
|
||||
const offset = { x: pointerX - x1, y: pointerY - y1 };
|
||||
selectedElements.forEach((element) => {
|
||||
|
||||
// we do not want a frame and its elements to be selected at the same time
|
||||
// but when it happens (due to some bug), we want to avoid updating element
|
||||
// in the frame twice, hence the use of set
|
||||
const elementsToUpdate = new Set<NonDeletedExcalidrawElement>(
|
||||
selectedElements,
|
||||
);
|
||||
const frames = selectedElements
|
||||
.filter((e) => isFrameElement(e))
|
||||
.map((f) => f.id);
|
||||
|
||||
if (frames.length > 0) {
|
||||
const elementsInFrames = scene
|
||||
.getNonDeletedElements()
|
||||
.filter((e) => e.frameId !== null)
|
||||
.filter((e) => frames.includes(e.frameId!));
|
||||
|
||||
elementsInFrames.forEach((element) => elementsToUpdate.add(element));
|
||||
}
|
||||
|
||||
elementsToUpdate.forEach((element) => {
|
||||
updateElementCoords(
|
||||
lockDirection,
|
||||
distanceX,
|
||||
|
@ -38,7 +61,13 @@ export const dragSelectedElements = (
|
|||
(appState.editingGroupId && !isSelectedViaGroup(appState, element))
|
||||
) {
|
||||
const textElement = getBoundTextElement(element);
|
||||
if (textElement) {
|
||||
if (
|
||||
textElement &&
|
||||
// when container is added to a frame, so will its bound text
|
||||
// so the text is already in `elementsToUpdate` and we should avoid
|
||||
// updating its coords again
|
||||
(!textElement.frameId || !frames.includes(textElement.frameId))
|
||||
) {
|
||||
updateElementCoords(
|
||||
lockDirection,
|
||||
distanceX,
|
||||
|
@ -50,7 +79,7 @@ export const dragSelectedElements = (
|
|||
}
|
||||
}
|
||||
updateBoundElements(element, {
|
||||
simultaneouslyUpdated: selectedElements,
|
||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -2,6 +2,7 @@ import {
|
|||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
ExcalidrawFrameElement,
|
||||
} from "./types";
|
||||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||
import { isLinearElementType } from "./typeChecks";
|
||||
|
@ -49,7 +50,11 @@ export {
|
|||
getDragOffsetXY,
|
||||
dragNewElement,
|
||||
} from "./dragElements";
|
||||
export { isTextElement, isExcalidrawElement } from "./typeChecks";
|
||||
export {
|
||||
isTextElement,
|
||||
isExcalidrawElement,
|
||||
isFrameElement,
|
||||
} from "./typeChecks";
|
||||
export { textWysiwyg } from "./textWysiwyg";
|
||||
export { redrawTextBoundingBox } from "./textElement";
|
||||
export {
|
||||
|
@ -74,6 +79,13 @@ export const getNonDeletedElements = (elements: readonly ExcalidrawElement[]) =>
|
|||
(element) => !element.isDeleted,
|
||||
) as readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
export const getNonDeletedFrames = (
|
||||
frames: readonly ExcalidrawFrameElement[],
|
||||
) =>
|
||||
frames.filter(
|
||||
(frame) => !frame.isDeleted,
|
||||
) as readonly NonDeleted<ExcalidrawFrameElement>[];
|
||||
|
||||
export const isNonDeletedElement = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
): element is NonDeleted<T> => !element.isDeleted;
|
||||
|
|
|
@ -594,7 +594,7 @@ export class LinearElementEditor {
|
|||
}
|
||||
|
||||
static handlePointerDown(
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
appState: AppState,
|
||||
history: History,
|
||||
scenePointer: { x: number; y: number },
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
ExcalidrawFreeDrawElement,
|
||||
FontFamilyValues,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawFrameElement,
|
||||
} from "../element/types";
|
||||
import {
|
||||
arrayToMap,
|
||||
|
@ -20,15 +21,13 @@ import {
|
|||
isTestEnv,
|
||||
} from "../utils";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
import { bumpVersion, mutateElement, newElementWith } from "./mutateElement";
|
||||
import { bumpVersion, newElementWith } from "./mutateElement";
|
||||
import { getNewGroupIdsForDuplication } from "../groups";
|
||||
import { AppState } from "../types";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
import { adjustXYWithRotation } from "../math";
|
||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
getBoundTextElementOffset,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
measureText,
|
||||
normalizeText,
|
||||
|
@ -44,7 +43,6 @@ import {
|
|||
DEFAULT_VERTICAL_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { isArrowElement } from "./typeChecks";
|
||||
import { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
|
@ -53,6 +51,7 @@ export type ElementConstructorOpts = MarkOptional<
|
|||
| "height"
|
||||
| "angle"
|
||||
| "groupIds"
|
||||
| "frameId"
|
||||
| "boundElements"
|
||||
| "seed"
|
||||
| "version"
|
||||
|
@ -85,6 +84,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
|||
height = 0,
|
||||
angle = 0,
|
||||
groupIds = [],
|
||||
frameId = null,
|
||||
roundness = null,
|
||||
boundElements = null,
|
||||
link = null,
|
||||
|
@ -109,6 +109,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
|||
roughness,
|
||||
opacity,
|
||||
groupIds,
|
||||
frameId,
|
||||
roundness,
|
||||
seed: rest.seed ?? randomInteger(),
|
||||
version: rest.version || 1,
|
||||
|
@ -129,6 +130,21 @@ export const newElement = (
|
|||
): NonDeleted<ExcalidrawGenericElement> =>
|
||||
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
||||
|
||||
export const newFrameElement = (
|
||||
opts: ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawFrameElement> => {
|
||||
const frameElement = newElementWith(
|
||||
{
|
||||
..._newElementBase<ExcalidrawFrameElement>("frame", opts),
|
||||
type: "frame",
|
||||
name: null,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return frameElement;
|
||||
};
|
||||
|
||||
/** computes element x/y offset based on textAlign/verticalAlign */
|
||||
const getTextElementPositionOffsets = (
|
||||
opts: {
|
||||
|
@ -161,6 +177,7 @@ export const newTextElement = (
|
|||
containerId?: ExcalidrawTextContainer["id"] | null;
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
|
||||
isFrameName?: boolean;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
|
@ -195,6 +212,7 @@ export const newTextElement = (
|
|||
containerId: opts.containerId || null,
|
||||
originalText: text,
|
||||
lineHeight,
|
||||
isFrameName: opts.isFrameName || false,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
@ -211,8 +229,6 @@ const getAdjustedDimensions = (
|
|||
height: number;
|
||||
baseline: number;
|
||||
} => {
|
||||
const container = getContainerElement(element);
|
||||
|
||||
const {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
|
@ -268,27 +284,6 @@ const getAdjustedDimensions = (
|
|||
);
|
||||
}
|
||||
|
||||
// make sure container dimensions are set properly when
|
||||
// text editor overflows beyond viewport dimensions
|
||||
if (container) {
|
||||
const boundTextElementPadding = getBoundTextElementOffset(element);
|
||||
|
||||
const containerDims = getContainerDims(container);
|
||||
let height = containerDims.height;
|
||||
let width = containerDims.width;
|
||||
if (nextHeight > height - boundTextElementPadding * 2) {
|
||||
height = nextHeight + boundTextElementPadding * 2;
|
||||
}
|
||||
if (nextWidth > width - boundTextElementPadding * 2) {
|
||||
width = nextWidth + boundTextElementPadding * 2;
|
||||
}
|
||||
if (
|
||||
!isArrowElement(container) &&
|
||||
(height !== containerDims.height || width !== containerDims.width)
|
||||
) {
|
||||
mutateElement(container, { height, width });
|
||||
}
|
||||
}
|
||||
return {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
|
@ -638,6 +633,10 @@ export const duplicateElements = (
|
|||
: null;
|
||||
}
|
||||
|
||||
if (clonedElement.frameId) {
|
||||
clonedElement.frameId = maybeGetNewId(clonedElement.frameId);
|
||||
}
|
||||
|
||||
clonedElements.push(clonedElement);
|
||||
}
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ import {
|
|||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isFrameElement,
|
||||
isFreeDrawElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
|
@ -160,12 +161,17 @@ const rotateSingleElement = (
|
|||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
|
||||
if (shouldRotateWithDiscreteAngle) {
|
||||
angle += SHIFT_LOCKING_ANGLE / 2;
|
||||
angle -= angle % SHIFT_LOCKING_ANGLE;
|
||||
let angle: number;
|
||||
if (isFrameElement(element)) {
|
||||
angle = 0;
|
||||
} else {
|
||||
angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
|
||||
if (shouldRotateWithDiscreteAngle) {
|
||||
angle += SHIFT_LOCKING_ANGLE / 2;
|
||||
angle -= angle % SHIFT_LOCKING_ANGLE;
|
||||
}
|
||||
angle = normalizeAngle(angle);
|
||||
}
|
||||
angle = normalizeAngle(angle);
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
|
||||
mutateElement(element, { angle });
|
||||
|
@ -877,39 +883,49 @@ const rotateMultipleElements = (
|
|||
centerAngle += SHIFT_LOCKING_ANGLE / 2;
|
||||
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
|
||||
}
|
||||
elements.forEach((element) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const origAngle =
|
||||
pointerDownState.originalElements.get(element.id)?.angle ?? element.angle;
|
||||
const [rotatedCX, rotatedCY] = rotate(
|
||||
cx,
|
||||
cy,
|
||||
centerX,
|
||||
centerY,
|
||||
centerAngle + origAngle - element.angle,
|
||||
);
|
||||
mutateElement(element, {
|
||||
x: element.x + (rotatedCX - cx),
|
||||
y: element.y + (rotatedCY - cy),
|
||||
angle: normalizeAngle(centerAngle + origAngle),
|
||||
});
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
if (boundTextElementId) {
|
||||
const textElement =
|
||||
Scene.getScene(element)?.getElement<ExcalidrawTextElementWithContainer>(
|
||||
boundTextElementId,
|
||||
);
|
||||
if (textElement && !isArrowElement(element)) {
|
||||
mutateElement(textElement, {
|
||||
x: textElement.x + (rotatedCX - cx),
|
||||
y: textElement.y + (rotatedCY - cy),
|
||||
|
||||
elements
|
||||
.filter((element) => element.type !== "frame")
|
||||
.forEach((element) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const origAngle =
|
||||
pointerDownState.originalElements.get(element.id)?.angle ??
|
||||
element.angle;
|
||||
const [rotatedCX, rotatedCY] = rotate(
|
||||
cx,
|
||||
cy,
|
||||
centerX,
|
||||
centerY,
|
||||
centerAngle + origAngle - element.angle,
|
||||
);
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
x: element.x + (rotatedCX - cx),
|
||||
y: element.y + (rotatedCY - cy),
|
||||
angle: normalizeAngle(centerAngle + origAngle),
|
||||
});
|
||||
},
|
||||
false,
|
||||
);
|
||||
updateBoundElements(element, { simultaneouslyUpdated: elements });
|
||||
|
||||
const boundText = getBoundTextElement(element);
|
||||
if (boundText && !isArrowElement(element)) {
|
||||
mutateElement(
|
||||
boundText,
|
||||
{
|
||||
x: boundText.x + (rotatedCX - cx),
|
||||
y: boundText.y + (rotatedCY - cy),
|
||||
angle: normalizeAngle(centerAngle + origAngle),
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Scene.getScene(elements[0])?.informMutation();
|
||||
};
|
||||
|
||||
export const getResizeOffsetXY = (
|
||||
|
|
|
@ -845,10 +845,12 @@ export const getTextBindableContainerAtPosition = (
|
|||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
|
||||
if (
|
||||
isArrowElement(elements[index]) &&
|
||||
isHittingElementNotConsideringBoundingBox(elements[index], appState, [
|
||||
x,
|
||||
y,
|
||||
])
|
||||
isHittingElementNotConsideringBoundingBox(
|
||||
elements[index],
|
||||
appState,
|
||||
null,
|
||||
[x, y],
|
||||
)
|
||||
) {
|
||||
hitElement = elements[index];
|
||||
break;
|
||||
|
@ -865,7 +867,6 @@ export const VALID_CONTAINER_TYPES = new Set([
|
|||
"rectangle",
|
||||
"ellipse",
|
||||
"diamond",
|
||||
"image",
|
||||
"arrow",
|
||||
]);
|
||||
|
||||
|
|
|
@ -26,6 +26,17 @@ ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
|||
const tab = " ";
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
const getTextEditor = () => {
|
||||
return document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
};
|
||||
|
||||
const updateTextEditor = (editor: HTMLTextAreaElement, value: string) => {
|
||||
fireEvent.change(editor, { target: { value } });
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
};
|
||||
|
||||
describe("textWysiwyg", () => {
|
||||
describe("start text editing", () => {
|
||||
const { h } = window;
|
||||
|
@ -190,9 +201,7 @@ describe("textWysiwyg", () => {
|
|||
|
||||
mouse.clickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
|
@ -214,9 +223,7 @@ describe("textWysiwyg", () => {
|
|||
|
||||
mouse.doubleClickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
|
@ -243,9 +250,7 @@ describe("textWysiwyg", () => {
|
|||
textElement = UI.createElement("text");
|
||||
|
||||
mouse.clickOn(textElement);
|
||||
textarea = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
)!;
|
||||
textarea = getTextEditor();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
@ -455,17 +460,11 @@ describe("textWysiwyg", () => {
|
|||
UI.clickTool("text");
|
||||
mouse.clickAt(750, 300);
|
||||
|
||||
textarea = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
)!;
|
||||
fireEvent.change(textarea, {
|
||||
target: {
|
||||
value:
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
|
||||
},
|
||||
});
|
||||
|
||||
textarea.dispatchEvent(new Event("input"));
|
||||
textarea = getTextEditor();
|
||||
updateTextEditor(
|
||||
textarea,
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
|
||||
);
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
textarea.blur();
|
||||
expect(textarea.style.width).toBe("792px");
|
||||
|
@ -513,11 +512,9 @@ describe("textWysiwyg", () => {
|
|||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
@ -543,11 +540,9 @@ describe("textWysiwyg", () => {
|
|||
]);
|
||||
expect(text.angle).toBe(rectangle.angle);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
@ -572,9 +567,7 @@ describe("textWysiwyg", () => {
|
|||
API.setSelectedElements([diamond]);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const value = new Array(1000).fill("1").join("\n");
|
||||
|
@ -587,9 +580,7 @@ describe("textWysiwyg", () => {
|
|||
expect(diamond.height).toBe(50020);
|
||||
|
||||
// Clearing text to simulate height decrease
|
||||
expect(() =>
|
||||
fireEvent.input(editor, { target: { value: "" } }),
|
||||
).not.toThrow();
|
||||
expect(() => updateTextEditor(editor, "")).not.toThrow();
|
||||
|
||||
expect(diamond.height).toBe(70);
|
||||
});
|
||||
|
@ -611,9 +602,7 @@ describe("textWysiwyg", () => {
|
|||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(null);
|
||||
mouse.down();
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
||||
|
@ -628,11 +617,9 @@ describe("textWysiwyg", () => {
|
|||
expect(text.containerId).toBe(rectangle.id);
|
||||
|
||||
mouse.down();
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
||||
|
@ -652,13 +639,11 @@ describe("textWysiwyg", () => {
|
|||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
|
@ -689,11 +674,8 @@ describe("textWysiwyg", () => {
|
|||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
@ -717,17 +699,9 @@ describe("textWysiwyg", () => {
|
|||
freedraw.y + freedraw.height / 2,
|
||||
);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Hello World!",
|
||||
},
|
||||
});
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
|
||||
expect(freedraw.boundElements).toBe(null);
|
||||
expect(h.elements[1].type).toBe("text");
|
||||
|
@ -759,11 +733,9 @@ describe("textWysiwyg", () => {
|
|||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(null);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
@ -776,17 +748,12 @@ describe("textWysiwyg", () => {
|
|||
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
updateTextEditor(
|
||||
editor,
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
);
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
expect(h.elements.length).toBe(2);
|
||||
expect(h.elements[1].type).toBe("text");
|
||||
|
@ -826,12 +793,10 @@ describe("textWysiwyg", () => {
|
|||
mouse.down();
|
||||
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil);
|
||||
UI.clickTool("text");
|
||||
|
@ -841,9 +806,7 @@ describe("textWysiwyg", () => {
|
|||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
mouse.down();
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
editor.select();
|
||||
fireEvent.click(screen.getByTitle(/code/i));
|
||||
|
@ -876,17 +839,9 @@ describe("textWysiwyg", () => {
|
|||
|
||||
Keyboard.keyDown(KEYS.ENTER);
|
||||
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Hello World!",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
editor.blur();
|
||||
|
@ -905,17 +860,8 @@ describe("textWysiwyg", () => {
|
|||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Hello",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
|
@ -943,13 +889,11 @@ describe("textWysiwyg", () => {
|
|||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
|
@ -982,11 +926,9 @@ describe("textWysiwyg", () => {
|
|||
// Bind first text
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
|
@ -1005,11 +947,9 @@ describe("textWysiwyg", () => {
|
|||
it("should respect text alignment when resizing", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
||||
// should center align horizontally and vertically by default
|
||||
|
@ -1024,9 +964,7 @@ describe("textWysiwyg", () => {
|
|||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
editor.select();
|
||||
|
||||
|
@ -1049,9 +987,7 @@ describe("textWysiwyg", () => {
|
|||
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
editor.select();
|
||||
|
||||
|
@ -1089,11 +1025,9 @@ describe("textWysiwyg", () => {
|
|||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
@ -1106,11 +1040,9 @@ describe("textWysiwyg", () => {
|
|||
it("should scale font size correctly when resizing using shift", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
const textElement = h.elements[1] as ExcalidrawTextElement;
|
||||
expect(rectangle.width).toBe(90);
|
||||
|
@ -1128,11 +1060,9 @@ describe("textWysiwyg", () => {
|
|||
it("should bind text correctly when container duplicated with alt-drag", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
|
@ -1162,11 +1092,9 @@ describe("textWysiwyg", () => {
|
|||
|
||||
it("undo should work", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: h.elements[1].id, type: "text" },
|
||||
|
@ -1201,54 +1129,64 @@ describe("textWysiwyg", () => {
|
|||
|
||||
it("should not allow bound text with only whitespaces", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, { target: { value: " " } });
|
||||
updateTextEditor(editor, " ");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([]);
|
||||
expect(h.elements[1].isDeleted).toBe(true);
|
||||
});
|
||||
|
||||
it("should restore original container height and clear cache once text is unbind", async () => {
|
||||
const originalRectHeight = rectangle.height;
|
||||
expect(rectangle.height).toBe(originalRectHeight);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: { value: "Online whiteboard collaboration made easy" },
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
height: 75,
|
||||
width: 90,
|
||||
});
|
||||
editor.blur();
|
||||
expect(rectangle.height).toBe(185);
|
||||
mouse.select(rectangle);
|
||||
const originalRectHeight = container.height;
|
||||
expect(container.height).toBe(originalRectHeight);
|
||||
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "Online whiteboard collaboration made easy",
|
||||
});
|
||||
|
||||
h.elements = [container, text];
|
||||
API.setSelectedElements([container, text]);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
});
|
||||
const contextMenu = document.querySelector(".context-menu");
|
||||
let contextMenu = document.querySelector(".context-menu");
|
||||
|
||||
fireEvent.click(
|
||||
queryByText(contextMenu as HTMLElement, "Bind text to the container")!,
|
||||
);
|
||||
|
||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text).toBe(
|
||||
"Online \nwhitebo\nard \ncollabo\nration \nmade \neasy",
|
||||
);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
});
|
||||
contextMenu = document.querySelector(".context-menu");
|
||||
fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!);
|
||||
expect(h.elements[0].boundElements).toEqual([]);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
|
||||
expect(getOriginalContainerHeightFromCache(container.id)).toBe(null);
|
||||
|
||||
expect(rectangle.height).toBe(originalRectHeight);
|
||||
expect(container.height).toBe(originalRectHeight);
|
||||
});
|
||||
|
||||
it("should reset the container height cache when resizing", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
|
@ -1258,9 +1196,7 @@ describe("textWysiwyg", () => {
|
|||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
@ -1273,12 +1209,8 @@ describe("textWysiwyg", () => {
|
|||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
|
||||
mouse.select(rectangle);
|
||||
|
@ -1302,12 +1234,8 @@ describe("textWysiwyg", () => {
|
|||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
||||
|
@ -1338,17 +1266,12 @@ describe("textWysiwyg", () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
editor.select();
|
||||
});
|
||||
|
||||
|
@ -1459,17 +1382,12 @@ describe("textWysiwyg", () => {
|
|||
it("should wrap text in a container when wrap text in container triggered from context menu", async () => {
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
updateTextEditor(
|
||||
editor,
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
);
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
|
||||
editor.select();
|
||||
|
@ -1541,5 +1459,54 @@ describe("textWysiwyg", () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shouldn't bind to container if container has bound text not centered and text tool is used", async () => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
// Bind first text
|
||||
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello!");
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign,
|
||||
).toBe(VERTICAL_ALIGN.MIDDLE);
|
||||
|
||||
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
editor.blur();
|
||||
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign,
|
||||
).toBe(VERTICAL_ALIGN.BOTTOM);
|
||||
|
||||
// Attempt to Bind 2nd text using text tool
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(
|
||||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Excalidraw");
|
||||
editor.blur();
|
||||
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: h.elements[1].id, type: "text" },
|
||||
]);
|
||||
text = h.elements[2] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(null);
|
||||
expect(text.text).toBe("Excalidraw");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
isBoundToContainer,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { CLASSES, isSafari, VERTICAL_ALIGN } from "../constants";
|
||||
import { CLASSES, isSafari } from "../constants";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
|
@ -23,12 +23,10 @@ import { AppState } from "../types";
|
|||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getBoundTextElementId,
|
||||
getContainerCoords,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getTextElementAngle,
|
||||
getTextWidth,
|
||||
measureText,
|
||||
normalizeText,
|
||||
redrawTextBoundingBox,
|
||||
wrapText,
|
||||
|
@ -36,6 +34,7 @@ import {
|
|||
getBoundTextMaxWidth,
|
||||
computeContainerDimensionForBoundText,
|
||||
detectLineHeight,
|
||||
computeBoundTextPosition,
|
||||
} from "./textElement";
|
||||
import {
|
||||
actionDecreaseFontSize,
|
||||
|
@ -162,7 +161,7 @@ export const textWysiwyg = ({
|
|||
let textElementWidth = updatedTextElement.width;
|
||||
// Set to element height by default since that's
|
||||
// what is going to be used for unbounded text
|
||||
let textElementHeight = updatedTextElement.height;
|
||||
const textElementHeight = updatedTextElement.height;
|
||||
|
||||
if (container && updatedTextElement.containerId) {
|
||||
if (isArrowElement(container)) {
|
||||
|
@ -179,15 +178,6 @@ export const textWysiwyg = ({
|
|||
editable,
|
||||
);
|
||||
const containerDims = getContainerDims(container);
|
||||
// using editor.style.height to get the accurate height of text editor
|
||||
const editorHeight = Number(editable.style.height.slice(0, -2));
|
||||
if (editorHeight > 0) {
|
||||
textElementHeight = editorHeight;
|
||||
}
|
||||
if (propertiesUpdated) {
|
||||
// update height of the editor after properties updated
|
||||
textElementHeight = updatedTextElement.height;
|
||||
}
|
||||
|
||||
let originalContainerData;
|
||||
if (propertiesUpdated) {
|
||||
|
@ -232,22 +222,12 @@ export const textWysiwyg = ({
|
|||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: targetContainerHeight });
|
||||
}
|
||||
// Start pushing text upward until a diff of 30px (padding)
|
||||
// is reached
|
||||
else {
|
||||
const containerCoords = getContainerCoords(container);
|
||||
|
||||
// vertically center align the text
|
||||
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
||||
if (!isArrowElement(container)) {
|
||||
coordY =
|
||||
containerCoords.y + maxHeight / 2 - textElementHeight / 2;
|
||||
}
|
||||
}
|
||||
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY = containerCoords.y + (maxHeight - textElementHeight);
|
||||
}
|
||||
} else {
|
||||
const { y } = computeBoundTextPosition(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
coordY = y;
|
||||
}
|
||||
}
|
||||
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
|
||||
|
@ -388,25 +368,6 @@ export const textWysiwyg = ({
|
|||
};
|
||||
|
||||
editable.oninput = () => {
|
||||
const updatedTextElement = Scene.getScene(element)?.getElement(
|
||||
id,
|
||||
) as ExcalidrawTextElement;
|
||||
const font = getFontString(updatedTextElement);
|
||||
if (isBoundToContainer(element)) {
|
||||
const container = getContainerElement(element);
|
||||
const wrappedText = wrapText(
|
||||
normalizeText(editable.value),
|
||||
font,
|
||||
getBoundTextMaxWidth(container!),
|
||||
);
|
||||
const { width, height } = measureText(
|
||||
wrappedText,
|
||||
font,
|
||||
updatedTextElement.lineHeight,
|
||||
);
|
||||
editable.style.width = `${width}px`;
|
||||
editable.style.height = `${height}px`;
|
||||
}
|
||||
onChange(normalizeText(editable.value));
|
||||
};
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import { getElementAbsoluteCoords } from "./bounds";
|
|||
import { rotate } from "../math";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import { isTextElement } from ".";
|
||||
import { isLinearElement } from "./typeChecks";
|
||||
import { isFrameElement, isLinearElement } from "./typeChecks";
|
||||
import { DEFAULT_SPACING } from "../renderer/renderScene";
|
||||
|
||||
export type TransformHandleDirection =
|
||||
|
@ -44,6 +44,14 @@ export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
|
|||
w: true,
|
||||
};
|
||||
|
||||
export const OMIT_SIDES_FOR_FRAME = {
|
||||
e: true,
|
||||
s: true,
|
||||
n: true,
|
||||
w: true,
|
||||
rotation: true,
|
||||
};
|
||||
|
||||
const OMIT_SIDES_FOR_TEXT_ELEMENT = {
|
||||
e: true,
|
||||
s: true,
|
||||
|
@ -249,6 +257,10 @@ export const getTransformHandles = (
|
|||
}
|
||||
} else if (isTextElement(element)) {
|
||||
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
|
||||
} else if (isFrameElement(element)) {
|
||||
omitSides = {
|
||||
rotation: true,
|
||||
};
|
||||
}
|
||||
const dashedLineMargin = isLinearElement(element)
|
||||
? DEFAULT_SPACING + 8
|
||||
|
|
|
@ -30,15 +30,6 @@ describe("Test TypeChecks", () => {
|
|||
}),
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "image",
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return false for text bindable containers without bound text", () => {
|
||||
|
@ -62,5 +53,14 @@ describe("Test TypeChecks", () => {
|
|||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "image",
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
ExcalidrawImageElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawFrameElement,
|
||||
RoundnessType,
|
||||
} from "./types";
|
||||
|
||||
|
@ -45,6 +46,12 @@ export const isTextElement = (
|
|||
return element != null && element.type === "text";
|
||||
};
|
||||
|
||||
export const isFrameElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawFrameElement => {
|
||||
return element != null && element.type === "frame";
|
||||
};
|
||||
|
||||
export const isFreeDrawElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawFreeDrawElement => {
|
||||
|
@ -119,7 +126,6 @@ export const isTextBindableContainer = (
|
|||
(element.type === "rectangle" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "image" ||
|
||||
isArrowElement(element))
|
||||
);
|
||||
};
|
||||
|
|
|
@ -53,6 +53,7 @@ type _ExcalidrawElementBase = Readonly<{
|
|||
/** List of groups the element belongs to.
|
||||
Ordered from deepest to shallowest. */
|
||||
groupIds: readonly GroupId[];
|
||||
frameId: string | null;
|
||||
/** other elements that are bound to this element */
|
||||
boundElements:
|
||||
| readonly Readonly<{
|
||||
|
@ -98,6 +99,11 @@ export type InitializedExcalidrawImageElement = MarkNonNullable<
|
|||
"fileId"
|
||||
>;
|
||||
|
||||
export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
|
||||
type: "frame";
|
||||
name: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* These are elements that don't have any additional properties.
|
||||
*/
|
||||
|
@ -117,7 +123,8 @@ export type ExcalidrawElement =
|
|||
| ExcalidrawTextElement
|
||||
| ExcalidrawLinearElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement;
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement;
|
||||
|
||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||
isDeleted: boolean;
|
||||
|
@ -148,13 +155,13 @@ export type ExcalidrawBindableElement =
|
|||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawImageElement;
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement;
|
||||
|
||||
export type ExcalidrawTextContainer =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawArrowElement;
|
||||
|
||||
export type ExcalidrawTextElementWithContainer = {
|
||||
|
|
|
@ -157,6 +157,8 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
window.addEventListener("offline", this.onOfflineStatusToggle);
|
||||
window.addEventListener(EVENT.UNLOAD, this.onUnload);
|
||||
|
||||
this.onOfflineStatusToggle();
|
||||
|
||||
const collabAPI: CollabAPI = {
|
||||
isCollaborating: this.isCollaborating,
|
||||
onPointerUpdate: this.onPointerUpdate,
|
||||
|
@ -168,7 +170,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
};
|
||||
|
||||
appJotaiStore.set(collabAPIAtom, collabAPI);
|
||||
this.onOfflineStatusToggle();
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV === ENV.TEST ||
|
||||
|
@ -380,6 +381,13 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
startCollaboration = async (
|
||||
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
||||
): Promise<ImportedDataState | null> => {
|
||||
if (!this.state.username) {
|
||||
import("@excalidraw/random-username").then(({ getRandomUsername }) => {
|
||||
const username = getRandomUsername();
|
||||
this.onUsernameChange(username);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.portal.socket) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import { LanguageList } from "./LanguageList";
|
|||
export const AppMainMenu: React.FC<{
|
||||
setCollabDialogShown: (toggle: boolean) => any;
|
||||
isCollaborating: boolean;
|
||||
isCollabEnabled: boolean;
|
||||
}> = React.memo((props) => {
|
||||
return (
|
||||
<MainMenu>
|
||||
|
@ -13,10 +14,12 @@ export const AppMainMenu: React.FC<{
|
|||
<MainMenu.DefaultItems.SaveToActiveFile />
|
||||
<MainMenu.DefaultItems.Export />
|
||||
<MainMenu.DefaultItems.SaveAsImage />
|
||||
<MainMenu.DefaultItems.LiveCollaborationTrigger
|
||||
isCollaborating={props.isCollaborating}
|
||||
onSelect={() => props.setCollabDialogShown(true)}
|
||||
/>
|
||||
{props.isCollabEnabled && (
|
||||
<MainMenu.DefaultItems.LiveCollaborationTrigger
|
||||
isCollaborating={props.isCollaborating}
|
||||
onSelect={() => props.setCollabDialogShown(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
|
|
|
@ -6,6 +6,7 @@ import { isExcalidrawPlusSignedUser } from "../app_constants";
|
|||
|
||||
export const AppWelcomeScreen: React.FC<{
|
||||
setCollabDialogShown: (toggle: boolean) => any;
|
||||
isCollabEnabled: boolean;
|
||||
}> = React.memo((props) => {
|
||||
const { t } = useI18n();
|
||||
let headingContent;
|
||||
|
@ -46,9 +47,11 @@ export const AppWelcomeScreen: React.FC<{
|
|||
<WelcomeScreen.Center.Menu>
|
||||
<WelcomeScreen.Center.MenuItemLoadScene />
|
||||
<WelcomeScreen.Center.MenuItemHelp />
|
||||
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
|
||||
onSelect={() => props.setCollabDialogShown(true)}
|
||||
/>
|
||||
{props.isCollabEnabled && (
|
||||
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
|
||||
onSelect={() => props.setCollabDialogShown(true)}
|
||||
/>
|
||||
)}
|
||||
{!isExcalidrawPlusSignedUser && (
|
||||
<WelcomeScreen.Center.MenuItemLink
|
||||
href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
|
||||
|
|
|
@ -16,7 +16,7 @@ import { MIME_TYPES } from "../../constants";
|
|||
import { trackEvent } from "../../analytics";
|
||||
import { getFrame } from "../../utils";
|
||||
|
||||
const exportToExcalidrawPlus = async (
|
||||
export const exportToExcalidrawPlus = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: Partial<AppState>,
|
||||
files: BinaryFiles,
|
||||
|
|
|
@ -263,7 +263,7 @@ export const loadScene = async (
|
|||
data = restore(
|
||||
await importFromBackend(id, privateKey),
|
||||
localDataState?.appState,
|
||||
convertToExcalidrawElements(localDataState?.elements),
|
||||
localDataState?.elements,
|
||||
{ repairBindings: true, refreshDimensions: false },
|
||||
);
|
||||
} else {
|
||||
|
@ -283,11 +283,15 @@ export const loadScene = async (
|
|||
};
|
||||
};
|
||||
|
||||
type ExportToBackendResult =
|
||||
| { url: null; errorMessage: string }
|
||||
| { url: string; errorMessage: null };
|
||||
|
||||
export const exportToBackend = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Partial<AppState>,
|
||||
files: BinaryFiles,
|
||||
) => {
|
||||
): Promise<ExportToBackendResult> => {
|
||||
const encryptionKey = await generateEncryptionKey("string");
|
||||
|
||||
const payload = await compressData(
|
||||
|
@ -328,14 +332,18 @@ export const exportToBackend = async (
|
|||
files: filesToUpload,
|
||||
});
|
||||
|
||||
window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
|
||||
return { url: urlString, errorMessage: null };
|
||||
} else if (json.error_class === "RequestTooLargeError") {
|
||||
window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
|
||||
} else {
|
||||
window.alert(t("alerts.couldNotCreateShareableLink"));
|
||||
return {
|
||||
url: null,
|
||||
errorMessage: t("alerts.couldNotCreateShareableLinkTooBig"),
|
||||
};
|
||||
}
|
||||
|
||||
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
window.alert(t("alerts.couldNotCreateShareableLink"));
|
||||
|
||||
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
|
||||
}
|
||||
};
|
||||
|
|
135
src/excalidraw-app/debug.ts
Normal file
135
src/excalidraw-app/debug.ts
Normal file
|
@ -0,0 +1,135 @@
|
|||
declare global {
|
||||
interface Window {
|
||||
debug: typeof Debug;
|
||||
}
|
||||
}
|
||||
|
||||
const lessPrecise = (num: number, precision = 5) =>
|
||||
parseFloat(num.toPrecision(precision));
|
||||
|
||||
const getAvgFrameTime = (times: number[]) =>
|
||||
lessPrecise(times.reduce((a, b) => a + b) / times.length);
|
||||
|
||||
const getFps = (frametime: number) => lessPrecise(1000 / frametime);
|
||||
|
||||
export class Debug {
|
||||
public static DEBUG_LOG_TIMES = true;
|
||||
|
||||
private static TIMES_AGGR: Record<string, { t: number; times: number[] }> =
|
||||
{};
|
||||
private static TIMES_AVG: Record<
|
||||
string,
|
||||
{ t: number; times: number[]; avg: number | null }
|
||||
> = {};
|
||||
private static LAST_DEBUG_LOG_CALL = 0;
|
||||
private static DEBUG_LOG_INTERVAL_ID: null | number = null;
|
||||
|
||||
private static setupInterval = () => {
|
||||
if (Debug.DEBUG_LOG_INTERVAL_ID === null) {
|
||||
console.info("%c(starting perf recording)", "color: lime");
|
||||
Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000);
|
||||
}
|
||||
Debug.LAST_DEBUG_LOG_CALL = Date.now();
|
||||
};
|
||||
|
||||
private static debugLogger = () => {
|
||||
if (
|
||||
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
|
||||
Debug.DEBUG_LOG_INTERVAL_ID !== null
|
||||
) {
|
||||
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
|
||||
Debug.DEBUG_LOG_INTERVAL_ID = null;
|
||||
for (const [name, { avg }] of Object.entries(Debug.TIMES_AVG)) {
|
||||
if (avg != null) {
|
||||
console.info(
|
||||
`%c${name} run avg: ${avg}ms (${getFps(avg)} fps)`,
|
||||
"color: blue",
|
||||
);
|
||||
}
|
||||
}
|
||||
console.info("%c(stopping perf recording)", "color: red");
|
||||
Debug.TIMES_AGGR = {};
|
||||
Debug.TIMES_AVG = {};
|
||||
return;
|
||||
}
|
||||
if (Debug.DEBUG_LOG_TIMES) {
|
||||
for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) {
|
||||
if (times.length) {
|
||||
console.info(
|
||||
name,
|
||||
lessPrecise(times.reduce((a, b) => a + b)),
|
||||
times.sort((a, b) => a - b).map((x) => lessPrecise(x)),
|
||||
);
|
||||
Debug.TIMES_AGGR[name] = { t, times: [] };
|
||||
}
|
||||
}
|
||||
for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
|
||||
if (times.length) {
|
||||
const avgFrameTime = getAvgFrameTime(times);
|
||||
console.info(name, `${avgFrameTime}ms (${getFps(avgFrameTime)} fps)`);
|
||||
Debug.TIMES_AVG[name] = {
|
||||
t,
|
||||
times: [],
|
||||
avg:
|
||||
avg != null ? getAvgFrameTime([avg, avgFrameTime]) : avgFrameTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static logTime = (time?: number, name = "default") => {
|
||||
Debug.setupInterval();
|
||||
const now = performance.now();
|
||||
const { t, times } = (Debug.TIMES_AGGR[name] = Debug.TIMES_AGGR[name] || {
|
||||
t: 0,
|
||||
times: [],
|
||||
});
|
||||
if (t) {
|
||||
times.push(time != null ? time : now - t);
|
||||
}
|
||||
Debug.TIMES_AGGR[name].t = now;
|
||||
};
|
||||
public static logTimeAverage = (time?: number, name = "default") => {
|
||||
Debug.setupInterval();
|
||||
const now = performance.now();
|
||||
const { t, times } = (Debug.TIMES_AVG[name] = Debug.TIMES_AVG[name] || {
|
||||
t: 0,
|
||||
times: [],
|
||||
});
|
||||
if (t) {
|
||||
times.push(time != null ? time : now - t);
|
||||
}
|
||||
Debug.TIMES_AVG[name].t = now;
|
||||
};
|
||||
|
||||
private static logWrapper =
|
||||
(type: "logTime" | "logTimeAverage") =>
|
||||
<T extends any[], R>(fn: (...args: T) => R, name = "default") => {
|
||||
return (...args: T) => {
|
||||
const t0 = performance.now();
|
||||
const ret = fn(...args);
|
||||
Debug.logTime(performance.now() - t0, name);
|
||||
return ret;
|
||||
};
|
||||
};
|
||||
|
||||
public static logTimeWrap = Debug.logWrapper("logTime");
|
||||
public static logTimeAverageWrap = Debug.logWrapper("logTimeAverage");
|
||||
|
||||
public static perfWrap = <T extends any[], R>(
|
||||
fn: (...args: T) => R,
|
||||
name = "default",
|
||||
) => {
|
||||
return (...args: T) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.time(name);
|
||||
const ret = fn(...args);
|
||||
// eslint-disable-next-line no-console
|
||||
console.timeEnd(name);
|
||||
return ret;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
window.debug = Debug;
|
|
@ -42,6 +42,7 @@ import {
|
|||
preventUnload,
|
||||
ResolvablePromise,
|
||||
resolvablePromise,
|
||||
isRunningInIframe,
|
||||
} from "../utils";
|
||||
import {
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
|
@ -68,7 +69,10 @@ import {
|
|||
} from "./data/localStorage";
|
||||
import CustomStats from "./CustomStats";
|
||||
import { restore, restoreAppState, RestoredDataState } from "../data/restore";
|
||||
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
|
||||
import {
|
||||
ExportToExcalidrawPlus,
|
||||
exportToExcalidrawPlus,
|
||||
} from "./components/ExportToExcalidrawPlus";
|
||||
import { updateStaleImageStatuses } from "./data/FileManager";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { isInitializedImageElement } from "../element/typeChecks";
|
||||
|
@ -88,6 +92,10 @@ import { appJotaiStore } from "./app-jotai";
|
|||
import "./index.scss";
|
||||
import { ResolutionType } from "../utility-types";
|
||||
import { convertToExcalidrawElements } from "../data/transform";
|
||||
import { ShareableLinkDialog } from "../components/ShareableLinkDialog";
|
||||
import { openConfirmModal } from "../components/OverwriteConfirm/OverwriteConfirmState";
|
||||
import { OverwriteConfirmDialog } from "../components/OverwriteConfirm/OverwriteConfirm";
|
||||
import Trans from "../components/Trans";
|
||||
|
||||
polyfill();
|
||||
|
||||
|
@ -98,8 +106,21 @@ languageDetector.init({
|
|||
languageUtils: {},
|
||||
});
|
||||
|
||||
const shareableLinkConfirmDialog = {
|
||||
title: t("overwriteConfirm.modal.shareableLink.title"),
|
||||
description: (
|
||||
<Trans
|
||||
i18nKey="overwriteConfirm.modal.shareableLink.description"
|
||||
bold={(text) => <strong>{text}</strong>}
|
||||
br={() => <br />}
|
||||
/>
|
||||
),
|
||||
actionLabel: t("overwriteConfirm.modal.shareableLink.button"),
|
||||
color: "danger",
|
||||
} as const;
|
||||
|
||||
const initializeScene = async (opts: {
|
||||
collabAPI: CollabAPI;
|
||||
collabAPI: CollabAPI | null;
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
}): Promise<
|
||||
{ scene: ExcalidrawInitialDataState | null } & (
|
||||
|
@ -129,7 +150,7 @@ const initializeScene = async (opts: {
|
|||
// don't prompt for collab scenes because we don't override local storage
|
||||
roomLinkData ||
|
||||
// otherwise, prompt whether user wants to override current scene
|
||||
window.confirm(t("alerts.loadSceneOverridePrompt"))
|
||||
(await openConfirmModal(shareableLinkConfirmDialog))
|
||||
) {
|
||||
if (jsonBackendMatch) {
|
||||
scene = await loadScene(
|
||||
|
@ -168,7 +189,7 @@ const initializeScene = async (opts: {
|
|||
const data = await loadFromBlob(await request.blob(), null, null);
|
||||
if (
|
||||
!scene.elements.length ||
|
||||
window.confirm(t("alerts.loadSceneOverridePrompt"))
|
||||
(await openConfirmModal(shareableLinkConfirmDialog))
|
||||
) {
|
||||
return { scene: data, isExternalScene };
|
||||
}
|
||||
|
@ -184,7 +205,7 @@ const initializeScene = async (opts: {
|
|||
}
|
||||
}
|
||||
|
||||
if (roomLinkData) {
|
||||
if (roomLinkData && opts.collabAPI) {
|
||||
const { excalidrawAPI } = opts;
|
||||
|
||||
const scene = await opts.collabAPI.startCollaboration(roomLinkData);
|
||||
|
@ -238,6 +259,7 @@ export const appLangCodeAtom = atom(
|
|||
const ExcalidrawWrapper = () => {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
||||
const isCollabDisabled = isRunningInIframe();
|
||||
|
||||
// initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
@ -273,7 +295,7 @@ const ExcalidrawWrapper = () => {
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!collabAPI || !excalidrawAPI) {
|
||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -284,7 +306,7 @@ const ExcalidrawWrapper = () => {
|
|||
if (!data.scene) {
|
||||
return;
|
||||
}
|
||||
if (collabAPI.isCollaborating()) {
|
||||
if (collabAPI?.isCollaborating()) {
|
||||
if (data.scene.elements) {
|
||||
collabAPI
|
||||
.fetchImageFilesFromFirebase({
|
||||
|
@ -355,7 +377,7 @@ const ExcalidrawWrapper = () => {
|
|||
const libraryUrlTokens = parseLibraryTokensFromUrl();
|
||||
if (!libraryUrlTokens) {
|
||||
if (
|
||||
collabAPI.isCollaborating() &&
|
||||
collabAPI?.isCollaborating() &&
|
||||
!isCollaborationLink(window.location.href)
|
||||
) {
|
||||
collabAPI.stopCollaboration(false);
|
||||
|
@ -384,7 +406,10 @@ const ExcalidrawWrapper = () => {
|
|||
if (isTestEnv()) {
|
||||
return;
|
||||
}
|
||||
if (!document.hidden && !collabAPI.isCollaborating()) {
|
||||
if (
|
||||
!document.hidden &&
|
||||
((collabAPI && !collabAPI.isCollaborating()) || isCollabDisabled)
|
||||
) {
|
||||
// don't sync if local state is newer or identical to browser state
|
||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
||||
const localDataState = importFromLocalStorage();
|
||||
|
@ -400,7 +425,7 @@ const ExcalidrawWrapper = () => {
|
|||
excalidrawAPI.updateLibrary({
|
||||
libraryItems: getLibraryItemsFromStorage(),
|
||||
});
|
||||
collabAPI.setUsername(username || "");
|
||||
collabAPI?.setUsername(username || "");
|
||||
}
|
||||
|
||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_FILES)) {
|
||||
|
@ -468,7 +493,7 @@ const ExcalidrawWrapper = () => {
|
|||
);
|
||||
clearTimeout(titleTimeout);
|
||||
};
|
||||
}, [collabAPI, excalidrawAPI, setLangCode]);
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||
|
||||
useEffect(() => {
|
||||
const unloadHandler = (event: BeforeUnloadEvent) => {
|
||||
|
@ -551,6 +576,10 @@ const ExcalidrawWrapper = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const onExportToBackend = async (
|
||||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: Partial<AppState>,
|
||||
|
@ -562,7 +591,7 @@ const ExcalidrawWrapper = () => {
|
|||
}
|
||||
if (canvas) {
|
||||
try {
|
||||
await exportToBackend(
|
||||
const { url, errorMessage } = await exportToBackend(
|
||||
exportedElements,
|
||||
{
|
||||
...appState,
|
||||
|
@ -572,6 +601,14 @@ const ExcalidrawWrapper = () => {
|
|||
},
|
||||
files,
|
||||
);
|
||||
|
||||
if (errorMessage) {
|
||||
setErrorMessage(errorMessage);
|
||||
}
|
||||
|
||||
if (url) {
|
||||
setLatestShareableLink(url);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
const { width, height } = canvas;
|
||||
|
@ -651,7 +688,7 @@ const ExcalidrawWrapper = () => {
|
|||
autoFocus={true}
|
||||
theme={theme}
|
||||
renderTopRightUI={(isMobile) => {
|
||||
if (isMobile) {
|
||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
|
@ -665,21 +702,53 @@ const ExcalidrawWrapper = () => {
|
|||
<AppMainMenu
|
||||
setCollabDialogShown={setCollabDialogShown}
|
||||
isCollaborating={isCollaborating}
|
||||
isCollabEnabled={!isCollabDisabled}
|
||||
/>
|
||||
<AppWelcomeScreen setCollabDialogShown={setCollabDialogShown} />
|
||||
<AppWelcomeScreen
|
||||
setCollabDialogShown={setCollabDialogShown}
|
||||
isCollabEnabled={!isCollabDisabled}
|
||||
/>
|
||||
<OverwriteConfirmDialog>
|
||||
<OverwriteConfirmDialog.Actions.ExportToImage />
|
||||
<OverwriteConfirmDialog.Actions.SaveToDisk />
|
||||
{excalidrawAPI && (
|
||||
<OverwriteConfirmDialog.Action
|
||||
title={t("overwriteConfirm.action.excalidrawPlus.title")}
|
||||
actionLabel={t("overwriteConfirm.action.excalidrawPlus.button")}
|
||||
onClick={() => {
|
||||
exportToExcalidrawPlus(
|
||||
excalidrawAPI.getSceneElements(),
|
||||
excalidrawAPI.getAppState(),
|
||||
excalidrawAPI.getFiles(),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("overwriteConfirm.action.excalidrawPlus.description")}
|
||||
</OverwriteConfirmDialog.Action>
|
||||
)}
|
||||
</OverwriteConfirmDialog>
|
||||
<AppFooter />
|
||||
{isCollaborating && isOffline && (
|
||||
<div className="collab-offline-warning">
|
||||
{t("alerts.collabOfflineWarning")}
|
||||
</div>
|
||||
)}
|
||||
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
|
||||
{latestShareableLink && (
|
||||
<ShareableLinkDialog
|
||||
link={latestShareableLink}
|
||||
onCloseRequest={() => setLatestShareableLink(null)}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
)}
|
||||
{excalidrawAPI && !isCollabDisabled && (
|
||||
<Collab excalidrawAPI={excalidrawAPI} />
|
||||
)}
|
||||
{errorMessage && (
|
||||
<ErrorDialog onClose={() => setErrorMessage("")}>
|
||||
{errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
</Excalidraw>
|
||||
{errorMessage && (
|
||||
<ErrorDialog onClose={() => setErrorMessage("")}>
|
||||
{errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
708
src/frame.ts
Normal file
708
src/frame.ts
Normal file
|
@ -0,0 +1,708 @@
|
|||
import {
|
||||
getCommonBounds,
|
||||
getElementAbsoluteCoords,
|
||||
isTextElement,
|
||||
} from "./element";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import { isPointWithinBounds } from "./math";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getContainerElement,
|
||||
} from "./element/textElement";
|
||||
import { arrayToMap, findIndex } from "./utils";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import { AppState } from "./types";
|
||||
import { getElementsWithinSelection, getSelectedElements } from "./scene";
|
||||
import { isFrameElement } from "./element";
|
||||
import { moveOneRight } from "./zindex";
|
||||
import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
|
||||
import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene";
|
||||
import { getElementLineSegments } from "./element/bounds";
|
||||
|
||||
// --------------------------- Frame State ------------------------------------
|
||||
export const bindElementsToFramesAfterDuplication = (
|
||||
nextElements: ExcalidrawElement[],
|
||||
oldElements: readonly ExcalidrawElement[],
|
||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
) => {
|
||||
const nextElementMap = arrayToMap(nextElements) as Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement
|
||||
>;
|
||||
|
||||
for (const element of oldElements) {
|
||||
if (element.frameId) {
|
||||
// use its frameId to get the new frameId
|
||||
const nextElementId = oldIdToDuplicatedId.get(element.id);
|
||||
const nextFrameId = oldIdToDuplicatedId.get(element.frameId);
|
||||
if (nextElementId) {
|
||||
const nextElement = nextElementMap.get(nextElementId);
|
||||
if (nextElement) {
|
||||
mutateElement(
|
||||
nextElement,
|
||||
{
|
||||
frameId: nextFrameId ?? element.frameId,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --------------------------- Frame Geometry ---------------------------------
|
||||
class Point {
|
||||
x: number;
|
||||
y: number;
|
||||
|
||||
constructor(x: number, y: number) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
}
|
||||
|
||||
class LineSegment {
|
||||
first: Point;
|
||||
second: Point;
|
||||
|
||||
constructor(pointA: Point, pointB: Point) {
|
||||
this.first = pointA;
|
||||
this.second = pointB;
|
||||
}
|
||||
|
||||
public getBoundingBox(): [Point, Point] {
|
||||
return [
|
||||
new Point(
|
||||
Math.min(this.first.x, this.second.x),
|
||||
Math.min(this.first.y, this.second.y),
|
||||
),
|
||||
new Point(
|
||||
Math.max(this.first.x, this.second.x),
|
||||
Math.max(this.first.y, this.second.y),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// https://martin-thoma.com/how-to-check-if-two-line-segments-intersect/
|
||||
class FrameGeometry {
|
||||
private static EPSILON = 0.000001;
|
||||
|
||||
private static crossProduct(a: Point, b: Point) {
|
||||
return a.x * b.y - b.x * a.y;
|
||||
}
|
||||
|
||||
private static doBoundingBoxesIntersect(
|
||||
a: [Point, Point],
|
||||
b: [Point, Point],
|
||||
) {
|
||||
return (
|
||||
a[0].x <= b[1].x &&
|
||||
a[1].x >= b[0].x &&
|
||||
a[0].y <= b[1].y &&
|
||||
a[1].y >= b[0].y
|
||||
);
|
||||
}
|
||||
|
||||
private static isPointOnLine(a: LineSegment, b: Point) {
|
||||
const aTmp = new LineSegment(
|
||||
new Point(0, 0),
|
||||
new Point(a.second.x - a.first.x, a.second.y - a.first.y),
|
||||
);
|
||||
const bTmp = new Point(b.x - a.first.x, b.y - a.first.y);
|
||||
const r = this.crossProduct(aTmp.second, bTmp);
|
||||
return Math.abs(r) < this.EPSILON;
|
||||
}
|
||||
|
||||
private static isPointRightOfLine(a: LineSegment, b: Point) {
|
||||
const aTmp = new LineSegment(
|
||||
new Point(0, 0),
|
||||
new Point(a.second.x - a.first.x, a.second.y - a.first.y),
|
||||
);
|
||||
const bTmp = new Point(b.x - a.first.x, b.y - a.first.y);
|
||||
return this.crossProduct(aTmp.second, bTmp) < 0;
|
||||
}
|
||||
|
||||
private static lineSegmentTouchesOrCrossesLine(
|
||||
a: LineSegment,
|
||||
b: LineSegment,
|
||||
) {
|
||||
return (
|
||||
this.isPointOnLine(a, b.first) ||
|
||||
this.isPointOnLine(a, b.second) ||
|
||||
(this.isPointRightOfLine(a, b.first)
|
||||
? !this.isPointRightOfLine(a, b.second)
|
||||
: this.isPointRightOfLine(a, b.second))
|
||||
);
|
||||
}
|
||||
|
||||
private static doLineSegmentsIntersect(
|
||||
a: [readonly [number, number], readonly [number, number]],
|
||||
b: [readonly [number, number], readonly [number, number]],
|
||||
) {
|
||||
const aSegment = new LineSegment(
|
||||
new Point(a[0][0], a[0][1]),
|
||||
new Point(a[1][0], a[1][1]),
|
||||
);
|
||||
const bSegment = new LineSegment(
|
||||
new Point(b[0][0], b[0][1]),
|
||||
new Point(b[1][0], b[1][1]),
|
||||
);
|
||||
|
||||
const box1 = aSegment.getBoundingBox();
|
||||
const box2 = bSegment.getBoundingBox();
|
||||
return (
|
||||
this.doBoundingBoxesIntersect(box1, box2) &&
|
||||
this.lineSegmentTouchesOrCrossesLine(aSegment, bSegment) &&
|
||||
this.lineSegmentTouchesOrCrossesLine(bSegment, aSegment)
|
||||
);
|
||||
}
|
||||
|
||||
public static isElementIntersectingFrame(
|
||||
element: ExcalidrawElement,
|
||||
frame: ExcalidrawFrameElement,
|
||||
) {
|
||||
const frameLineSegments = getElementLineSegments(frame);
|
||||
|
||||
const elementLineSegments = getElementLineSegments(element);
|
||||
|
||||
const intersecting = frameLineSegments.some((frameLineSegment) =>
|
||||
elementLineSegments.some((elementLineSegment) =>
|
||||
this.doLineSegmentsIntersect(frameLineSegment, elementLineSegment),
|
||||
),
|
||||
);
|
||||
|
||||
return intersecting;
|
||||
}
|
||||
}
|
||||
|
||||
export const getElementsCompletelyInFrame = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
) =>
|
||||
omitGroupsContainingFrames(
|
||||
getElementsWithinSelection(elements, frame, false),
|
||||
).filter(
|
||||
(element) =>
|
||||
(element.type !== "frame" && !element.frameId) ||
|
||||
element.frameId === frame.id,
|
||||
);
|
||||
|
||||
export const isElementContainingFrame = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
element: ExcalidrawElement,
|
||||
frame: ExcalidrawFrameElement,
|
||||
) => {
|
||||
return getElementsWithinSelection(elements, element).some(
|
||||
(e) => e.id === frame.id,
|
||||
);
|
||||
};
|
||||
|
||||
export const getElementsIntersectingFrame = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
) =>
|
||||
elements.filter((element) =>
|
||||
FrameGeometry.isElementIntersectingFrame(element, frame),
|
||||
);
|
||||
|
||||
export const elementsAreInFrameBounds = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
) => {
|
||||
const [selectionX1, selectionY1, selectionX2, selectionY2] =
|
||||
getElementAbsoluteCoords(frame);
|
||||
|
||||
const [elementX1, elementY1, elementX2, elementY2] =
|
||||
getCommonBounds(elements);
|
||||
|
||||
return (
|
||||
selectionX1 <= elementX1 &&
|
||||
selectionY1 <= elementY1 &&
|
||||
selectionX2 >= elementX2 &&
|
||||
selectionY2 >= elementY2
|
||||
);
|
||||
};
|
||||
|
||||
export const elementOverlapsWithFrame = (
|
||||
element: ExcalidrawElement,
|
||||
frame: ExcalidrawFrameElement,
|
||||
) => {
|
||||
return (
|
||||
elementsAreInFrameBounds([element], frame) ||
|
||||
FrameGeometry.isElementIntersectingFrame(element, frame) ||
|
||||
isElementContainingFrame([frame], element, frame)
|
||||
);
|
||||
};
|
||||
|
||||
export const isCursorInFrame = (
|
||||
cursorCoords: {
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
frame: NonDeleted<ExcalidrawFrameElement>,
|
||||
) => {
|
||||
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame);
|
||||
|
||||
return isPointWithinBounds(
|
||||
[fx1, fy1],
|
||||
[cursorCoords.x, cursorCoords.y],
|
||||
[fx2, fy2],
|
||||
);
|
||||
};
|
||||
|
||||
export const groupsAreAtLeastIntersectingTheFrame = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
groupIds: readonly string[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
) => {
|
||||
const elementsInGroup = groupIds.flatMap((groupId) =>
|
||||
getElementsInGroup(elements, groupId),
|
||||
);
|
||||
|
||||
if (elementsInGroup.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!elementsInGroup.find(
|
||||
(element) =>
|
||||
elementsAreInFrameBounds([element], frame) ||
|
||||
FrameGeometry.isElementIntersectingFrame(element, frame),
|
||||
);
|
||||
};
|
||||
|
||||
export const groupsAreCompletelyOutOfFrame = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
groupIds: readonly string[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
) => {
|
||||
const elementsInGroup = groupIds.flatMap((groupId) =>
|
||||
getElementsInGroup(elements, groupId),
|
||||
);
|
||||
|
||||
if (elementsInGroup.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
elementsInGroup.find(
|
||||
(element) =>
|
||||
elementsAreInFrameBounds([element], frame) ||
|
||||
FrameGeometry.isElementIntersectingFrame(element, frame),
|
||||
) === undefined
|
||||
);
|
||||
};
|
||||
|
||||
// --------------------------- Frame Utils ------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a map of frameId to frame elements. Includes empty frames.
|
||||
*/
|
||||
export const groupByFrames = (elements: readonly ExcalidrawElement[]) => {
|
||||
const frameElementsMap = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement[]
|
||||
>();
|
||||
|
||||
for (const element of elements) {
|
||||
const frameId = isFrameElement(element) ? element.id : element.frameId;
|
||||
if (frameId && !frameElementsMap.has(frameId)) {
|
||||
frameElementsMap.set(frameId, getFrameElements(elements, frameId));
|
||||
}
|
||||
}
|
||||
|
||||
return frameElementsMap;
|
||||
};
|
||||
|
||||
export const getFrameElements = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
frameId: string,
|
||||
) => allElements.filter((element) => element.frameId === frameId);
|
||||
|
||||
export const getElementsInResizingFrame = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
frame: ExcalidrawFrameElement,
|
||||
appState: AppState,
|
||||
): ExcalidrawElement[] => {
|
||||
const prevElementsInFrame = getFrameElements(allElements, frame.id);
|
||||
const nextElementsInFrame = new Set<ExcalidrawElement>(prevElementsInFrame);
|
||||
|
||||
const elementsCompletelyInFrame = new Set([
|
||||
...getElementsCompletelyInFrame(allElements, frame),
|
||||
...prevElementsInFrame.filter((element) =>
|
||||
isElementContainingFrame(allElements, element, frame),
|
||||
),
|
||||
]);
|
||||
|
||||
const elementsNotCompletelyInFrame = prevElementsInFrame.filter(
|
||||
(element) => !elementsCompletelyInFrame.has(element),
|
||||
);
|
||||
|
||||
// for elements that are completely in the frame
|
||||
// if they are part of some groups, then those groups are still
|
||||
// considered to belong to the frame
|
||||
const groupsToKeep = new Set<string>(
|
||||
Array.from(elementsCompletelyInFrame).flatMap(
|
||||
(element) => element.groupIds,
|
||||
),
|
||||
);
|
||||
|
||||
for (const element of elementsNotCompletelyInFrame) {
|
||||
if (!FrameGeometry.isElementIntersectingFrame(element, frame)) {
|
||||
if (element.groupIds.length === 0) {
|
||||
nextElementsInFrame.delete(element);
|
||||
}
|
||||
} else if (element.groupIds.length > 0) {
|
||||
// group element intersects with the frame, we should keep the groups
|
||||
// that this element is part of
|
||||
for (const id of element.groupIds) {
|
||||
groupsToKeep.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const element of elementsNotCompletelyInFrame) {
|
||||
if (element.groupIds.length > 0) {
|
||||
let shouldRemoveElement = true;
|
||||
|
||||
for (const id of element.groupIds) {
|
||||
if (groupsToKeep.has(id)) {
|
||||
shouldRemoveElement = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRemoveElement) {
|
||||
nextElementsInFrame.delete(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const individualElementsCompletelyInFrame = Array.from(
|
||||
elementsCompletelyInFrame,
|
||||
).filter((element) => element.groupIds.length === 0);
|
||||
|
||||
for (const element of individualElementsCompletelyInFrame) {
|
||||
nextElementsInFrame.add(element);
|
||||
}
|
||||
|
||||
const newGroupElementsCompletelyInFrame = Array.from(
|
||||
elementsCompletelyInFrame,
|
||||
).filter((element) => element.groupIds.length > 0);
|
||||
|
||||
const groupIds = selectGroupsFromGivenElements(
|
||||
newGroupElementsCompletelyInFrame,
|
||||
appState,
|
||||
);
|
||||
|
||||
// new group elements
|
||||
for (const [id, isSelected] of Object.entries(groupIds)) {
|
||||
if (isSelected) {
|
||||
const elementsInGroup = getElementsInGroup(allElements, id);
|
||||
|
||||
if (elementsAreInFrameBounds(elementsInGroup, frame)) {
|
||||
for (const element of elementsInGroup) {
|
||||
nextElementsInFrame.add(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...nextElementsInFrame].filter((element) => {
|
||||
return !(isTextElement(element) && element.containerId);
|
||||
});
|
||||
};
|
||||
|
||||
export const getElementsInNewFrame = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
frame: ExcalidrawFrameElement,
|
||||
) => {
|
||||
return omitGroupsContainingFrames(
|
||||
allElements,
|
||||
getElementsCompletelyInFrame(allElements, frame),
|
||||
);
|
||||
};
|
||||
|
||||
export const getContainingFrame = (
|
||||
element: ExcalidrawElement,
|
||||
/**
|
||||
* Optionally an elements map, in case the elements aren't in the Scene yet.
|
||||
* Takes precedence over Scene elements, even if the element exists
|
||||
* in Scene elements and not the supplied elements map.
|
||||
*/
|
||||
elementsMap?: Map<string, ExcalidrawElement>,
|
||||
) => {
|
||||
if (element.frameId) {
|
||||
if (elementsMap) {
|
||||
return (elementsMap.get(element.frameId) ||
|
||||
null) as null | ExcalidrawFrameElement;
|
||||
}
|
||||
return (
|
||||
(Scene.getScene(element)?.getElement(
|
||||
element.frameId,
|
||||
) as ExcalidrawFrameElement) || null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// --------------------------- Frame Operations -------------------------------
|
||||
export const addElementsToFrame = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
elementsToAdd: NonDeletedExcalidrawElement[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
) => {
|
||||
const _elementsToAdd: ExcalidrawElement[] = [];
|
||||
|
||||
for (const element of elementsToAdd) {
|
||||
_elementsToAdd.push(element);
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
_elementsToAdd.push(boundTextElement);
|
||||
}
|
||||
}
|
||||
|
||||
let nextElements = allElements.slice();
|
||||
|
||||
const frameBoundary = findIndex(nextElements, (e) => e.frameId === frame.id);
|
||||
|
||||
for (const element of omitGroupsContainingFrames(
|
||||
allElements,
|
||||
_elementsToAdd,
|
||||
)) {
|
||||
if (element.frameId !== frame.id && !isFrameElement(element)) {
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
frameId: frame.id,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
const frameIndex = findIndex(nextElements, (e) => e.id === frame.id);
|
||||
const elementIndex = findIndex(nextElements, (e) => e.id === element.id);
|
||||
|
||||
if (elementIndex < frameBoundary) {
|
||||
nextElements = [
|
||||
...nextElements.slice(0, elementIndex),
|
||||
...nextElements.slice(elementIndex + 1, frameBoundary),
|
||||
element,
|
||||
...nextElements.slice(frameBoundary),
|
||||
];
|
||||
} else if (elementIndex > frameIndex) {
|
||||
nextElements = [
|
||||
...nextElements.slice(0, frameIndex),
|
||||
element,
|
||||
...nextElements.slice(frameIndex, elementIndex),
|
||||
...nextElements.slice(elementIndex + 1),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nextElements;
|
||||
};
|
||||
|
||||
export const removeElementsFromFrame = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
elementsToRemove: NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const _elementsToRemove: ExcalidrawElement[] = [];
|
||||
|
||||
for (const element of elementsToRemove) {
|
||||
if (element.frameId) {
|
||||
_elementsToRemove.push(element);
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
_elementsToRemove.push(boundTextElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const element of _elementsToRemove) {
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
frameId: null,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
const nextElements = moveOneRight(
|
||||
allElements,
|
||||
appState,
|
||||
Array.from(_elementsToRemove),
|
||||
);
|
||||
|
||||
return nextElements;
|
||||
};
|
||||
|
||||
export const removeAllElementsFromFrame = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
frame: ExcalidrawFrameElement,
|
||||
appState: AppState,
|
||||
) => {
|
||||
const elementsInFrame = getFrameElements(allElements, frame.id);
|
||||
return removeElementsFromFrame(allElements, elementsInFrame, appState);
|
||||
};
|
||||
|
||||
export const replaceAllElementsInFrame = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
nextElementsInFrame: ExcalidrawElement[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
appState: AppState,
|
||||
) => {
|
||||
return addElementsToFrame(
|
||||
removeAllElementsFromFrame(allElements, frame, appState),
|
||||
nextElementsInFrame,
|
||||
frame,
|
||||
);
|
||||
};
|
||||
|
||||
/** does not mutate elements, but return new ones */
|
||||
export const updateFrameMembershipOfSelectedElements = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
appState: AppState,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(allElements, appState);
|
||||
const elementsToFilter = new Set<ExcalidrawElement>(selectedElements);
|
||||
|
||||
if (appState.editingGroupId) {
|
||||
for (const element of selectedElements) {
|
||||
if (element.groupIds.length === 0) {
|
||||
elementsToFilter.add(element);
|
||||
} else {
|
||||
element.groupIds
|
||||
.flatMap((gid) => getElementsInGroup(allElements, gid))
|
||||
.forEach((element) => elementsToFilter.add(element));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const elementsToRemove = new Set<ExcalidrawElement>();
|
||||
|
||||
elementsToFilter.forEach((element) => {
|
||||
if (
|
||||
element.frameId &&
|
||||
!isFrameElement(element) &&
|
||||
!isElementInFrame(element, allElements, appState)
|
||||
) {
|
||||
elementsToRemove.add(element);
|
||||
}
|
||||
});
|
||||
|
||||
return elementsToRemove.size > 0
|
||||
? removeElementsFromFrame(allElements, [...elementsToRemove], appState)
|
||||
: allElements;
|
||||
};
|
||||
|
||||
/**
|
||||
* filters out elements that are inside groups that contain a frame element
|
||||
* anywhere in the group tree
|
||||
*/
|
||||
export const omitGroupsContainingFrames = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
/** subset of elements you want to filter. Optional perf optimization so we
|
||||
* don't have to filter all elements unnecessarily
|
||||
*/
|
||||
selectedElements?: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
const uniqueGroupIds = new Set<string>();
|
||||
for (const el of selectedElements || allElements) {
|
||||
const topMostGroupId = el.groupIds[el.groupIds.length - 1];
|
||||
if (topMostGroupId) {
|
||||
uniqueGroupIds.add(topMostGroupId);
|
||||
}
|
||||
}
|
||||
|
||||
const rejectedGroupIds = new Set<string>();
|
||||
for (const groupId of uniqueGroupIds) {
|
||||
if (
|
||||
getElementsInGroup(allElements, groupId).some((el) => isFrameElement(el))
|
||||
) {
|
||||
rejectedGroupIds.add(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
return (selectedElements || allElements).filter(
|
||||
(el) => !rejectedGroupIds.has(el.groupIds[el.groupIds.length - 1]),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* depending on the appState, return target frame, which is the frame the given element
|
||||
* is going to be added to or remove from
|
||||
*/
|
||||
export const getTargetFrame = (
|
||||
element: ExcalidrawElement,
|
||||
appState: AppState,
|
||||
) => {
|
||||
const _element = isTextElement(element)
|
||||
? getContainerElement(element) || element
|
||||
: element;
|
||||
|
||||
return appState.selectedElementIds[_element.id] &&
|
||||
appState.selectedElementsAreBeingDragged
|
||||
? appState.frameToHighlight
|
||||
: getContainingFrame(_element);
|
||||
};
|
||||
|
||||
// given an element, return if the element is in some frame
|
||||
export const isElementInFrame = (
|
||||
element: ExcalidrawElement,
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
appState: AppState,
|
||||
) => {
|
||||
const frame = getTargetFrame(element, appState);
|
||||
const _element = isTextElement(element)
|
||||
? getContainerElement(element) || element
|
||||
: element;
|
||||
|
||||
if (frame) {
|
||||
if (_element.groupIds.length === 0) {
|
||||
return elementOverlapsWithFrame(_element, frame);
|
||||
}
|
||||
|
||||
const allElementsInGroup = new Set(
|
||||
_element.groupIds.flatMap((gid) => getElementsInGroup(allElements, gid)),
|
||||
);
|
||||
|
||||
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
|
||||
const selectedElements = new Set(
|
||||
getSelectedElements(allElements, appState),
|
||||
);
|
||||
|
||||
const editingGroupOverlapsFrame = appState.frameToHighlight !== null;
|
||||
|
||||
if (editingGroupOverlapsFrame) {
|
||||
return true;
|
||||
}
|
||||
|
||||
selectedElements.forEach((selectedElement) => {
|
||||
allElementsInGroup.delete(selectedElement);
|
||||
});
|
||||
}
|
||||
|
||||
for (const elementInGroup of allElementsInGroup) {
|
||||
if (isFrameElement(elementInGroup)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const elementInGroup of allElementsInGroup) {
|
||||
if (elementOverlapsWithFrame(elementInGroup, frame)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
|
@ -2,6 +2,7 @@ import { GroupId, ExcalidrawElement, NonDeleted } from "./element/types";
|
|||
import { AppState } from "./types";
|
||||
import { getSelectedElements } from "./scene";
|
||||
import { getBoundTextElement } from "./element/textElement";
|
||||
import { makeNextSelectedElementIds } from "./scene/selection";
|
||||
|
||||
export const selectGroup = (
|
||||
groupId: GroupId,
|
||||
|
@ -67,13 +68,21 @@ export const getSelectedGroupIds = (appState: AppState): GroupId[] =>
|
|||
export const selectGroupsForSelectedElements = (
|
||||
appState: AppState,
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[],
|
||||
prevAppState: AppState,
|
||||
): AppState => {
|
||||
let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
if (!selectedElements.length) {
|
||||
return { ...nextAppState, editingGroupId: null };
|
||||
return {
|
||||
...nextAppState,
|
||||
editingGroupId: null,
|
||||
selectedElementIds: makeNextSelectedElementIds(
|
||||
nextAppState.selectedElementIds,
|
||||
prevAppState,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
for (const selectedElement of selectedElements) {
|
||||
|
@ -91,9 +100,39 @@ export const selectGroupsForSelectedElements = (
|
|||
}
|
||||
}
|
||||
|
||||
nextAppState.selectedElementIds = makeNextSelectedElementIds(
|
||||
nextAppState.selectedElementIds,
|
||||
prevAppState,
|
||||
);
|
||||
|
||||
return nextAppState;
|
||||
};
|
||||
|
||||
// given a list of elements, return the the actual group ids that should be selected
|
||||
// or used to update the elements
|
||||
export const selectGroupsFromGivenElements = (
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
|
||||
|
||||
for (const element of elements) {
|
||||
let groupIds = element.groupIds;
|
||||
if (appState.editingGroupId) {
|
||||
const indexOfEditingGroup = groupIds.indexOf(appState.editingGroupId);
|
||||
if (indexOfEditingGroup > -1) {
|
||||
groupIds = groupIds.slice(0, indexOfEditingGroup);
|
||||
}
|
||||
}
|
||||
if (groupIds.length > 0) {
|
||||
const groupId = groupIds[groupIds.length - 1];
|
||||
nextAppState = selectGroup(groupId, nextAppState, elements);
|
||||
}
|
||||
}
|
||||
|
||||
return nextAppState.selectedGroupIds;
|
||||
};
|
||||
|
||||
export const editGroupForSelectedElement = (
|
||||
appState: AppState,
|
||||
element: NonDeleted<ExcalidrawElement>,
|
||||
|
@ -186,3 +225,18 @@ export const getMaximumGroups = (
|
|||
|
||||
return Array.from(groups.values());
|
||||
};
|
||||
|
||||
export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => {
|
||||
const allGroups = elements.flatMap((element) => element.groupIds);
|
||||
const groupCount = new Map<string, number>();
|
||||
let maxGroup = 0;
|
||||
|
||||
for (const group of allGroups) {
|
||||
groupCount.set(group, (groupCount.get(group) ?? 0) + 1);
|
||||
if (groupCount.get(group)! > maxGroup) {
|
||||
maxGroup = groupCount.get(group)!;
|
||||
}
|
||||
}
|
||||
|
||||
return maxGroup === elements.length;
|
||||
};
|
||||
|
|
|
@ -10,6 +10,7 @@ export const CODES = {
|
|||
BRACKET_LEFT: "BracketLeft",
|
||||
ONE: "Digit1",
|
||||
TWO: "Digit2",
|
||||
THREE: "Digit3",
|
||||
NINE: "Digit9",
|
||||
QUOTE: "Quote",
|
||||
ZERO: "Digit0",
|
||||
|
@ -42,6 +43,7 @@ export const KEYS = {
|
|||
CHEVRON_RIGHT: ">",
|
||||
PERIOD: ".",
|
||||
COMMA: ",",
|
||||
SUBTRACT: "-",
|
||||
|
||||
A: "a",
|
||||
C: "c",
|
||||
|
|
|
@ -124,6 +124,8 @@
|
|||
},
|
||||
"statusPublished": "نُشر",
|
||||
"sidebarLock": "إبقاء الشريط الجانبي مفتوح",
|
||||
"selectAllElementsInFrame": "",
|
||||
"removeAllElementsFromFrame": "",
|
||||
"eyeDropper": ""
|
||||
},
|
||||
"library": {
|
||||
|
@ -221,7 +223,9 @@
|
|||
"penMode": "وضع القلم - امنع اللمس",
|
||||
"link": "إضافة/تحديث الرابط للشكل المحدد",
|
||||
"eraser": "ممحاة",
|
||||
"hand": ""
|
||||
"frame": "",
|
||||
"hand": "",
|
||||
"extraTools": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "إجراءات اللوحة",
|
||||
|
|
453
src/locales/az-AZ.json
Normal file
453
src/locales/az-AZ.json
Normal file
|
@ -0,0 +1,453 @@
|
|||
{
|
||||
"labels": {
|
||||
"paste": "Yapışdır",
|
||||
"pasteAsPlaintext": "Düz mətn kimi yapışdırın",
|
||||
"pasteCharts": "Diaqramları yapışdırın",
|
||||
"selectAll": "Hamısını seç",
|
||||
"multiSelect": "Seçimə element əlavə edin",
|
||||
"moveCanvas": "Kanvası köçürün",
|
||||
"cut": "Kəs",
|
||||
"copy": "Kopyala",
|
||||
"copyAsPng": "PNG olaraq panoya kopyala",
|
||||
"copyAsSvg": "SVG olaraq panoya kopyala",
|
||||
"copyText": "Mətn olaraq panoya kopyala",
|
||||
"bringForward": "Önə daşı",
|
||||
"sendToBack": "Geriyə göndərin",
|
||||
"bringToFront": "Önə gətirin",
|
||||
"sendBackward": "Geriyə göndərin",
|
||||
"delete": "Sil",
|
||||
"copyStyles": "Stilləri kopyalayın",
|
||||
"pasteStyles": "Stilləri yapışdırın",
|
||||
"stroke": "Strok rəngi",
|
||||
"background": "Arxa fon",
|
||||
"fill": "Doldur",
|
||||
"strokeWidth": "Strok eni",
|
||||
"strokeStyle": "Strok stili",
|
||||
"strokeStyle_solid": "Solid",
|
||||
"strokeStyle_dashed": "Kəsik",
|
||||
"strokeStyle_dotted": "Nöqtəli",
|
||||
"sloppiness": "Səliqəsizlik",
|
||||
"opacity": "Şəffaflıq",
|
||||
"textAlign": "Mətni uyğunlaşdır",
|
||||
"edges": "Kənarlar",
|
||||
"sharp": "Kəskin",
|
||||
"round": "Dəyirmi",
|
||||
"arrowheads": "Ox ucları",
|
||||
"arrowhead_none": "Heç biri",
|
||||
"arrowhead_arrow": "Ox",
|
||||
"arrowhead_bar": "Çubuq",
|
||||
"arrowhead_dot": "Nöqtə",
|
||||
"arrowhead_triangle": "Üçbucaq",
|
||||
"fontSize": "Şrift ölçüsü",
|
||||
"fontFamily": "Şrift qrupu",
|
||||
"addWatermark": "\"Made with Excalidraw\" əlavə et",
|
||||
"handDrawn": "Əllə çəkilmiş",
|
||||
"normal": "Normal",
|
||||
"code": "Kod",
|
||||
"small": "Kiçik",
|
||||
"medium": "Orta",
|
||||
"large": "Böyük",
|
||||
"veryLarge": "Çox böyük",
|
||||
"solid": "Solid",
|
||||
"hachure": "Ştrix",
|
||||
"zigzag": "Ziqzaq",
|
||||
"crossHatch": "Çarpaz dəlik",
|
||||
"thin": "İncə",
|
||||
"bold": "Qalın",
|
||||
"left": "Sol",
|
||||
"center": "Mərkəz",
|
||||
"right": "Sağ",
|
||||
"extraBold": "Ekstra qalın",
|
||||
"architect": "Memar",
|
||||
"artist": "Rəssam",
|
||||
"cartoonist": "Karikaturaçı",
|
||||
"fileTitle": "Fayl adı",
|
||||
"colorPicker": "Rəng seçən",
|
||||
"canvasColors": "Kanvas üzərində istifadə olunur",
|
||||
"canvasBackground": "Kanvas arxa fonu",
|
||||
"drawingCanvas": "Kanvas çəkmək",
|
||||
"layers": "Qatlar",
|
||||
"actions": "Hərəkətlər",
|
||||
"language": "Dil",
|
||||
"liveCollaboration": "Canlı əməkdaşlıq...",
|
||||
"duplicateSelection": "Dublikat",
|
||||
"untitled": "Başlıqsız",
|
||||
"name": "Ad",
|
||||
"yourName": "Adınız",
|
||||
"madeWithExcalidraw": "Excalidraw ilə hazırlanmışdır",
|
||||
"group": "Qrup şəklində seçim",
|
||||
"ungroup": "Qrupsuz seçim",
|
||||
"collaborators": "",
|
||||
"showGrid": "",
|
||||
"addToLibrary": "",
|
||||
"removeFromLibrary": "",
|
||||
"libraryLoadingMessage": "",
|
||||
"libraries": "",
|
||||
"loadingScene": "",
|
||||
"align": "",
|
||||
"alignTop": "",
|
||||
"alignBottom": "",
|
||||
"alignLeft": "",
|
||||
"alignRight": "",
|
||||
"centerVertically": "",
|
||||
"centerHorizontally": "",
|
||||
"distributeHorizontally": "",
|
||||
"distributeVertically": "",
|
||||
"flipHorizontal": "",
|
||||
"flipVertical": "",
|
||||
"viewMode": "",
|
||||
"share": "",
|
||||
"showStroke": "",
|
||||
"showBackground": "",
|
||||
"toggleTheme": "",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": "",
|
||||
"decreaseFontSize": "",
|
||||
"increaseFontSize": "",
|
||||
"unbindText": "",
|
||||
"bindText": "",
|
||||
"createContainerFromText": "",
|
||||
"link": {
|
||||
"edit": "",
|
||||
"create": "",
|
||||
"label": ""
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
"lockAll": "",
|
||||
"unlockAll": ""
|
||||
},
|
||||
"statusPublished": "",
|
||||
"sidebarLock": "",
|
||||
"selectAllElementsInFrame": "",
|
||||
"removeAllElementsFromFrame": "",
|
||||
"eyeDropper": ""
|
||||
},
|
||||
"library": {
|
||||
"noItems": "",
|
||||
"hint_emptyLibrary": "",
|
||||
"hint_emptyPrivateLibrary": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "",
|
||||
"exportJSON": "",
|
||||
"exportImage": "",
|
||||
"export": "",
|
||||
"copyToClipboard": "",
|
||||
"save": "",
|
||||
"saveAs": "",
|
||||
"load": "",
|
||||
"getShareableLink": "",
|
||||
"close": "",
|
||||
"selectLanguage": "",
|
||||
"scrollBackToContent": "",
|
||||
"zoomIn": "",
|
||||
"zoomOut": "",
|
||||
"resetZoom": "",
|
||||
"menu": "",
|
||||
"done": "",
|
||||
"edit": "",
|
||||
"undo": "",
|
||||
"redo": "",
|
||||
"resetLibrary": "",
|
||||
"createNewRoom": "",
|
||||
"fullScreen": "",
|
||||
"darkMode": "",
|
||||
"lightMode": "",
|
||||
"zenMode": "",
|
||||
"exitZenMode": "",
|
||||
"cancel": "",
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "",
|
||||
"couldNotCreateShareableLink": "",
|
||||
"couldNotCreateShareableLinkTooBig": "",
|
||||
"couldNotLoadInvalidFile": "",
|
||||
"importBackendFailed": "",
|
||||
"cannotExportEmptyCanvas": "",
|
||||
"couldNotCopyToClipboard": "",
|
||||
"decryptFailed": "",
|
||||
"uploadedSecurly": "",
|
||||
"loadSceneOverridePrompt": "",
|
||||
"collabStopOverridePrompt": "",
|
||||
"errorAddingToLibrary": "",
|
||||
"errorRemovingFromLibrary": "",
|
||||
"confirmAddLibrary": "",
|
||||
"imageDoesNotContainScene": "",
|
||||
"cannotRestoreFromImage": "",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
"imageInsertError": "",
|
||||
"fileTooBig": "",
|
||||
"svgImageInsertError": "",
|
||||
"invalidSVGString": "",
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": "",
|
||||
"collabSaveFailed": "",
|
||||
"collabSaveFailed_sizeExceeded": "",
|
||||
"brave_measure_text_error": {
|
||||
"line1": "",
|
||||
"line2": "",
|
||||
"line3": "",
|
||||
"line4": ""
|
||||
}
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "",
|
||||
"image": "",
|
||||
"rectangle": "",
|
||||
"diamond": "",
|
||||
"ellipse": "",
|
||||
"arrow": "",
|
||||
"line": "",
|
||||
"freedraw": "",
|
||||
"text": "",
|
||||
"library": "",
|
||||
"lock": "",
|
||||
"penMode": "",
|
||||
"link": "",
|
||||
"eraser": "",
|
||||
"frame": "",
|
||||
"hand": "",
|
||||
"extraTools": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "",
|
||||
"selectedShapeActions": "",
|
||||
"shapes": ""
|
||||
},
|
||||
"hints": {
|
||||
"canvasPanning": "",
|
||||
"linearElement": "",
|
||||
"freeDraw": "",
|
||||
"text": "",
|
||||
"text_selected": "",
|
||||
"text_editing": "",
|
||||
"linearElementMulti": "",
|
||||
"lockAngle": "",
|
||||
"resize": "",
|
||||
"resizeImage": "",
|
||||
"rotate": "",
|
||||
"lineEditor_info": "",
|
||||
"lineEditor_pointSelected": "",
|
||||
"lineEditor_nothingSelected": "",
|
||||
"placeImage": "",
|
||||
"publishLibrary": "",
|
||||
"bindTextToElement": "",
|
||||
"deepBoxSelect": "",
|
||||
"eraserRevert": "",
|
||||
"firefox_clipboard_write": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "",
|
||||
"canvasTooBig": "",
|
||||
"canvasTooBigTip": ""
|
||||
},
|
||||
"errorSplash": {
|
||||
"headingMain": "",
|
||||
"clearCanvasMessage": "",
|
||||
"clearCanvasCaveat": "",
|
||||
"trackedToSentry": "",
|
||||
"openIssueMessage": "",
|
||||
"sceneContent": ""
|
||||
},
|
||||
"roomDialog": {
|
||||
"desc_intro": "",
|
||||
"desc_privacy": "",
|
||||
"button_startSession": "",
|
||||
"button_stopSession": "",
|
||||
"desc_inProgressIntro": "",
|
||||
"desc_shareLink": "",
|
||||
"desc_exitSession": "",
|
||||
"shareTitle": ""
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "",
|
||||
"disk_details": "",
|
||||
"disk_button": "",
|
||||
"link_title": "",
|
||||
"link_details": "",
|
||||
"link_button": "",
|
||||
"excalidrawplus_description": "",
|
||||
"excalidrawplus_button": "",
|
||||
"excalidrawplus_exportError": ""
|
||||
},
|
||||
"helpDialog": {
|
||||
"blog": "",
|
||||
"click": "",
|
||||
"deepSelect": "",
|
||||
"deepBoxSelect": "",
|
||||
"curvedArrow": "",
|
||||
"curvedLine": "",
|
||||
"documentation": "",
|
||||
"doubleClick": "",
|
||||
"drag": "",
|
||||
"editor": "",
|
||||
"editLineArrowPoints": "",
|
||||
"editText": "",
|
||||
"github": "",
|
||||
"howto": "",
|
||||
"or": "",
|
||||
"preventBinding": "",
|
||||
"tools": "",
|
||||
"shortcuts": "",
|
||||
"textFinish": "",
|
||||
"textNewLine": "",
|
||||
"title": "",
|
||||
"view": "",
|
||||
"zoomToFit": "",
|
||||
"zoomToSelection": "",
|
||||
"toggleElementLock": "",
|
||||
"movePageUpDown": "",
|
||||
"movePageLeftRight": ""
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": ""
|
||||
},
|
||||
"publishDialog": {
|
||||
"title": "",
|
||||
"itemName": "",
|
||||
"authorName": "",
|
||||
"githubUsername": "",
|
||||
"twitterUsername": "",
|
||||
"libraryName": "",
|
||||
"libraryDesc": "",
|
||||
"website": "",
|
||||
"placeholder": {
|
||||
"authorName": "",
|
||||
"libraryName": "",
|
||||
"libraryDesc": "",
|
||||
"githubHandle": "",
|
||||
"twitterHandle": "",
|
||||
"website": ""
|
||||
},
|
||||
"errors": {
|
||||
"required": "",
|
||||
"website": ""
|
||||
},
|
||||
"noteDescription": "",
|
||||
"noteGuidelines": "",
|
||||
"noteLicense": "",
|
||||
"noteItems": "",
|
||||
"atleastOneLibItem": "",
|
||||
"republishWarning": ""
|
||||
},
|
||||
"publishSuccessDialog": {
|
||||
"title": "",
|
||||
"content": ""
|
||||
},
|
||||
"confirmDialog": {
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromLib": ""
|
||||
},
|
||||
"imageExportDialog": {
|
||||
"header": "",
|
||||
"label": {
|
||||
"withBackground": "",
|
||||
"onlySelected": "",
|
||||
"darkMode": "",
|
||||
"embedScene": "",
|
||||
"scale": "",
|
||||
"padding": ""
|
||||
},
|
||||
"tooltip": {
|
||||
"embedScene": ""
|
||||
},
|
||||
"title": {
|
||||
"exportToPng": "",
|
||||
"exportToSvg": "",
|
||||
"copyPngToClipboard": ""
|
||||
},
|
||||
"button": {
|
||||
"exportToPng": "",
|
||||
"exportToSvg": "",
|
||||
"copyPngToClipboard": ""
|
||||
}
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "",
|
||||
"link": ""
|
||||
},
|
||||
"stats": {
|
||||
"angle": "",
|
||||
"element": "",
|
||||
"elements": "",
|
||||
"height": "",
|
||||
"scene": "",
|
||||
"selected": "",
|
||||
"storage": "",
|
||||
"title": "",
|
||||
"total": "",
|
||||
"version": "",
|
||||
"versionCopy": "",
|
||||
"versionNotAvailable": "",
|
||||
"width": ""
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "",
|
||||
"copyToClipboard": "",
|
||||
"copyToClipboardAsPng": "",
|
||||
"fileSaved": "",
|
||||
"fileSavedToFilename": "",
|
||||
"canvas": "",
|
||||
"selection": "",
|
||||
"pasteAsSingleElement": ""
|
||||
},
|
||||
"colors": {
|
||||
"transparent": "",
|
||||
"black": "",
|
||||
"white": "",
|
||||
"red": "",
|
||||
"pink": "",
|
||||
"grape": "",
|
||||
"violet": "",
|
||||
"gray": "",
|
||||
"blue": "",
|
||||
"cyan": "",
|
||||
"teal": "",
|
||||
"green": "",
|
||||
"yellow": "",
|
||||
"orange": "",
|
||||
"bronze": ""
|
||||
},
|
||||
"welcomeScreen": {
|
||||
"app": {
|
||||
"center_heading": "",
|
||||
"center_heading_plus": "",
|
||||
"menuHint": ""
|
||||
},
|
||||
"defaults": {
|
||||
"menuHint": "",
|
||||
"center_heading": "",
|
||||
"toolbarHint": "",
|
||||
"helpHint": ""
|
||||
}
|
||||
},
|
||||
"colorPicker": {
|
||||
"mostUsedCustomColors": "",
|
||||
"colors": "",
|
||||
"shades": "",
|
||||
"hexCode": "",
|
||||
"noShades": ""
|
||||
}
|
||||
}
|
|
@ -124,6 +124,8 @@
|
|||
},
|
||||
"statusPublished": "",
|
||||
"sidebarLock": "",
|
||||
"selectAllElementsInFrame": "",
|
||||
"removeAllElementsFromFrame": "",
|
||||
"eyeDropper": ""
|
||||
},
|
||||
"library": {
|
||||
|
@ -221,7 +223,9 @@
|
|||
"penMode": "",
|
||||
"link": "",
|
||||
"eraser": "",
|
||||
"hand": ""
|
||||
"frame": "",
|
||||
"hand": "",
|
||||
"extraTools": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Действия по платното",
|
||||
|
|
|
@ -124,6 +124,8 @@
|
|||
},
|
||||
"statusPublished": "প্রকাশিত",
|
||||
"sidebarLock": "লক",
|
||||
"selectAllElementsInFrame": "",
|
||||
"removeAllElementsFromFrame": "",
|
||||
"eyeDropper": ""
|
||||
},
|
||||
"library": {
|
||||
|
@ -221,7 +223,9 @@
|
|||
"penMode": "",
|
||||
"link": "একটি নির্বাচিত আকৃতির জন্য লিঙ্ক যোগ বা আপডেট করুন",
|
||||
"eraser": "ঝাড়ন",
|
||||
"hand": ""
|
||||
"frame": "",
|
||||
"hand": "",
|
||||
"extraTools": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "ক্যানভাস কার্যকলাপ",
|
||||
|
|
|
@ -124,6 +124,8 @@
|
|||
},
|
||||
"statusPublished": "Publicat",
|
||||
"sidebarLock": "Manté la barra lateral oberta",
|
||||
"selectAllElementsInFrame": "",
|
||||
"removeAllElementsFromFrame": "",
|
||||
"eyeDropper": ""
|
||||
},
|
||||
"library": {
|
||||
|
@ -221,7 +223,9 @@
|
|||
"penMode": "Mode de llapis - evita tocar",
|
||||
"link": "Afegeix / actualitza l'enllaç per a la forma seleccionada",
|
||||
"eraser": "Esborrador",
|
||||
"hand": "Mà (eina de desplaçament)"
|
||||
"frame": "",
|
||||
"hand": "Mà (eina de desplaçament)",
|
||||
"extraTools": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Accions del llenç",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"labels": {
|
||||
"paste": "Vložit",
|
||||
"pasteAsPlaintext": "",
|
||||
"pasteAsPlaintext": "Vložit jako prostý text",
|
||||
"pasteCharts": "Vložit grafy",
|
||||
"selectAll": "Vybrat vše",
|
||||
"multiSelect": "Přidat prvek do výběru",
|
||||
|
@ -49,9 +49,9 @@
|
|||
"large": "Velké",
|
||||
"veryLarge": "Velmi velké",
|
||||
"solid": "Plný",
|
||||
"hachure": "",
|
||||
"zigzag": "",
|
||||
"crossHatch": "",
|
||||
"hachure": "Hachure",
|
||||
"zigzag": "Klikatě",
|
||||
"crossHatch": "Křížový šrafování",
|
||||
"thin": "Tenký",
|
||||
"bold": "Tlustý",
|
||||
"left": "Vlevo",
|
||||
|
@ -60,7 +60,7 @@
|
|||
"extraBold": "Extra tlustý",
|
||||
"architect": "Architekt",
|
||||
"artist": "Umělec",
|
||||
"cartoonist": "",
|
||||
"cartoonist": "Kartoonista",
|
||||
"fileTitle": "Název souboru",
|
||||
"colorPicker": "Výběr barvy",
|
||||
"canvasColors": "Použito na plátně",
|
||||
|
@ -106,7 +106,7 @@
|
|||
"increaseFontSize": "Zvětšit písmo",
|
||||
"unbindText": "Zrušit vazbu textu",
|
||||
"bindText": "Vázat text s kontejnerem",
|
||||
"createContainerFromText": "",
|
||||
"createContainerFromText": "Zabalit text do kontejneru",
|
||||
"link": {
|
||||
"edit": "Upravit odkaz",
|
||||
"create": "Vytvořit odkaz",
|
||||
|
@ -124,7 +124,9 @@
|
|||
},
|
||||
"statusPublished": "Zveřejněno",
|
||||
"sidebarLock": "Ponechat postranní panel otevřený",
|
||||
"eyeDropper": ""
|
||||
"selectAllElementsInFrame": "",
|
||||
"removeAllElementsFromFrame": "",
|
||||
"eyeDropper": "Vyberte barvu z plátna"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "Dosud neexistují žádné položky...",
|
||||
|
@ -177,38 +179,38 @@
|
|||
"decryptFailed": "Nelze dešifrovat data.",
|
||||
"uploadedSecurly": "Nahrávání je zabezpečeno koncovým šifrováním, což znamená, že server Excalidraw ani třetí strany nemohou obsah přečíst.",
|
||||
"loadSceneOverridePrompt": "Načítání externího výkresu nahradí váš existující obsah. Přejete si pokračovat?",
|
||||
"collabStopOverridePrompt": "",
|
||||
"collabStopOverridePrompt": "Zastavení relace přepíše vaše předchozí, lokálně uložené kresby. Jste si jisti?\n\n(Pokud chcete zachovat místní kresbu, jednoduše zavřete kartu prohlížeče)",
|
||||
"errorAddingToLibrary": "Položku nelze přidat do knihovny",
|
||||
"errorRemovingFromLibrary": "Položku nelze odstranit z knihovny",
|
||||
"confirmAddLibrary": "Tímto přidáte {{numShapes}} tvarů do tvé knihovny. Jste si jisti?",
|
||||
"imageDoesNotContainScene": "Zdá se, že tento obrázek neobsahuje žádná data o scéně. Zapnuli jste při exportu vkládání scény?",
|
||||
"cannotRestoreFromImage": "",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "",
|
||||
"collabOfflineWarning": ""
|
||||
"cannotRestoreFromImage": "Scénu nelze obnovit z tohoto souboru obrázku",
|
||||
"invalidSceneUrl": "Nelze importovat scénu z zadané URL. Je buď poškozená, nebo neobsahuje platná JSON data Excalidraw.",
|
||||
"resetLibrary": "Tímto vymažete vaši knihovnu. Jste si jisti?",
|
||||
"removeItemsFromsLibrary": "Smazat {{count}} položek z knihovny?",
|
||||
"invalidEncryptionKey": "Šifrovací klíč musí mít 22 znaků. Live spolupráce je zakázána.",
|
||||
"collabOfflineWarning": "Není k dispozici žádné internetové připojení.\nVaše změny nebudou uloženy!"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
"imageInsertError": "",
|
||||
"fileTooBig": "",
|
||||
"svgImageInsertError": "",
|
||||
"invalidSVGString": "",
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": "",
|
||||
"collabSaveFailed": "",
|
||||
"collabSaveFailed_sizeExceeded": "",
|
||||
"unsupportedFileType": "Nepodporovaný typ souboru.",
|
||||
"imageInsertError": "Nelze vložit obrázek. Zkuste to později...",
|
||||
"fileTooBig": "Soubor je příliš velký. Maximální povolená velikost je {{maxSize}}.",
|
||||
"svgImageInsertError": "Nelze vložit SVG obrázek. Značení SVG je neplatné.",
|
||||
"invalidSVGString": "Neplatný SVG.",
|
||||
"cannotResolveCollabServer": "Nelze se připojit ke sdílenému serveru. Prosím obnovte stránku a zkuste to znovu.",
|
||||
"importLibraryError": "Nelze načíst knihovnu",
|
||||
"collabSaveFailed": "Nelze uložit do databáze na serveru. Pokud problémy přetrvávají, měli byste uložit soubor lokálně, abyste se ujistili, že neztratíte svou práci.",
|
||||
"collabSaveFailed_sizeExceeded": "Nelze uložit do databáze na serveru, plátno se zdá být příliš velké. Měli byste uložit soubor lokálně, abyste se ujistili, že neztratíte svou práci.",
|
||||
"brave_measure_text_error": {
|
||||
"line1": "",
|
||||
"line2": "",
|
||||
"line3": "",
|
||||
"line4": ""
|
||||
"line1": "Vypadá to, že používáte Brave prohlížeč s povoleným nastavením <bold>Aggressively Block Fingerprinting</bold>.",
|
||||
"line2": "To by mohlo vést k narušení <bold>Textových elementů</bold> ve vašich výkresech.",
|
||||
"line3": "Důrazně doporučujeme zakázat toto nastavení. Můžete sledovat <link>tyto kroky</link> jak to udělat.",
|
||||
"line4": "Pokud vypnutí tohoto nastavení neopravuje zobrazení textových prvků, prosím, otevřete <issueLink>problém</issueLink> na našem GitHubu, nebo nám napište na <discordLink>Discord</discordLink>"
|
||||
}
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Výběr",
|
||||
"image": "",
|
||||
"image": "Vložit obrázek",
|
||||
"rectangle": "Obdélník",
|
||||
"diamond": "Diamant",
|
||||
"ellipse": "Elipsa",
|
||||
|
@ -216,69 +218,71 @@
|
|||
"line": "Čára",
|
||||
"freedraw": "Kreslení",
|
||||
"text": "Text",
|
||||
"library": "",
|
||||
"lock": "",
|
||||
"penMode": "",
|
||||
"link": "",
|
||||
"library": "Knihovna",
|
||||
"lock": "Po kreslení ponechat vybraný nástroj aktivní",
|
||||
"penMode": "Režim Pera - zabránit dotyku",
|
||||
"link": "Přidat/aktualizovat odkaz pro vybraný tvar",
|
||||
"eraser": "Guma",
|
||||
"hand": ""
|
||||
"frame": "",
|
||||
"hand": "Ruka (nástroj pro posouvání)",
|
||||
"extraTools": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "",
|
||||
"selectedShapeActions": "",
|
||||
"canvasActions": "Akce plátna",
|
||||
"selectedShapeActions": "Akce vybraného tvaru",
|
||||
"shapes": "Tvary"
|
||||
},
|
||||
"hints": {
|
||||
"canvasPanning": "",
|
||||
"linearElement": "",
|
||||
"freeDraw": "",
|
||||
"text": "",
|
||||
"text_selected": "",
|
||||
"text_editing": "",
|
||||
"linearElementMulti": "",
|
||||
"lockAngle": "",
|
||||
"resize": "",
|
||||
"resizeImage": "",
|
||||
"rotate": "",
|
||||
"lineEditor_info": "",
|
||||
"lineEditor_pointSelected": "",
|
||||
"lineEditor_nothingSelected": "",
|
||||
"placeImage": "",
|
||||
"publishLibrary": "",
|
||||
"bindTextToElement": "",
|
||||
"deepBoxSelect": "",
|
||||
"eraserRevert": "",
|
||||
"firefox_clipboard_write": ""
|
||||
"canvasPanning": "Chcete-li přesunout plátno, podržte kolečko nebo mezerník při tažení nebo použijte nástroj Ruka",
|
||||
"linearElement": "Kliknutím pro více bodů, táhnutím pro jednu čáru",
|
||||
"freeDraw": "Klikněte a táhněte, pro ukončení pusťte",
|
||||
"text": "Tip: Text můžete také přidat dvojitým kliknutím kdekoli pomocí nástroje pro výběr",
|
||||
"text_selected": "Dvojklikem nebo stisknutím klávesy ENTER upravíte text",
|
||||
"text_editing": "Stiskněte Escape nebo Ctrl/Cmd+ENTER pro dokončení úprav",
|
||||
"linearElementMulti": "Klikněte na poslední bod nebo stiskněte Escape anebo Enter pro dokončení",
|
||||
"lockAngle": "Úhel můžete omezit podržením SHIFT",
|
||||
"resize": "Můžete omezit proporce podržením SHIFT při změně velikosti,\npodržte ALT pro změnu velikosti od středu",
|
||||
"resizeImage": "Můžete volně změnit velikost podržením SHIFT,\npodržením klávesy ALT změníte velikosti od středu",
|
||||
"rotate": "Úhly můžete omezit podržením SHIFT při otáčení",
|
||||
"lineEditor_info": "Podržte Ctrl/Cmd a dvakrát klikněte nebo stiskněte Ctrl/Cmd + Enter pro úpravu bodů",
|
||||
"lineEditor_pointSelected": "Stisknutím tlačítka Delete odstraňte bod(y),\nCtrl/Cmd+D pro duplicitu nebo táhnutím pro přesun",
|
||||
"lineEditor_nothingSelected": "Vyberte bod, který chcete upravit (podržením klávesy SHIFT vyberete více položek),\nnebo podržením klávesy Alt a kliknutím přidáte nové body",
|
||||
"placeImage": "Kliknutím umístěte obrázek, nebo klepnutím a přetažením ručně nastavíte jeho velikost",
|
||||
"publishLibrary": "Publikovat vlastní knihovnu",
|
||||
"bindTextToElement": "Stiskněte Enter pro přidání textu",
|
||||
"deepBoxSelect": "Podržte Ctrl/Cmd pro hluboký výběr a pro zabránění táhnutí",
|
||||
"eraserRevert": "Podržením klávesy Alt vrátíte prvky označené pro smazání",
|
||||
"firefox_clipboard_write": "Tato funkce může být povolena nastavením vlajky \"dom.events.asyncClipboard.clipboardItem\" na \"true\". Chcete-li změnit vlajky prohlížeče ve Firefoxu, navštivte stránku \"about:config\"."
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "",
|
||||
"canvasTooBig": "",
|
||||
"canvasTooBigTip": ""
|
||||
"cannotShowPreview": "Náhled nelze zobrazit",
|
||||
"canvasTooBig": "Plátno je možná příliš velké.",
|
||||
"canvasTooBigTip": "Tip: zkus posunout nejvzdálenější prvky trochu blíže k sobě."
|
||||
},
|
||||
"errorSplash": {
|
||||
"headingMain": "",
|
||||
"clearCanvasMessage": "",
|
||||
"clearCanvasCaveat": "",
|
||||
"headingMain": "Chyba. Zkuste <button>znovu načíst stránku</button>.",
|
||||
"clearCanvasMessage": "Pokud opětovné načtení nefunguje, zkuste <button>vymazat plátno</button>.",
|
||||
"clearCanvasCaveat": " To povede ke ztrátě dat ",
|
||||
"trackedToSentry": "Chyba identifikátoru {{eventId}} byl zaznamenán v našem systému.",
|
||||
"openIssueMessage": "",
|
||||
"sceneContent": ""
|
||||
"openIssueMessage": "Byli jsme velmi opatrní, abychom neuváděli informace o Vaší scéně. Pokud vaše scéna není soukromá, zvažte prosím sledování na našem <button>bug trackeru</button>. Uveďte prosím níže uvedené informace kopírováním a vložením do problému na GitHubu.",
|
||||
"sceneContent": "Obsah scény:"
|
||||
},
|
||||
"roomDialog": {
|
||||
"desc_intro": "",
|
||||
"desc_privacy": "",
|
||||
"button_startSession": "",
|
||||
"button_stopSession": "",
|
||||
"desc_inProgressIntro": "",
|
||||
"desc_shareLink": "",
|
||||
"desc_exitSession": "",
|
||||
"shareTitle": ""
|
||||
"desc_intro": "Můžete pozvat lidi na vaši aktuální scénu ke spolupráci s vámi.",
|
||||
"desc_privacy": "Nebojte se, relace používá end-to-end šifrování, takže cokoliv nakreslíte zůstane soukromé. Ani náš server nebude schopen vidět, s čím budete pracovat.",
|
||||
"button_startSession": "Zahájit relaci",
|
||||
"button_stopSession": "Ukončit relaci",
|
||||
"desc_inProgressIntro": "Živá spolupráce právě probíhá.",
|
||||
"desc_shareLink": "Sdílejte tento odkaz s každým, s kým chcete spolupracovat:",
|
||||
"desc_exitSession": "Zastavením relace se odpojíte od místnosti, ale budete moci pokračovat v práci s touto scénou lokálně. Všimněte si, že to nebude mít vliv na ostatní lidi a budou stále moci spolupracovat na jejich verzi.",
|
||||
"shareTitle": "Připojte se k aktivní spolupráci na Excalidraw"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": ""
|
||||
"title": "Chyba"
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "",
|
||||
"disk_details": "",
|
||||
"disk_title": "Uložit na disk",
|
||||
"disk_details": "Exportovat data scény do souboru, ze kterého můžete importovat později.",
|
||||
"disk_button": "Uložit do souboru",
|
||||
"link_title": "Odkaz pro sdílení",
|
||||
"link_details": "Exportovat jako odkaz pouze pro čtení.",
|
||||
|
@ -290,18 +294,18 @@
|
|||
"helpDialog": {
|
||||
"blog": "Přečtěte si náš blog",
|
||||
"click": "kliknutí",
|
||||
"deepSelect": "",
|
||||
"deepBoxSelect": "",
|
||||
"deepSelect": "Hluboký výběr",
|
||||
"deepBoxSelect": "Hluboký výběr uvnitř boxu a zabránění táhnnutí",
|
||||
"curvedArrow": "Zakřivená šipka",
|
||||
"curvedLine": "Zakřivená čára",
|
||||
"documentation": "Dokumentace",
|
||||
"doubleClick": "dvojklik",
|
||||
"drag": "tažení",
|
||||
"editor": "Editor",
|
||||
"editLineArrowPoints": "",
|
||||
"editText": "",
|
||||
"github": "",
|
||||
"howto": "",
|
||||
"editLineArrowPoints": "Upravit body linií/šipek",
|
||||
"editText": "Upravit text / přidat popis",
|
||||
"github": "Našel jsi problém? Nahlaš ho",
|
||||
"howto": "Sledujte naše návody",
|
||||
"or": "nebo",
|
||||
"preventBinding": "Zabránit vázání šipky",
|
||||
"tools": "Nástroje",
|
||||
|
@ -313,8 +317,8 @@
|
|||
"zoomToFit": "Přiblížit na zobrazení všech prvků",
|
||||
"zoomToSelection": "Přiblížit na výběr",
|
||||
"toggleElementLock": "Zamknout/odemknout výběr",
|
||||
"movePageUpDown": "",
|
||||
"movePageLeftRight": ""
|
||||
"movePageUpDown": "Posunout stránku nahoru/dolů",
|
||||
"movePageLeftRight": "Přesunout stránku doleva/doprava"
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": "Vymazat plátno"
|
||||
|
@ -342,46 +346,46 @@
|
|||
},
|
||||
"noteDescription": "Odešlete svou knihovnu, pro zařazení do <link>veřejného úložiště knihoven</link>, odkud ji budou moci při kreslení využít i ostatní uživatelé.",
|
||||
"noteGuidelines": "Knihovna musí být nejdříve ručně schválena. Přečtěte si prosím <link>pokyny</link>",
|
||||
"noteLicense": "",
|
||||
"noteItems": "",
|
||||
"atleastOneLibItem": "",
|
||||
"republishWarning": ""
|
||||
"noteLicense": "Odesláním souhlasíte s tím, že knihovna bude zveřejněna pod <link>MIT licencí</link>, stručně řečeno, kdokoli ji může používat bez omezení.",
|
||||
"noteItems": "Každá položka knihovny musí mít svůj vlastní název, aby byla filtrovatelná. Následující položky knihovny budou zahrnuty:",
|
||||
"atleastOneLibItem": "Vyberte alespoň jednu položku knihovny, kterou chcete začít",
|
||||
"republishWarning": "Poznámka: některé z vybraných položek jsou označeny jako již zveřejněné/odeslané. Položky byste měli znovu odeslat pouze při aktualizaci existující knihovny nebo podání."
|
||||
},
|
||||
"publishSuccessDialog": {
|
||||
"title": "Knihovna byla odeslána",
|
||||
"content": ""
|
||||
"content": "Děkujeme vám {{authorName}}. Vaše knihovna byla odeslána k posouzení. Stav můžete sledovat <link>zde</link>"
|
||||
},
|
||||
"confirmDialog": {
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromLib": ""
|
||||
"resetLibrary": "Resetovat knihovnu",
|
||||
"removeItemsFromLib": "Odstranit vybrané položky z knihovny"
|
||||
},
|
||||
"imageExportDialog": {
|
||||
"header": "",
|
||||
"header": "Exportovat obrázek",
|
||||
"label": {
|
||||
"withBackground": "",
|
||||
"onlySelected": "",
|
||||
"darkMode": "",
|
||||
"embedScene": "",
|
||||
"scale": "",
|
||||
"padding": ""
|
||||
"withBackground": "Pozadí",
|
||||
"onlySelected": "Pouze vybrané",
|
||||
"darkMode": "Tmavý režim",
|
||||
"embedScene": "Vložit scénu",
|
||||
"scale": "Měřítko",
|
||||
"padding": "Odsazení"
|
||||
},
|
||||
"tooltip": {
|
||||
"embedScene": ""
|
||||
"embedScene": "Data scény budou uložena do exportovaného souboru PNG/SVG tak, aby z něj mohla být scéna obnovena.\nZvýší se velikost exportovaného souboru."
|
||||
},
|
||||
"title": {
|
||||
"exportToPng": "",
|
||||
"exportToSvg": "",
|
||||
"copyPngToClipboard": ""
|
||||
"exportToPng": "Exportovat do PNG",
|
||||
"exportToSvg": "Exportovat do SVG",
|
||||
"copyPngToClipboard": "Kopírovat PNG do schránky"
|
||||
},
|
||||
"button": {
|
||||
"exportToPng": "",
|
||||
"exportToSvg": "",
|
||||
"copyPngToClipboard": ""
|
||||
"exportToPng": "PNG",
|
||||
"exportToSvg": "SVG",
|
||||
"copyPngToClipboard": "Kopírovat do schránky"
|
||||
}
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "",
|
||||
"link": ""
|
||||
"tooltip": "Vaše kresby jsou end-to-end šifrované, takže servery Excalidraw je nikdy neuvidí.",
|
||||
"link": "Blog příspěvek na end-to-end šifrování v Excalidraw"
|
||||
},
|
||||
"stats": {
|
||||
"angle": "Úhel",
|
||||
|
@ -402,48 +406,48 @@
|
|||
"addedToLibrary": "Přidáno do knihovny",
|
||||
"copyStyles": "Styly byly zkopírovány.",
|
||||
"copyToClipboard": "Zkopírováno do schránky.",
|
||||
"copyToClipboardAsPng": "",
|
||||
"copyToClipboardAsPng": "{{exportSelection}} zkopírován do schránky jako PNG\n({{exportColorScheme}})",
|
||||
"fileSaved": "Soubor byl uložen.",
|
||||
"fileSavedToFilename": "Uloženo do {filename}",
|
||||
"canvas": "plátno",
|
||||
"selection": "výběr",
|
||||
"pasteAsSingleElement": ""
|
||||
"pasteAsSingleElement": "Pomocí {{shortcut}} vložte jako jeden prvek,\nnebo vložte do existujícího textového editoru"
|
||||
},
|
||||
"colors": {
|
||||
"transparent": "Průhledná",
|
||||
"black": "",
|
||||
"white": "",
|
||||
"red": "",
|
||||
"pink": "",
|
||||
"grape": "",
|
||||
"violet": "",
|
||||
"gray": "",
|
||||
"blue": "",
|
||||
"cyan": "",
|
||||
"teal": "",
|
||||
"green": "",
|
||||
"yellow": "",
|
||||
"orange": "",
|
||||
"bronze": ""
|
||||
"black": "Černá",
|
||||
"white": "Bílá",
|
||||
"red": "Červená",
|
||||
"pink": "Růžová",
|
||||
"grape": "Vínová",
|
||||
"violet": "Fialová",
|
||||
"gray": "Šedá",
|
||||
"blue": "Modrá",
|
||||
"cyan": "Azurová",
|
||||
"teal": "Modrozelená",
|
||||
"green": "Zelená",
|
||||
"yellow": "Žlutá",
|
||||
"orange": "Oranžová",
|
||||
"bronze": "Bronzová"
|
||||
},
|
||||
"welcomeScreen": {
|
||||
"app": {
|
||||
"center_heading": "",
|
||||
"center_heading_plus": "",
|
||||
"menuHint": ""
|
||||
"center_heading": "Všechna vaše data jsou uložena lokálně ve vašem prohlížeči.",
|
||||
"center_heading_plus": "Chcete místo toho přejít na Excalidraw+?",
|
||||
"menuHint": "Export, nastavení, jazyky, ..."
|
||||
},
|
||||
"defaults": {
|
||||
"menuHint": "",
|
||||
"center_heading": "",
|
||||
"toolbarHint": "",
|
||||
"helpHint": ""
|
||||
"menuHint": "Export, nastavení a další...",
|
||||
"center_heading": "Diagramy. Vytvořeny. Jednoduše.",
|
||||
"toolbarHint": "Vyberte nástroj a začněte kreslit!",
|
||||
"helpHint": "Zkratky a pomoc"
|
||||
}
|
||||
},
|
||||
"colorPicker": {
|
||||
"mostUsedCustomColors": "",
|
||||
"colors": "",
|
||||
"shades": "",
|
||||
"hexCode": "",
|
||||
"noShades": ""
|
||||
"mostUsedCustomColors": "Nejpoužívanější vlastní barvy",
|
||||
"colors": "Barvy",
|
||||
"shades": "Stíny",
|
||||
"hexCode": "Hex kód",
|
||||
"noShades": "Pro tuto barvu nejsou k dispozici žádné odstíny"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -124,6 +124,8 @@
|
|||
},
|
||||
"statusPublished": "",
|
||||
"sidebarLock": "",
|
||||
"selectAllElementsInFrame": "",
|
||||
"removeAllElementsFromFrame": "",
|
||||
"eyeDropper": ""
|
||||
},
|
||||
"library": {
|
||||
|
@ -221,7 +223,9 @@
|
|||
"penMode": "",
|
||||
"link": "",
|
||||
"eraser": "",
|
||||
"hand": ""
|
||||
"frame": "",
|
||||
"hand": "",
|
||||
"extraTools": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue