Merge remote-tracking branch 'origin/master' into aakansha-create-text-containers-programmatically

This commit is contained in:
Aakansha Doshi 2023-07-14 17:03:54 +05:30
commit 2ff0528a4f
186 changed files with 8275 additions and 1805 deletions

View file

@ -20,14 +20,10 @@ REACT_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when # whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers. # debugging Service Workers.
REACT_APP_DEV_DISABLE_LIVE_RELOAD= REACT_APP_DEV_DISABLE_LIVE_RELOAD=
REACT_APP_DISABLE_TRACKING=true
FAST_REFRESH=false FAST_REFRESH=false
# MATOMO
REACT_APP_MATOMO_URL=
REACT_APP_CDN_MATOMO_TRACKER_URL=
REACT_APP_MATOMO_SITE_ID=
#Debug flags #Debug flags
# To enable bounding box for text containers # To enable bounding box for text containers

View file

@ -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"}' 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_PLUS_APP=https://app.excalidraw.com
REACT_APP_DISABLE_TRACKING=

View file

@ -306,30 +306,32 @@ This is the history API. history.clear() will clear the history.
## scrollToContent ## scrollToContent
<pre> ```tsx
(<br /> (
{" "} target?: ExcalidrawElement | ExcalidrawElement[],
target?:{" "} opts?:
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115"> | {
ExcalidrawElement fitToContent?: boolean;
</a>{" "} animate?: boolean;
&#124;{" "} duration?: number;
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115"> }
ExcalidrawElement | {
</a> fitToViewport?: boolean;
[], viewportZoomFactor?: number;
<br /> animate?: boolean;
{" "}opts?: &#123; fitToContent?: boolean; animate?: boolean; duration?: number duration?: number;
&#125; }
<br />) => void ) => void
</pre> ```
Scroll the nearest element out of the elements supplied to the center of the viewport. Defaults to the elements on the scene. 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 | | Attribute | type | default | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| target | <code>ExcalidrawElement &#124; ExcalidrawElement[]</code> | All scene elements | The element(s) to scroll to. | | target | [ExcalidrawElement](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) &#124; [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. | | 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.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`. | | opts.duration | number | 500 | Duration of the animation if `opts.animate` is `true`. |

View file

@ -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. 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 ## Setup
### Option 1 - Manual ### Option 1 - Manual

View file

@ -19,6 +19,8 @@
] ]
}, },
"dependencies": { "dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/random-username": "1.0.0",
"@radix-ui/react-popover": "1.0.3", "@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-tabs": "1.0.2", "@radix-ui/react-tabs": "1.0.2",
"@sentry/browser": "6.2.5", "@sentry/browser": "6.2.5",
@ -27,6 +29,7 @@
"@testing-library/react": "12.1.5", "@testing-library/react": "12.1.5",
"@tldraw/vec": "1.7.1", "@tldraw/vec": "1.7.1",
"browser-fs-access": "0.29.1", "browser-fs-access": "0.29.1",
"canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1", "clsx": "1.1.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"fake-indexeddb": "3.1.7", "fake-indexeddb": "3.1.7",
@ -106,7 +109,7 @@
"<rootDir>/src/packages/excalidraw/example" "<rootDir>/src/packages/excalidraw/example"
], ],
"transformIgnorePatterns": [ "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 "resetMocks": false
}, },

View file

@ -148,33 +148,6 @@
// setting this so that libraries installation reuses this window tab. // setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw"; window.name = "_excalidraw";
</script> </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) --> <!-- FIXME: remove this when we update CRA (fix SW caching) -->
<style> <style>
@ -227,17 +200,39 @@
<h1 class="visually-hidden">Excalidraw</h1> <h1 class="visually-hidden">Excalidraw</h1>
</header> </header>
<div id="root"></div> <div id="root"></div>
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true') { %>
<!-- 100% privacy friendly analytics --> <!-- 100% privacy friendly analytics -->
<script <script>
async // need to load this script dynamically bcs. of iframe embed tracking
defer var scriptEle = document.createElement("script");
src="https://scripts.simpleanalyticscdn.com/latest.js" scriptEle.setAttribute(
></script> "src",
<noscript "https://scripts.simpleanalyticscdn.com/latest.js",
><img );
src="https://queue.simpleanalyticscdn.com/noscript.gif" scriptEle.setAttribute("type", "text/javascript");
alt="" scriptEle.setAttribute("defer", true);
referrerpolicy="no-referrer-when-downgrade" scriptEle.setAttribute("async", true);
/></noscript> // 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> </body>
</html> </html>

View file

@ -12,7 +12,10 @@ export const actionAddToLibrary = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true, {
includeBoundTextElement: true,
includeElementsInFrames: true,
},
); );
if (selectedElements.some((element) => element.type === "image")) { if (selectedElements.some((element) => element.type === "image")) {
return { return {

View file

@ -10,6 +10,7 @@ import {
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n"; import { t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
@ -17,10 +18,20 @@ import { AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
const enableActionGroup = ( const alignActionsPredicate = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, 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 = ( const alignSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@ -36,14 +47,16 @@ const alignSelectedElements = (
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
return elements.map( return updateFrameMembershipOfSelectedElements(
(element) => updatedElementsMap.get(element.id) || element, elements.map((element) => updatedElementsMap.get(element.id) || element),
appState,
); );
}; };
export const actionAlignTop = register({ export const actionAlignTop = register({
name: "alignTop", name: "alignTop",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@ -58,7 +71,7 @@ export const actionAlignTop = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!alignActionsPredicate(elements, appState)}
type="button" type="button"
icon={AlignTopIcon} icon={AlignTopIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@ -74,6 +87,7 @@ export const actionAlignTop = register({
export const actionAlignBottom = register({ export const actionAlignBottom = register({
name: "alignBottom", name: "alignBottom",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@ -88,7 +102,7 @@ export const actionAlignBottom = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!alignActionsPredicate(elements, appState)}
type="button" type="button"
icon={AlignBottomIcon} icon={AlignBottomIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@ -104,6 +118,7 @@ export const actionAlignBottom = register({
export const actionAlignLeft = register({ export const actionAlignLeft = register({
name: "alignLeft", name: "alignLeft",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@ -118,7 +133,7 @@ export const actionAlignLeft = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!alignActionsPredicate(elements, appState)}
type="button" type="button"
icon={AlignLeftIcon} icon={AlignLeftIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@ -134,7 +149,7 @@ export const actionAlignLeft = register({
export const actionAlignRight = register({ export const actionAlignRight = register({
name: "alignRight", name: "alignRight",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@ -149,7 +164,7 @@ export const actionAlignRight = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!alignActionsPredicate(elements, appState)}
type="button" type="button"
icon={AlignRightIcon} icon={AlignRightIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@ -165,7 +180,7 @@ export const actionAlignRight = register({
export const actionAlignVerticallyCentered = register({ export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered", name: "alignVerticallyCentered",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@ -178,7 +193,7 @@ export const actionAlignVerticallyCentered = register({
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!alignActionsPredicate(elements, appState)}
type="button" type="button"
icon={CenterVerticallyIcon} icon={CenterVerticallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@ -192,6 +207,7 @@ export const actionAlignVerticallyCentered = register({
export const actionAlignHorizontallyCentered = register({ export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered", name: "alignHorizontallyCentered",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
appState, appState,
@ -204,7 +220,7 @@ export const actionAlignHorizontallyCentered = register({
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
hidden={!enableActionGroup(elements, appState)} hidden={!alignActionsPredicate(elements, appState)}
type="button" type="button"
icon={CenterHorizontallyIcon} icon={CenterHorizontallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}

View file

@ -31,6 +31,7 @@ import {
} from "../element/types"; } from "../element/types";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { AppState } from "../types"; import { AppState } from "../types";
import { Mutable } from "../utility-types";
import { getFontString } from "../utils"; import { getFontString } from "../utils";
import { register } from "./register"; import { register } from "./register";
@ -211,7 +212,7 @@ export const actionWrapTextInContainer = register({
appState, appState,
); );
let updatedElements: readonly ExcalidrawElement[] = elements.slice(); let updatedElements: readonly ExcalidrawElement[] = elements.slice();
const containerIds: AppState["selectedElementIds"] = {}; const containerIds: Mutable<AppState["selectedElementIds"]> = {};
for (const textElement of selectedElements) { for (const textElement of selectedElements) {
if (isTextElement(textElement)) { if (isTextElement(textElement)) {
@ -249,6 +250,7 @@ export const actionWrapTextInContainer = register({
"rectangle", "rectangle",
), ),
groupIds: textElement.groupIds, groupIds: textElement.groupIds,
frameId: textElement.frameId,
}); });
// update bindings // update bindings

View file

@ -20,6 +20,7 @@ import {
isHandToolActive, isHandToolActive,
} from "../appState"; } from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors"; import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { Bounds } from "../element/bounds";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
@ -206,7 +207,7 @@ export const actionResetZoom = register({
}); });
const zoomValueToFitBoundsOnViewport = ( const zoomValueToFitBoundsOnViewport = (
bounds: [number, number, number, number], bounds: Bounds,
viewportDimensions: { width: number; height: number }, viewportDimensions: { width: number; height: number },
) => { ) => {
const [x1, y1, x2, y2] = bounds; const [x1, y1, x2, y2] = bounds;
@ -224,50 +225,96 @@ const zoomValueToFitBoundsOnViewport = (
return clampedZoomValueToFitElements as NormalizedZoomValue; return clampedZoomValueToFitElements as NormalizedZoomValue;
}; };
export const zoomToFitElements = ( export const zoomToFit = ({
elements: readonly ExcalidrawElement[], targetElements,
appState: Readonly<AppState>, appState,
zoomToSelection: boolean, fitToViewport = false,
) => { viewportZoomFactor = 0.7,
const nonDeletedElements = getNonDeletedElements(elements); }: {
const selectedElements = getSelectedElements(nonDeletedElements, appState); targetElements: readonly ExcalidrawElement[];
appState: Readonly<AppState>;
const commonBounds = /** whether to fit content to viewport (beyond >100%) */
zoomToSelection && selectedElements.length > 0 fitToViewport: boolean;
? getCommonBounds(selectedElements) /** zoom content to cover X of the viewport, when fitToViewport=true */
: getCommonBounds(nonDeletedElements); viewportZoomFactor?: number;
}) => {
const newZoom = { const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
value: zoomValueToFitBoundsOnViewport(commonBounds, {
width: appState.width,
height: appState.height,
}),
};
const [x1, y1, x2, y2] = commonBounds; const [x1, y1, x2, y2] = commonBounds;
const centerX = (x1 + x2) / 2; const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 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 { return {
appState: { appState: {
...appState, ...appState,
...centerScrollOn({ scrollX,
scenePoint: { x: centerX, y: centerY }, scrollY,
viewportDimensions: { zoom: { value: newZoomValue },
width: appState.width,
height: appState.height,
},
zoom: newZoom,
}),
zoom: newZoom,
}, },
commitToHistory: false, commitToHistory: false,
}; };
}; };
export const actionZoomToSelected = register({ // Note, this action differs from actionZoomToFitSelection in that it doesn't
name: "zoomToSelection", // 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" }, 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) => keyTest: (event) =>
event.code === CODES.TWO && event.code === CODES.TWO &&
event.shiftKey && event.shiftKey &&
@ -275,11 +322,34 @@ export const actionZoomToSelected = register({
!event[KEYS.CTRL_OR_CMD], !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({ export const actionZoomToFit = register({
name: "zoomToFit", name: "zoomToFit",
viewMode: true, viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, false), perform: (elements, appState) =>
zoomToFit({ targetElements: elements, appState, fitToViewport: false }),
keyTest: (event) => keyTest: (event) =>
event.code === CODES.ONE && event.code === CODES.ONE &&
event.shiftKey && event.shiftKey &&

View file

@ -16,9 +16,12 @@ export const actionCopy = register({
name: "copy", name: "copy",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, _, app) => { 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 { return {
commitToHistory: false, commitToHistory: false,
@ -75,7 +78,10 @@ export const actionCopyAsSvg = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true, {
includeBoundTextElement: true,
includeElementsInFrames: true,
},
); );
try { try {
await exportCanvas( await exportCanvas(
@ -119,7 +125,10 @@ export const actionCopyAsPng = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true, {
includeBoundTextElement: true,
includeElementsInFrames: true,
},
); );
try { try {
await exportCanvas( await exportCanvas(
@ -172,7 +181,9 @@ export const copyText = register({
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true, {
includeBoundTextElement: true,
},
); );
const text = selectedElements const text = selectedElements
@ -191,7 +202,9 @@ export const copyText = register({
predicate: (elements, appState) => { predicate: (elements, appState) => {
return ( return (
probablySupportsClipboardWriteText && probablySupportsClipboardWriteText &&
getSelectedElements(elements, appState, true).some(isTextElement) getSelectedElements(elements, appState, {
includeBoundTextElement: true,
}).some(isTextElement)
); );
}, },
contextItemLabel: "labels.copyText", contextItemLabel: "labels.copyText",

View file

@ -1,4 +1,4 @@
import { isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
@ -18,11 +18,23 @@ const deleteSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
) => { ) => {
const framesToBeDeleted = new Set(
getSelectedElements(
elements.filter((el) => el.type === "frame"),
appState,
).map((el) => el.id),
);
return { return {
elements: elements.map((el) => { elements: elements.map((el) => {
if (appState.selectedElementIds[el.id]) { if (appState.selectedElementIds[el.id]) {
return newElementWith(el, { isDeleted: true }); return newElementWith(el, { isDeleted: true });
} }
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
return newElementWith(el, { isDeleted: true });
}
if ( if (
isBoundToContainer(el) && isBoundToContainer(el) &&
appState.selectedElementIds[el.containerId] appState.selectedElementIds[el.containerId]

View file

@ -6,6 +6,7 @@ import { ToolButton } from "../components/ToolButton";
import { distributeElements, Distribution } from "../distribute"; import { distributeElements, Distribution } from "../distribute";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n"; import { t } from "../i18n";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
@ -16,7 +17,17 @@ import { register } from "./register";
const enableActionGroup = ( const enableActionGroup = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, 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 = ( const distributeSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@ -32,8 +43,9 @@ const distributeSelectedElements = (
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
return elements.map( return updateFrameMembershipOfSelectedElements(
(element) => updatedElementsMap.get(element.id) || element, elements.map((element) => updatedElementsMap.get(element.id) || element),
appState,
); );
}; };

View file

@ -2,7 +2,7 @@ import { KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { duplicateElement, getNonDeletedElements } from "../element"; import { duplicateElement, getNonDeletedElements } from "../element";
import { getSelectedElements, isSomeElementSelected } from "../scene"; import { isSomeElementSelected } from "../scene";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
@ -20,9 +20,17 @@ import {
bindTextToShapeAfterDuplication, bindTextToShapeAfterDuplication,
getBoundTextElement, getBoundTextElement,
} from "../element/textElement"; } from "../element/textElement";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer, isFrameElement } from "../element/typeChecks";
import { normalizeElementOrder } from "../element/sortElements"; import { normalizeElementOrder } from "../element/sortElements";
import { DuplicateIcon } from "../components/icons"; import { DuplicateIcon } from "../components/icons";
import {
bindElementsToFramesAfterDuplication,
getFrameElements,
} from "../frame";
import {
excludeElementsInFramesFromSelection,
getSelectedElements,
} from "../scene/selection";
export const actionDuplicateSelection = register({ export const actionDuplicateSelection = register({
name: "duplicateSelection", name: "duplicateSelection",
@ -94,8 +102,11 @@ const duplicateElements = (
return newElement; return newElement;
}; };
const selectedElementIds = arrayToMap( const idsOfElementsToDuplicate = arrayToMap(
getSelectedElements(sortedElements, appState, true), getSelectedElements(sortedElements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
); );
// Ids of elements that have already been processed so we don't push them // Ids of elements that have already been processed so we don't push them
@ -129,12 +140,25 @@ const duplicateElements = (
} }
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (selectedElementIds.get(element.id)) { const isElementAFrame = isFrameElement(element);
// if a group or a container/bound-text, duplicate atomically
if (element.groupIds.length || boundTextElement) { 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); const groupId = getSelectedGroupForElement(appState, element);
if (groupId) { 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( elementsWithClones.push(
...markAsProcessed([ ...markAsProcessed([
...groupElements, ...groupElements,
@ -156,10 +180,34 @@ const duplicateElements = (
); );
continue; 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 { } else {
elementsWithClones.push(...markAsProcessed([element])); elementsWithClones.push(...markAsProcessed([element]));
} }
@ -200,6 +248,14 @@ const duplicateElements = (
oldElements, oldElements,
oldIdToDuplicatedId, oldIdToDuplicatedId,
); );
bindElementsToFramesAfterDuplication(
finalElements,
oldElements,
oldIdToDuplicatedId,
);
const nextElementsToSelect =
excludeElementsInFramesFromSelection(newElements);
return { return {
elements: finalElements, elements: finalElements,
@ -207,7 +263,7 @@ const duplicateElements = (
{ {
...appState, ...appState,
selectedGroupIds: {}, selectedGroupIds: {},
selectedElementIds: newElements.reduce( selectedElementIds: nextElementsToSelect.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => { (acc: Record<ExcalidrawElement["id"], true>, element) => {
if (!isBoundToContainer(element)) { if (!isBoundToContainer(element)) {
acc[element.id] = true; acc[element.id] = true;
@ -218,6 +274,7 @@ const duplicateElements = (
), ),
}, },
getNonDeletedElements(finalElements), getNonDeletedElements(finalElements),
appState,
), ),
}; };
}; };

View file

@ -11,8 +11,17 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
export const actionToggleElementLock = register({ export const actionToggleElementLock = register({
name: "toggleElementLock", name: "toggleElementLock",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return !selectedElements.some(
(element) => element.locked && element.frameId,
);
},
perform: (elements, appState) => { perform: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState, true); const selectedElements = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
});
if (!selectedElements.length) { if (!selectedElements.length) {
return false; return false;
@ -38,8 +47,10 @@ export const actionToggleElementLock = register({
}; };
}, },
contextItemLabel: (elements, appState) => { contextItemLabel: (elements, appState) => {
const selected = getSelectedElements(elements, appState, false); const selected = getSelectedElements(elements, appState, {
if (selected.length === 1) { includeBoundTextElement: false,
});
if (selected.length === 1 && selected[0].type !== "frame") {
return selected[0].locked return selected[0].locked
? "labels.elementLock.unlock" ? "labels.elementLock.unlock"
: "labels.elementLock.lock"; : "labels.elementLock.lock";
@ -54,7 +65,9 @@ export const actionToggleElementLock = register({
event.key.toLocaleLowerCase() === KEYS.L && event.key.toLocaleLowerCase() === KEYS.L &&
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
event.shiftKey && event.shiftKey &&
getSelectedElements(elements, appState, false).length > 0 getSelectedElements(elements, appState, {
includeBoundTextElement: false,
}).length > 0
); );
}, },
}); });

View file

@ -125,13 +125,6 @@ export const actionFinalize = register({
{ x, y }, { x, y },
); );
} }
if (
!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw"
) {
appState.selectedElementIds[multiPointElement.id] = true;
}
} }
if ( if (

View file

@ -12,13 +12,17 @@ import {
isBindingEnabled, isBindingEnabled,
unbindLinearElements, unbindLinearElements,
} from "../element/binding"; } from "../element/binding";
import { updateFrameMembershipOfSelectedElements } from "../frame";
export const actionFlipHorizontal = register({ export const actionFlipHorizontal = register({
name: "flipHorizontal", name: "flipHorizontal",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: flipSelectedElements(elements, appState, "horizontal"), elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(elements, appState, "horizontal"),
appState,
),
appState, appState,
commitToHistory: true, commitToHistory: true,
}; };
@ -32,7 +36,10 @@ export const actionFlipVertical = register({
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
return { return {
elements: flipSelectedElements(elements, appState, "vertical"), elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(elements, appState, "vertical"),
appState,
),
appState, appState,
commitToHistory: true, commitToHistory: true,
}; };
@ -50,6 +57,9 @@ const flipSelectedElements = (
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
{
includeElementsInFrames: true,
},
); );
const updatedElements = flipElements( const updatedElements = flipElements(

143
src/actions/actionFrame.ts Normal file
View 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,
});

View file

@ -17,9 +17,19 @@ import {
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { randomId } from "../random"; import { randomId } from "../random";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; import {
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawTextElement,
} from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer } from "../element/typeChecks";
import {
getElementsInResizingFrame,
groupByFrames,
removeElementsFromFrame,
replaceAllElementsInFrame,
} from "../frame";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => { const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) { if (elements.length >= 2) {
@ -45,7 +55,9 @@ const enableActionGroup = (
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true, {
includeBoundTextElement: true,
},
); );
return ( return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements) selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
@ -55,11 +67,13 @@ const enableActionGroup = (
export const actionGroup = register({ export const actionGroup = register({
name: "group", name: "group",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true, {
includeBoundTextElement: true,
},
); );
if (selectedElements.length < 2) { if (selectedElements.length < 2) {
// nothing to group // nothing to group
@ -86,9 +100,31 @@ export const actionGroup = register({
return { appState, elements, commitToHistory: false }; 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 newGroupId = randomId();
const selectElementIds = arrayToMap(selectedElements); const selectElementIds = arrayToMap(selectedElements);
const updatedElements = elements.map((element) => {
nextElements = nextElements.map((element) => {
if (!selectElementIds.get(element.id)) { if (!selectElementIds.get(element.id)) {
return element; return element;
} }
@ -102,17 +138,16 @@ export const actionGroup = register({
}); });
// keep the z order within the group the same, but move them // keep the z order within the group the same, but move them
// to the z order of the highest element in the layer stack // 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 lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
const lastGroupElementIndex = const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup);
updatedElements.lastIndexOf(lastElementInGroup); const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1); const elementsBeforeGroup = nextElements
const elementsBeforeGroup = updatedElements
.slice(0, lastGroupElementIndex) .slice(0, lastGroupElementIndex)
.filter( .filter(
(updatedElement) => !isElementInGroup(updatedElement, newGroupId), (updatedElement) => !isElementInGroup(updatedElement, newGroupId),
); );
const updatedElementsInOrder = [ nextElements = [
...elementsBeforeGroup, ...elementsBeforeGroup,
...elementsInGroup, ...elementsInGroup,
...elementsAfterGroup, ...elementsAfterGroup,
@ -122,9 +157,9 @@ export const actionGroup = register({
appState: selectGroup( appState: selectGroup(
newGroupId, newGroupId,
{ ...appState, selectedGroupIds: {} }, { ...appState, selectedGroupIds: {} },
getNonDeletedElements(updatedElementsInOrder), getNonDeletedElements(nextElements),
), ),
elements: updatedElementsInOrder, elements: nextElements,
commitToHistory: true, commitToHistory: true,
}; };
}, },
@ -148,14 +183,23 @@ export const actionGroup = register({
export const actionUngroup = register({ export const actionUngroup = register({
name: "ungroup", name: "ungroup",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
const groupIds = getSelectedGroupIds(appState); const groupIds = getSelectedGroupIds(appState);
if (groupIds.length === 0) { if (groupIds.length === 0) {
return { appState, elements, commitToHistory: false }; 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 boundTextElementIds: ExcalidrawTextElement["id"][] = [];
const nextElements = elements.map((element) => { nextElements = nextElements.map((element) => {
if (isBoundToContainer(element)) { if (isBoundToContainer(element)) {
boundTextElementIds.push(element.id); boundTextElementIds.push(element.id);
} }
@ -174,15 +218,35 @@ export const actionUngroup = register({
const updateAppState = selectGroupsForSelectedElements( const updateAppState = selectGroupsForSelectedElements(
{ ...appState, selectedGroupIds: {} }, { ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements), getNonDeletedElements(nextElements),
appState,
); );
frames.forEach((frame) => {
if (frame) {
nextElements = replaceAllElementsInFrame(
nextElements,
getElementsInResizingFrame(nextElements, frame, appState),
frame,
appState,
);
}
});
// remove binded text elements from selection // remove binded text elements from selection
boundTextElementIds.forEach( updateAppState.selectedElementIds = Object.entries(
(id) => (updateAppState.selectedElementIds[id] = false), updateAppState.selectedElementIds,
).reduce(
(acc: { [key: ExcalidrawElement["id"]]: true }, [id, selected]) => {
if (selected && !boundTextElementIds.includes(id)) {
acc[id] = true;
}
return acc;
},
{},
); );
return { return {
appState: updateAppState, appState: updateAppState,
elements: nextElements, elements: nextElements,
commitToHistory: true, commitToHistory: true,
}; };

View file

@ -21,7 +21,9 @@ export const actionToggleLinearEditor = register({
const selectedElement = getSelectedElements( const selectedElement = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true, {
includeBoundTextElement: true,
},
)[0] as ExcalidrawLinearElement; )[0] as ExcalidrawLinearElement;
const editingLinearElement = const editingLinearElement =
@ -40,7 +42,9 @@ export const actionToggleLinearEditor = register({
const selectedElement = getSelectedElements( const selectedElement = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
true, {
includeBoundTextElement: true,
},
)[0] as ExcalidrawLinearElement; )[0] as ExcalidrawLinearElement;
return appState.editingLinearElement?.elementId === selectedElement.id return appState.editingLinearElement?.elementId === selectedElement.id
? "labels.lineEditor.exit" ? "labels.lineEditor.exit"

View file

@ -67,7 +67,6 @@ export const actionFullScreen = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
keyTest: (event) => event.key === KEYS.F && !event[KEYS.CTRL_OR_CMD],
}); });
export const actionShortcuts = register({ export const actionShortcuts = register({

View file

@ -1,4 +1,4 @@
import { getClientColors } from "../clients"; import { getClientColor } from "../clients";
import { Avatar } from "../components/Avatar"; import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { Collaborator } from "../types"; import { Collaborator } from "../types";
@ -31,15 +31,14 @@ export const actionGoToCollaborator = register({
commitToHistory: false, commitToHistory: false,
}; };
}, },
PanelComponent: ({ appState, updateData, data }) => { PanelComponent: ({ updateData, data }) => {
const [clientId, collaborator] = data as [string, Collaborator]; const [clientId, collaborator] = data as [string, Collaborator];
const { background, stroke } = getClientColors(clientId, appState); const background = getClientColor(clientId);
return ( return (
<Avatar <Avatar
color={background} color={background}
border={stroke}
onClick={() => updateData(collaborator.pointer)} onClick={() => updateData(collaborator.pointer)}
name={collaborator.username || ""} name={collaborator.username || ""}
src={collaborator.avatarUrl} src={collaborator.avatarUrl}

View file

@ -102,8 +102,11 @@ const changeProperty = (
includeBoundText = false, includeBoundText = false,
) => { ) => {
const selectedElementIds = arrayToMap( const selectedElementIds = arrayToMap(
getSelectedElements(elements, appState, includeBoundText), getSelectedElements(elements, appState, {
includeBoundTextElement: includeBoundText,
}),
); );
return elements.map((element) => { return elements.map((element) => {
if ( if (
selectedElementIds.get(element.id) || selectedElementIds.get(element.id) ||

View file

@ -5,6 +5,7 @@ import { getNonDeletedElements, isTextElement } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { isLinearElement } from "../element/typeChecks"; import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
export const actionSelectAll = register({ export const actionSelectAll = register({
name: "selectAll", name: "selectAll",
@ -13,19 +14,18 @@ export const actionSelectAll = register({
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
return false; return false;
} }
const selectedElementIds = elements.reduce(
(map: Record<ExcalidrawElement["id"], true>, element) => { const selectedElementIds = excludeElementsInFramesFromSelection(
if ( elements.filter(
(element) =>
!element.isDeleted && !element.isDeleted &&
!(isTextElement(element) && element.containerId) && !(isTextElement(element) && element.containerId) &&
!element.locked !element.locked,
) { ),
map[element.id] = true; ).reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
} map[element.id] = true;
return map; return map;
}, }, {});
{},
);
return { return {
appState: selectGroupsForSelectedElements( appState: selectGroupsForSelectedElements(
@ -41,6 +41,7 @@ export const actionSelectAll = register({
selectedElementIds, selectedElementIds,
}, },
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState,
), ),
commitToHistory: true, commitToHistory: true,
}; };

View file

@ -20,6 +20,7 @@ import {
hasBoundTextElement, hasBoundTextElement,
canApplyRoundnessTypeToElement, canApplyRoundnessTypeToElement,
getDefaultRoundnessTypeForElement, getDefaultRoundnessTypeForElement,
isFrameElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
@ -64,7 +65,9 @@ export const actionPasteStyles = register({
return { elements, commitToHistory: false }; return { elements, commitToHistory: false };
} }
const selectedElements = getSelectedElements(elements, appState, true); const selectedElements = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
});
const selectedElementIds = selectedElements.map((element) => element.id); const selectedElementIds = selectedElements.map((element) => element.id);
return { return {
elements: elements.map((element) => { 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 newElement;
} }
return element; return element;

View file

@ -82,7 +82,8 @@ export type ActionName =
| "zoomOut" | "zoomOut"
| "resetZoom" | "resetZoom"
| "zoomToFit" | "zoomToFit"
| "zoomToSelection" | "zoomToFitSelection"
| "zoomToFitSelectionInViewport"
| "changeFontFamily" | "changeFontFamily"
| "changeTextAlign" | "changeTextAlign"
| "changeVerticalAlign" | "changeVerticalAlign"
@ -116,6 +117,11 @@ export type ActionName =
| "toggleLinearEditor" | "toggleLinearEditor"
| "toggleEraserTool" | "toggleEraserTool"
| "toggleHandTool" | "toggleHandTool"
| "selectAllElementsInFrame"
| "removeAllElementsFromFrame"
| "updateFrameRendering"
| "setFrameAsActiveTool"
| "createContainerFromText"
| "wrapTextInContainer"; | "wrapTextInContainer";
export type PanelComponentProps = { export type PanelComponentProps = {

View file

@ -1,6 +1,6 @@
import { ExcalidrawElement } from "./element/types"; import { ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement"; import { newElementWith } from "./element/mutateElement";
import { Box, getCommonBoundingBox } from "./element/bounds"; import { BoundingBox, getCommonBoundingBox } from "./element/bounds";
import { getMaximumGroups } from "./groups"; import { getMaximumGroups } from "./groups";
export interface Alignment { export interface Alignment {
@ -33,7 +33,7 @@ export const alignElements = (
const calculateTranslation = ( const calculateTranslation = (
group: ExcalidrawElement[], group: ExcalidrawElement[],
selectionBoundingBox: Box, selectionBoundingBox: BoundingBox,
{ axis, position }: Alignment, { axis, position }: Alignment,
): { x: number; y: number } => { ): { x: number; y: number } => {
const groupBoundingBox = getCommonBoundingBox(group); const groupBoundingBox = getCommonBoundingBox(group);

View file

@ -5,6 +5,9 @@ export const trackEvent = (
value?: number, value?: number,
) => { ) => {
try { 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 // Uncomment the next line to track locally
// console.log("Track Event", { category, action, label, value }); // console.log("Track Event", { category, action, label, value });
@ -12,12 +15,8 @@ export const trackEvent = (
return; return;
} }
if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID && window.gtag) { if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
window.gtag("event", action, { return;
event_category: category,
event_label: label,
value,
});
} }
if (window.sa_event) { if (window.sa_event) {
@ -27,14 +26,6 @@ export const trackEvent = (
value, value,
}); });
} }
if (window.fathom) {
window.fathom.trackEvent(action, {
category,
label,
value,
});
}
} catch (error) { } catch (error) {
console.error("error during analytics", error); console.error("error during analytics", error);
} }

View file

@ -78,11 +78,16 @@ export const getDefaultAppState = (): Omit<
scrollY: 0, scrollY: 0,
selectedElementIds: {}, selectedElementIds: {},
selectedGroupIds: {}, selectedGroupIds: {},
selectedElementsAreBeingDragged: false,
selectionElement: null, selectionElement: null,
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,
showStats: false, showStats: false,
startBoundElement: null, startBoundElement: null,
suggestedBindings: [], suggestedBindings: [],
frameRendering: { enabled: true, clip: true, name: true, outline: true },
frameToHighlight: null,
editingFrame: null,
elementsToHighlight: null,
toast: null, toast: null,
viewBackgroundColor: COLOR_PALETTE.white, viewBackgroundColor: COLOR_PALETTE.white,
zenModeEnabled: false, zenModeEnabled: false,
@ -176,11 +181,20 @@ const APP_STATE_STORAGE_CONF = (<
scrollY: { browser: true, export: false, server: false }, scrollY: { browser: true, export: false, server: false },
selectedElementIds: { browser: true, export: false, server: false }, selectedElementIds: { browser: true, export: false, server: false },
selectedGroupIds: { 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 }, selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
showStats: { browser: true, export: false, server: false }, showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false }, startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { 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 }, toast: { browser: false, export: false, server: false },
viewBackgroundColor: { browser: true, export: true, server: true }, viewBackgroundColor: { browser: true, export: true, server: true },
width: { browser: false, export: false, server: false }, width: { browser: false, export: false, server: false },

View file

@ -180,7 +180,7 @@ const commonProps = {
locked: false, locked: false,
} as const; } as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => { const getChartDimensions = (spreadsheet: Spreadsheet) => {
const chartWidth = const chartWidth =
(BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP; (BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
const chartHeight = BAR_HEIGHT + BAR_GAP * 2; const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
@ -250,7 +250,7 @@ const chartLines = (
groupId: string, groupId: string,
backgroundColor: string, backgroundColor: string,
): ChartElements => { ): ChartElements => {
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet); const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
const xLine = newLinearElement({ const xLine = newLinearElement({
backgroundColor, backgroundColor,
groupIds: [groupId], groupIds: [groupId],
@ -313,7 +313,7 @@ const chartBaseElements = (
backgroundColor: string, backgroundColor: string,
debug?: boolean, debug?: boolean,
): ChartElements => { ): ChartElements => {
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet); const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
const title = spreadsheet.title const title = spreadsheet.title
? newTextElement({ ? newTextElement({

View file

@ -1,31 +1,31 @@
import { function hashToInteger(id: string) {
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX, let hash = 0;
DEFAULT_ELEMENT_STROKE_COLOR_INDEX, if (id.length === 0) {
getAllColorsSpecificShade, return hash;
} 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;
}
} }
// Naive way of getting an integer out of the clientId for (let i = 0; i < id.length; i++) {
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0); const char = id.charCodeAt(i);
hash = (hash << 5) - hash + char;
}
return hash;
}
return { export const getClientColor = (
background: BG_COLORS[sum % BG_COLORS.length], /**
stroke: STROKE_COLORS[sum % STROKE_COLORS.length], * 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}%)`;
}; };
/** /**

View file

@ -7,6 +7,9 @@ import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants"; import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
import { isInitializedImageElement } from "./element/typeChecks"; import { isInitializedImageElement } from "./element/typeChecks";
import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame";
import { isPromiseLike, isTestEnv } from "./utils"; import { isPromiseLike, isTestEnv } from "./utils";
type ElementsClipboard = { type ElementsClipboard = {
@ -57,6 +60,9 @@ export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
files: BinaryFiles | null, files: BinaryFiles | null,
) => { ) => {
const framesToCopy = new Set(
elements.filter((element) => element.type === "frame"),
);
let foundFile = false; let foundFile = false;
const _files = elements.reduce((acc, element) => { const _files = elements.reduce((acc, element) => {
@ -78,7 +84,20 @@ export const copyToClipboard = async (
// select binded text elements when copying // select binded text elements when copying
const contents: ElementsClipboard = { const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard, 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, files: files ? _files : undefined,
}; };
const json = JSON.stringify(contents); const json = JSON.stringify(contents);

View file

@ -1,4 +1,4 @@
import React from "react"; import React, { useState } from "react";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, PointerType } from "../element/types"; import { ExcalidrawElement, PointerType } from "../element/types";
@ -35,6 +35,9 @@ import {
} from "../element/textElement"; } from "../element/textElement";
import "./Actions.scss"; import "./Actions.scss";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { extraToolsIcon, frameToolIcon } from "./icons";
import { KEYS } from "../keys";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
@ -89,7 +92,8 @@ export const SelectedShapeActions = ({
<div> <div>
{((hasStrokeColor(appState.activeTool.type) && {((hasStrokeColor(appState.activeTool.type) &&
appState.activeTool.type !== "image" && appState.activeTool.type !== "image" &&
commonSelectedType !== "image") || commonSelectedType !== "image" &&
commonSelectedType !== "frame") ||
targetElements.some((element) => hasStrokeColor(element.type))) && targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")} renderAction("changeStrokeColor")}
</div> </div>
@ -220,28 +224,78 @@ export const ShapesSwitcher = ({
setAppState: React.Component<any, UIAppState>["setState"]; setAppState: React.Component<any, UIAppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void; onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: UIAppState; appState: UIAppState;
}) => ( }) => {
<> const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { const device = useDevice();
const label = t(`toolBar.${value}`); return (
const letter = <>
key && capitalizeString(typeof key === "string" ? key : key[0]); {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
const shortcut = letter const label = t(`toolBar.${value}`);
? `${letter} ${t("helpDialog.or")} ${numericKey}` const letter =
: `${numericKey}`; key && capitalizeString(typeof key === "string" ? key : key[0]);
return ( 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 <ToolButton
className={clsx("Shape", { fillable })} className={clsx("Shape", { fillable: false })}
key={value}
type="radio" type="radio"
icon={icon} icon={frameToolIcon}
checked={activeTool.type === value} checked={activeTool.type === "frame"}
name="editor-current-shape" name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`} title={`${capitalizeString(
keyBindingLabel={numericKey || letter} t("toolBar.frame"),
aria-label={capitalizeString(label)} )} ${KEYS.F.toLocaleUpperCase()}`}
aria-keyshortcuts={shortcut} keyBindingLabel={KEYS.F.toLocaleUpperCase()}
data-testid={`toolbar-${value}`} aria-label={capitalizeString(t("toolBar.frame"))}
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
data-testid={`toolbar-frame`}
onPointerDown={({ pointerType }) => { onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") { if (!appState.penDetected && pointerType === "pen") {
setAppState({ setAppState({
@ -251,30 +305,54 @@ export const ShapesSwitcher = ({
} }
}} }}
onChange={({ pointerType }) => { onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) { trackEvent("toolbar", "frame", "ui");
trackEvent("toolbar", value, "ui");
}
const nextActiveTool = updateActiveTool(appState, { const nextActiveTool = updateActiveTool(appState, {
type: value, type: "frame",
}); });
setAppState({ setAppState({
activeTool: nextActiveTool, activeTool: nextActiveTool,
multiElement: null, multiElement: null,
selectedElementIds: {}, 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 = ({ export const ZoomActions = ({
renderAction, renderAction,

File diff suppressed because it is too large Load diff

View file

@ -10,10 +10,9 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
color: $oc-white;
cursor: pointer; cursor: pointer;
font-size: 0.625rem; font-size: 0.75rem;
font-weight: 500; font-weight: 800;
line-height: 1; line-height: 1;
&-img { &-img {

View file

@ -6,7 +6,6 @@ import { getNameInitial } from "../clients";
type AvatarProps = { type AvatarProps = {
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void; onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
color: string; color: string;
border: string;
name: string; name: string;
src?: string; src?: string;
}; };

View file

@ -1,4 +1,4 @@
import { isTransparent, isWritableElement } from "../../utils"; import { isInteractive, isTransparent, isWritableElement } from "../../utils";
import { ExcalidrawElement } from "../../element/types"; import { ExcalidrawElement } from "../../element/types";
import { AppState } from "../../types"; import { AppState } from "../../types";
import { TopPicks } from "./TopPicks"; import { TopPicks } from "./TopPicks";
@ -121,11 +121,14 @@ const ColorPickerPopupContent = ({
} }
}} }}
onCloseAutoFocus={(e) => { onCloseAutoFocus={(e) => {
e.preventDefault();
e.stopPropagation(); e.stopPropagation();
// prevents focusing the trigger
e.preventDefault();
// return focus to excalidraw container // return focus to excalidraw container unless
if (container) { // 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(); container.focus();
} }

View file

@ -17,16 +17,34 @@ import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenu"; import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { jotaiScope } from "../jotai"; import { jotaiScope } from "../jotai";
export type DialogSize = number | "small" | "regular" | "wide" | undefined;
export interface DialogProps { export interface DialogProps {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
size?: "small" | "regular" | "wide"; size?: DialogSize;
onCloseRequest(): void; onCloseRequest(): void;
title: React.ReactNode | false; title: React.ReactNode | false;
autofocus?: boolean; autofocus?: boolean;
closeOnClickOutside?: 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) => { export const Dialog = (props: DialogProps) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>(); const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement); const [lastActiveElement] = useState(document.activeElement);
@ -85,9 +103,7 @@ export const Dialog = (props: DialogProps) => {
<Modal <Modal
className={clsx("Dialog", props.className)} className={clsx("Dialog", props.className)}
labelledBy="dialog-title" labelledBy="dialog-title"
maxWidth={ maxWidth={getDialogSize(props.size)}
props.size === "wide" ? 1024 : props.size === "small" ? 550 : 800
}
onCloseRequest={onClose} onCloseRequest={onClose}
closeOnClickOutside={props.closeOnClickOutside} closeOnClickOutside={props.closeOnClickOutside}
> >

View file

@ -2,20 +2,140 @@
.excalidraw { .excalidraw {
.ExcButton { .ExcButton {
&--color-primary { --text-color: transparent;
color: var(--input-bg-color); --border-color: transparent;
--back-color: transparent;
--accent-color: var(--color-primary); color: var(--text-color);
--accent-color-hover: var(--color-primary-darker); background-color: var(--back-color);
--accent-color-active: var(--color-primary-darkest); 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-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); &:hover {
--accent-color-hover: #d65550; --back-color: var(--color-danger-darker);
--accent-color-active: #d1413c; }
&: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; display: flex;
@ -25,6 +145,8 @@
flex-wrap: nowrap; flex-wrap: nowrap;
border-radius: 0.5rem; border-radius: 0.5rem;
border-width: 1px;
border-style: solid;
font-family: "Assistant"; font-family: "Assistant";
@ -33,9 +155,9 @@
transition: all 150ms ease-out; transition: all 150ms ease-out;
&--size-large { &--size-large {
font-weight: 400; font-weight: 600;
font-size: 0.875rem; font-size: 0.875rem;
height: 3rem; min-height: 3rem;
padding: 0.5rem 1.5rem; padding: 0.5rem 1.5rem;
gap: 0.75rem; gap: 0.75rem;
@ -45,48 +167,22 @@
&--size-medium { &--size-medium {
font-weight: 600; font-weight: 600;
font-size: 0.75rem; font-size: 0.75rem;
height: 2.5rem; min-height: 2.5rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
gap: 0.5rem; gap: 0.5rem;
letter-spacing: normal; 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 { &--variant-icon {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
width: 3rem; width: 3rem;
} }
&--fullWidth {
width: 100%;
}
&__icon { &__icon {
width: 1.25rem; width: 1.25rem;
height: 1.25rem; height: 1.25rem;

View file

@ -4,7 +4,7 @@ import clsx from "clsx";
import "./FilledButton.scss"; import "./FilledButton.scss";
export type ButtonVariant = "filled" | "outlined" | "icon"; 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 ButtonSize = "medium" | "large";
export type FilledButtonProps = { export type FilledButtonProps = {
@ -17,6 +17,7 @@ export type FilledButtonProps = {
color?: ButtonColor; color?: ButtonColor;
size?: ButtonSize; size?: ButtonSize;
className?: string; className?: string;
fullWidth?: boolean;
startIcon?: React.ReactNode; startIcon?: React.ReactNode;
}; };
@ -31,6 +32,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
variant = "filled", variant = "filled",
color = "primary", color = "primary",
size = "medium", size = "medium",
fullWidth,
className, className,
}, },
ref, ref,
@ -42,6 +44,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
`ExcButton--color-${color}`, `ExcButton--color-${color}`,
`ExcButton--variant-${variant}`, `ExcButton--variant-${variant}`,
`ExcButton--size-${size}`, `ExcButton--size-${size}`,
{ "ExcButton--fullWidth": fullWidth },
className, className,
)} )}
onClick={onClick} onClick={onClick}

View file

@ -12,7 +12,7 @@ const Header = () => (
<div className="HelpDialog__header"> <div className="HelpDialog__header">
<a <a
className="HelpDialog__btn" className="HelpDialog__btn"
href="https://github.com/excalidraw/excalidraw#documentation" href="https://docs.excalidraw.com"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@ -164,6 +164,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("toolBar.eraser")} label={t("toolBar.eraser")}
shortcuts={[KEYS.E, KEYS["0"]]} shortcuts={[KEYS.E, KEYS["0"]]}
/> />
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
<Shortcut <Shortcut
label={t("labels.eyeDropper")} label={t("labels.eyeDropper")}
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]} shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}

View file

@ -84,7 +84,10 @@ const ImageExportModal = ({
const [renderError, setRenderError] = useState<Error | null>(null); const [renderError, setRenderError] = useState<Error | null>(null);
const exportedElements = exportSelected const exportedElements = exportSelected
? getSelectedElements(elements, appState, true) ? getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
})
: elements; : elements;
useEffect(() => { useEffect(() => {

View file

@ -41,6 +41,7 @@ import { jotaiScope } from "../jotai";
import { Provider, useAtom, useAtomValue } from "jotai"; import { Provider, useAtom, useAtomValue } from "jotai";
import MainMenu from "./main-menu/MainMenu"; import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog"; import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
import { HandButton } from "./HandButton"; import { HandButton } from "./HandButton";
import { isHandToolActive } from "../appState"; import { isHandToolActive } from "../appState";
import { TunnelsContext, useInitializeTunnels } from "../context/tunnels"; 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 = ({ const LayerUI = ({
actionManager, actionManager,
appState, appState,
@ -204,12 +214,7 @@ const LayerUI = ({
return ( return (
<FixedSideContainer side="top"> <FixedSideContainer side="top">
<div className="App-menu App-menu_top"> <div className="App-menu App-menu_top">
<Stack.Col <Stack.Col gap={6} className={clsx("App-menu_top__left")}>
gap={6}
className={clsx("App-menu_top__left", {
"disable-pointerEvents": appState.zenModeEnabled,
})}
>
{renderCanvasActions()} {renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()} {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col> </Stack.Col>
@ -254,7 +259,7 @@ const LayerUI = ({
title={t("toolBar.lock")} title={t("toolBar.lock")}
/> />
<div className="App-toolbar__divider"></div> <div className="App-toolbar__divider" />
<HandButton <HandButton
checked={isHandToolActive(appState)} checked={isHandToolActive(appState)}
@ -348,6 +353,7 @@ const LayerUI = ({
> >
{t("toolBar.library")} {t("toolBar.library")}
</DefaultSidebar.Trigger> </DefaultSidebar.Trigger>
<DefaultOverwriteConfirmDialog />
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{appState.isLoading && <LoadingMessage delay={250} />} {appState.isLoading && <LoadingMessage delay={250} />}
@ -379,6 +385,7 @@ const LayerUI = ({
/> />
)} )}
<ActiveConfirmDialog /> <ActiveConfirmDialog />
<tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()} {renderImageExportDialog()}
{renderJSONExportDialog()} {renderJSONExportDialog()}
{appState.pasteDialog.shown && ( {appState.pasteDialog.shown && (
@ -392,7 +399,7 @@ const LayerUI = ({
} }
/> />
)} )}
{device.isMobile && !eyeDropperState && ( {device.isMobile && (
<MobileMenu <MobileMenu
appState={appState} appState={appState}
elements={elements} elements={elements}

View file

@ -148,7 +148,11 @@ const usePendingElementsMemo = (
appState: UIAppState, appState: UIAppState,
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
) => { ) => {
const create = () => getSelectedElements(elements, appState, true); const create = () =>
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const val = useRef(create()); const val = useRef(create());
const prevAppState = useRef<UIAppState>(appState); const prevAppState = useRef<UIAppState>(appState);
const prevElements = useRef(elements); const prevElements = useRef(elements);

View file

@ -3,7 +3,7 @@
.excalidraw { .excalidraw {
&.excalidraw-modal-container { &.excalidraw-modal-container {
position: absolute; position: absolute;
z-index: 10; z-index: var(--zIndex-modal);
} }
.Modal { .Modal {

View 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);
}
}
}
}
}

View 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 };

View 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 };

View 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,
});
});
}

View 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;
}
}
}
}

View 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>
);
};

View file

@ -1,4 +1,10 @@
import { forwardRef, useRef, useImperativeHandle, KeyboardEvent } from "react"; import {
forwardRef,
useRef,
useImperativeHandle,
KeyboardEvent,
useLayoutEffect,
} from "react";
import clsx from "clsx"; import clsx from "clsx";
import "./TextField.scss"; import "./TextField.scss";
@ -12,6 +18,7 @@ export type TextFieldProps = {
readonly?: boolean; readonly?: boolean;
fullWidth?: boolean; fullWidth?: boolean;
selectOnRender?: boolean;
label?: string; label?: string;
placeholder?: string; placeholder?: string;
@ -19,13 +26,28 @@ export type TextFieldProps = {
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>( export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
( (
{ value, onChange, label, fullWidth, placeholder, readonly, onKeyDown }, {
value,
onChange,
label,
fullWidth,
placeholder,
readonly,
selectOnRender,
onKeyDown,
},
ref, ref,
) => { ) => {
const innerRef = useRef<HTMLInputElement | null>(null); const innerRef = useRef<HTMLInputElement | null>(null);
useImperativeHandle(ref, () => innerRef.current!); useImperativeHandle(ref, () => innerRef.current!);
useLayoutEffect(() => {
if (selectOnRender) {
innerRef.current?.select();
}
}, [selectOnRender]);
return ( return (
<div <div
className={clsx("ExcTextField", { className={clsx("ExcTextField", {

View file

@ -1,6 +1,6 @@
import "./ToolIcon.scss"; import "./ToolIcon.scss";
import React, { useEffect, useRef, useState } from "react"; import React, { CSSProperties, useEffect, useRef, useState } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { useExcalidrawContainer } from "./App"; import { useExcalidrawContainer } from "./App";
import { AbortError } from "../errors"; import { AbortError } from "../errors";
@ -25,6 +25,7 @@ type ToolButtonBaseProps = {
visible?: boolean; visible?: boolean;
selected?: boolean; selected?: boolean;
className?: string; className?: string;
style?: CSSProperties;
isLoading?: boolean; isLoading?: boolean;
}; };
@ -114,6 +115,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
"ToolIcon--plain": props.type === "icon", "ToolIcon--plain": props.type === "icon",
}, },
)} )}
style={props.style}
data-testid={props["data-testid"]} data-testid={props["data-testid"]}
hidden={props.hidden} hidden={props.hidden}
title={props.title} title={props.title}

View file

@ -15,7 +15,24 @@
height: 1.5rem; height: 1.5rem;
align-self: center; align-self: center;
background-color: var(--default-border-color); 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;
}
} }

View file

@ -6,7 +6,7 @@
Roboto, Helvetica, Arial, sans-serif; Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font); font-family: var(--ui-font);
position: fixed; position: fixed;
z-index: 1000; z-index: var(--zIndex-popup);
padding: 8px; padding: 8px;
border-radius: 6px; border-radius: 6px;

View file

@ -1,23 +1,23 @@
import clsx from "clsx"; import clsx from "clsx";
import { useUIAppState } from "../../context/ui-appState";
import { useDevice } from "../App"; import { useDevice } from "../App";
const MenuTrigger = ({ const MenuTrigger = ({
className = "", className = "",
children, children,
onToggle, onToggle,
title,
...rest
}: { }: {
className?: string; className?: string;
children: React.ReactNode; children: React.ReactNode;
onToggle: () => void; onToggle: () => void;
}) => { title?: string;
const appState = useUIAppState(); } & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const device = useDevice(); const device = useDevice();
const classNames = clsx( const classNames = clsx(
`dropdown-menu-button ${className}`, `dropdown-menu-button ${className}`,
"zen-mode-transition", "zen-mode-transition",
{ {
"transition-left": appState.zenModeEnabled,
"dropdown-menu-button--mobile": device.isMobile, "dropdown-menu-button--mobile": device.isMobile,
}, },
).trim(); ).trim();
@ -28,6 +28,8 @@ const MenuTrigger = ({
onClick={onToggle} onClick={onToggle}
type="button" type="button"
data-testid="dropdown-menu-button" data-testid="dropdown-menu-button"
title={title}
{...rest}
> >
{children} {children}
</button> </button>

View file

@ -12,17 +12,17 @@ describe("Test internal component fallback rendering", () => {
</div>, </div>,
); );
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2); expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>( const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container", ".excalidraw-container",
); );
expect( expect(
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length, queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
).toBe(1); ).toBe(1);
expect( expect(
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length, queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
).toBe(1); ).toBe(1);
}); });
@ -36,17 +36,17 @@ describe("Test internal component fallback rendering", () => {
</div>, </div>,
); );
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2); expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>( const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container", ".excalidraw-container",
); );
expect( expect(
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length, queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
).toBe(1); ).toBe(1);
expect( expect(
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length, queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
).toBe(1); ).toBe(1);
}); });
@ -62,17 +62,17 @@ describe("Test internal component fallback rendering", () => {
</div>, </div>,
); );
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2); expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>( const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container", ".excalidraw-container",
); );
expect( expect(
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length, queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
).toBe(1); ).toBe(1);
expect( expect(
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length, queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
).toBe(1); ).toBe(1);
}); });
@ -84,17 +84,17 @@ describe("Test internal component fallback rendering", () => {
</div>, </div>,
); );
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2); expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>( const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container", ".excalidraw-container",
); );
expect( expect(
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length, queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
).toBe(1); ).toBe(1);
expect( expect(
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length, queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
).toBe(1); ).toBe(1);
}); });
}); });

View file

@ -1608,6 +1608,16 @@ export const tablerCheckIcon = createIcon(
tablerIconProps, 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( export const eyeDropperIcon = createIcon(
<g strokeWidth={1.25}> <g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
@ -1616,3 +1626,24 @@ export const eyeDropperIcon = createIcon(
</g>, </g>,
tablerIconProps, 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,
);

View file

@ -1,6 +1,10 @@
import { getShortcutFromShortcutName } from "../../actions/shortcuts"; import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { useExcalidrawSetAppState, useExcalidrawActionManager } from "../App"; import {
useExcalidrawSetAppState,
useExcalidrawActionManager,
useExcalidrawElements,
} from "../App";
import { import {
ExportIcon, ExportIcon,
ExportImageIcon, ExportImageIcon,
@ -29,19 +33,42 @@ import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { jotaiScope } from "../../jotai"; import { jotaiScope } from "../../jotai";
import { useUIAppState } from "../../context/ui-appState"; import { useUIAppState } from "../../context/ui-appState";
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
import Trans from "../Trans";
export const LoadScene = () => { export const LoadScene = () => {
const { t } = useI18n(); const { t } = useI18n();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
const elements = useExcalidrawElements();
if (!actionManager.isActionEnabled(actionLoadScene)) { if (!actionManager.isActionEnabled(actionLoadScene)) {
return null; 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 ( return (
<DropdownMenuItem <DropdownMenuItem
icon={LoadIcon} icon={LoadIcon}
onSelect={() => actionManager.executeAction(actionLoadScene)} onSelect={handleSelect}
data-testid="load-button" data-testid="load-button"
shortcut={getShortcutFromShortcutName("loadScene")} shortcut={getShortcutFromShortcutName("loadScene")}
aria-label={t("buttons.load")} aria-label={t("buttons.load")}

View file

@ -42,6 +42,7 @@ const MainMenu = Object.assign(
openMenu: appState.openMenu === "canvas" ? null : "canvas", openMenu: appState.openMenu === "canvas" ? null : "canvas",
}); });
}} }}
data-testid="main-menu-trigger"
> >
{HamburgerMenuIcon} {HamburgerMenuIcon}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>

View file

@ -94,6 +94,17 @@ export const THEME = {
DARK: "dark", 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 WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
export const DEFAULT_FONT_SIZE = 20; export const DEFAULT_FONT_SIZE = 20;

View file

@ -12,6 +12,7 @@ type TunnelsContextValue = {
FooterCenterTunnel: Tunnel; FooterCenterTunnel: Tunnel;
DefaultSidebarTriggerTunnel: Tunnel; DefaultSidebarTriggerTunnel: Tunnel;
DefaultSidebarTabTriggersTunnel: Tunnel; DefaultSidebarTabTriggersTunnel: Tunnel;
OverwriteConfirmDialogTunnel: Tunnel;
jotaiScope: symbol; jotaiScope: symbol;
}; };
@ -30,6 +31,7 @@ export const useInitializeTunnels = () => {
FooterCenterTunnel: tunnel(), FooterCenterTunnel: tunnel(),
DefaultSidebarTriggerTunnel: tunnel(), DefaultSidebarTriggerTunnel: tunnel(),
DefaultSidebarTabTriggersTunnel: tunnel(), DefaultSidebarTabTriggersTunnel: tunnel(),
OverwriteConfirmDialogTunnel: tunnel(),
jotaiScope: Symbol(), jotaiScope: Symbol(),
}; };
}, []); }, []);

View file

@ -5,6 +5,15 @@
--zIndex-canvas: 1; --zIndex-canvas: 1;
--zIndex-wysiwyg: 2; --zIndex-wysiwyg: 2;
--zIndex-layerUI: 3; --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 { .excalidraw {

View file

@ -27,10 +27,6 @@
--popup-secondary-bg-color: #{$oc-gray-1}; --popup-secondary-bg-color: #{$oc-gray-1};
--popup-text-color: #{$oc-black}; --popup-text-color: #{$oc-black};
--popup-text-inverted-color: #{$oc-white}; --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}; --select-highlight-color: #{$oc-blue-5};
--shadow-island: 0px 7px 14px rgba(0, 0, 0, 0.05), --shadow-island: 0px 7px 14px rgba(0, 0, 0, 0.05),
0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798),
@ -99,9 +95,33 @@
--color-gray-100: #121212; --color-gray-100: #121212;
--color-warning: #fceeca; --color-warning: #fceeca;
--color-warning-dark: #f5c354;
--color-warning-darker: #f3ab2c;
--color-warning-darkest: #ec8b14;
--color-text-warning: var(--text-primary-color); --color-text-warning: var(--text-primary-color);
--color-danger: #db6965; --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-promo: #e70078;
--color-success: #268029; --color-success: #268029;
--color-success-lighter: #cafccc; --color-success-lighter: #cafccc;
@ -177,6 +197,27 @@
--color-text-warning: var(--color-gray-80); --color-text-warning: var(--color-gray-80);
--color-danger: #ffa8a5; --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; --color-promo: #d297ff;
} }
} }

View file

@ -45,6 +45,7 @@ import {
ExcalidrawProgrammaticAPI, ExcalidrawProgrammaticAPI,
convertToExcalidrawElements, convertToExcalidrawElements,
} from "../data/transform"; } from "../data/transform";
import { normalizeLink } from "./url";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@ -66,6 +67,7 @@ export const AllowedExcalidrawActiveTools: Record<
freedraw: true, freedraw: true,
eraser: false, eraser: false,
custom: true, custom: true,
frame: true,
hand: true, hand: true,
}; };
@ -131,6 +133,7 @@ export const restoreElementWithProperties = <
height: element.height || 0, height: element.height || 0,
seed: element.seed ?? 1, seed: element.seed ?? 1,
groupIds: element.groupIds ?? [], groupIds: element.groupIds ?? [],
frameId: element.frameId ?? null,
roundness: element.roundness roundness: element.roundness
? element.roundness ? element.roundness
: element.strokeSharpness === "round" : element.strokeSharpness === "round"
@ -146,7 +149,7 @@ export const restoreElementWithProperties = <
? element.boundElementIds.map((id) => ({ type: "arrow", id })) ? element.boundElementIds.map((id) => ({ type: "arrow", id }))
: element.boundElements ?? [], : element.boundElements ?? [],
updated: element.updated ?? getUpdatedTimestamp(), updated: element.updated ?? getUpdatedTimestamp(),
link: element.link ?? null, link: element.link ? normalizeLink(element.link) : null,
locked: element.locked ?? false, locked: element.locked ?? false,
}; };
@ -276,6 +279,10 @@ const restoreElement = (
return restoreElementWithProperties(element, {}); return restoreElementWithProperties(element, {});
case "diamond": case "diamond":
return restoreElementWithProperties(element, {}); 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. // 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 // 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 = ( export const restoreElements = (
elements: ExcalidrawProgrammaticAPI["elements"], elements: ExcalidrawProgrammaticAPI["elements"],
/** NOTE doesn't serve for reconciliation */ /** NOTE doesn't serve for reconciliation */
@ -415,6 +440,10 @@ export const restoreElements = (
// repair binding. Mutates elements. // repair binding. Mutates elements.
const restoredElementsMap = arrayToMap(restoredElements); const restoredElementsMap = arrayToMap(restoredElements);
for (const element of restoredElements) { for (const element of restoredElements) {
if (element.frameId) {
repairFrameMembership(element, restoredElementsMap);
}
if (isTextElement(element) && element.containerId) { if (isTextElement(element) && element.containerId) {
repairBoundElement(element, restoredElementsMap); repairBoundElement(element, restoredElementsMap);
} else if (element.boundElements) { } else if (element.boundElements) {

30
src/data/url.test.tsx Normal file
View 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
View 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("/"));
};

View file

@ -29,6 +29,7 @@ import { getTooltipDiv, updateTooltipPosition } from "../components/Tooltip";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { isPointHittingElementBoundingBox } from "./collision"; import { isPointHittingElementBoundingBox } from "./collision";
import { getElementAbsoluteCoords } from "./"; import { getElementAbsoluteCoords } from "./";
import { isLocalLink, normalizeLink } from "../data/url";
import "./Hyperlink.scss"; import "./Hyperlink.scss";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
@ -166,7 +167,7 @@ export const Hyperlink = ({
/> />
) : ( ) : (
<a <a
href={element.link || ""} href={normalizeLink(element.link || "")}
className={clsx("excalidraw-hyperlinkContainer-link", { className={clsx("excalidraw-hyperlinkContainer-link", {
"d-none": isEditing, "d-none": isEditing,
})} })}
@ -177,7 +178,13 @@ export const Hyperlink = ({
EVENT.EXCALIDRAW_LINK, EVENT.EXCALIDRAW_LINK,
event.nativeEvent, event.nativeEvent,
); );
onLinkOpen(element, customEvent); onLinkOpen(
{
...element,
link: normalizeLink(element.link),
},
customEvent,
);
if (customEvent.defaultPrevented) { if (customEvent.defaultPrevented) {
event.preventDefault(); event.preventDefault();
} }
@ -231,21 +238,6 @@ const getCoordsForPopover = (
return { x, y }; 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({ export const actionLink = register({
name: "hyperlink", name: "hyperlink",
perform: (elements, appState) => { perform: (elements, appState) => {
@ -344,7 +336,7 @@ export const isPointHittingLinkIcon = (
if ( if (
!isMobile && !isMobile &&
appState.viewModeEnabled && appState.viewModeEnabled &&
isPointHittingElementBoundingBox(element, [x, y], threshold) isPointHittingElementBoundingBox(element, [x, y], threshold, null)
) { ) {
return true; return true;
} }
@ -440,7 +432,9 @@ export const shouldHideLinkPopup = (
const threshold = 15 / appState.zoom.value; const threshold = 15 / appState.zoom.value;
// hitbox to prevent hiding when hovered in element bounding box // 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; return false;
} }
const [x1, y1, x2] = getElementAbsoluteCoords(element); const [x1, y1, x2] = getElementAbsoluteCoords(element);

View file

@ -39,7 +39,7 @@ export type SuggestedPointBinding = [
]; ];
export const shouldEnableBindingForPointerEvent = ( export const shouldEnableBindingForPointerEvent = (
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLElement>,
) => { ) => {
return !event[KEYS.CTRL_OR_CMD]; return !event[KEYS.CTRL_OR_CMD];
}; };

View file

@ -6,7 +6,7 @@ import {
NonDeleted, NonDeleted,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
} from "./types"; } from "./types";
import { distance2d, rotate } from "../math"; import { distance2d, rotate, rotatePoint } from "../math";
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { Drawable, Op } from "roughjs/bin/core"; import { Drawable, Op } from "roughjs/bin/core";
import { Point } from "../types"; import { Point } from "../types";
@ -25,10 +25,101 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { Mutable } from "../utility-types"; import { Mutable } from "../utility-types";
// x and y position of top left corner, x and y position of bottom right corner export type RectangleBox = {
export type Bounds = readonly [number, number, number, number]; x: number;
y: number;
width: number;
height: number;
angle: number;
};
type MaybeQuadraticSolution = [number | null, number | null] | false; 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 // 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. // This set of functions retrieves the absolute position of the 4 points.
export const getElementAbsoluteCoords = ( 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 = ( export const pointRelativeTo = (
element: ExcalidrawElement, element: ExcalidrawElement,
absoluteCoords: Point, absoluteCoords: Point,
@ -454,64 +650,12 @@ const getLinearElementRotatedBounds = (
return coords; return coords;
}; };
// We could cache this stuff export const getElementBounds = (element: ExcalidrawElement): Bounds => {
export const getElementBounds = ( return ElementBounds.getBounds(element);
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 getCommonBounds = ( export const getCommonBounds = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
): [number, number, number, number] => { ): Bounds => {
if (!elements.length) { if (!elements.length) {
return [0, 0, 0, 0]; return [0, 0, 0, 0];
} }
@ -608,7 +752,7 @@ export const getElementPointsCoords = (
export const getClosestElementBounds = ( export const getClosestElementBounds = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
from: { x: number; y: number }, from: { x: number; y: number },
): [number, number, number, number] => { ): Bounds => {
if (!elements.length) { if (!elements.length) {
return [0, 0, 0, 0]; return [0, 0, 0, 0];
} }
@ -629,7 +773,7 @@ export const getClosestElementBounds = (
return getElementBounds(closestElement); return getElementBounds(closestElement);
}; };
export interface Box { export interface BoundingBox {
minX: number; minX: number;
minY: number; minY: number;
maxX: number; maxX: number;
@ -642,7 +786,7 @@ export interface Box {
export const getCommonBoundingBox = ( export const getCommonBoundingBox = (
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[], elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
): Box => { ): BoundingBox => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return { return {
minX, minX,

View file

@ -26,10 +26,16 @@ import {
ExcalidrawImageElement, ExcalidrawImageElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
StrokeRoundness, StrokeRoundness,
ExcalidrawFrameElement,
} from "./types"; } from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds"; import {
import { Point } from "../types"; getElementAbsoluteCoords,
getCurvePathOps,
getRectangleBoxAbsoluteCoords,
RectangleBox,
} from "./bounds";
import { FrameNameBoundsCache, Point } from "../types";
import { Drawable } from "roughjs/bin/core"; import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types"; import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement"; import { getShapeForElement } from "../renderer/renderElement";
@ -61,6 +67,7 @@ const isElementDraggableFromInside = (
export const hitTest = ( export const hitTest = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
appState: AppState, appState: AppState,
frameNameBoundsCache: FrameNameBoundsCache,
x: number, x: number,
y: number, y: number,
): boolean => { ): boolean => {
@ -72,22 +79,39 @@ export const hitTest = (
isElementSelected(appState, element) && isElementSelected(appState, element) &&
shouldShowBoundingBox([element], appState) shouldShowBoundingBox([element], appState)
) { ) {
return isPointHittingElementBoundingBox(element, point, threshold); return isPointHittingElementBoundingBox(
element,
point,
threshold,
frameNameBoundsCache,
);
} }
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement) { if (boundTextElement) {
const isHittingBoundTextElement = hitTest(boundTextElement, appState, x, y); const isHittingBoundTextElement = hitTest(
boundTextElement,
appState,
frameNameBoundsCache,
x,
y,
);
if (isHittingBoundTextElement) { if (isHittingBoundTextElement) {
return true; return true;
} }
} }
return isHittingElementNotConsideringBoundingBox(element, appState, point); return isHittingElementNotConsideringBoundingBox(
element,
appState,
frameNameBoundsCache,
point,
);
}; };
export const isHittingElementBoundingBoxWithoutHittingElement = ( export const isHittingElementBoundingBoxWithoutHittingElement = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
appState: AppState, appState: AppState,
frameNameBoundsCache: FrameNameBoundsCache,
x: number, x: number,
y: number, y: number,
): boolean => { ): 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 // 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 // eg for linear elements text can be outside the element bounding box
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement && hitTest(boundTextElement, appState, x, y)) { if (
boundTextElement &&
hitTest(boundTextElement, appState, frameNameBoundsCache, x, y)
) {
return false; return false;
} }
return ( return (
!isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) && !isHittingElementNotConsideringBoundingBox(
isPointHittingElementBoundingBox(element, [x, y], threshold) element,
appState,
frameNameBoundsCache,
[x, y],
) &&
isPointHittingElementBoundingBox(
element,
[x, y],
threshold,
frameNameBoundsCache,
)
); );
}; };
export const isHittingElementNotConsideringBoundingBox = ( export const isHittingElementNotConsideringBoundingBox = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
appState: AppState, appState: AppState,
frameNameBoundsCache: FrameNameBoundsCache | null,
point: Point, point: Point,
): boolean => { ): boolean => {
const threshold = 10 / appState.zoom.value; const threshold = 10 / appState.zoom.value;
@ -117,7 +155,13 @@ export const isHittingElementNotConsideringBoundingBox = (
: isElementDraggableFromInside(element) : isElementDraggableFromInside(element)
? isInsideCheck ? isInsideCheck
: isNearCheck; : isNearCheck;
return hitTestPointAgainstElement({ element, point, threshold, check }); return hitTestPointAgainstElement({
element,
point,
threshold,
check,
frameNameBoundsCache,
});
}; };
const isElementSelected = ( const isElementSelected = (
@ -129,7 +173,22 @@ export const isPointHittingElementBoundingBox = (
element: NonDeleted<ExcalidrawElement>, element: NonDeleted<ExcalidrawElement>,
[x, y]: Point, [x, y]: Point,
threshold: number, 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 [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const elementCenterX = (x1 + x2) / 2; const elementCenterX = (x1 + x2) / 2;
const elementCenterY = (y1 + y2) / 2; const elementCenterY = (y1 + y2) / 2;
@ -157,7 +216,13 @@ export const bindingBorderTest = (
const threshold = maxBindingGap(element, element.width, element.height); const threshold = maxBindingGap(element, element.width, element.height);
const check = isOutsideCheck; const check = isOutsideCheck;
const point: Point = [x, y]; const point: Point = [x, y];
return hitTestPointAgainstElement({ element, point, threshold, check }); return hitTestPointAgainstElement({
element,
point,
threshold,
check,
frameNameBoundsCache: null,
});
}; };
export const maxBindingGap = ( export const maxBindingGap = (
@ -177,6 +242,7 @@ type HitTestArgs = {
point: Point; point: Point;
threshold: number; threshold: number;
check: (distance: number, threshold: number) => boolean; check: (distance: number, threshold: number) => boolean;
frameNameBoundsCache: FrameNameBoundsCache | null;
}; };
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => { 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.", "This should not happen, we need to investigate why it does.",
); );
return false; 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 "rectangle":
case "image": case "image":
case "text": case "text":
case "frame":
return distanceToRectangle(element, point); return distanceToRectangle(element, point);
case "diamond": case "diamond":
return distanceToDiamond(element, point); return distanceToDiamond(element, point);
@ -248,7 +336,8 @@ const distanceToRectangle = (
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawImageElement, | ExcalidrawImageElement
| ExcalidrawFrameElement,
point: Point, point: Point,
): number => { ): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); 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 = ( const distanceToDiamond = (
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
point: Point, point: Point,
@ -457,8 +554,7 @@ const pointRelativeToElement = (
): [GA.Point, GA.Point, number, number] => { ): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple); const point = GAPoint.from(pointTuple);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); 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` // GA has angle orientation opposite to `rotate`
const rotate = GATransform.rotation(center, element.angle); const rotate = GATransform.rotation(center, element.angle);
const pointRotated = GATransform.apply(rotate, point); const pointRotated = GATransform.apply(rotate, point);
@ -466,9 +562,26 @@ const pointRelativeToElement = (
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter); const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
const elementPos = GA.offset(element.x, element.y); const elementPos = GA.offset(element.x, element.y);
const pointRelToPos = GA.sub(pointRotated, elementPos); const pointRelToPos = GA.sub(pointRotated, elementPos);
const [ax, ay, bx, by] = elementCoords; const halfWidth = (x2 - x1) / 2;
const halfWidth = (bx - ax) / 2; const halfHeight = (y2 - y1) / 2;
const halfHeight = (by - ay) / 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]; return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
}; };
@ -490,7 +603,7 @@ const relativizationToElementCenter = (
element: ExcalidrawElement, element: ExcalidrawElement,
): GA.Transform => { ): GA.Transform => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); 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` // GA has angle orientation opposite to `rotate`
const rotate = GATransform.rotation(center, element.angle); const rotate = GATransform.rotation(center, element.angle);
const translate = GA.reverse( const translate = GA.reverse(
@ -499,8 +612,13 @@ const relativizationToElementCenter = (
return GATransform.compose(rotate, translate); return GATransform.compose(rotate, translate);
}; };
const coordsCenter = ([ax, ay, bx, by]: Bounds): GA.Point => { const coordsCenter = (
return GA.point((ax + bx) / 2, (ay + by) / 2); 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 // The focus distance is the oriented ratio between the size of
@ -531,6 +649,7 @@ export const determineFocusDistance = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "frame":
return c / (hwidth * (nabs + q * mabs)); return c / (hwidth * (nabs + q * mabs));
case "diamond": case "diamond":
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight); return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
@ -548,7 +667,7 @@ export const determineFocusPoint = (
): Point => { ): Point => {
if (focus === 0) { if (focus === 0) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); 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); return GAPoint.toTuple(center);
} }
const relateToCenter = relativizationToElementCenter(element); const relateToCenter = relativizationToElementCenter(element);
@ -563,6 +682,7 @@ export const determineFocusPoint = (
case "image": case "image":
case "text": case "text":
case "diamond": case "diamond":
case "frame":
point = findFocusPointForRectangulars(element, focus, adjecentPointRel); point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
break; break;
case "ellipse": case "ellipse":
@ -613,6 +733,7 @@ const getSortedElementLineIntersections = (
case "image": case "image":
case "text": case "text":
case "diamond": case "diamond":
case "frame":
const corners = getCorners(element); const corners = getCorners(element);
intersections = corners intersections = corners
.flatMap((point, i) => { .flatMap((point, i) => {
@ -646,7 +767,8 @@ const getCorners = (
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawTextElement, | ExcalidrawTextElement
| ExcalidrawFrameElement,
scale: number = 1, scale: number = 1,
): GA.Point[] => { ): GA.Point[] => {
const hx = (scale * element.width) / 2; const hx = (scale * element.width) / 2;
@ -655,6 +777,7 @@ const getCorners = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "frame":
return [ return [
GA.point(hx, hy), GA.point(hx, hy),
GA.point(hx, -hy), GA.point(hx, -hy),
@ -802,7 +925,8 @@ export const findFocusPointForRectangulars = (
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawTextElement, | ExcalidrawTextElement
| ExcalidrawFrameElement,
// Between -1 and 1 for how far away should the focus point be relative // Between -1 and 1 for how far away should the focus point be relative
// to the size of the element. Sign determines orientation. // to the size of the element. Sign determines orientation.
relativeDistance: number, relativeDistance: number,

View file

@ -6,6 +6,8 @@ import { NonDeletedExcalidrawElement } from "./types";
import { AppState, PointerDownState } from "../types"; import { AppState, PointerDownState } from "../types";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
import { isSelectedViaGroup } from "../groups"; import { isSelectedViaGroup } from "../groups";
import Scene from "../scene/Scene";
import { isFrameElement } from "./typeChecks";
export const dragSelectedElements = ( export const dragSelectedElements = (
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
@ -16,10 +18,31 @@ export const dragSelectedElements = (
distanceX: number = 0, distanceX: number = 0,
distanceY: number = 0, distanceY: number = 0,
appState: AppState, appState: AppState,
scene: Scene,
) => { ) => {
const [x1, y1] = getCommonBounds(selectedElements); const [x1, y1] = getCommonBounds(selectedElements);
const offset = { x: pointerX - x1, y: pointerY - y1 }; 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( updateElementCoords(
lockDirection, lockDirection,
distanceX, distanceX,
@ -38,7 +61,13 @@ export const dragSelectedElements = (
(appState.editingGroupId && !isSelectedViaGroup(appState, element)) (appState.editingGroupId && !isSelectedViaGroup(appState, element))
) { ) {
const textElement = getBoundTextElement(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( updateElementCoords(
lockDirection, lockDirection,
distanceX, distanceX,
@ -50,7 +79,7 @@ export const dragSelectedElements = (
} }
} }
updateBoundElements(element, { updateBoundElements(element, {
simultaneouslyUpdated: selectedElements, simultaneouslyUpdated: Array.from(elementsToUpdate),
}); });
}); });
}; };

View file

@ -2,6 +2,7 @@ import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeleted, NonDeleted,
ExcalidrawFrameElement,
} from "./types"; } from "./types";
import { isInvisiblySmallElement } from "./sizeHelpers"; import { isInvisiblySmallElement } from "./sizeHelpers";
import { isLinearElementType } from "./typeChecks"; import { isLinearElementType } from "./typeChecks";
@ -49,7 +50,11 @@ export {
getDragOffsetXY, getDragOffsetXY,
dragNewElement, dragNewElement,
} from "./dragElements"; } from "./dragElements";
export { isTextElement, isExcalidrawElement } from "./typeChecks"; export {
isTextElement,
isExcalidrawElement,
isFrameElement,
} from "./typeChecks";
export { textWysiwyg } from "./textWysiwyg"; export { textWysiwyg } from "./textWysiwyg";
export { redrawTextBoundingBox } from "./textElement"; export { redrawTextBoundingBox } from "./textElement";
export { export {
@ -74,6 +79,13 @@ export const getNonDeletedElements = (elements: readonly ExcalidrawElement[]) =>
(element) => !element.isDeleted, (element) => !element.isDeleted,
) as readonly NonDeletedExcalidrawElement[]; ) as readonly NonDeletedExcalidrawElement[];
export const getNonDeletedFrames = (
frames: readonly ExcalidrawFrameElement[],
) =>
frames.filter(
(frame) => !frame.isDeleted,
) as readonly NonDeleted<ExcalidrawFrameElement>[];
export const isNonDeletedElement = <T extends ExcalidrawElement>( export const isNonDeletedElement = <T extends ExcalidrawElement>(
element: T, element: T,
): element is NonDeleted<T> => !element.isDeleted; ): element is NonDeleted<T> => !element.isDeleted;

View file

@ -594,7 +594,7 @@ export class LinearElementEditor {
} }
static handlePointerDown( static handlePointerDown(
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLElement>,
appState: AppState, appState: AppState,
history: History, history: History,
scenePointer: { x: number; y: number }, scenePointer: { x: number; y: number },

View file

@ -12,6 +12,7 @@ import {
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
FontFamilyValues, FontFamilyValues,
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawFrameElement,
} from "../element/types"; } from "../element/types";
import { import {
arrayToMap, arrayToMap,
@ -20,15 +21,13 @@ import {
isTestEnv, isTestEnv,
} from "../utils"; } from "../utils";
import { randomInteger, randomId } from "../random"; import { randomInteger, randomId } from "../random";
import { bumpVersion, mutateElement, newElementWith } from "./mutateElement"; import { bumpVersion, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups"; import { getNewGroupIdsForDuplication } from "../groups";
import { AppState } from "../types"; import { AppState } from "../types";
import { getElementAbsoluteCoords } from "."; import { getElementAbsoluteCoords } from ".";
import { adjustXYWithRotation } from "../math"; import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds"; import { getResizedElementAbsoluteCoords } from "./bounds";
import { import {
getBoundTextElementOffset,
getContainerDims,
getContainerElement, getContainerElement,
measureText, measureText,
normalizeText, normalizeText,
@ -44,7 +43,6 @@ import {
DEFAULT_VERTICAL_ALIGN, DEFAULT_VERTICAL_ALIGN,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import { isArrowElement } from "./typeChecks";
import { MarkOptional, Merge, Mutable } from "../utility-types"; import { MarkOptional, Merge, Mutable } from "../utility-types";
export type ElementConstructorOpts = MarkOptional< export type ElementConstructorOpts = MarkOptional<
@ -53,6 +51,7 @@ export type ElementConstructorOpts = MarkOptional<
| "height" | "height"
| "angle" | "angle"
| "groupIds" | "groupIds"
| "frameId"
| "boundElements" | "boundElements"
| "seed" | "seed"
| "version" | "version"
@ -85,6 +84,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
height = 0, height = 0,
angle = 0, angle = 0,
groupIds = [], groupIds = [],
frameId = null,
roundness = null, roundness = null,
boundElements = null, boundElements = null,
link = null, link = null,
@ -109,6 +109,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
roughness, roughness,
opacity, opacity,
groupIds, groupIds,
frameId,
roundness, roundness,
seed: rest.seed ?? randomInteger(), seed: rest.seed ?? randomInteger(),
version: rest.version || 1, version: rest.version || 1,
@ -129,6 +130,21 @@ export const newElement = (
): NonDeleted<ExcalidrawGenericElement> => ): NonDeleted<ExcalidrawGenericElement> =>
_newElementBase<ExcalidrawGenericElement>(opts.type, opts); _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 */ /** computes element x/y offset based on textAlign/verticalAlign */
const getTextElementPositionOffsets = ( const getTextElementPositionOffsets = (
opts: { opts: {
@ -161,6 +177,7 @@ export const newTextElement = (
containerId?: ExcalidrawTextContainer["id"] | null; containerId?: ExcalidrawTextContainer["id"] | null;
lineHeight?: ExcalidrawTextElement["lineHeight"]; lineHeight?: ExcalidrawTextElement["lineHeight"];
strokeWidth?: ExcalidrawTextElement["strokeWidth"]; strokeWidth?: ExcalidrawTextElement["strokeWidth"];
isFrameName?: boolean;
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => { ): NonDeleted<ExcalidrawTextElement> => {
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY; const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
@ -195,6 +212,7 @@ export const newTextElement = (
containerId: opts.containerId || null, containerId: opts.containerId || null,
originalText: text, originalText: text,
lineHeight, lineHeight,
isFrameName: opts.isFrameName || false,
}, },
{}, {},
); );
@ -211,8 +229,6 @@ const getAdjustedDimensions = (
height: number; height: number;
baseline: number; baseline: number;
} => { } => {
const container = getContainerElement(element);
const { const {
width: nextWidth, width: nextWidth,
height: nextHeight, 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 { return {
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
@ -638,6 +633,10 @@ export const duplicateElements = (
: null; : null;
} }
if (clonedElement.frameId) {
clonedElement.frameId = maybeGetNewId(clonedElement.frameId);
}
clonedElements.push(clonedElement); clonedElements.push(clonedElement);
} }

View file

@ -27,6 +27,7 @@ import {
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
isFrameElement,
isFreeDrawElement, isFreeDrawElement,
isImageElement, isImageElement,
isLinearElement, isLinearElement,
@ -160,12 +161,17 @@ const rotateSingleElement = (
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx); let angle: number;
if (shouldRotateWithDiscreteAngle) { if (isFrameElement(element)) {
angle += SHIFT_LOCKING_ANGLE / 2; angle = 0;
angle -= angle % SHIFT_LOCKING_ANGLE; } 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); const boundTextElementId = getBoundTextElementId(element);
mutateElement(element, { angle }); mutateElement(element, { angle });
@ -877,39 +883,49 @@ const rotateMultipleElements = (
centerAngle += SHIFT_LOCKING_ANGLE / 2; centerAngle += SHIFT_LOCKING_ANGLE / 2;
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE; centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
} }
elements.forEach((element) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); elements
const cx = (x1 + x2) / 2; .filter((element) => element.type !== "frame")
const cy = (y1 + y2) / 2; .forEach((element) => {
const origAngle = const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
pointerDownState.originalElements.get(element.id)?.angle ?? element.angle; const cx = (x1 + x2) / 2;
const [rotatedCX, rotatedCY] = rotate( const cy = (y1 + y2) / 2;
cx, const origAngle =
cy, pointerDownState.originalElements.get(element.id)?.angle ??
centerX, element.angle;
centerY, const [rotatedCX, rotatedCY] = rotate(
centerAngle + origAngle - element.angle, cx,
); cy,
mutateElement(element, { centerX,
x: element.x + (rotatedCX - cx), centerY,
y: element.y + (rotatedCY - cy), centerAngle + origAngle - element.angle,
angle: normalizeAngle(centerAngle + origAngle), );
}); mutateElement(
const boundTextElementId = getBoundTextElementId(element); element,
if (boundTextElementId) { {
const textElement = x: element.x + (rotatedCX - cx),
Scene.getScene(element)?.getElement<ExcalidrawTextElementWithContainer>( y: element.y + (rotatedCY - cy),
boundTextElementId,
);
if (textElement && !isArrowElement(element)) {
mutateElement(textElement, {
x: textElement.x + (rotatedCX - cx),
y: textElement.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle), 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 = ( export const getResizeOffsetXY = (

View file

@ -845,10 +845,12 @@ export const getTextBindableContainerAtPosition = (
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]); const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
if ( if (
isArrowElement(elements[index]) && isArrowElement(elements[index]) &&
isHittingElementNotConsideringBoundingBox(elements[index], appState, [ isHittingElementNotConsideringBoundingBox(
x, elements[index],
y, appState,
]) null,
[x, y],
)
) { ) {
hitElement = elements[index]; hitElement = elements[index];
break; break;
@ -865,7 +867,6 @@ export const VALID_CONTAINER_TYPES = new Set([
"rectangle", "rectangle",
"ellipse", "ellipse",
"diamond", "diamond",
"image",
"arrow", "arrow",
]); ]);

View file

@ -26,6 +26,17 @@ ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const tab = " "; const tab = " ";
const mouse = new Pointer("mouse"); 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("textWysiwyg", () => {
describe("start text editing", () => { describe("start text editing", () => {
const { h } = window; const { h } = window;
@ -190,9 +201,7 @@ describe("textWysiwyg", () => {
mouse.clickAt(text.x + 50, text.y + 50); mouse.clickAt(text.x + 50, text.y + 50);
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).toBe(text.id); expect(h.state.editingElement?.id).toBe(text.id);
@ -214,9 +223,7 @@ describe("textWysiwyg", () => {
mouse.doubleClickAt(text.x + 50, text.y + 50); mouse.doubleClickAt(text.x + 50, text.y + 50);
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingElement?.id).toBe(text.id); expect(h.state.editingElement?.id).toBe(text.id);
@ -243,9 +250,7 @@ describe("textWysiwyg", () => {
textElement = UI.createElement("text"); textElement = UI.createElement("text");
mouse.clickOn(textElement); mouse.clickOn(textElement);
textarea = document.querySelector( textarea = getTextEditor();
".excalidraw-textEditorContainer > textarea",
)!;
}); });
afterAll(() => { afterAll(() => {
@ -455,17 +460,11 @@ describe("textWysiwyg", () => {
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(750, 300); mouse.clickAt(750, 300);
textarea = document.querySelector( textarea = getTextEditor();
".excalidraw-textEditorContainer > textarea", updateTextEditor(
)!; textarea,
fireEvent.change(textarea, { "Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
target: { );
value:
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
},
});
textarea.dispatchEvent(new Event("input"));
await new Promise((cb) => setTimeout(cb, 0)); await new Promise((cb) => setTimeout(cb, 0));
textarea.blur(); textarea.blur();
expect(textarea.style.width).toBe("792px"); expect(textarea.style.width).toBe("792px");
@ -513,11 +512,9 @@ describe("textWysiwyg", () => {
{ id: text.id, type: "text" }, { id: text.id, type: "text" },
]); ]);
mouse.down(); mouse.down();
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } }); updateTextEditor(editor, "Hello World!");
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
editor.blur(); editor.blur();
@ -543,11 +540,9 @@ describe("textWysiwyg", () => {
]); ]);
expect(text.angle).toBe(rectangle.angle); expect(text.angle).toBe(rectangle.angle);
mouse.down(); mouse.down();
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } }); updateTextEditor(editor, "Hello World!");
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
editor.blur(); editor.blur();
@ -572,9 +567,7 @@ describe("textWysiwyg", () => {
API.setSelectedElements([diamond]); API.setSelectedElements([diamond]);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
const value = new Array(1000).fill("1").join("\n"); const value = new Array(1000).fill("1").join("\n");
@ -587,9 +580,7 @@ describe("textWysiwyg", () => {
expect(diamond.height).toBe(50020); expect(diamond.height).toBe(50020);
// Clearing text to simulate height decrease // Clearing text to simulate height decrease
expect(() => expect(() => updateTextEditor(editor, "")).not.toThrow();
fireEvent.input(editor, { target: { value: "" } }),
).not.toThrow();
expect(diamond.height).toBe(70); expect(diamond.height).toBe(70);
}); });
@ -611,9 +602,7 @@ describe("textWysiwyg", () => {
expect(text.type).toBe("text"); expect(text.type).toBe("text");
expect(text.containerId).toBe(null); expect(text.containerId).toBe(null);
mouse.down(); mouse.down();
let editor = document.querySelector( let editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
editor.blur(); editor.blur();
@ -628,11 +617,9 @@ describe("textWysiwyg", () => {
expect(text.containerId).toBe(rectangle.id); expect(text.containerId).toBe(rectangle.id);
mouse.down(); mouse.down();
editor = document.querySelector( editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } }); updateTextEditor(editor, "Hello World!");
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
editor.blur(); editor.blur();
@ -652,13 +639,11 @@ describe("textWysiwyg", () => {
const text = h.elements[1] as ExcalidrawTextElementWithContainer; const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text"); expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id); expect(text.containerId).toBe(rectangle.id);
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello World!" } }); updateTextEditor(editor, "Hello World!");
editor.blur(); editor.blur();
expect(rectangle.boundElements).toStrictEqual([ expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" }, { id: text.id, type: "text" },
@ -689,11 +674,8 @@ describe("textWysiwyg", () => {
{ id: text.id, type: "text" }, { id: text.id, type: "text" },
]); ]);
mouse.down(); mouse.down();
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea", updateTextEditor(editor, "Hello World!");
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
editor.blur(); editor.blur();
@ -717,17 +699,9 @@ describe("textWysiwyg", () => {
freedraw.y + freedraw.height / 2, freedraw.y + freedraw.height / 2,
); );
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea", updateTextEditor(editor, "Hello World!");
) as HTMLTextAreaElement;
fireEvent.change(editor, {
target: {
value: "Hello World!",
},
});
fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
editor.dispatchEvent(new Event("input"));
expect(freedraw.boundElements).toBe(null); expect(freedraw.boundElements).toBe(null);
expect(h.elements[1].type).toBe("text"); expect(h.elements[1].type).toBe("text");
@ -759,11 +733,9 @@ describe("textWysiwyg", () => {
expect(text.type).toBe("text"); expect(text.type).toBe("text");
expect(text.containerId).toBe(null); expect(text.containerId).toBe(null);
mouse.down(); mouse.down();
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } }); updateTextEditor(editor, "Hello World!");
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
editor.blur(); editor.blur();
@ -776,17 +748,12 @@ describe("textWysiwyg", () => {
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(20, 30); mouse.clickAt(20, 30);
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { updateTextEditor(
target: { editor,
value: "Excalidraw is an opensource virtual collaborative whiteboard", "Excalidraw is an opensource virtual collaborative whiteboard",
}, );
});
editor.dispatchEvent(new Event("input"));
await new Promise((cb) => setTimeout(cb, 0)); await new Promise((cb) => setTimeout(cb, 0));
expect(h.elements.length).toBe(2); expect(h.elements.length).toBe(2);
expect(h.elements[1].type).toBe("text"); expect(h.elements[1].type).toBe("text");
@ -826,12 +793,10 @@ describe("textWysiwyg", () => {
mouse.down(); mouse.down();
const text = h.elements[1] as ExcalidrawTextElementWithContainer; const text = h.elements[1] as ExcalidrawTextElementWithContainer;
let editor = document.querySelector( let editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello World!" } }); updateTextEditor(editor, "Hello World!");
editor.blur(); editor.blur();
expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil); expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil);
UI.clickTool("text"); UI.clickTool("text");
@ -841,9 +806,7 @@ describe("textWysiwyg", () => {
rectangle.y + rectangle.height / 2, rectangle.y + rectangle.height / 2,
); );
mouse.down(); mouse.down();
editor = document.querySelector( editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
editor.select(); editor.select();
fireEvent.click(screen.getByTitle(/code/i)); fireEvent.click(screen.getByTitle(/code/i));
@ -876,17 +839,9 @@ describe("textWysiwyg", () => {
Keyboard.keyDown(KEYS.ENTER); Keyboard.keyDown(KEYS.ENTER);
let text = h.elements[1] as ExcalidrawTextElementWithContainer; let text = h.elements[1] as ExcalidrawTextElementWithContainer;
let editor = document.querySelector( let editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { updateTextEditor(editor, "Hello World!");
target: {
value: "Hello World!",
},
});
editor.dispatchEvent(new Event("input"));
await new Promise((cb) => setTimeout(cb, 0)); await new Promise((cb) => setTimeout(cb, 0));
editor.blur(); editor.blur();
@ -905,17 +860,8 @@ describe("textWysiwyg", () => {
mouse.select(rectangle); mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
editor = document.querySelector( editor = getTextEditor();
".excalidraw-textEditorContainer > textarea", updateTextEditor(editor, "Hello");
) as HTMLTextAreaElement;
fireEvent.change(editor, {
target: {
value: "Hello",
},
});
editor.dispatchEvent(new Event("input"));
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
@ -943,13 +889,11 @@ describe("textWysiwyg", () => {
const text = h.elements[1] as ExcalidrawTextElementWithContainer; const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id); expect(text.containerId).toBe(rectangle.id);
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello World!" } }); updateTextEditor(editor, "Hello World!");
editor.blur(); editor.blur();
expect(rectangle.boundElements).toStrictEqual([ expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" }, { id: text.id, type: "text" },
@ -982,11 +926,9 @@ describe("textWysiwyg", () => {
// Bind first text // Bind first text
const text = h.elements[1] as ExcalidrawTextElementWithContainer; const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id); expect(text.containerId).toBe(rectangle.id);
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello World!" } }); updateTextEditor(editor, "Hello World!");
editor.blur(); editor.blur();
expect(rectangle.boundElements).toStrictEqual([ expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" }, { id: text.id, type: "text" },
@ -1005,11 +947,9 @@ describe("textWysiwyg", () => {
it("should respect text alignment when resizing", async () => { it("should respect text alignment when resizing", async () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
let editor = document.querySelector( let editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello" } }); updateTextEditor(editor, "Hello");
editor.blur(); editor.blur();
// should center align horizontally and vertically by default // should center align horizontally and vertically by default
@ -1024,9 +964,7 @@ describe("textWysiwyg", () => {
mouse.select(rectangle); mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
editor = document.querySelector( editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
editor.select(); editor.select();
@ -1049,9 +987,7 @@ describe("textWysiwyg", () => {
mouse.select(rectangle); mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
editor = document.querySelector( editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
editor.select(); editor.select();
@ -1089,11 +1025,9 @@ describe("textWysiwyg", () => {
expect(text.type).toBe("text"); expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id); expect(text.containerId).toBe(rectangle.id);
mouse.down(); mouse.down();
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } }); updateTextEditor(editor, "Hello World!");
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
editor.blur(); editor.blur();
@ -1106,11 +1040,9 @@ describe("textWysiwyg", () => {
it("should scale font size correctly when resizing using shift", async () => { it("should scale font size correctly when resizing using shift", async () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello" } }); updateTextEditor(editor, "Hello");
editor.blur(); editor.blur();
const textElement = h.elements[1] as ExcalidrawTextElement; const textElement = h.elements[1] as ExcalidrawTextElement;
expect(rectangle.width).toBe(90); expect(rectangle.width).toBe(90);
@ -1128,11 +1060,9 @@ describe("textWysiwyg", () => {
it("should bind text correctly when container duplicated with alt-drag", async () => { it("should bind text correctly when container duplicated with alt-drag", async () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello" } }); updateTextEditor(editor, "Hello");
editor.blur(); editor.blur();
expect(h.elements.length).toBe(2); expect(h.elements.length).toBe(2);
@ -1162,11 +1092,9 @@ describe("textWysiwyg", () => {
it("undo should work", async () => { it("undo should work", async () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello" } }); updateTextEditor(editor, "Hello");
editor.blur(); editor.blur();
expect(rectangle.boundElements).toStrictEqual([ expect(rectangle.boundElements).toStrictEqual([
{ id: h.elements[1].id, type: "text" }, { id: h.elements[1].id, type: "text" },
@ -1201,54 +1129,64 @@ describe("textWysiwyg", () => {
it("should not allow bound text with only whitespaces", async () => { it("should not allow bound text with only whitespaces", async () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: " " } }); updateTextEditor(editor, " ");
editor.blur(); editor.blur();
expect(rectangle.boundElements).toStrictEqual([]); expect(rectangle.boundElements).toStrictEqual([]);
expect(h.elements[1].isDeleted).toBe(true); expect(h.elements[1].isDeleted).toBe(true);
}); });
it("should restore original container height and clear cache once text is unbind", async () => { it("should restore original container height and clear cache once text is unbind", async () => {
const originalRectHeight = rectangle.height; const container = API.createElement({
expect(rectangle.height).toBe(originalRectHeight); type: "rectangle",
height: 75,
Keyboard.keyPress(KEYS.ENTER); width: 90,
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" },
}); });
editor.blur(); const originalRectHeight = container.height;
expect(rectangle.height).toBe(185); expect(container.height).toBe(originalRectHeight);
mouse.select(rectangle);
const text = API.createElement({
type: "text",
text: "Online whiteboard collaboration made easy",
});
h.elements = [container, text];
API.setSelectedElements([container, text]);
fireEvent.contextMenu(GlobalTestState.canvas, { fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2, button: 2,
clientX: 20, clientX: 20,
clientY: 30, 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")!); fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!);
expect(h.elements[0].boundElements).toEqual([]); 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 () => { it("should reset the container height cache when resizing", async () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
let editor = document.querySelector( let editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello" } }); updateTextEditor(editor, "Hello");
editor.blur(); editor.blur();
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]); resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
@ -1258,9 +1196,7 @@ describe("textWysiwyg", () => {
mouse.select(rectangle); mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
editor = document.querySelector( editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
editor.blur(); editor.blur();
@ -1273,12 +1209,8 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea", updateTextEditor(editor, "Hello World!");
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello World!" } });
editor.blur(); editor.blur();
mouse.select(rectangle); mouse.select(rectangle);
@ -1302,12 +1234,8 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75); expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea", updateTextEditor(editor, "Hello World!");
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello World!" } });
editor.blur(); editor.blur();
expect( expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight, (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
@ -1338,17 +1266,12 @@ describe("textWysiwyg", () => {
beforeEach(async () => { beforeEach(async () => {
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
editor = document.querySelector( editor = getTextEditor();
".excalidraw-textEditorContainer > textarea", updateTextEditor(editor, "Hello");
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello" } });
editor.blur(); editor.blur();
mouse.select(rectangle); mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
editor = document.querySelector( editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
editor.select(); 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 () => { it("should wrap text in a container when wrap text in container triggered from context menu", async () => {
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(20, 30); mouse.clickAt(20, 30);
const editor = document.querySelector( const editor = getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { updateTextEditor(
target: { editor,
value: "Excalidraw is an opensource virtual collaborative whiteboard", "Excalidraw is an opensource virtual collaborative whiteboard",
}, );
});
editor.dispatchEvent(new Event("input"));
await new Promise((cb) => setTimeout(cb, 0)); await new Promise((cb) => setTimeout(cb, 0));
editor.select(); 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");
});
}); });
}); });

View file

@ -11,7 +11,7 @@ import {
isBoundToContainer, isBoundToContainer,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { CLASSES, isSafari, VERTICAL_ALIGN } from "../constants"; import { CLASSES, isSafari } from "../constants";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@ -23,12 +23,10 @@ import { AppState } from "../types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { import {
getBoundTextElementId, getBoundTextElementId,
getContainerCoords,
getContainerDims, getContainerDims,
getContainerElement, getContainerElement,
getTextElementAngle, getTextElementAngle,
getTextWidth, getTextWidth,
measureText,
normalizeText, normalizeText,
redrawTextBoundingBox, redrawTextBoundingBox,
wrapText, wrapText,
@ -36,6 +34,7 @@ import {
getBoundTextMaxWidth, getBoundTextMaxWidth,
computeContainerDimensionForBoundText, computeContainerDimensionForBoundText,
detectLineHeight, detectLineHeight,
computeBoundTextPosition,
} from "./textElement"; } from "./textElement";
import { import {
actionDecreaseFontSize, actionDecreaseFontSize,
@ -162,7 +161,7 @@ export const textWysiwyg = ({
let textElementWidth = updatedTextElement.width; let textElementWidth = updatedTextElement.width;
// Set to element height by default since that's // Set to element height by default since that's
// what is going to be used for unbounded text // what is going to be used for unbounded text
let textElementHeight = updatedTextElement.height; const textElementHeight = updatedTextElement.height;
if (container && updatedTextElement.containerId) { if (container && updatedTextElement.containerId) {
if (isArrowElement(container)) { if (isArrowElement(container)) {
@ -179,15 +178,6 @@ export const textWysiwyg = ({
editable, editable,
); );
const containerDims = getContainerDims(container); 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; let originalContainerData;
if (propertiesUpdated) { if (propertiesUpdated) {
@ -232,22 +222,12 @@ export const textWysiwyg = ({
container.type, container.type,
); );
mutateElement(container, { height: targetContainerHeight }); mutateElement(container, { height: targetContainerHeight });
} } else {
// Start pushing text upward until a diff of 30px (padding) const { y } = computeBoundTextPosition(
// is reached container,
else { updatedTextElement as ExcalidrawTextElementWithContainer,
const containerCoords = getContainerCoords(container); );
coordY = y;
// 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);
}
} }
} }
const [viewportX, viewportY] = getViewportCoords(coordX, coordY); const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
@ -388,25 +368,6 @@ export const textWysiwyg = ({
}; };
editable.oninput = () => { 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)); onChange(normalizeText(editable.value));
}; };
} }

View file

@ -8,7 +8,7 @@ import { getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math"; import { rotate } from "../math";
import { AppState, Zoom } from "../types"; import { AppState, Zoom } from "../types";
import { isTextElement } from "."; import { isTextElement } from ".";
import { isLinearElement } from "./typeChecks"; import { isFrameElement, isLinearElement } from "./typeChecks";
import { DEFAULT_SPACING } from "../renderer/renderScene"; import { DEFAULT_SPACING } from "../renderer/renderScene";
export type TransformHandleDirection = export type TransformHandleDirection =
@ -44,6 +44,14 @@ export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
w: true, w: true,
}; };
export const OMIT_SIDES_FOR_FRAME = {
e: true,
s: true,
n: true,
w: true,
rotation: true,
};
const OMIT_SIDES_FOR_TEXT_ELEMENT = { const OMIT_SIDES_FOR_TEXT_ELEMENT = {
e: true, e: true,
s: true, s: true,
@ -249,6 +257,10 @@ export const getTransformHandles = (
} }
} else if (isTextElement(element)) { } else if (isTextElement(element)) {
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT; omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
} else if (isFrameElement(element)) {
omitSides = {
rotation: true,
};
} }
const dashedLineMargin = isLinearElement(element) const dashedLineMargin = isLinearElement(element)
? DEFAULT_SPACING + 8 ? DEFAULT_SPACING + 8

View file

@ -30,15 +30,6 @@ describe("Test TypeChecks", () => {
}), }),
), ),
).toBeTruthy(); ).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", () => { it("should return false for text bindable containers without bound text", () => {
@ -62,5 +53,14 @@ describe("Test TypeChecks", () => {
), ),
).toBeFalsy(); ).toBeFalsy();
}); });
expect(
hasBoundTextElement(
API.createElement({
type: "image",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeFalsy();
}); });
}); });

View file

@ -12,6 +12,7 @@ import {
ExcalidrawImageElement, ExcalidrawImageElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawFrameElement,
RoundnessType, RoundnessType,
} from "./types"; } from "./types";
@ -45,6 +46,12 @@ export const isTextElement = (
return element != null && element.type === "text"; return element != null && element.type === "text";
}; };
export const isFrameElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawFrameElement => {
return element != null && element.type === "frame";
};
export const isFreeDrawElement = ( export const isFreeDrawElement = (
element?: ExcalidrawElement | null, element?: ExcalidrawElement | null,
): element is ExcalidrawFreeDrawElement => { ): element is ExcalidrawFreeDrawElement => {
@ -119,7 +126,6 @@ export const isTextBindableContainer = (
(element.type === "rectangle" || (element.type === "rectangle" ||
element.type === "diamond" || element.type === "diamond" ||
element.type === "ellipse" || element.type === "ellipse" ||
element.type === "image" ||
isArrowElement(element)) isArrowElement(element))
); );
}; };

View file

@ -53,6 +53,7 @@ type _ExcalidrawElementBase = Readonly<{
/** List of groups the element belongs to. /** List of groups the element belongs to.
Ordered from deepest to shallowest. */ Ordered from deepest to shallowest. */
groupIds: readonly GroupId[]; groupIds: readonly GroupId[];
frameId: string | null;
/** other elements that are bound to this element */ /** other elements that are bound to this element */
boundElements: boundElements:
| readonly Readonly<{ | readonly Readonly<{
@ -98,6 +99,11 @@ export type InitializedExcalidrawImageElement = MarkNonNullable<
"fileId" "fileId"
>; >;
export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
type: "frame";
name: string | null;
};
/** /**
* These are elements that don't have any additional properties. * These are elements that don't have any additional properties.
*/ */
@ -117,7 +123,8 @@ export type ExcalidrawElement =
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawLinearElement | ExcalidrawLinearElement
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawImageElement; | ExcalidrawImageElement
| ExcalidrawFrameElement;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & { export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
isDeleted: boolean; isDeleted: boolean;
@ -148,13 +155,13 @@ export type ExcalidrawBindableElement =
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawEllipseElement | ExcalidrawEllipseElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawImageElement; | ExcalidrawImageElement
| ExcalidrawFrameElement;
export type ExcalidrawTextContainer = export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawEllipseElement | ExcalidrawEllipseElement
| ExcalidrawImageElement
| ExcalidrawArrowElement; | ExcalidrawArrowElement;
export type ExcalidrawTextElementWithContainer = { export type ExcalidrawTextElementWithContainer = {

View file

@ -157,6 +157,8 @@ class Collab extends PureComponent<Props, CollabState> {
window.addEventListener("offline", this.onOfflineStatusToggle); window.addEventListener("offline", this.onOfflineStatusToggle);
window.addEventListener(EVENT.UNLOAD, this.onUnload); window.addEventListener(EVENT.UNLOAD, this.onUnload);
this.onOfflineStatusToggle();
const collabAPI: CollabAPI = { const collabAPI: CollabAPI = {
isCollaborating: this.isCollaborating, isCollaborating: this.isCollaborating,
onPointerUpdate: this.onPointerUpdate, onPointerUpdate: this.onPointerUpdate,
@ -168,7 +170,6 @@ class Collab extends PureComponent<Props, CollabState> {
}; };
appJotaiStore.set(collabAPIAtom, collabAPI); appJotaiStore.set(collabAPIAtom, collabAPI);
this.onOfflineStatusToggle();
if ( if (
process.env.NODE_ENV === ENV.TEST || process.env.NODE_ENV === ENV.TEST ||
@ -380,6 +381,13 @@ class Collab extends PureComponent<Props, CollabState> {
startCollaboration = async ( startCollaboration = async (
existingRoomLinkData: null | { roomId: string; roomKey: string }, existingRoomLinkData: null | { roomId: string; roomKey: string },
): Promise<ImportedDataState | null> => { ): Promise<ImportedDataState | null> => {
if (!this.state.username) {
import("@excalidraw/random-username").then(({ getRandomUsername }) => {
const username = getRandomUsername();
this.onUsernameChange(username);
});
}
if (this.portal.socket) { if (this.portal.socket) {
return null; return null;
} }

View file

@ -6,6 +6,7 @@ import { LanguageList } from "./LanguageList";
export const AppMainMenu: React.FC<{ export const AppMainMenu: React.FC<{
setCollabDialogShown: (toggle: boolean) => any; setCollabDialogShown: (toggle: boolean) => any;
isCollaborating: boolean; isCollaborating: boolean;
isCollabEnabled: boolean;
}> = React.memo((props) => { }> = React.memo((props) => {
return ( return (
<MainMenu> <MainMenu>
@ -13,10 +14,12 @@ export const AppMainMenu: React.FC<{
<MainMenu.DefaultItems.SaveToActiveFile /> <MainMenu.DefaultItems.SaveToActiveFile />
<MainMenu.DefaultItems.Export /> <MainMenu.DefaultItems.Export />
<MainMenu.DefaultItems.SaveAsImage /> <MainMenu.DefaultItems.SaveAsImage />
<MainMenu.DefaultItems.LiveCollaborationTrigger {props.isCollabEnabled && (
isCollaborating={props.isCollaborating} <MainMenu.DefaultItems.LiveCollaborationTrigger
onSelect={() => props.setCollabDialogShown(true)} isCollaborating={props.isCollaborating}
/> onSelect={() => props.setCollabDialogShown(true)}
/>
)}
<MainMenu.DefaultItems.Help /> <MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas /> <MainMenu.DefaultItems.ClearCanvas />

View file

@ -6,6 +6,7 @@ import { isExcalidrawPlusSignedUser } from "../app_constants";
export const AppWelcomeScreen: React.FC<{ export const AppWelcomeScreen: React.FC<{
setCollabDialogShown: (toggle: boolean) => any; setCollabDialogShown: (toggle: boolean) => any;
isCollabEnabled: boolean;
}> = React.memo((props) => { }> = React.memo((props) => {
const { t } = useI18n(); const { t } = useI18n();
let headingContent; let headingContent;
@ -46,9 +47,11 @@ export const AppWelcomeScreen: React.FC<{
<WelcomeScreen.Center.Menu> <WelcomeScreen.Center.Menu>
<WelcomeScreen.Center.MenuItemLoadScene /> <WelcomeScreen.Center.MenuItemLoadScene />
<WelcomeScreen.Center.MenuItemHelp /> <WelcomeScreen.Center.MenuItemHelp />
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger {props.isCollabEnabled && (
onSelect={() => props.setCollabDialogShown(true)} <WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
/> onSelect={() => props.setCollabDialogShown(true)}
/>
)}
{!isExcalidrawPlusSignedUser && ( {!isExcalidrawPlusSignedUser && (
<WelcomeScreen.Center.MenuItemLink <WelcomeScreen.Center.MenuItemLink
href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest" href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"

View file

@ -16,7 +16,7 @@ import { MIME_TYPES } from "../../constants";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
import { getFrame } from "../../utils"; import { getFrame } from "../../utils";
const exportToExcalidrawPlus = async ( export const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: Partial<AppState>, appState: Partial<AppState>,
files: BinaryFiles, files: BinaryFiles,

View file

@ -263,7 +263,7 @@ export const loadScene = async (
data = restore( data = restore(
await importFromBackend(id, privateKey), await importFromBackend(id, privateKey),
localDataState?.appState, localDataState?.appState,
convertToExcalidrawElements(localDataState?.elements), localDataState?.elements,
{ repairBindings: true, refreshDimensions: false }, { repairBindings: true, refreshDimensions: false },
); );
} else { } else {
@ -283,11 +283,15 @@ export const loadScene = async (
}; };
}; };
type ExportToBackendResult =
| { url: null; errorMessage: string }
| { url: string; errorMessage: null };
export const exportToBackend = async ( export const exportToBackend = async (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: Partial<AppState>, appState: Partial<AppState>,
files: BinaryFiles, files: BinaryFiles,
) => { ): Promise<ExportToBackendResult> => {
const encryptionKey = await generateEncryptionKey("string"); const encryptionKey = await generateEncryptionKey("string");
const payload = await compressData( const payload = await compressData(
@ -328,14 +332,18 @@ export const exportToBackend = async (
files: filesToUpload, files: filesToUpload,
}); });
window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString); return { url: urlString, errorMessage: null };
} else if (json.error_class === "RequestTooLargeError") { } else if (json.error_class === "RequestTooLargeError") {
window.alert(t("alerts.couldNotCreateShareableLinkTooBig")); return {
} else { url: null,
window.alert(t("alerts.couldNotCreateShareableLink")); errorMessage: t("alerts.couldNotCreateShareableLinkTooBig"),
};
} }
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
window.alert(t("alerts.couldNotCreateShareableLink"));
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
} }
}; };

135
src/excalidraw-app/debug.ts Normal file
View 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;

View file

@ -42,6 +42,7 @@ import {
preventUnload, preventUnload,
ResolvablePromise, ResolvablePromise,
resolvablePromise, resolvablePromise,
isRunningInIframe,
} from "../utils"; } from "../utils";
import { import {
FIREBASE_STORAGE_PREFIXES, FIREBASE_STORAGE_PREFIXES,
@ -68,7 +69,10 @@ import {
} from "./data/localStorage"; } from "./data/localStorage";
import CustomStats from "./CustomStats"; import CustomStats from "./CustomStats";
import { restore, restoreAppState, RestoredDataState } from "../data/restore"; import { restore, restoreAppState, RestoredDataState } from "../data/restore";
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus"; import {
ExportToExcalidrawPlus,
exportToExcalidrawPlus,
} from "./components/ExportToExcalidrawPlus";
import { updateStaleImageStatuses } from "./data/FileManager"; import { updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { isInitializedImageElement } from "../element/typeChecks"; import { isInitializedImageElement } from "../element/typeChecks";
@ -88,6 +92,10 @@ import { appJotaiStore } from "./app-jotai";
import "./index.scss"; import "./index.scss";
import { ResolutionType } from "../utility-types"; import { ResolutionType } from "../utility-types";
import { convertToExcalidrawElements } from "../data/transform"; 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(); polyfill();
@ -98,8 +106,21 @@ languageDetector.init({
languageUtils: {}, 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: { const initializeScene = async (opts: {
collabAPI: CollabAPI; collabAPI: CollabAPI | null;
excalidrawAPI: ExcalidrawImperativeAPI; excalidrawAPI: ExcalidrawImperativeAPI;
}): Promise< }): Promise<
{ scene: ExcalidrawInitialDataState | null } & ( { scene: ExcalidrawInitialDataState | null } & (
@ -129,7 +150,7 @@ const initializeScene = async (opts: {
// don't prompt for collab scenes because we don't override local storage // don't prompt for collab scenes because we don't override local storage
roomLinkData || roomLinkData ||
// otherwise, prompt whether user wants to override current scene // otherwise, prompt whether user wants to override current scene
window.confirm(t("alerts.loadSceneOverridePrompt")) (await openConfirmModal(shareableLinkConfirmDialog))
) { ) {
if (jsonBackendMatch) { if (jsonBackendMatch) {
scene = await loadScene( scene = await loadScene(
@ -168,7 +189,7 @@ const initializeScene = async (opts: {
const data = await loadFromBlob(await request.blob(), null, null); const data = await loadFromBlob(await request.blob(), null, null);
if ( if (
!scene.elements.length || !scene.elements.length ||
window.confirm(t("alerts.loadSceneOverridePrompt")) (await openConfirmModal(shareableLinkConfirmDialog))
) { ) {
return { scene: data, isExternalScene }; return { scene: data, isExternalScene };
} }
@ -184,7 +205,7 @@ const initializeScene = async (opts: {
} }
} }
if (roomLinkData) { if (roomLinkData && opts.collabAPI) {
const { excalidrawAPI } = opts; const { excalidrawAPI } = opts;
const scene = await opts.collabAPI.startCollaboration(roomLinkData); const scene = await opts.collabAPI.startCollaboration(roomLinkData);
@ -238,6 +259,7 @@ export const appLangCodeAtom = atom(
const ExcalidrawWrapper = () => { const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [langCode, setLangCode] = useAtom(appLangCodeAtom); const [langCode, setLangCode] = useAtom(appLangCodeAtom);
const isCollabDisabled = isRunningInIframe();
// initial state // initial state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -273,7 +295,7 @@ const ExcalidrawWrapper = () => {
}); });
useEffect(() => { useEffect(() => {
if (!collabAPI || !excalidrawAPI) { if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
return; return;
} }
@ -284,7 +306,7 @@ const ExcalidrawWrapper = () => {
if (!data.scene) { if (!data.scene) {
return; return;
} }
if (collabAPI.isCollaborating()) { if (collabAPI?.isCollaborating()) {
if (data.scene.elements) { if (data.scene.elements) {
collabAPI collabAPI
.fetchImageFilesFromFirebase({ .fetchImageFilesFromFirebase({
@ -355,7 +377,7 @@ const ExcalidrawWrapper = () => {
const libraryUrlTokens = parseLibraryTokensFromUrl(); const libraryUrlTokens = parseLibraryTokensFromUrl();
if (!libraryUrlTokens) { if (!libraryUrlTokens) {
if ( if (
collabAPI.isCollaborating() && collabAPI?.isCollaborating() &&
!isCollaborationLink(window.location.href) !isCollaborationLink(window.location.href)
) { ) {
collabAPI.stopCollaboration(false); collabAPI.stopCollaboration(false);
@ -384,7 +406,10 @@ const ExcalidrawWrapper = () => {
if (isTestEnv()) { if (isTestEnv()) {
return; 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 // don't sync if local state is newer or identical to browser state
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) { if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
const localDataState = importFromLocalStorage(); const localDataState = importFromLocalStorage();
@ -400,7 +425,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.updateLibrary({ excalidrawAPI.updateLibrary({
libraryItems: getLibraryItemsFromStorage(), libraryItems: getLibraryItemsFromStorage(),
}); });
collabAPI.setUsername(username || ""); collabAPI?.setUsername(username || "");
} }
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_FILES)) { if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_FILES)) {
@ -468,7 +493,7 @@ const ExcalidrawWrapper = () => {
); );
clearTimeout(titleTimeout); clearTimeout(titleTimeout);
}; };
}, [collabAPI, excalidrawAPI, setLangCode]); }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
useEffect(() => { useEffect(() => {
const unloadHandler = (event: BeforeUnloadEvent) => { const unloadHandler = (event: BeforeUnloadEvent) => {
@ -551,6 +576,10 @@ const ExcalidrawWrapper = () => {
} }
}; };
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
null,
);
const onExportToBackend = async ( const onExportToBackend = async (
exportedElements: readonly NonDeletedExcalidrawElement[], exportedElements: readonly NonDeletedExcalidrawElement[],
appState: Partial<AppState>, appState: Partial<AppState>,
@ -562,7 +591,7 @@ const ExcalidrawWrapper = () => {
} }
if (canvas) { if (canvas) {
try { try {
await exportToBackend( const { url, errorMessage } = await exportToBackend(
exportedElements, exportedElements,
{ {
...appState, ...appState,
@ -572,6 +601,14 @@ const ExcalidrawWrapper = () => {
}, },
files, files,
); );
if (errorMessage) {
setErrorMessage(errorMessage);
}
if (url) {
setLatestShareableLink(url);
}
} catch (error: any) { } catch (error: any) {
if (error.name !== "AbortError") { if (error.name !== "AbortError") {
const { width, height } = canvas; const { width, height } = canvas;
@ -651,7 +688,7 @@ const ExcalidrawWrapper = () => {
autoFocus={true} autoFocus={true}
theme={theme} theme={theme}
renderTopRightUI={(isMobile) => { renderTopRightUI={(isMobile) => {
if (isMobile) { if (isMobile || !collabAPI || isCollabDisabled) {
return null; return null;
} }
return ( return (
@ -665,21 +702,53 @@ const ExcalidrawWrapper = () => {
<AppMainMenu <AppMainMenu
setCollabDialogShown={setCollabDialogShown} setCollabDialogShown={setCollabDialogShown}
isCollaborating={isCollaborating} 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 /> <AppFooter />
{isCollaborating && isOffline && ( {isCollaborating && isOffline && (
<div className="collab-offline-warning"> <div className="collab-offline-warning">
{t("alerts.collabOfflineWarning")} {t("alerts.collabOfflineWarning")}
</div> </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> </Excalidraw>
{errorMessage && (
<ErrorDialog onClose={() => setErrorMessage("")}>
{errorMessage}
</ErrorDialog>
)}
</div> </div>
); );
}; };

708
src/frame.ts Normal file
View 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;
};

View file

@ -2,6 +2,7 @@ import { GroupId, ExcalidrawElement, NonDeleted } from "./element/types";
import { AppState } from "./types"; import { AppState } from "./types";
import { getSelectedElements } from "./scene"; import { getSelectedElements } from "./scene";
import { getBoundTextElement } from "./element/textElement"; import { getBoundTextElement } from "./element/textElement";
import { makeNextSelectedElementIds } from "./scene/selection";
export const selectGroup = ( export const selectGroup = (
groupId: GroupId, groupId: GroupId,
@ -67,13 +68,21 @@ export const getSelectedGroupIds = (appState: AppState): GroupId[] =>
export const selectGroupsForSelectedElements = ( export const selectGroupsForSelectedElements = (
appState: AppState, appState: AppState,
elements: readonly NonDeleted<ExcalidrawElement>[], elements: readonly NonDeleted<ExcalidrawElement>[],
prevAppState: AppState,
): AppState => { ): AppState => {
let nextAppState: AppState = { ...appState, selectedGroupIds: {} }; let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
if (!selectedElements.length) { if (!selectedElements.length) {
return { ...nextAppState, editingGroupId: null }; return {
...nextAppState,
editingGroupId: null,
selectedElementIds: makeNextSelectedElementIds(
nextAppState.selectedElementIds,
prevAppState,
),
};
} }
for (const selectedElement of selectedElements) { for (const selectedElement of selectedElements) {
@ -91,9 +100,39 @@ export const selectGroupsForSelectedElements = (
} }
} }
nextAppState.selectedElementIds = makeNextSelectedElementIds(
nextAppState.selectedElementIds,
prevAppState,
);
return nextAppState; 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 = ( export const editGroupForSelectedElement = (
appState: AppState, appState: AppState,
element: NonDeleted<ExcalidrawElement>, element: NonDeleted<ExcalidrawElement>,
@ -186,3 +225,18 @@ export const getMaximumGroups = (
return Array.from(groups.values()); 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;
};

View file

@ -10,6 +10,7 @@ export const CODES = {
BRACKET_LEFT: "BracketLeft", BRACKET_LEFT: "BracketLeft",
ONE: "Digit1", ONE: "Digit1",
TWO: "Digit2", TWO: "Digit2",
THREE: "Digit3",
NINE: "Digit9", NINE: "Digit9",
QUOTE: "Quote", QUOTE: "Quote",
ZERO: "Digit0", ZERO: "Digit0",
@ -42,6 +43,7 @@ export const KEYS = {
CHEVRON_RIGHT: ">", CHEVRON_RIGHT: ">",
PERIOD: ".", PERIOD: ".",
COMMA: ",", COMMA: ",",
SUBTRACT: "-",
A: "a", A: "a",
C: "c", C: "c",

View file

@ -124,6 +124,8 @@
}, },
"statusPublished": "نُشر", "statusPublished": "نُشر",
"sidebarLock": "إبقاء الشريط الجانبي مفتوح", "sidebarLock": "إبقاء الشريط الجانبي مفتوح",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "" "eyeDropper": ""
}, },
"library": { "library": {
@ -221,7 +223,9 @@
"penMode": "وضع القلم - امنع اللمس", "penMode": "وضع القلم - امنع اللمس",
"link": "إضافة/تحديث الرابط للشكل المحدد", "link": "إضافة/تحديث الرابط للشكل المحدد",
"eraser": "ممحاة", "eraser": "ممحاة",
"hand": "" "frame": "",
"hand": "",
"extraTools": ""
}, },
"headings": { "headings": {
"canvasActions": "إجراءات اللوحة", "canvasActions": "إجراءات اللوحة",

453
src/locales/az-AZ.json Normal file
View 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": ""
}
}

View file

@ -124,6 +124,8 @@
}, },
"statusPublished": "", "statusPublished": "",
"sidebarLock": "", "sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "" "eyeDropper": ""
}, },
"library": { "library": {
@ -221,7 +223,9 @@
"penMode": "", "penMode": "",
"link": "", "link": "",
"eraser": "", "eraser": "",
"hand": "" "frame": "",
"hand": "",
"extraTools": ""
}, },
"headings": { "headings": {
"canvasActions": "Действия по платното", "canvasActions": "Действия по платното",

View file

@ -124,6 +124,8 @@
}, },
"statusPublished": "প্রকাশিত", "statusPublished": "প্রকাশিত",
"sidebarLock": "লক", "sidebarLock": "লক",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "" "eyeDropper": ""
}, },
"library": { "library": {
@ -221,7 +223,9 @@
"penMode": "", "penMode": "",
"link": "একটি নির্বাচিত আকৃতির জন্য লিঙ্ক যোগ বা আপডেট করুন", "link": "একটি নির্বাচিত আকৃতির জন্য লিঙ্ক যোগ বা আপডেট করুন",
"eraser": "ঝাড়ন", "eraser": "ঝাড়ন",
"hand": "" "frame": "",
"hand": "",
"extraTools": ""
}, },
"headings": { "headings": {
"canvasActions": "ক্যানভাস কার্যকলাপ", "canvasActions": "ক্যানভাস কার্যকলাপ",

View file

@ -124,6 +124,8 @@
}, },
"statusPublished": "Publicat", "statusPublished": "Publicat",
"sidebarLock": "Manté la barra lateral oberta", "sidebarLock": "Manté la barra lateral oberta",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "" "eyeDropper": ""
}, },
"library": { "library": {
@ -221,7 +223,9 @@
"penMode": "Mode de llapis - evita tocar", "penMode": "Mode de llapis - evita tocar",
"link": "Afegeix / actualitza l'enllaç per a la forma seleccionada", "link": "Afegeix / actualitza l'enllaç per a la forma seleccionada",
"eraser": "Esborrador", "eraser": "Esborrador",
"hand": "Mà (eina de desplaçament)" "frame": "",
"hand": "Mà (eina de desplaçament)",
"extraTools": ""
}, },
"headings": { "headings": {
"canvasActions": "Accions del llenç", "canvasActions": "Accions del llenç",

View file

@ -1,7 +1,7 @@
{ {
"labels": { "labels": {
"paste": "Vložit", "paste": "Vložit",
"pasteAsPlaintext": "", "pasteAsPlaintext": "Vložit jako prostý text",
"pasteCharts": "Vložit grafy", "pasteCharts": "Vložit grafy",
"selectAll": "Vybrat vše", "selectAll": "Vybrat vše",
"multiSelect": "Přidat prvek do výběru", "multiSelect": "Přidat prvek do výběru",
@ -49,9 +49,9 @@
"large": "Velké", "large": "Velké",
"veryLarge": "Velmi velké", "veryLarge": "Velmi velké",
"solid": "Plný", "solid": "Plný",
"hachure": "", "hachure": "Hachure",
"zigzag": "", "zigzag": "Klikatě",
"crossHatch": "", "crossHatch": "Křížový šrafování",
"thin": "Tenký", "thin": "Tenký",
"bold": "Tlustý", "bold": "Tlustý",
"left": "Vlevo", "left": "Vlevo",
@ -60,7 +60,7 @@
"extraBold": "Extra tlustý", "extraBold": "Extra tlustý",
"architect": "Architekt", "architect": "Architekt",
"artist": "Umělec", "artist": "Umělec",
"cartoonist": "", "cartoonist": "Kartoonista",
"fileTitle": "Název souboru", "fileTitle": "Název souboru",
"colorPicker": "Výběr barvy", "colorPicker": "Výběr barvy",
"canvasColors": "Použito na plátně", "canvasColors": "Použito na plátně",
@ -106,7 +106,7 @@
"increaseFontSize": "Zvětšit písmo", "increaseFontSize": "Zvětšit písmo",
"unbindText": "Zrušit vazbu textu", "unbindText": "Zrušit vazbu textu",
"bindText": "Vázat text s kontejnerem", "bindText": "Vázat text s kontejnerem",
"createContainerFromText": "", "createContainerFromText": "Zabalit text do kontejneru",
"link": { "link": {
"edit": "Upravit odkaz", "edit": "Upravit odkaz",
"create": "Vytvořit odkaz", "create": "Vytvořit odkaz",
@ -124,7 +124,9 @@
}, },
"statusPublished": "Zveřejněno", "statusPublished": "Zveřejněno",
"sidebarLock": "Ponechat postranní panel otevřený", "sidebarLock": "Ponechat postranní panel otevřený",
"eyeDropper": "" "selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "Vyberte barvu z plátna"
}, },
"library": { "library": {
"noItems": "Dosud neexistují žádné položky...", "noItems": "Dosud neexistují žádné položky...",
@ -177,38 +179,38 @@
"decryptFailed": "Nelze dešifrovat data.", "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.", "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?", "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", "errorAddingToLibrary": "Položku nelze přidat do knihovny",
"errorRemovingFromLibrary": "Položku nelze odstranit z knihovny", "errorRemovingFromLibrary": "Položku nelze odstranit z knihovny",
"confirmAddLibrary": "Tímto přidáte {{numShapes}} tvarů do tvé knihovny. Jste si jisti?", "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?", "imageDoesNotContainScene": "Zdá se, že tento obrázek neobsahuje žádná data o scéně. Zapnuli jste při exportu vkládání scény?",
"cannotRestoreFromImage": "", "cannotRestoreFromImage": "Scénu nelze obnovit z tohoto souboru obrázku",
"invalidSceneUrl": "", "invalidSceneUrl": "Nelze importovat scénu z zadané URL. Je buď poškozená, nebo neobsahuje platná JSON data Excalidraw.",
"resetLibrary": "", "resetLibrary": "Tímto vymažete vaši knihovnu. Jste si jisti?",
"removeItemsFromsLibrary": "", "removeItemsFromsLibrary": "Smazat {{count}} položek z knihovny?",
"invalidEncryptionKey": "", "invalidEncryptionKey": "Šifrovací klíč musí mít 22 znaků. Live spolupráce je zakázána.",
"collabOfflineWarning": "" "collabOfflineWarning": "Není k dispozici žádné internetové připojení.\nVaše změny nebudou uloženy!"
}, },
"errors": { "errors": {
"unsupportedFileType": "", "unsupportedFileType": "Nepodporovaný typ souboru.",
"imageInsertError": "", "imageInsertError": "Nelze vložit obrázek. Zkuste to později...",
"fileTooBig": "", "fileTooBig": "Soubor je příliš velký. Maximální povolená velikost je {{maxSize}}.",
"svgImageInsertError": "", "svgImageInsertError": "Nelze vložit SVG obrázek. Značení SVG je neplatné.",
"invalidSVGString": "", "invalidSVGString": "Neplatný SVG.",
"cannotResolveCollabServer": "", "cannotResolveCollabServer": "Nelze se připojit ke sdílenému serveru. Prosím obnovte stránku a zkuste to znovu.",
"importLibraryError": "", "importLibraryError": "Nelze načíst knihovnu",
"collabSaveFailed": "", "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": "", "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": { "brave_measure_text_error": {
"line1": "", "line1": "Vypadá to, že používáte Brave prohlížeč s povoleným nastavením <bold>Aggressively Block Fingerprinting</bold>.",
"line2": "", "line2": "To by mohlo vést k narušení <bold>Textových elementů</bold> ve vašich výkresech.",
"line3": "", "line3": "Důrazně doporučujeme zakázat toto nastavení. Můžete sledovat <link>tyto kroky</link> jak to udělat.",
"line4": "" "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": { "toolBar": {
"selection": "Výběr", "selection": "Výběr",
"image": "", "image": "Vložit obrázek",
"rectangle": "Obdélník", "rectangle": "Obdélník",
"diamond": "Diamant", "diamond": "Diamant",
"ellipse": "Elipsa", "ellipse": "Elipsa",
@ -216,69 +218,71 @@
"line": "Čára", "line": "Čára",
"freedraw": "Kreslení", "freedraw": "Kreslení",
"text": "Text", "text": "Text",
"library": "", "library": "Knihovna",
"lock": "", "lock": "Po kreslení ponechat vybraný nástroj aktivní",
"penMode": "", "penMode": "Režim Pera - zabránit dotyku",
"link": "", "link": "Přidat/aktualizovat odkaz pro vybraný tvar",
"eraser": "Guma", "eraser": "Guma",
"hand": "" "frame": "",
"hand": "Ruka (nástroj pro posouvání)",
"extraTools": ""
}, },
"headings": { "headings": {
"canvasActions": "", "canvasActions": "Akce plátna",
"selectedShapeActions": "", "selectedShapeActions": "Akce vybraného tvaru",
"shapes": "Tvary" "shapes": "Tvary"
}, },
"hints": { "hints": {
"canvasPanning": "", "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": "", "linearElement": "Kliknutím pro více bodů, táhnutím pro jednu čáru",
"freeDraw": "", "freeDraw": "Klikněte a táhněte, pro ukončení pusťte",
"text": "", "text": "Tip: Text můžete také přidat dvojitým kliknutím kdekoli pomocí nástroje pro výběr",
"text_selected": "", "text_selected": "Dvojklikem nebo stisknutím klávesy ENTER upravíte text",
"text_editing": "", "text_editing": "Stiskněte Escape nebo Ctrl/Cmd+ENTER pro dokončení úprav",
"linearElementMulti": "", "linearElementMulti": "Klikněte na poslední bod nebo stiskněte Escape anebo Enter pro dokončení",
"lockAngle": "", "lockAngle": "Úhel můžete omezit podržením SHIFT",
"resize": "", "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": "", "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": "", "rotate": "Úhly můžete omezit podržením SHIFT při otáčení",
"lineEditor_info": "", "lineEditor_info": "Podržte Ctrl/Cmd a dvakrát klikněte nebo stiskněte Ctrl/Cmd + Enter pro úpravu bodů",
"lineEditor_pointSelected": "", "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": "", "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": "", "placeImage": "Kliknutím umístěte obrázek, nebo klepnutím a přetažením ručně nastavíte jeho velikost",
"publishLibrary": "", "publishLibrary": "Publikovat vlastní knihovnu",
"bindTextToElement": "", "bindTextToElement": "Stiskněte Enter pro přidání textu",
"deepBoxSelect": "", "deepBoxSelect": "Podržte Ctrl/Cmd pro hluboký výběr a pro zabránění táhnutí",
"eraserRevert": "", "eraserRevert": "Podržením klávesy Alt vrátíte prvky označené pro smazání",
"firefox_clipboard_write": "" "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": { "canvasError": {
"cannotShowPreview": "", "cannotShowPreview": "Náhled nelze zobrazit",
"canvasTooBig": "", "canvasTooBig": "Plátno je možná příliš velké.",
"canvasTooBigTip": "" "canvasTooBigTip": "Tip: zkus posunout nejvzdálenější prvky trochu blíže k sobě."
}, },
"errorSplash": { "errorSplash": {
"headingMain": "", "headingMain": "Chyba. Zkuste <button>znovu načíst stránku</button>.",
"clearCanvasMessage": "", "clearCanvasMessage": "Pokud opětovné načtení nefunguje, zkuste <button>vymazat plátno</button>.",
"clearCanvasCaveat": "", "clearCanvasCaveat": " To povede ke ztrátě dat ",
"trackedToSentry": "Chyba identifikátoru {{eventId}} byl zaznamenán v našem systému.", "trackedToSentry": "Chyba identifikátoru {{eventId}} byl zaznamenán v našem systému.",
"openIssueMessage": "", "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": "" "sceneContent": "Obsah scény:"
}, },
"roomDialog": { "roomDialog": {
"desc_intro": "", "desc_intro": "Můžete pozvat lidi na vaši aktuální scénu ke spolupráci s vámi.",
"desc_privacy": "", "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": "", "button_startSession": "Zahájit relaci",
"button_stopSession": "", "button_stopSession": "Ukončit relaci",
"desc_inProgressIntro": "", "desc_inProgressIntro": "Živá spolupráce právě probíhá.",
"desc_shareLink": "", "desc_shareLink": "Sdílejte tento odkaz s každým, s kým chcete spolupracovat:",
"desc_exitSession": "", "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": "" "shareTitle": "Připojte se k aktivní spolupráci na Excalidraw"
}, },
"errorDialog": { "errorDialog": {
"title": "" "title": "Chyba"
}, },
"exportDialog": { "exportDialog": {
"disk_title": "", "disk_title": "Uložit na disk",
"disk_details": "", "disk_details": "Exportovat data scény do souboru, ze kterého můžete importovat později.",
"disk_button": "Uložit do souboru", "disk_button": "Uložit do souboru",
"link_title": "Odkaz pro sdílení", "link_title": "Odkaz pro sdílení",
"link_details": "Exportovat jako odkaz pouze pro čtení.", "link_details": "Exportovat jako odkaz pouze pro čtení.",
@ -290,18 +294,18 @@
"helpDialog": { "helpDialog": {
"blog": "Přečtěte si náš blog", "blog": "Přečtěte si náš blog",
"click": "kliknutí", "click": "kliknutí",
"deepSelect": "", "deepSelect": "Hluboký výběr",
"deepBoxSelect": "", "deepBoxSelect": "Hluboký výběr uvnitř boxu a zabránění táhnnutí",
"curvedArrow": "Zakřivená šipka", "curvedArrow": "Zakřivená šipka",
"curvedLine": "Zakřivená čára", "curvedLine": "Zakřivená čára",
"documentation": "Dokumentace", "documentation": "Dokumentace",
"doubleClick": "dvojklik", "doubleClick": "dvojklik",
"drag": "tažení", "drag": "tažení",
"editor": "Editor", "editor": "Editor",
"editLineArrowPoints": "", "editLineArrowPoints": "Upravit body linií/šipek",
"editText": "", "editText": "Upravit text / přidat popis",
"github": "", "github": "Našel jsi problém? Nahlaš ho",
"howto": "", "howto": "Sledujte naše návody",
"or": "nebo", "or": "nebo",
"preventBinding": "Zabránit vázání šipky", "preventBinding": "Zabránit vázání šipky",
"tools": "Nástroje", "tools": "Nástroje",
@ -313,8 +317,8 @@
"zoomToFit": "Přiblížit na zobrazení všech prvků", "zoomToFit": "Přiblížit na zobrazení všech prvků",
"zoomToSelection": "Přiblížit na výběr", "zoomToSelection": "Přiblížit na výběr",
"toggleElementLock": "Zamknout/odemknout výběr", "toggleElementLock": "Zamknout/odemknout výběr",
"movePageUpDown": "", "movePageUpDown": "Posunout stránku nahoru/dolů",
"movePageLeftRight": "" "movePageLeftRight": "Přesunout stránku doleva/doprava"
}, },
"clearCanvasDialog": { "clearCanvasDialog": {
"title": "Vymazat plátno" "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é.", "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>", "noteGuidelines": "Knihovna musí být nejdříve ručně schválena. Přečtěte si prosím <link>pokyny</link>",
"noteLicense": "", "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": "", "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": "", "atleastOneLibItem": "Vyberte alespoň jednu položku knihovny, kterou chcete začít",
"republishWarning": "" "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": { "publishSuccessDialog": {
"title": "Knihovna byla odeslána", "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": { "confirmDialog": {
"resetLibrary": "", "resetLibrary": "Resetovat knihovnu",
"removeItemsFromLib": "" "removeItemsFromLib": "Odstranit vybrané položky z knihovny"
}, },
"imageExportDialog": { "imageExportDialog": {
"header": "", "header": "Exportovat obrázek",
"label": { "label": {
"withBackground": "", "withBackground": "Pozadí",
"onlySelected": "", "onlySelected": "Pouze vybrané",
"darkMode": "", "darkMode": "Tmavý režim",
"embedScene": "", "embedScene": "Vložit scénu",
"scale": "", "scale": "Měřítko",
"padding": "" "padding": "Odsazení"
}, },
"tooltip": { "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": { "title": {
"exportToPng": "", "exportToPng": "Exportovat do PNG",
"exportToSvg": "", "exportToSvg": "Exportovat do SVG",
"copyPngToClipboard": "" "copyPngToClipboard": "Kopírovat PNG do schránky"
}, },
"button": { "button": {
"exportToPng": "", "exportToPng": "PNG",
"exportToSvg": "", "exportToSvg": "SVG",
"copyPngToClipboard": "" "copyPngToClipboard": "Kopírovat do schránky"
} }
}, },
"encrypted": { "encrypted": {
"tooltip": "", "tooltip": "Vaše kresby jsou end-to-end šifrované, takže servery Excalidraw je nikdy neuvidí.",
"link": "" "link": "Blog příspěvek na end-to-end šifrování v Excalidraw"
}, },
"stats": { "stats": {
"angle": "Úhel", "angle": "Úhel",
@ -402,48 +406,48 @@
"addedToLibrary": "Přidáno do knihovny", "addedToLibrary": "Přidáno do knihovny",
"copyStyles": "Styly byly zkopírovány.", "copyStyles": "Styly byly zkopírovány.",
"copyToClipboard": "Zkopírováno do schránky.", "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.", "fileSaved": "Soubor byl uložen.",
"fileSavedToFilename": "Uloženo do {filename}", "fileSavedToFilename": "Uloženo do {filename}",
"canvas": "plátno", "canvas": "plátno",
"selection": "výběr", "selection": "výběr",
"pasteAsSingleElement": "" "pasteAsSingleElement": "Pomocí {{shortcut}} vložte jako jeden prvek,\nnebo vložte do existujícího textového editoru"
}, },
"colors": { "colors": {
"transparent": "Průhledná", "transparent": "Průhledná",
"black": "", "black": "Černá",
"white": "", "white": "Bílá",
"red": "", "red": "Červená",
"pink": "", "pink": "Růžová",
"grape": "", "grape": "Vínová",
"violet": "", "violet": "Fialová",
"gray": "", "gray": "Šedá",
"blue": "", "blue": "Modrá",
"cyan": "", "cyan": "Azurová",
"teal": "", "teal": "Modrozelená",
"green": "", "green": "Zelená",
"yellow": "", "yellow": "Žlutá",
"orange": "", "orange": "Oranžová",
"bronze": "" "bronze": "Bronzová"
}, },
"welcomeScreen": { "welcomeScreen": {
"app": { "app": {
"center_heading": "", "center_heading": "Všechna vaše data jsou uložena lokálně ve vašem prohlížeči.",
"center_heading_plus": "", "center_heading_plus": "Chcete místo toho přejít na Excalidraw+?",
"menuHint": "" "menuHint": "Export, nastavení, jazyky, ..."
}, },
"defaults": { "defaults": {
"menuHint": "", "menuHint": "Export, nastavení a další...",
"center_heading": "", "center_heading": "Diagramy. Vytvořeny. Jednoduše.",
"toolbarHint": "", "toolbarHint": "Vyberte nástroj a začněte kreslit!",
"helpHint": "" "helpHint": "Zkratky a pomoc"
} }
}, },
"colorPicker": { "colorPicker": {
"mostUsedCustomColors": "", "mostUsedCustomColors": "Nejpoužívanější vlastní barvy",
"colors": "", "colors": "Barvy",
"shades": "", "shades": "Stíny",
"hexCode": "", "hexCode": "Hex kód",
"noShades": "" "noShades": "Pro tuto barvu nejsou k dispozici žádné odstíny"
} }
} }

View file

@ -124,6 +124,8 @@
}, },
"statusPublished": "", "statusPublished": "",
"sidebarLock": "", "sidebarLock": "",
"selectAllElementsInFrame": "",
"removeAllElementsFromFrame": "",
"eyeDropper": "" "eyeDropper": ""
}, },
"library": { "library": {
@ -221,7 +223,9 @@
"penMode": "", "penMode": "",
"link": "", "link": "",
"eraser": "", "eraser": "",
"hand": "" "frame": "",
"hand": "",
"extraTools": ""
}, },
"headings": { "headings": {
"canvasActions": "", "canvasActions": "",

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