mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage
This commit is contained in:
commit
81e3dd5406
348 changed files with 57065 additions and 25106 deletions
|
@ -15,6 +15,16 @@ Please add the latest change on the top under the correct section.
|
|||
|
||||
### Features
|
||||
|
||||
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
|
||||
|
||||
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
|
||||
|
||||
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||
|
||||
- Add `useHandleLibrary`'s `opts.migrationAdapter` adapter to handle library migration during init, when migrating from one data store to another (e.g. from LocalStorage to IndexedDB). [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||
|
||||
- Soft-deprecate `useHandleLibrary`'s `opts.getInitialLibraryItems` in favor of `opts.adapter`. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||
|
||||
- Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638).
|
||||
|
||||
- Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450)
|
||||
|
@ -25,8 +35,18 @@ Please add the latest change on the top under the correct section.
|
|||
|
||||
### Breaking Changes
|
||||
|
||||
- `updateScene` API has changed due to the added `Store` component as part of the multiplayer undo / redo initiative. Specifically, `sceneData` property `commitToHistory: boolean` was replaced with `storeAction: StoreActionType`. Make sure to update all instances of `updateScene` according to the _before / after_ table below. [#7898](https://github.com/excalidraw/excalidraw/pull/7898)
|
||||
|
||||
| | Before `commitToHistory` | After `storeAction` | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| _Immediately undoable_ | `true` | `"capture"` | As before, use for all updates which should be recorded by the store & history. Should be used for the most of the local updates. These updates will _immediately_ make it to the local undo / redo stacks. |
|
||||
| _Eventually undoable_ | `false` | `"none"` | Similar to before, use for all updates which should not be recorded immediately (likely exceptions which are part of some async multi-step process) or those not meant to be recorded at all (i.e. updates to `collaborators` object, parts of `AppState` which are not observed by the store & history - not `ObservedAppState`).<br/><br/>**IMPORTANT** It's likely you should switch to `"update"` in all the other cases. Otherwise, all such updates would end up being recorded with the next `"capture"` - triggered either by the next `updateScene` or internally by the editor. These updates will _eventually_ make it to the local undo / redo stacks. |
|
||||
| _Never undoable_ | n/a | `"update"` | **NEW**: previously there was no equivalent for this value. Now, it's recommended to use `"update"` for all remote updates (from the other clients), scene initialization, or those updates, which should not be locally "undoable". These updates will _never_ make it to the local undo / redo stacks. |
|
||||
|
||||
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
||||
|
||||
- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. In case of custom font usage, extend the `FONT_METRICS` object with the related properties.
|
||||
|
||||
- Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed
|
||||
|
||||
#### Bundler
|
||||
|
@ -85,8 +105,6 @@ define: {
|
|||
|
||||
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
|
||||
|
||||
---
|
||||
|
||||
## 0.17.0 (2023-11-14)
|
||||
|
||||
### Features
|
||||
|
|
|
@ -3,6 +3,7 @@ import { deepCopyElement } from "../element/newElement";
|
|||
import { randomId } from "../random";
|
||||
import { t } from "../i18n";
|
||||
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
|
@ -17,7 +18,7 @@ export const actionAddToLibrary = register({
|
|||
for (const type of LIBRARY_DISABLED_TYPES) {
|
||||
if (selectedElements.some((element) => element.type === type)) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
||||
|
@ -41,7 +42,7 @@ export const actionAddToLibrary = register({
|
|||
})
|
||||
.then(() => {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
toast: { message: t("toast.addedToLibrary") },
|
||||
|
@ -50,7 +51,7 @@ export const actionAddToLibrary = register({
|
|||
})
|
||||
.catch((error) => {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
|
@ -58,5 +59,5 @@ export const actionAddToLibrary = register({
|
|||
};
|
||||
});
|
||||
},
|
||||
contextItemLabel: "labels.addToLibrary",
|
||||
label: "labels.addToLibrary",
|
||||
});
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { alignElements, Alignment } from "../align";
|
||||
import type { Alignment } from "../align";
|
||||
import { alignElements } from "../align";
|
||||
import {
|
||||
AlignBottomIcon,
|
||||
AlignLeftIcon,
|
||||
|
@ -10,18 +11,19 @@ import {
|
|||
import { ToolButton } from "../components/ToolButton";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { StoreAction } from "../store";
|
||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const alignActionsPredicate = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
appState: UIAppState,
|
||||
_: unknown,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
|
@ -59,6 +61,8 @@ const alignSelectedElements = (
|
|||
|
||||
export const actionAlignTop = register({
|
||||
name: "alignTop",
|
||||
label: "labels.alignTop",
|
||||
icon: AlignTopIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
|
@ -68,7 +72,7 @@ export const actionAlignTop = register({
|
|||
position: "start",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -90,6 +94,8 @@ export const actionAlignTop = register({
|
|||
|
||||
export const actionAlignBottom = register({
|
||||
name: "alignBottom",
|
||||
label: "labels.alignBottom",
|
||||
icon: AlignBottomIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
|
@ -99,7 +105,7 @@ export const actionAlignBottom = register({
|
|||
position: "end",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -121,6 +127,8 @@ export const actionAlignBottom = register({
|
|||
|
||||
export const actionAlignLeft = register({
|
||||
name: "alignLeft",
|
||||
label: "labels.alignLeft",
|
||||
icon: AlignLeftIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
|
@ -130,7 +138,7 @@ export const actionAlignLeft = register({
|
|||
position: "start",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -152,6 +160,8 @@ export const actionAlignLeft = register({
|
|||
|
||||
export const actionAlignRight = register({
|
||||
name: "alignRight",
|
||||
label: "labels.alignRight",
|
||||
icon: AlignRightIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
|
@ -161,7 +171,7 @@ export const actionAlignRight = register({
|
|||
position: "end",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -183,6 +193,8 @@ export const actionAlignRight = register({
|
|||
|
||||
export const actionAlignVerticallyCentered = register({
|
||||
name: "alignVerticallyCentered",
|
||||
label: "labels.centerVertically",
|
||||
icon: CenterVerticallyIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
|
@ -192,7 +204,7 @@ export const actionAlignVerticallyCentered = register({
|
|||
position: "center",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
|
@ -210,6 +222,8 @@ export const actionAlignVerticallyCentered = register({
|
|||
|
||||
export const actionAlignHorizontallyCentered = register({
|
||||
name: "alignHorizontallyCentered",
|
||||
label: "labels.centerHorizontally",
|
||||
icon: CenterHorizontallyIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
|
@ -219,7 +233,7 @@ export const actionAlignHorizontallyCentered = register({
|
|||
position: "center",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
|
|
|
@ -23,19 +23,22 @@ import {
|
|||
isTextBindableContainer,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "../element/typeChecks";
|
||||
import {
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { Mutable } from "../utility-types";
|
||||
import type { AppState } from "../types";
|
||||
import type { Mutable } from "../utility-types";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { syncMovedIndices } from "../fractionalIndex";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionUnbindText = register({
|
||||
name: "unbindText",
|
||||
contextItemLabel: "labels.unbindText",
|
||||
label: "labels.unbindText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
@ -48,22 +51,22 @@ export const actionUnbindText = register({
|
|||
selectedElements.forEach((element) => {
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement) {
|
||||
const { width, height, baseline } = measureTextElement(
|
||||
boundTextElement,
|
||||
{
|
||||
text: boundTextElement.originalText,
|
||||
},
|
||||
);
|
||||
const { width, height } = measureTextElement(boundTextElement, {
|
||||
text: boundTextElement.originalText,
|
||||
});
|
||||
const originalContainerHeight = getOriginalContainerHeightFromCache(
|
||||
element.id,
|
||||
);
|
||||
resetOriginalContainerCache(element.id);
|
||||
const { x, y } = computeBoundTextPosition(element, boundTextElement);
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
element,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||
containerId: null,
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
text: boundTextElement.originalText,
|
||||
x,
|
||||
y,
|
||||
|
@ -81,14 +84,14 @@ export const actionUnbindText = register({
|
|||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const actionBindText = register({
|
||||
name: "bindText",
|
||||
contextItemLabel: "labels.bindText",
|
||||
label: "labels.bindText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
@ -145,7 +148,11 @@ export const actionBindText = register({
|
|||
}),
|
||||
});
|
||||
const originalContainerHeight = container.height;
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
redrawTextBoundingBox(
|
||||
textElement,
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
// overwritting the cache with original container height so
|
||||
// it can be restored when unbind
|
||||
updateOriginalContainerCache(container.id, originalContainerHeight);
|
||||
|
@ -153,7 +160,7 @@ export const actionBindText = register({
|
|||
return {
|
||||
elements: pushTextAboveContainer(elements, container, textElement),
|
||||
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
@ -173,6 +180,8 @@ const pushTextAboveContainer = (
|
|||
(ele) => ele.id === container.id,
|
||||
);
|
||||
updatedElements.splice(containerIndex + 1, 0, textElement);
|
||||
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
|
||||
|
||||
return updatedElements;
|
||||
};
|
||||
|
||||
|
@ -191,12 +200,14 @@ const pushContainerBelowText = (
|
|||
(ele) => ele.id === textElement.id,
|
||||
);
|
||||
updatedElements.splice(textElementIndex, 0, container);
|
||||
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
|
||||
|
||||
return updatedElements;
|
||||
};
|
||||
|
||||
export const actionWrapTextInContainer = register({
|
||||
name: "wrapTextInContainer",
|
||||
contextItemLabel: "labels.createContainerFromText",
|
||||
label: "labels.createContainerFromText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
@ -286,13 +297,18 @@ export const actionWrapTextInContainer = register({
|
|||
},
|
||||
false,
|
||||
);
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
redrawTextBoundingBox(
|
||||
textElement,
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
updatedElements = pushContainerBelowText(
|
||||
[...updatedElements, container],
|
||||
container,
|
||||
textElement,
|
||||
);
|
||||
|
||||
containerIds[container.id] = true;
|
||||
}
|
||||
}
|
||||
|
@ -303,7 +319,7 @@ export const actionWrapTextInContainer = register({
|
|||
...appState,
|
||||
selectedElementIds: containerIds,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,15 +1,30 @@
|
|||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||
import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
|
||||
import {
|
||||
handIcon,
|
||||
MoonIcon,
|
||||
SunIcon,
|
||||
TrashIcon,
|
||||
zoomAreaIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
ZoomResetIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
|
||||
import {
|
||||
CURSOR_TYPE,
|
||||
MAX_ZOOM,
|
||||
MIN_ZOOM,
|
||||
THEME,
|
||||
ZOOM_STEP,
|
||||
} from "../constants";
|
||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import type { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
|
@ -20,11 +35,14 @@ import {
|
|||
isHandToolActive,
|
||||
} from "../appState";
|
||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||
import { SceneBounds } from "../element/bounds";
|
||||
import type { SceneBounds } from "../element/bounds";
|
||||
import { setCursor } from "../cursor";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
label: "labels.canvasBackground",
|
||||
paletteName: "Change canvas background color",
|
||||
trackEvent: false,
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
|
@ -35,7 +53,9 @@ export const actionChangeViewBackgroundColor = register({
|
|||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, ...value },
|
||||
commitToHistory: !!value.viewBackgroundColor,
|
||||
storeAction: !!value.viewBackgroundColor
|
||||
? StoreAction.CAPTURE
|
||||
: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
||||
|
@ -59,6 +79,9 @@ export const actionChangeViewBackgroundColor = register({
|
|||
|
||||
export const actionClearCanvas = register({
|
||||
name: "clearCanvas",
|
||||
label: "labels.clearCanvas",
|
||||
paletteName: "Clear canvas",
|
||||
icon: TrashIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
|
@ -88,14 +111,16 @@ export const actionClearCanvas = register({
|
|||
? { ...appState.activeTool, type: "selection" }
|
||||
: appState.activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const actionZoomIn = register({
|
||||
name: "zoomIn",
|
||||
label: "buttons.zoomIn",
|
||||
viewMode: true,
|
||||
icon: ZoomInIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
|
@ -111,16 +136,17 @@ export const actionZoomIn = register({
|
|||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
className="zoom-in-button zoom-button"
|
||||
icon={ZoomInIcon}
|
||||
title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
|
||||
aria-label={t("buttons.zoomIn")}
|
||||
disabled={appState.zoom.value >= MAX_ZOOM}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
|
@ -133,6 +159,8 @@ export const actionZoomIn = register({
|
|||
|
||||
export const actionZoomOut = register({
|
||||
name: "zoomOut",
|
||||
label: "buttons.zoomOut",
|
||||
icon: ZoomOutIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
|
@ -149,16 +177,17 @@ export const actionZoomOut = register({
|
|||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
className="zoom-out-button zoom-button"
|
||||
icon={ZoomOutIcon}
|
||||
title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
|
||||
aria-label={t("buttons.zoomOut")}
|
||||
disabled={appState.zoom.value <= MIN_ZOOM}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
|
@ -171,6 +200,8 @@ export const actionZoomOut = register({
|
|||
|
||||
export const actionResetZoom = register({
|
||||
name: "resetZoom",
|
||||
label: "buttons.resetZoom",
|
||||
icon: ZoomResetIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
|
@ -187,7 +218,7 @@ export const actionResetZoom = register({
|
|||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
|
@ -262,8 +293,8 @@ export const zoomToFitBounds = ({
|
|||
|
||||
// Apply clamping to newZoomValue to be between 10% and 3000%
|
||||
newZoomValue = Math.min(
|
||||
Math.max(newZoomValue, 0.1),
|
||||
30.0,
|
||||
Math.max(newZoomValue, MIN_ZOOM),
|
||||
MAX_ZOOM,
|
||||
) as NormalizedZoomValue;
|
||||
|
||||
scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
|
||||
|
@ -294,7 +325,7 @@ export const zoomToFitBounds = ({
|
|||
scrollY,
|
||||
zoom: { value: newZoomValue },
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -326,6 +357,8 @@ export const zoomToFit = ({
|
|||
// size, it won't be zoomed in.
|
||||
export const actionZoomToFitSelectionInViewport = register({
|
||||
name: "zoomToFitSelectionInViewport",
|
||||
label: "labels.zoomToFitViewport",
|
||||
icon: zoomAreaIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
@ -349,6 +382,8 @@ export const actionZoomToFitSelectionInViewport = register({
|
|||
|
||||
export const actionZoomToFitSelection = register({
|
||||
name: "zoomToFitSelection",
|
||||
label: "helpDialog.zoomToSelection",
|
||||
icon: zoomAreaIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
@ -371,6 +406,8 @@ export const actionZoomToFitSelection = register({
|
|||
|
||||
export const actionZoomToFit = register({
|
||||
name: "zoomToFit",
|
||||
label: "helpDialog.zoomToFit",
|
||||
icon: zoomAreaIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) =>
|
||||
|
@ -391,6 +428,13 @@ export const actionZoomToFit = register({
|
|||
|
||||
export const actionToggleTheme = register({
|
||||
name: "toggleTheme",
|
||||
label: (_, appState) => {
|
||||
return appState.theme === THEME.DARK
|
||||
? "buttons.lightMode"
|
||||
: "buttons.darkMode";
|
||||
},
|
||||
keywords: ["toggle", "dark", "light", "mode", "theme"],
|
||||
icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_, appState, value) => {
|
||||
|
@ -400,7 +444,7 @@ export const actionToggleTheme = register({
|
|||
theme:
|
||||
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||
|
@ -411,6 +455,7 @@ export const actionToggleTheme = register({
|
|||
|
||||
export const actionToggleEraserTool = register({
|
||||
name: "toggleEraserTool",
|
||||
label: "toolBar.eraser",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
@ -437,7 +482,7 @@ export const actionToggleEraserTool = register({
|
|||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.E,
|
||||
|
@ -445,7 +490,11 @@ export const actionToggleEraserTool = register({
|
|||
|
||||
export const actionToggleHandTool = register({
|
||||
name: "toggleHandTool",
|
||||
label: "toolBar.hand",
|
||||
paletteName: "Toggle hand tool",
|
||||
trackEvent: { category: "toolbar" },
|
||||
icon: handIcon,
|
||||
viewMode: false,
|
||||
perform: (elements, appState, _, app) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
|
@ -472,7 +521,7 @@ export const actionToggleHandTool = register({
|
|||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
|
|
@ -13,9 +13,13 @@ import { exportCanvas, prepareElementsForExport } from "../data/index";
|
|||
import { isTextElement } from "../element";
|
||||
import { t } from "../i18n";
|
||||
import { isFirefox } from "../constants";
|
||||
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionCopy = register({
|
||||
name: "copy",
|
||||
label: "labels.copy",
|
||||
icon: DuplicateIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
|
||||
const elementsToCopy = app.scene.getSelectedElements({
|
||||
|
@ -28,7 +32,7 @@ export const actionCopy = register({
|
|||
await copyToClipboard(elementsToCopy, app.files, event);
|
||||
} catch (error: any) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
|
@ -37,16 +41,16 @@ export const actionCopy = register({
|
|||
}
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.copy",
|
||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||
keyTest: undefined,
|
||||
});
|
||||
|
||||
export const actionPaste = register({
|
||||
name: "paste",
|
||||
label: "labels.paste",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, data, app) => {
|
||||
let types;
|
||||
|
@ -63,7 +67,7 @@ export const actionPaste = register({
|
|||
|
||||
if (isFirefox) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("hints.firefox_clipboard_write"),
|
||||
|
@ -72,7 +76,7 @@ export const actionPaste = register({
|
|||
}
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("errors.asyncPasteFailedOnRead"),
|
||||
|
@ -85,7 +89,7 @@ export const actionPaste = register({
|
|||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("errors.asyncPasteFailedOnParse"),
|
||||
|
@ -94,32 +98,34 @@ export const actionPaste = register({
|
|||
}
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.paste",
|
||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||
keyTest: undefined,
|
||||
});
|
||||
|
||||
export const actionCut = register({
|
||||
name: "cut",
|
||||
label: "labels.cut",
|
||||
icon: cutIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, event: ClipboardEvent | null, app) => {
|
||||
actionCopy.perform(elements, appState, event, app);
|
||||
return actionDeleteSelected.perform(elements, appState);
|
||||
return actionDeleteSelected.perform(elements, appState, null, app);
|
||||
},
|
||||
contextItemLabel: "labels.cut",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
|
||||
});
|
||||
|
||||
export const actionCopyAsSvg = register({
|
||||
name: "copyAsSvg",
|
||||
label: "labels.copyAsSvg",
|
||||
icon: svgIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -138,10 +144,11 @@ export const actionCopyAsSvg = register({
|
|||
{
|
||||
...appState,
|
||||
exportingFrame,
|
||||
name: app.getName(),
|
||||
},
|
||||
);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
@ -150,23 +157,25 @@ export const actionCopyAsSvg = register({
|
|||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
}
|
||||
},
|
||||
predicate: (elements) => {
|
||||
return probablySupportsClipboardWriteText && elements.length > 0;
|
||||
},
|
||||
contextItemLabel: "labels.copyAsSvg",
|
||||
keywords: ["svg", "clipboard", "copy"],
|
||||
});
|
||||
|
||||
export const actionCopyAsPng = register({
|
||||
name: "copyAsPng",
|
||||
label: "labels.copyAsPng",
|
||||
icon: pngIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
}
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
|
@ -184,6 +193,7 @@ export const actionCopyAsPng = register({
|
|||
await exportCanvas("clipboard", exportedElements, appState, app.files, {
|
||||
...appState,
|
||||
exportingFrame,
|
||||
name: app.getName(),
|
||||
});
|
||||
return {
|
||||
appState: {
|
||||
|
@ -199,7 +209,7 @@ export const actionCopyAsPng = register({
|
|||
}),
|
||||
},
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
@ -208,19 +218,20 @@ export const actionCopyAsPng = register({
|
|||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
}
|
||||
},
|
||||
predicate: (elements) => {
|
||||
return probablySupportsClipboardBlob && elements.length > 0;
|
||||
},
|
||||
contextItemLabel: "labels.copyAsPng",
|
||||
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
||||
keywords: ["png", "clipboard", "copy"],
|
||||
});
|
||||
|
||||
export const copyText = register({
|
||||
name: "copyText",
|
||||
label: "labels.copyText",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
|
@ -236,9 +247,13 @@ export const copyText = register({
|
|||
return acc;
|
||||
}, [])
|
||||
.join("\n\n");
|
||||
copyTextToSystemClipboard(text);
|
||||
try {
|
||||
copyTextToSystemClipboard(text);
|
||||
} catch (e) {
|
||||
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
||||
}
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
predicate: (elements, appState, _, app) => {
|
||||
|
@ -252,5 +267,5 @@ export const copyText = register({
|
|||
.some(isTextElement)
|
||||
);
|
||||
},
|
||||
contextItemLabel: "labels.copyText",
|
||||
keywords: ["text", "clipboard", "copy"],
|
||||
});
|
||||
|
|
|
@ -4,8 +4,8 @@ import { ToolButton } from "../components/ToolButton";
|
|||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import type { AppState } from "../types";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { getElementsInGroup } from "../groups";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
|
@ -13,6 +13,7 @@ import { fixBindingsAfterDeletion } from "../element/binding";
|
|||
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { TrashIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
const deleteSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
|
@ -72,8 +73,10 @@ const handleGroupEditingState = (
|
|||
|
||||
export const actionDeleteSelected = register({
|
||||
name: "deleteSelectedElements",
|
||||
label: "labels.delete",
|
||||
icon: TrashIcon,
|
||||
trackEvent: { category: "element", action: "delete" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, formData, app) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const {
|
||||
elementId,
|
||||
|
@ -81,7 +84,8 @@ export const actionDeleteSelected = register({
|
|||
startBindingElement,
|
||||
endBindingElement,
|
||||
} = appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
|
@ -109,7 +113,7 @@ export const actionDeleteSelected = register({
|
|||
...nextAppState,
|
||||
editingLinearElement: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -141,7 +145,7 @@ export const actionDeleteSelected = register({
|
|||
: [0],
|
||||
},
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
let { elements: nextElements, appState: nextAppState } =
|
||||
|
@ -161,13 +165,14 @@ export const actionDeleteSelected = register({
|
|||
multiElement: null,
|
||||
activeEmbeddable: null,
|
||||
},
|
||||
commitToHistory: isSomeElementSelected(
|
||||
storeAction: isSomeElementSelected(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
),
|
||||
)
|
||||
? StoreAction.CAPTURE
|
||||
: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.delete",
|
||||
keyTest: (event, appState, elements) =>
|
||||
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
|
||||
!event[KEYS.CTRL_OR_CMD],
|
||||
|
|
|
@ -3,15 +3,17 @@ import {
|
|||
DistributeVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { distributeElements, Distribution } from "../distribute";
|
||||
import type { Distribution } from "../distribute";
|
||||
import { distributeElements } from "../distribute";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { t } from "../i18n";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { StoreAction } from "../store";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -49,6 +51,7 @@ const distributeSelectedElements = (
|
|||
|
||||
export const distributeHorizontally = register({
|
||||
name: "distributeHorizontally",
|
||||
label: "labels.distributeHorizontally",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
|
@ -57,7 +60,7 @@ export const distributeHorizontally = register({
|
|||
space: "between",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -79,6 +82,7 @@ export const distributeHorizontally = register({
|
|||
|
||||
export const distributeVertically = register({
|
||||
name: "distributeVertically",
|
||||
label: "labels.distributeVertically",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
|
@ -87,7 +91,7 @@ export const distributeVertically = register({
|
|||
space: "between",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { duplicateElement, getNonDeletedElements } from "../element";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
|
@ -12,9 +12,9 @@ import {
|
|||
getSelectedGroupForElement,
|
||||
getElementsInGroup,
|
||||
} from "../groups";
|
||||
import { AppState } from "../types";
|
||||
import type { AppState } from "../types";
|
||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||
import { ActionResult } from "./types";
|
||||
import type { ActionResult } from "./types";
|
||||
import { GRID_SIZE } from "../constants";
|
||||
import {
|
||||
bindTextToShapeAfterDuplication,
|
||||
|
@ -31,14 +31,22 @@ import {
|
|||
excludeElementsInFramesFromSelection,
|
||||
getSelectedElements,
|
||||
} from "../scene/selection";
|
||||
import { syncMovedIndices } from "../fractionalIndex";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionDuplicateSelection = register({
|
||||
name: "duplicateSelection",
|
||||
label: "labels.duplicateSelection",
|
||||
icon: DuplicateIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, formData, app) => {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
// duplicate selected point(s) if editing a line
|
||||
if (appState.editingLinearElement) {
|
||||
const ret = LinearElementEditor.duplicateSelectedPoints(appState);
|
||||
const ret = LinearElementEditor.duplicateSelectedPoints(
|
||||
appState,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (!ret) {
|
||||
return false;
|
||||
|
@ -47,16 +55,15 @@ export const actionDuplicateSelection = register({
|
|||
return {
|
||||
elements,
|
||||
appState: ret.appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...duplicateElements(elements, appState),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.duplicateSelection",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
|
@ -85,6 +92,7 @@ const duplicateElements = (
|
|||
const newElements: ExcalidrawElement[] = [];
|
||||
const oldElements: ExcalidrawElement[] = [];
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
||||
|
||||
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
|
||||
const newElement = duplicateElement(
|
||||
|
@ -96,6 +104,7 @@ const duplicateElements = (
|
|||
y: element.y + GRID_SIZE / 2,
|
||||
},
|
||||
);
|
||||
duplicatedElementsMap.set(newElement.id, newElement);
|
||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||
oldElements.push(element);
|
||||
newElements.push(newElement);
|
||||
|
@ -233,8 +242,10 @@ const duplicateElements = (
|
|||
}
|
||||
|
||||
// step (3)
|
||||
|
||||
const finalElements = finalElementsReversed.reverse();
|
||||
const finalElements = syncMovedIndices(
|
||||
finalElementsReversed.reverse(),
|
||||
arrayToMap(newElements),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import { LockedIcon, UnlockedIcon } from "../components/icons";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { StoreAction } from "../store";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -10,11 +13,31 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
|||
|
||||
export const actionToggleElementLock = register({
|
||||
name: "toggleElementLock",
|
||||
label: (elements, appState, app) => {
|
||||
const selected = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: false,
|
||||
});
|
||||
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
|
||||
return selected[0].locked
|
||||
? "labels.elementLock.unlock"
|
||||
: "labels.elementLock.lock";
|
||||
}
|
||||
|
||||
return shouldLock(selected)
|
||||
? "labels.elementLock.lockAll"
|
||||
: "labels.elementLock.unlockAll";
|
||||
},
|
||||
icon: (appState, elements) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return shouldLock(selectedElements) ? LockedIcon : UnlockedIcon;
|
||||
},
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return !selectedElements.some(
|
||||
(element) => element.locked && element.frameId,
|
||||
return (
|
||||
selectedElements.length > 0 &&
|
||||
!selectedElements.some((element) => element.locked && element.frameId)
|
||||
);
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
|
@ -44,24 +67,9 @@ export const actionToggleElementLock = register({
|
|||
? null
|
||||
: appState.selectedLinearElement,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: (elements, appState, app) => {
|
||||
const selected = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: false,
|
||||
});
|
||||
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
|
||||
return selected[0].locked
|
||||
? "labels.elementLock.unlock"
|
||||
: "labels.elementLock.lock";
|
||||
}
|
||||
|
||||
return shouldLock(selected)
|
||||
? "labels.elementLock.lockAll"
|
||||
: "labels.elementLock.unlockAll";
|
||||
},
|
||||
keyTest: (event, appState, elements, app) => {
|
||||
return (
|
||||
event.key.toLocaleLowerCase() === KEYS.L &&
|
||||
|
@ -77,10 +85,16 @@ export const actionToggleElementLock = register({
|
|||
|
||||
export const actionUnlockAllElements = register({
|
||||
name: "unlockAllElements",
|
||||
paletteName: "Unlock all elements",
|
||||
trackEvent: { category: "canvas" },
|
||||
viewMode: false,
|
||||
predicate: (elements) => {
|
||||
return elements.some((element) => element.locked);
|
||||
icon: UnlockedIcon,
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return (
|
||||
selectedElements.length === 0 &&
|
||||
elements.some((element) => element.locked)
|
||||
);
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const lockedElements = elements.filter((el) => el.locked);
|
||||
|
@ -98,8 +112,8 @@ export const actionUnlockAllElements = register({
|
|||
lockedElements.map((el) => [el.id, true]),
|
||||
),
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.elementLock.unlockAll",
|
||||
label: "labels.elementLock.unlockAll",
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { questionCircle, saveAs } from "../components/icons";
|
||||
import { ExportIcon, questionCircle, saveAs } from "../components/icons";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
|
@ -16,24 +16,26 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
|
|||
import { getNonDeletedElements } from "../element";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { Theme } from "../element/types";
|
||||
import type { Theme } from "../element/types";
|
||||
|
||||
import "../components/ToolIcon.scss";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
label: "labels.fileTitle",
|
||||
trackEvent: false,
|
||||
perform: (_elements, appState, value) => {
|
||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||
return {
|
||||
appState: { ...appState, name: value },
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, appProps, data }) => (
|
||||
PanelComponent: ({ appState, updateData, appProps, data, app }) => (
|
||||
<ProjectName
|
||||
label={t("labels.fileTitle")}
|
||||
value={appState.name || "Unnamed"}
|
||||
value={app.getName()}
|
||||
onChange={(name: string) => updateData(name)}
|
||||
isNameEditable={
|
||||
typeof appProps.name === "undefined" && !appState.viewModeEnabled
|
||||
}
|
||||
ignoreFocus={data?.ignoreFocus ?? false}
|
||||
/>
|
||||
),
|
||||
|
@ -41,11 +43,12 @@ export const actionChangeProjectName = register({
|
|||
|
||||
export const actionChangeExportScale = register({
|
||||
name: "changeExportScale",
|
||||
label: "imageExportDialog.scale",
|
||||
trackEvent: { category: "export", action: "scale" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportScale: value },
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements: allElements, appState, updateData }) => {
|
||||
|
@ -90,11 +93,12 @@ export const actionChangeExportScale = register({
|
|||
|
||||
export const actionChangeExportBackground = register({
|
||||
name: "changeExportBackground",
|
||||
label: "imageExportDialog.label.withBackground",
|
||||
trackEvent: { category: "export", action: "toggleBackground" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportBackground: value },
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
|
@ -109,11 +113,12 @@ export const actionChangeExportBackground = register({
|
|||
|
||||
export const actionChangeExportEmbedScene = register({
|
||||
name: "changeExportEmbedScene",
|
||||
label: "imageExportDialog.tooltip.embedScene",
|
||||
trackEvent: { category: "export", action: "embedScene" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportEmbedScene: value },
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
|
@ -131,6 +136,8 @@ export const actionChangeExportEmbedScene = register({
|
|||
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
label: "buttons.save",
|
||||
icon: ExportIcon,
|
||||
trackEvent: { category: "export" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
|
@ -144,11 +151,16 @@ export const actionSaveToActiveFile = register({
|
|||
|
||||
try {
|
||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||
? await resaveAsImageWithScene(elements, appState, app.files)
|
||||
: await saveAsJSON(elements, appState, app.files);
|
||||
? await resaveAsImageWithScene(
|
||||
elements,
|
||||
appState,
|
||||
app.files,
|
||||
app.getName(),
|
||||
)
|
||||
: await saveAsJSON(elements, appState, app.files, app.getName());
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
fileHandle,
|
||||
|
@ -170,7 +182,7 @@ export const actionSaveToActiveFile = register({
|
|||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { commitToHistory: false };
|
||||
return { storeAction: StoreAction.NONE };
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -179,6 +191,8 @@ export const actionSaveToActiveFile = register({
|
|||
|
||||
export const actionSaveFileToDisk = register({
|
||||
name: "saveFileToDisk",
|
||||
label: "exportDialog.disk_title",
|
||||
icon: ExportIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, value, app) => {
|
||||
|
@ -190,9 +204,10 @@ export const actionSaveFileToDisk = register({
|
|||
fileHandle: null,
|
||||
},
|
||||
app.files,
|
||||
app.getName(),
|
||||
);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
appState: {
|
||||
...appState,
|
||||
openDialog: null,
|
||||
|
@ -206,7 +221,7 @@ export const actionSaveFileToDisk = register({
|
|||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { commitToHistory: false };
|
||||
return { storeAction: StoreAction.NONE };
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
@ -227,6 +242,7 @@ export const actionSaveFileToDisk = register({
|
|||
|
||||
export const actionLoadScene = register({
|
||||
name: "loadScene",
|
||||
label: "buttons.load",
|
||||
trackEvent: { category: "export" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
|
@ -244,7 +260,7 @@ export const actionLoadScene = register({
|
|||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
files,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
|
@ -255,7 +271,7 @@ export const actionLoadScene = register({
|
|||
elements,
|
||||
appState: { ...appState, errorMessage: error.message },
|
||||
files: app.files,
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
@ -264,11 +280,12 @@ export const actionLoadScene = register({
|
|||
|
||||
export const actionExportWithDarkMode = register({
|
||||
name: "exportWithDarkMode",
|
||||
label: "imageExportDialog.label.darkMode",
|
||||
trackEvent: { category: "export", action: "toggleTheme" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportWithDarkMode: value },
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { KEYS } from "../keys";
|
||||
import { isInvisiblySmallElement } from "../element";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { arrayToMap, updateActiveTool } from "../utils";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { done } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
|
@ -8,28 +8,28 @@ import { register } from "./register";
|
|||
import { mutateElement } from "../element/mutateElement";
|
||||
import { isPathALoop } from "../math";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
maybeBindLinearElement,
|
||||
bindOrUnbindLinearElement,
|
||||
} from "../element/binding";
|
||||
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||
import { AppState } from "../types";
|
||||
import type { AppState } from "../types";
|
||||
import { resetCursor } from "../cursor";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
label: "",
|
||||
trackEvent: false,
|
||||
perform: (
|
||||
elements,
|
||||
appState,
|
||||
_,
|
||||
{ interactiveCanvas, focusContainer, scene },
|
||||
) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
const { interactiveCanvas, focusContainer, scene } = app;
|
||||
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
if (appState.editingLinearElement) {
|
||||
const { elementId, startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
|
||||
if (element) {
|
||||
if (isBindingElement(element)) {
|
||||
|
@ -37,6 +37,7 @@ export const actionFinalize = register({
|
|||
element,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
return {
|
||||
|
@ -48,8 +49,9 @@ export const actionFinalize = register({
|
|||
...appState,
|
||||
cursorButton: "up",
|
||||
editingLinearElement: null,
|
||||
selectedLinearElement: null,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -90,7 +92,9 @@ export const actionFinalize = register({
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isInvisiblySmallElement(multiPointElement)) {
|
||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||
newElements = newElements.filter(
|
||||
(el) => el.id !== multiPointElement.id,
|
||||
);
|
||||
|
@ -125,13 +129,9 @@ export const actionFinalize = register({
|
|||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
multiPointElement,
|
||||
-1,
|
||||
arrayToMap(elements),
|
||||
);
|
||||
maybeBindLinearElement(
|
||||
multiPointElement,
|
||||
appState,
|
||||
Scene.getScene(multiPointElement)!,
|
||||
{ x, y },
|
||||
);
|
||||
maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -186,11 +186,12 @@ export const actionFinalize = register({
|
|||
// To select the linear element when user has finished mutipoint editing
|
||||
selectedLinearElement:
|
||||
multiPointElement && isLinearElement(multiPointElement)
|
||||
? new LinearElementEditor(multiPointElement, scene)
|
||||
? new LinearElementEditor(multiPointElement)
|
||||
: appState.selectedLinearElement,
|
||||
pendingImageElementId: null,
|
||||
},
|
||||
commitToHistory: appState.activeTool.type === "freedraw",
|
||||
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
|
|
|
@ -1,26 +1,29 @@
|
|||
import { register } from "./register";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import {
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeleted,
|
||||
NonDeletedElementsMap,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../element/types";
|
||||
import { resizeMultipleElements } from "../element/resizeElements";
|
||||
import { AppState } from "../types";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getCommonBoundingBox } from "../element/bounds";
|
||||
import {
|
||||
bindOrUnbindSelectedElements,
|
||||
bindOrUnbindLinearElements,
|
||||
isBindingEnabled,
|
||||
unbindLinearElements,
|
||||
} from "../element/binding";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
|
||||
export const actionFlipHorizontal = register({
|
||||
name: "flipHorizontal",
|
||||
label: "labels.flipHorizontal",
|
||||
icon: flipHorizontal,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
|
@ -30,20 +33,22 @@ export const actionFlipHorizontal = register({
|
|||
app.scene.getNonDeletedElementsMap(),
|
||||
appState,
|
||||
"horizontal",
|
||||
app,
|
||||
),
|
||||
appState,
|
||||
app,
|
||||
),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.shiftKey && event.code === CODES.H,
|
||||
contextItemLabel: "labels.flipHorizontal",
|
||||
});
|
||||
|
||||
export const actionFlipVertical = register({
|
||||
name: "flipVertical",
|
||||
label: "labels.flipVertical",
|
||||
icon: flipVertical,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
|
@ -53,24 +58,25 @@ export const actionFlipVertical = register({
|
|||
app.scene.getNonDeletedElementsMap(),
|
||||
appState,
|
||||
"vertical",
|
||||
app,
|
||||
),
|
||||
appState,
|
||||
app,
|
||||
),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
|
||||
contextItemLabel: "labels.flipVertical",
|
||||
});
|
||||
|
||||
const flipSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
appState: Readonly<AppState>,
|
||||
flipDirection: "horizontal" | "vertical",
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
|
@ -86,6 +92,7 @@ const flipSelectedElements = (
|
|||
elementsMap,
|
||||
appState,
|
||||
flipDirection,
|
||||
app,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
@ -97,9 +104,10 @@ const flipSelectedElements = (
|
|||
|
||||
const flipElements = (
|
||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
appState: AppState,
|
||||
flipDirection: "horizontal" | "vertical",
|
||||
app: AppClassProperties,
|
||||
): ExcalidrawElement[] => {
|
||||
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
|
||||
|
||||
|
@ -109,13 +117,17 @@ const flipElements = (
|
|||
elementsMap,
|
||||
"nw",
|
||||
true,
|
||||
true,
|
||||
flipDirection === "horizontal" ? maxX : minX,
|
||||
flipDirection === "horizontal" ? minY : maxY,
|
||||
);
|
||||
|
||||
(isBindingEnabled(appState)
|
||||
? bindOrUnbindSelectedElements
|
||||
: unbindLinearElements)(selectedElements);
|
||||
bindOrUnbindLinearElements(
|
||||
selectedElements.filter(isLinearElement),
|
||||
app,
|
||||
isBindingEnabled(appState),
|
||||
[],
|
||||
);
|
||||
|
||||
return selectedElements;
|
||||
};
|
||||
|
|
|
@ -1,15 +1,20 @@
|
|||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { removeAllElementsFromFrame } from "../frame";
|
||||
import { getFrameChildren } from "../frame";
|
||||
import { KEYS } from "../keys";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { register } from "./register";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { frameToolIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
|
||||
const isSingleFrameSelected = (
|
||||
appState: UIAppState,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
return (
|
||||
|
@ -19,6 +24,7 @@ const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
|
|||
|
||||
export const actionSelectAllElementsInFrame = register({
|
||||
name: "selectAllElementsInFrame",
|
||||
label: "labels.selectAllElementsInFrame",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElement =
|
||||
|
@ -39,23 +45,23 @@ export const actionSelectAllElementsInFrame = register({
|
|||
return acc;
|
||||
}, {} as Record<ExcalidrawElement["id"], true>),
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.selectAllElementsInFrame",
|
||||
predicate: (elements, appState, _, app) =>
|
||||
isSingleFrameSelected(appState, app),
|
||||
});
|
||||
|
||||
export const actionRemoveAllElementsFromFrame = register({
|
||||
name: "removeAllElementsFromFrame",
|
||||
label: "labels.removeAllElementsFromFrame",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElement =
|
||||
|
@ -70,23 +76,23 @@ export const actionRemoveAllElementsFromFrame = register({
|
|||
[selectedElement.id]: true,
|
||||
},
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.removeAllElementsFromFrame",
|
||||
predicate: (elements, appState, _, app) =>
|
||||
isSingleFrameSelected(appState, app),
|
||||
});
|
||||
|
||||
export const actionupdateFrameRendering = register({
|
||||
name: "updateFrameRendering",
|
||||
label: "labels.updateFrameRendering",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => {
|
||||
|
@ -99,16 +105,18 @@ export const actionupdateFrameRendering = register({
|
|||
enabled: !appState.frameRendering.enabled,
|
||||
},
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.updateFrameRendering",
|
||||
checked: (appState: AppState) => appState.frameRendering.enabled,
|
||||
});
|
||||
|
||||
export const actionSetFrameAsActiveTool = register({
|
||||
name: "setFrameAsActiveTool",
|
||||
label: "toolBar.frame",
|
||||
trackEvent: { category: "toolbar" },
|
||||
icon: frameToolIcon,
|
||||
viewMode: false,
|
||||
perform: (elements, appState, _, app) => {
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
|
@ -127,7 +135,7 @@ export const actionSetFrameAsActiveTool = register({
|
|||
type: "frame",
|
||||
}),
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
|
|
|
@ -17,8 +17,12 @@ import {
|
|||
import { getNonDeletedElements } from "../element";
|
||||
import { randomId } from "../random";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
OrderedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import {
|
||||
getElementsInResizingFrame,
|
||||
|
@ -27,6 +31,8 @@ import {
|
|||
removeElementsFromFrame,
|
||||
replaceAllElementsInFrame,
|
||||
} from "../frame";
|
||||
import { syncMovedIndices } from "../fractionalIndex";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||
if (elements.length >= 2) {
|
||||
|
@ -61,6 +67,8 @@ const enableActionGroup = (
|
|||
|
||||
export const actionGroup = register({
|
||||
name: "group",
|
||||
label: "labels.group",
|
||||
icon: (appState) => <GroupIcon theme={appState.theme} />,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
|
@ -69,7 +77,7 @@ export const actionGroup = register({
|
|||
});
|
||||
if (selectedElements.length < 2) {
|
||||
// nothing to group
|
||||
return { appState, elements, commitToHistory: false };
|
||||
return { appState, elements, storeAction: StoreAction.NONE };
|
||||
}
|
||||
// if everything is already grouped into 1 group, there is nothing to do
|
||||
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||
|
@ -89,7 +97,7 @@ export const actionGroup = register({
|
|||
]);
|
||||
if (combinedSet.size === elementIdsInGroup.size) {
|
||||
// no incremental ids in the selected ids
|
||||
return { appState, elements, commitToHistory: false };
|
||||
return { appState, elements, storeAction: StoreAction.NONE };
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -131,18 +139,19 @@ export const actionGroup = register({
|
|||
// to the z order of the highest element in the layer stack
|
||||
const elementsInGroup = getElementsInGroup(nextElements, newGroupId);
|
||||
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
||||
const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup);
|
||||
const lastGroupElementIndex = nextElements.lastIndexOf(
|
||||
lastElementInGroup as OrderedExcalidrawElement,
|
||||
);
|
||||
const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
|
||||
const elementsBeforeGroup = nextElements
|
||||
.slice(0, lastGroupElementIndex)
|
||||
.filter(
|
||||
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
|
||||
);
|
||||
nextElements = [
|
||||
...elementsBeforeGroup,
|
||||
...elementsInGroup,
|
||||
...elementsAfterGroup,
|
||||
];
|
||||
const reorderedElements = syncMovedIndices(
|
||||
[...elementsBeforeGroup, ...elementsInGroup, ...elementsAfterGroup],
|
||||
arrayToMap(elementsInGroup),
|
||||
);
|
||||
|
||||
return {
|
||||
appState: {
|
||||
|
@ -153,11 +162,10 @@ export const actionGroup = register({
|
|||
getNonDeletedElements(nextElements),
|
||||
),
|
||||
},
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
elements: reorderedElements,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.group",
|
||||
predicate: (elements, appState, _, app) =>
|
||||
enableActionGroup(elements, appState, app),
|
||||
keyTest: (event) =>
|
||||
|
@ -177,11 +185,15 @@ export const actionGroup = register({
|
|||
|
||||
export const actionUngroup = register({
|
||||
name: "ungroup",
|
||||
label: "labels.ungroup",
|
||||
icon: (appState) => <UngroupIcon theme={appState.theme} />,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const groupIds = getSelectedGroupIds(appState);
|
||||
const elementsMap = arrayToMap(elements);
|
||||
|
||||
if (groupIds.length === 0) {
|
||||
return { appState, elements, commitToHistory: false };
|
||||
return { appState, elements, storeAction: StoreAction.NONE };
|
||||
}
|
||||
|
||||
let nextElements = [...elements];
|
||||
|
@ -226,7 +238,12 @@ export const actionUngroup = register({
|
|||
if (frame) {
|
||||
nextElements = replaceAllElementsInFrame(
|
||||
nextElements,
|
||||
getElementsInResizingFrame(nextElements, frame, appState),
|
||||
getElementsInResizingFrame(
|
||||
nextElements,
|
||||
frame,
|
||||
appState,
|
||||
elementsMap,
|
||||
),
|
||||
frame,
|
||||
app,
|
||||
);
|
||||
|
@ -249,14 +266,13 @@ export const actionUngroup = register({
|
|||
return {
|
||||
appState: { ...appState, ...updateAppState },
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.shiftKey &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.key === KEYS.G.toUpperCase(),
|
||||
contextItemLabel: "labels.ungroup",
|
||||
predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0,
|
||||
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
|
|
|
@ -1,105 +1,120 @@
|
|||
import { Action, ActionResult } from "./types";
|
||||
import type { Action, ActionResult } from "./types";
|
||||
import { UndoIcon, RedoIcon } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import History, { HistoryEntry } from "../history";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import type { History } from "../history";
|
||||
import { HistoryChangedEvent } from "../history";
|
||||
import type { AppState } from "../types";
|
||||
import { KEYS } from "../keys";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { isWindows } from "../constants";
|
||||
import type { SceneElementsMap } from "../element/types";
|
||||
import type { Store } from "../store";
|
||||
import { StoreAction } from "../store";
|
||||
import { useEmitter } from "../hooks/useEmitter";
|
||||
|
||||
const writeData = (
|
||||
prevElements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
updater: () => HistoryEntry | null,
|
||||
appState: Readonly<AppState>,
|
||||
updater: () => [SceneElementsMap, AppState] | void,
|
||||
): ActionResult => {
|
||||
const commitToHistory = false;
|
||||
if (
|
||||
!appState.multiElement &&
|
||||
!appState.resizingElement &&
|
||||
!appState.editingElement &&
|
||||
!appState.draggingElement
|
||||
) {
|
||||
const data = updater();
|
||||
if (data === null) {
|
||||
return { commitToHistory };
|
||||
const result = updater();
|
||||
|
||||
if (!result) {
|
||||
return { storeAction: StoreAction.NONE };
|
||||
}
|
||||
|
||||
const prevElementMap = arrayToMap(prevElements);
|
||||
const nextElements = data.elements;
|
||||
const nextElementMap = arrayToMap(nextElements);
|
||||
|
||||
const deletedElements = prevElements.filter(
|
||||
(prevElement) => !nextElementMap.has(prevElement.id),
|
||||
);
|
||||
const elements = nextElements
|
||||
.map((nextElement) =>
|
||||
newElementWith(
|
||||
prevElementMap.get(nextElement.id) || nextElement,
|
||||
nextElement,
|
||||
),
|
||||
)
|
||||
.concat(
|
||||
deletedElements.map((prevElement) =>
|
||||
newElementWith(prevElement, { isDeleted: true }),
|
||||
),
|
||||
);
|
||||
fixBindingsAfterDeletion(elements, deletedElements);
|
||||
const [nextElementsMap, nextAppState] = result;
|
||||
const nextElements = Array.from(nextElementsMap.values());
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: { ...appState, ...data.appState },
|
||||
commitToHistory,
|
||||
syncHistory: true,
|
||||
appState: nextAppState,
|
||||
elements: nextElements,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
};
|
||||
}
|
||||
return { commitToHistory };
|
||||
|
||||
return { storeAction: StoreAction.NONE };
|
||||
};
|
||||
|
||||
type ActionCreator = (history: History) => Action;
|
||||
type ActionCreator = (history: History, store: Store) => Action;
|
||||
|
||||
export const createUndoAction: ActionCreator = (history) => ({
|
||||
export const createUndoAction: ActionCreator = (history, store) => ({
|
||||
name: "undo",
|
||||
label: "buttons.undo",
|
||||
icon: UndoIcon,
|
||||
trackEvent: { category: "history" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.undoOnce()),
|
||||
writeData(appState, () =>
|
||||
history.undo(
|
||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||
appState,
|
||||
store.snapshot,
|
||||
),
|
||||
),
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.key.toLowerCase() === KEYS.Z &&
|
||||
!event.shiftKey,
|
||||
PanelComponent: ({ updateData, data }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={UndoIcon}
|
||||
aria-label={t("buttons.undo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
/>
|
||||
),
|
||||
commitToHistory: () => false,
|
||||
PanelComponent: ({ updateData, data }) => {
|
||||
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
||||
history.onHistoryChangedEmitter,
|
||||
new HistoryChangedEvent(),
|
||||
);
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={UndoIcon}
|
||||
aria-label={t("buttons.undo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
disabled={isUndoStackEmpty}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const createRedoAction: ActionCreator = (history) => ({
|
||||
export const createRedoAction: ActionCreator = (history, store) => ({
|
||||
name: "redo",
|
||||
label: "buttons.redo",
|
||||
icon: RedoIcon,
|
||||
trackEvent: { category: "history" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.redoOnce()),
|
||||
writeData(appState, () =>
|
||||
history.redo(
|
||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||
appState,
|
||||
store.snapshot,
|
||||
),
|
||||
),
|
||||
keyTest: (event) =>
|
||||
(event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
event.key.toLowerCase() === KEYS.Z) ||
|
||||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
||||
PanelComponent: ({ updateData, data }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={RedoIcon}
|
||||
aria-label={t("buttons.redo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
/>
|
||||
),
|
||||
commitToHistory: () => false,
|
||||
PanelComponent: ({ updateData, data }) => {
|
||||
const { isRedoStackEmpty } = useEmitter(
|
||||
history.onHistoryChangedEmitter,
|
||||
new HistoryChangedEvent(),
|
||||
);
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={RedoIcon}
|
||||
aria-label={t("buttons.redo")}
|
||||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
disabled={isRedoStackEmpty}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { ExcalidrawLinearElement } from "../element/types";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleLinearEditor = register({
|
||||
name: "toggleLinearEditor",
|
||||
trackEvent: {
|
||||
category: "element",
|
||||
},
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
perform(elements, appState, _, app) {
|
||||
const selectedElement = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
})[0] as ExcalidrawLinearElement;
|
||||
|
||||
const editingLinearElement =
|
||||
appState.editingLinearElement?.elementId === selectedElement.id
|
||||
? null
|
||||
: new LinearElementEditor(selectedElement, app.scene);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
editingLinearElement,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: (elements, appState, app) => {
|
||||
const selectedElement = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
})[0] as ExcalidrawLinearElement;
|
||||
return appState.editingLinearElement?.elementId === selectedElement.id
|
||||
? "labels.lineEditor.exit"
|
||||
: "labels.lineEditor.edit";
|
||||
},
|
||||
});
|
76
packages/excalidraw/actions/actionLinearEditor.tsx
Normal file
76
packages/excalidraw/actions/actionLinearEditor.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import type { ExcalidrawLinearElement } from "../element/types";
|
||||
import { StoreAction } from "../store";
|
||||
import { register } from "./register";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { lineEditorIcon } from "../components/icons";
|
||||
|
||||
export const actionToggleLinearEditor = register({
|
||||
name: "toggleLinearEditor",
|
||||
category: DEFAULT_CATEGORIES.elements,
|
||||
label: (elements, appState, app) => {
|
||||
const selectedElement = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
})[0] as ExcalidrawLinearElement | undefined;
|
||||
|
||||
return selectedElement?.type === "arrow"
|
||||
? "labels.lineEditor.editArrow"
|
||||
: "labels.lineEditor.edit";
|
||||
},
|
||||
keywords: ["line"],
|
||||
trackEvent: {
|
||||
category: "element",
|
||||
},
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
if (
|
||||
!appState.editingLinearElement &&
|
||||
selectedElements.length === 1 &&
|
||||
isLinearElement(selectedElements[0])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
perform(elements, appState, _, app) {
|
||||
const selectedElement = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
})[0] as ExcalidrawLinearElement;
|
||||
|
||||
const editingLinearElement =
|
||||
appState.editingLinearElement?.elementId === selectedElement.id
|
||||
? null
|
||||
: new LinearElementEditor(selectedElement);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
editingLinearElement,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, app }) => {
|
||||
const selectedElement = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
})[0] as ExcalidrawLinearElement;
|
||||
|
||||
const label = t(
|
||||
selectedElement.type === "arrow"
|
||||
? "labels.lineEditor.editArrow"
|
||||
: "labels.lineEditor.edit",
|
||||
);
|
||||
return (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={lineEditorIcon}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={() => updateData(null)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
55
packages/excalidraw/actions/actionLink.tsx
Normal file
55
packages/excalidraw/actions/actionLink.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
||||
import { LinkIcon } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { isEmbeddableElement } from "../element/typeChecks";
|
||||
import { t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { StoreAction } from "../store";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionLink = register({
|
||||
name: "hyperlink",
|
||||
label: (elements, appState) => getContextMenuLabel(elements, appState),
|
||||
icon: LinkIcon,
|
||||
perform: (elements, appState) => {
|
||||
if (appState.showHyperlinkPopup === "editor") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
showHyperlinkPopup: "editor",
|
||||
openMenu: null,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
trackEvent: { category: "hyperlink", action: "click" },
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return selectedElements.length === 1;
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={LinkIcon}
|
||||
aria-label={t(getContextMenuLabel(elements, appState))}
|
||||
title={`${
|
||||
isEmbeddableElement(elements[0])
|
||||
? t("labels.link.labelEmbed")
|
||||
: t("labels.link.label")
|
||||
} - ${getShortcutKey("CtrlOrCmd+K")}`}
|
||||
onClick={() => updateData(null)}
|
||||
selected={selectedElements.length === 1 && !!selectedElements[0].link}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -1,19 +1,21 @@
|
|||
import { HamburgerMenuIcon, palette } from "../components/icons";
|
||||
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||
import { register } from "./register";
|
||||
import { KEYS } from "../keys";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
name: "toggleCanvasMenu",
|
||||
label: "buttons.menu",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_, appState) => ({
|
||||
appState: {
|
||||
...appState,
|
||||
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
}),
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<ToolButton
|
||||
|
@ -28,13 +30,14 @@ export const actionToggleCanvasMenu = register({
|
|||
|
||||
export const actionToggleEditMenu = register({
|
||||
name: "toggleEditMenu",
|
||||
label: "buttons.edit",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_elements, appState) => ({
|
||||
appState: {
|
||||
...appState,
|
||||
openMenu: appState.openMenu === "shape" ? null : "shape",
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
}),
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
|
@ -53,6 +56,8 @@ export const actionToggleEditMenu = register({
|
|||
|
||||
export const actionShortcuts = register({
|
||||
name: "toggleShortcuts",
|
||||
label: "welcomeScreen.defaults.helpHint",
|
||||
icon: HelpIconThin,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "menu", action: "toggleHelpDialog" },
|
||||
perform: (_elements, appState, _, { focusContainer }) => {
|
||||
|
@ -69,7 +74,7 @@ export const actionShortcuts = register({
|
|||
name: "help",
|
||||
},
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
||||
|
|
|
@ -1,13 +1,20 @@
|
|||
import { getClientColor } from "../clients";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { GoToCollaboratorComponentProps } from "../components/UserList";
|
||||
import { eyeIcon } from "../components/icons";
|
||||
import type { GoToCollaboratorComponentProps } from "../components/UserList";
|
||||
import {
|
||||
eyeIcon,
|
||||
microphoneIcon,
|
||||
microphoneMutedIcon,
|
||||
} from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { Collaborator } from "../types";
|
||||
import { StoreAction } from "../store";
|
||||
import type { Collaborator } from "../types";
|
||||
import { register } from "./register";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const actionGoToCollaborator = register({
|
||||
name: "goToCollaborator",
|
||||
label: "Go to a collaborator",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "collab" },
|
||||
perform: (_elements, appState, collaborator: Collaborator) => {
|
||||
|
@ -21,7 +28,7 @@ export const actionGoToCollaborator = register({
|
|||
...appState,
|
||||
userToFollow: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -35,18 +42,49 @@ export const actionGoToCollaborator = register({
|
|||
// Close mobile menu
|
||||
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, data, appState }) => {
|
||||
const { clientId, collaborator, withName, isBeingFollowed } =
|
||||
const { socketId, collaborator, withName, isBeingFollowed } =
|
||||
data as GoToCollaboratorComponentProps;
|
||||
|
||||
const background = getClientColor(clientId);
|
||||
const background = getClientColor(socketId, collaborator);
|
||||
|
||||
const statusClassNames = clsx({
|
||||
"is-followed": isBeingFollowed,
|
||||
"is-current-user": collaborator.isCurrentUser === true,
|
||||
"is-speaking": collaborator.isSpeaking,
|
||||
"is-in-call": collaborator.isInCall,
|
||||
"is-muted": collaborator.isMuted,
|
||||
});
|
||||
|
||||
const statusIconJSX = collaborator.isInCall ? (
|
||||
collaborator.isSpeaking ? (
|
||||
<div
|
||||
className="UserList__collaborator-status-icon-speaking-indicator"
|
||||
title={t("userList.hint.isSpeaking")}
|
||||
>
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
) : collaborator.isMuted ? (
|
||||
<div
|
||||
className="UserList__collaborator-status-icon-microphone-muted"
|
||||
title={t("userList.hint.micMuted")}
|
||||
>
|
||||
{microphoneMutedIcon}
|
||||
</div>
|
||||
) : (
|
||||
<div title={t("userList.hint.inCall")}>{microphoneIcon}</div>
|
||||
)
|
||||
) : null;
|
||||
|
||||
return withName ? (
|
||||
<div
|
||||
className="dropdown-menu-item dropdown-menu-item-base UserList__collaborator"
|
||||
className={`dropdown-menu-item dropdown-menu-item-base UserList__collaborator ${statusClassNames}`}
|
||||
style={{ [`--avatar-size` as any]: "1.5rem" }}
|
||||
onClick={() => updateData<Collaborator>(collaborator)}
|
||||
>
|
||||
<Avatar
|
||||
|
@ -54,32 +92,42 @@ export const actionGoToCollaborator = register({
|
|||
onClick={() => {}}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
isBeingFollowed={isBeingFollowed}
|
||||
isCurrentUser={collaborator.isCurrentUser === true}
|
||||
className={statusClassNames}
|
||||
/>
|
||||
<div className="UserList__collaborator-name">
|
||||
{collaborator.username}
|
||||
</div>
|
||||
<div
|
||||
className="UserList__collaborator-follow-status-icon"
|
||||
style={{ visibility: isBeingFollowed ? "visible" : "hidden" }}
|
||||
title={isBeingFollowed ? t("userList.hint.followStatus") : undefined}
|
||||
aria-hidden
|
||||
>
|
||||
{eyeIcon}
|
||||
<div className="UserList__collaborator-status-icons" aria-hidden>
|
||||
{isBeingFollowed && (
|
||||
<div
|
||||
className="UserList__collaborator-status-icon-is-followed"
|
||||
title={t("userList.hint.followStatus")}
|
||||
>
|
||||
{eyeIcon}
|
||||
</div>
|
||||
)}
|
||||
{statusIconJSX}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Avatar
|
||||
color={background}
|
||||
onClick={() => {
|
||||
updateData(collaborator);
|
||||
}}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
isBeingFollowed={isBeingFollowed}
|
||||
isCurrentUser={collaborator.isCurrentUser === true}
|
||||
/>
|
||||
<div
|
||||
className={`UserList__collaborator UserList__collaborator--avatar-only ${statusClassNames}`}
|
||||
>
|
||||
<Avatar
|
||||
color={background}
|
||||
onClick={() => {
|
||||
updateData(collaborator);
|
||||
}}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
className={statusClassNames}
|
||||
/>
|
||||
{statusIconJSX && (
|
||||
<div className="UserList__collaborator-status-icon">
|
||||
{statusIconJSX}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { AppClassProperties, AppState, Primitive } from "../types";
|
||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||
import {
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
|
@ -49,6 +49,7 @@ import {
|
|||
ArrowheadCircleOutlineIcon,
|
||||
ArrowheadDiamondIcon,
|
||||
ArrowheadDiamondOutlineIcon,
|
||||
fontSizeIcon,
|
||||
} from "../components/icons";
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
|
@ -73,7 +74,7 @@ import {
|
|||
isLinearElement,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "../element/typeChecks";
|
||||
import {
|
||||
import type {
|
||||
Arrowhead,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
|
@ -95,6 +96,7 @@ import {
|
|||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||
|
||||
|
@ -209,6 +211,7 @@ const changeFontSize = (
|
|||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
app.scene.getContainerElement(oldElement),
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
||||
|
@ -229,7 +232,7 @@ const changeFontSize = (
|
|||
? [...newFontSizes][0]
|
||||
: fallbackValue ?? appState.currentItemFontSize,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -237,6 +240,7 @@ const changeFontSize = (
|
|||
|
||||
export const actionChangeStrokeColor = register({
|
||||
name: "changeStrokeColor",
|
||||
label: "labels.stroke",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
|
@ -258,7 +262,9 @@ export const actionChangeStrokeColor = register({
|
|||
...appState,
|
||||
...value,
|
||||
},
|
||||
commitToHistory: !!value.currentItemStrokeColor,
|
||||
storeAction: !!value.currentItemStrokeColor
|
||||
? StoreAction.CAPTURE
|
||||
: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||
|
@ -287,6 +293,7 @@ export const actionChangeStrokeColor = register({
|
|||
|
||||
export const actionChangeBackgroundColor = register({
|
||||
name: "changeBackgroundColor",
|
||||
label: "labels.changeBackground",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
|
@ -301,7 +308,9 @@ export const actionChangeBackgroundColor = register({
|
|||
...appState,
|
||||
...value,
|
||||
},
|
||||
commitToHistory: !!value.currentItemBackgroundColor,
|
||||
storeAction: !!value.currentItemBackgroundColor
|
||||
? StoreAction.CAPTURE
|
||||
: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||
|
@ -330,6 +339,7 @@ export const actionChangeBackgroundColor = register({
|
|||
|
||||
export const actionChangeFillStyle = register({
|
||||
name: "changeFillStyle",
|
||||
label: "labels.fill",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
trackEvent(
|
||||
|
@ -344,7 +354,7 @@ export const actionChangeFillStyle = register({
|
|||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemFillStyle: value },
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
|
@ -407,6 +417,7 @@ export const actionChangeFillStyle = register({
|
|||
|
||||
export const actionChangeStrokeWidth = register({
|
||||
name: "changeStrokeWidth",
|
||||
label: "labels.strokeWidth",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
|
@ -416,7 +427,7 @@ export const actionChangeStrokeWidth = register({
|
|||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemStrokeWidth: value },
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
|
@ -460,6 +471,7 @@ export const actionChangeStrokeWidth = register({
|
|||
|
||||
export const actionChangeSloppiness = register({
|
||||
name: "changeSloppiness",
|
||||
label: "labels.sloppiness",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
|
@ -470,7 +482,7 @@ export const actionChangeSloppiness = register({
|
|||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemRoughness: value },
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
|
@ -511,6 +523,7 @@ export const actionChangeSloppiness = register({
|
|||
|
||||
export const actionChangeStrokeStyle = register({
|
||||
name: "changeStrokeStyle",
|
||||
label: "labels.strokeStyle",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
|
@ -520,7 +533,7 @@ export const actionChangeStrokeStyle = register({
|
|||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemStrokeStyle: value },
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
|
@ -561,6 +574,7 @@ export const actionChangeStrokeStyle = register({
|
|||
|
||||
export const actionChangeOpacity = register({
|
||||
name: "changeOpacity",
|
||||
label: "labels.opacity",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
|
@ -574,7 +588,7 @@ export const actionChangeOpacity = register({
|
|||
true,
|
||||
),
|
||||
appState: { ...appState, currentItemOpacity: value },
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
|
@ -602,6 +616,7 @@ export const actionChangeOpacity = register({
|
|||
|
||||
export const actionChangeFontSize = register({
|
||||
name: "changeFontSize",
|
||||
label: "labels.fontSize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
return changeFontSize(elements, appState, app, () => value, value);
|
||||
|
@ -672,6 +687,8 @@ export const actionChangeFontSize = register({
|
|||
|
||||
export const actionDecreaseFontSize = register({
|
||||
name: "decreaseFontSize",
|
||||
label: "labels.decreaseFontSize",
|
||||
icon: fontSizeIcon,
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
return changeFontSize(elements, appState, app, (element) =>
|
||||
|
@ -694,6 +711,8 @@ export const actionDecreaseFontSize = register({
|
|||
|
||||
export const actionIncreaseFontSize = register({
|
||||
name: "increaseFontSize",
|
||||
label: "labels.increaseFontSize",
|
||||
icon: fontSizeIcon,
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
return changeFontSize(elements, appState, app, (element) =>
|
||||
|
@ -712,6 +731,7 @@ export const actionIncreaseFontSize = register({
|
|||
|
||||
export const actionChangeFontFamily = register({
|
||||
name: "changeFontFamily",
|
||||
label: "labels.fontFamily",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
return {
|
||||
|
@ -730,6 +750,7 @@ export const actionChangeFontFamily = register({
|
|||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
app.scene.getContainerElement(oldElement),
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
return newElement;
|
||||
}
|
||||
|
@ -742,7 +763,7 @@ export const actionChangeFontFamily = register({
|
|||
...appState,
|
||||
currentItemFontFamily: value,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
|
@ -814,6 +835,7 @@ export const actionChangeFontFamily = register({
|
|||
|
||||
export const actionChangeTextAlign = register({
|
||||
name: "changeTextAlign",
|
||||
label: "Change text alignment",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
return {
|
||||
|
@ -829,6 +851,7 @@ export const actionChangeTextAlign = register({
|
|||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
app.scene.getContainerElement(oldElement),
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
return newElement;
|
||||
}
|
||||
|
@ -841,7 +864,7 @@ export const actionChangeTextAlign = register({
|
|||
...appState,
|
||||
currentItemTextAlign: value,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
|
@ -902,6 +925,7 @@ export const actionChangeTextAlign = register({
|
|||
|
||||
export const actionChangeVerticalAlign = register({
|
||||
name: "changeVerticalAlign",
|
||||
label: "Change vertical alignment",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, value, app) => {
|
||||
return {
|
||||
|
@ -918,6 +942,7 @@ export const actionChangeVerticalAlign = register({
|
|||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
app.scene.getContainerElement(oldElement),
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
return newElement;
|
||||
}
|
||||
|
@ -929,7 +954,7 @@ export const actionChangeVerticalAlign = register({
|
|||
appState: {
|
||||
...appState,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
|
@ -990,6 +1015,7 @@ export const actionChangeVerticalAlign = register({
|
|||
|
||||
export const actionChangeRoundness = register({
|
||||
name: "changeRoundness",
|
||||
label: "Change edge roundness",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
|
@ -1009,7 +1035,7 @@ export const actionChangeRoundness = register({
|
|||
...appState,
|
||||
currentItemRoundness: value,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
|
@ -1128,6 +1154,7 @@ const getArrowheadOptions = (flip: boolean) => {
|
|||
|
||||
export const actionChangeArrowhead = register({
|
||||
name: "changeArrowhead",
|
||||
label: "Change arrowheads",
|
||||
trackEvent: false,
|
||||
perform: (
|
||||
elements,
|
||||
|
@ -1160,7 +1187,7 @@ export const actionChangeArrowhead = register({
|
|||
? "currentItemStartArrowhead"
|
||||
: "currentItemEndArrowhead"]: value.type,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
|
|
|
@ -2,14 +2,19 @@ import { KEYS } from "../keys";
|
|||
import { register } from "./register";
|
||||
import { selectGroupsForSelectedElements } from "../groups";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||
import { selectAllIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
label: "labels.selectAll",
|
||||
icon: selectAllIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
if (appState.editingLinearElement) {
|
||||
return false;
|
||||
|
@ -43,12 +48,11 @@ export const actionSelectAll = register({
|
|||
// single linear element selected
|
||||
Object.keys(selectedElementIds).length === 1 &&
|
||||
isLinearElement(elements[0])
|
||||
? new LinearElementEditor(elements[0], app.scene)
|
||||
? new LinearElementEditor(elements[0])
|
||||
: null,
|
||||
},
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.selectAll",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
||||
});
|
||||
|
|
|
@ -24,13 +24,17 @@ import {
|
|||
isArrowElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { ExcalidrawTextElement } from "../element/types";
|
||||
import type { ExcalidrawTextElement } from "../element/types";
|
||||
import { paintIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
export let copiedStyles: string = "{}";
|
||||
|
||||
export const actionCopyStyles = register({
|
||||
name: "copyStyles",
|
||||
label: "labels.copyStyles",
|
||||
icon: paintIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, formData, app) => {
|
||||
const elementsCopied = [];
|
||||
|
@ -51,23 +55,24 @@ export const actionCopyStyles = register({
|
|||
...appState,
|
||||
toast: { message: t("toast.copyStyles") },
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.copyStyles",
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
||||
});
|
||||
|
||||
export const actionPasteStyles = register({
|
||||
name: "pasteStyles",
|
||||
label: "labels.pasteStyles",
|
||||
icon: paintIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, formData, app) => {
|
||||
const elementsCopied = JSON.parse(copiedStyles);
|
||||
const pastedElement = elementsCopied[0];
|
||||
const boundTextElement = elementsCopied[1];
|
||||
if (!isExcalidrawElement(pastedElement)) {
|
||||
return { elements, commitToHistory: false };
|
||||
return { elements, storeAction: StoreAction.NONE };
|
||||
}
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState, {
|
||||
|
@ -128,7 +133,11 @@ export const actionPasteStyles = register({
|
|||
element.id === newElement.containerId,
|
||||
) || null;
|
||||
}
|
||||
redrawTextBoundingBox(newElement, container);
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
|
@ -152,10 +161,9 @@ export const actionPasteStyles = register({
|
|||
}
|
||||
return element;
|
||||
}),
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.pasteStyles",
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
||||
});
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { GRID_SIZE } from "../constants";
|
||||
import { AppState } from "../types";
|
||||
import type { AppState } from "../types";
|
||||
import { gridIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionToggleGridMode = register({
|
||||
name: "gridMode",
|
||||
icon: gridIcon,
|
||||
keywords: ["snap"],
|
||||
label: "labels.toggleGrid",
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
|
@ -17,13 +22,12 @@ export const actionToggleGridMode = register({
|
|||
gridSize: this.checked!(appState) ? null : GRID_SIZE,
|
||||
objectsSnapModeEnabled: false,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState: AppState) => appState.gridSize !== null,
|
||||
predicate: (element, appState, props) => {
|
||||
return typeof props.gridModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "labels.showGrid",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
||||
});
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
import { magnetIcon } from "../components/icons";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { StoreAction } from "../store";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleObjectsSnapMode = register({
|
||||
name: "objectsSnapMode",
|
||||
viewMode: true,
|
||||
label: "buttons.objectsSnapMode",
|
||||
icon: magnetIcon,
|
||||
viewMode: false,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.objectsSnapModeEnabled,
|
||||
|
@ -15,14 +19,13 @@ export const actionToggleObjectsSnapMode = register({
|
|||
objectsSnapModeEnabled: !this.checked!(appState),
|
||||
gridSize: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.objectsSnapModeEnabled,
|
||||
predicate: (elements, appState, appProps) => {
|
||||
return typeof appProps.objectsSnapModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "buttons.objectsSnapMode",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
|
||||
});
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
import { register } from "./register";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { abacusIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionToggleStats = register({
|
||||
name: "stats",
|
||||
label: "stats.title",
|
||||
icon: abacusIcon,
|
||||
paletteName: "Toggle stats",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "menu" },
|
||||
perform(elements, appState) {
|
||||
|
@ -11,11 +16,10 @@ export const actionToggleStats = register({
|
|||
...appState,
|
||||
showStats: !this.checked!(appState),
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.showStats,
|
||||
contextItemLabel: "stats.title",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
||||
});
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
import { eyeIcon } from "../components/icons";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { StoreAction } from "../store";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleViewMode = register({
|
||||
name: "viewMode",
|
||||
label: "labels.viewMode",
|
||||
paletteName: "Toggle view mode",
|
||||
icon: eyeIcon,
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
|
@ -14,14 +19,13 @@ export const actionToggleViewMode = register({
|
|||
...appState,
|
||||
viewModeEnabled: !this.checked!(appState),
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.viewModeEnabled,
|
||||
predicate: (elements, appState, appProps) => {
|
||||
return typeof appProps.viewModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "labels.viewMode",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
|
||||
});
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
import { coffeeIcon } from "../components/icons";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { StoreAction } from "../store";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleZenMode = register({
|
||||
name: "zenMode",
|
||||
label: "buttons.zenMode",
|
||||
icon: coffeeIcon,
|
||||
paletteName: "Toggle zen mode",
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
|
@ -14,14 +19,13 @@ export const actionToggleZenMode = register({
|
|||
...appState,
|
||||
zenModeEnabled: !this.checked!(appState),
|
||||
},
|
||||
commitToHistory: false,
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.zenModeEnabled,
|
||||
predicate: (elements, appState, appProps) => {
|
||||
return typeof appProps.zenModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "buttons.zenMode",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
|
||||
});
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import React from "react";
|
||||
import {
|
||||
moveOneLeft,
|
||||
moveOneRight,
|
||||
|
@ -16,18 +15,21 @@ import {
|
|||
SendToBackIcon,
|
||||
} from "../components/icons";
|
||||
import { isDarwin } from "../constants";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
export const actionSendBackward = register({
|
||||
name: "sendBackward",
|
||||
label: "labels.sendBackward",
|
||||
keywords: ["move down", "zindex", "layer"],
|
||||
icon: SendBackwardIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveOneLeft(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.sendBackward",
|
||||
keyPriority: 40,
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
|
@ -47,15 +49,17 @@ export const actionSendBackward = register({
|
|||
|
||||
export const actionBringForward = register({
|
||||
name: "bringForward",
|
||||
label: "labels.bringForward",
|
||||
keywords: ["move up", "zindex", "layer"],
|
||||
icon: BringForwardIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveOneRight(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.bringForward",
|
||||
keyPriority: 40,
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
|
@ -75,15 +79,17 @@ export const actionBringForward = register({
|
|||
|
||||
export const actionSendToBack = register({
|
||||
name: "sendToBack",
|
||||
label: "labels.sendToBack",
|
||||
keywords: ["move down", "zindex", "layer"],
|
||||
icon: SendToBackIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveAllLeft(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.sendToBack",
|
||||
keyTest: (event) =>
|
||||
isDarwin
|
||||
? event[KEYS.CTRL_OR_CMD] &&
|
||||
|
@ -110,16 +116,18 @@ export const actionSendToBack = register({
|
|||
|
||||
export const actionBringToFront = register({
|
||||
name: "bringToFront",
|
||||
label: "labels.bringToFront",
|
||||
keywords: ["move up", "zindex", "layer"],
|
||||
icon: BringToFrontIcon,
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveAllRight(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.bringToFront",
|
||||
keyTest: (event) =>
|
||||
isDarwin
|
||||
? event[KEYS.CTRL_OR_CMD] &&
|
||||
|
|
|
@ -83,6 +83,6 @@ export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
|
|||
|
||||
export { actionToggleStats } from "./actionToggleStats";
|
||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
export { actionLink } from "../element/Hyperlink";
|
||||
export { actionLink } from "./actionLink";
|
||||
export { actionToggleElementLock } from "./actionElementLock";
|
||||
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import {
|
||||
import type {
|
||||
Action,
|
||||
UpdaterFn,
|
||||
ActionName,
|
||||
|
@ -8,8 +8,11 @@ import {
|
|||
ActionSource,
|
||||
ActionPredicateFn,
|
||||
} from "./types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
OrderedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { isPromiseLike } from "../utils";
|
||||
|
||||
|
@ -48,13 +51,13 @@ export class ActionManager {
|
|||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
||||
|
||||
getAppState: () => Readonly<AppState>;
|
||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
||||
getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[];
|
||||
app: AppClassProperties;
|
||||
|
||||
constructor(
|
||||
updater: UpdaterFn,
|
||||
getAppState: () => AppState,
|
||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
||||
getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[],
|
||||
app: AppClassProperties,
|
||||
) {
|
||||
this.updater = (actionResult) => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Action } from "./types";
|
||||
import type { Action } from "./types";
|
||||
|
||||
export let actions: readonly Action[] = [];
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { isDarwin } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { SubtypeOf } from "../utility-types";
|
||||
import type { SubtypeOf } from "../utility-types";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { ActionName, CustomActionName } from "./types";
|
||||
import type { ActionName, CustomActionName } from "./types";
|
||||
|
||||
export type ShortcutName =
|
||||
| SubtypeOf<
|
||||
|
@ -37,9 +37,22 @@ export type ShortcutName =
|
|||
| "flipVertical"
|
||||
| "hyperlink"
|
||||
| "toggleElementLock"
|
||||
| "resetZoom"
|
||||
| "zoomOut"
|
||||
| "zoomIn"
|
||||
| "zoomToFit"
|
||||
| "zoomToFitSelectionInViewport"
|
||||
| "zoomToFitSelection"
|
||||
| "toggleEraserTool"
|
||||
| "toggleHandTool"
|
||||
| "setFrameAsActiveTool"
|
||||
| "saveFileToDisk"
|
||||
| "saveToActiveFile"
|
||||
| "toggleShortcuts"
|
||||
>
|
||||
| "saveScene"
|
||||
| "imageExport";
|
||||
| "imageExport"
|
||||
| "commandPalette";
|
||||
|
||||
export const registerCustomShortcuts = (
|
||||
shortcuts: Record<CustomActionName, string[]>,
|
||||
|
@ -56,6 +69,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||
loadScene: [getShortcutKey("CtrlOrCmd+O")],
|
||||
clearCanvas: [getShortcutKey("CtrlOrCmd+Delete")],
|
||||
imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")],
|
||||
commandPalette: [
|
||||
getShortcutKey("CtrlOrCmd+/"),
|
||||
getShortcutKey("CtrlOrCmd+Shift+P"),
|
||||
],
|
||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
||||
copy: [getShortcutKey("CtrlOrCmd+C")],
|
||||
paste: [getShortcutKey("CtrlOrCmd+V")],
|
||||
|
@ -93,10 +110,24 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||
viewMode: [getShortcutKey("Alt+R")],
|
||||
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
|
||||
toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
|
||||
resetZoom: [getShortcutKey("CtrlOrCmd+0")],
|
||||
zoomOut: [getShortcutKey("CtrlOrCmd+-")],
|
||||
zoomIn: [getShortcutKey("CtrlOrCmd++")],
|
||||
zoomToFitSelection: [getShortcutKey("Shift+3")],
|
||||
zoomToFit: [getShortcutKey("Shift+1")],
|
||||
zoomToFitSelectionInViewport: [getShortcutKey("Shift+2")],
|
||||
toggleEraserTool: [getShortcutKey("E")],
|
||||
toggleHandTool: [getShortcutKey("H")],
|
||||
setFrameAsActiveTool: [getShortcutKey("F")],
|
||||
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
|
||||
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
||||
toggleShortcuts: [getShortcutKey("?")],
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
||||
const shortcuts = shortcutMap[name];
|
||||
// if multiple shortcuts available, take the first one
|
||||
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
||||
return shortcuts && shortcuts.length > 0
|
||||
? shortcuts[idx] || shortcuts[0]
|
||||
: "";
|
||||
};
|
||||
|
|
|
@ -1,14 +1,24 @@
|
|||
import React from "react";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import {
|
||||
import type React from "react";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
OrderedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
ExcalidrawProps,
|
||||
BinaryFiles,
|
||||
UIAppState,
|
||||
} from "../types";
|
||||
import { MarkOptional } from "../utility-types";
|
||||
import type { MarkOptional } from "../utility-types";
|
||||
import type { StoreActionType } from "../store";
|
||||
|
||||
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
|
||||
export type ActionSource =
|
||||
| "ui"
|
||||
| "keyboard"
|
||||
| "contextMenu"
|
||||
| "api"
|
||||
| "commandPalette";
|
||||
|
||||
/** if false, the action should be prevented */
|
||||
export type ActionResult =
|
||||
|
@ -19,14 +29,13 @@ export type ActionResult =
|
|||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
> | null;
|
||||
files?: BinaryFiles | null;
|
||||
commitToHistory: boolean;
|
||||
syncHistory?: boolean;
|
||||
storeAction: StoreActionType;
|
||||
replaceFiles?: boolean;
|
||||
}
|
||||
| false;
|
||||
|
||||
type ActionFn = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
elements: readonly OrderedExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
formData: any,
|
||||
app: AppClassProperties,
|
||||
|
@ -138,7 +147,8 @@ export type ActionName =
|
|||
| "setFrameAsActiveTool"
|
||||
| "setEmbeddableAsActiveTool"
|
||||
| "createContainerFromText"
|
||||
| "wrapTextInContainer";
|
||||
| "wrapTextInContainer"
|
||||
| "commandPalette";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
|
@ -151,6 +161,20 @@ export type PanelComponentProps = {
|
|||
|
||||
export interface Action {
|
||||
name: ActionName;
|
||||
label:
|
||||
| string
|
||||
| ((
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
app: AppClassProperties,
|
||||
) => string);
|
||||
keywords?: string[];
|
||||
icon?:
|
||||
| React.ReactNode
|
||||
| ((
|
||||
appState: UIAppState,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => React.ReactNode);
|
||||
PanelComponent?: React.FC<PanelComponentProps>;
|
||||
perform: ActionFn;
|
||||
keyPriority?: number;
|
||||
|
@ -160,13 +184,6 @@ export interface Action {
|
|||
elements: readonly ExcalidrawElement[],
|
||||
app: AppClassProperties,
|
||||
) => boolean;
|
||||
contextItemLabel?:
|
||||
| string
|
||||
| ((
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
app: AppClassProperties,
|
||||
) => string);
|
||||
predicate?: (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { ElementsMap, ExcalidrawElement } from "./element/types";
|
||||
import type { ElementsMap, ExcalidrawElement } from "./element/types";
|
||||
import { newElementWith } from "./element/mutateElement";
|
||||
import { BoundingBox, getCommonBoundingBox } from "./element/bounds";
|
||||
import type { BoundingBox } from "./element/bounds";
|
||||
import { getCommonBoundingBox } from "./element/bounds";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
|
||||
export interface Alignment {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// place here categories that you want to track. We want to track just a
|
||||
// small subset of categories at a given time.
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = ["ai"] as string[];
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[];
|
||||
|
||||
export const trackEvent = (
|
||||
category: string,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { LaserPointer, LaserPointerOptions } from "@excalidraw/laser-pointer";
|
||||
import { AnimationFrameHandler } from "./animation-frame-handler";
|
||||
import { AppState } from "./types";
|
||||
import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
|
||||
import { LaserPointer } from "@excalidraw/laser-pointer";
|
||||
import type { AnimationFrameHandler } from "./animation-frame-handler";
|
||||
import type { AppState } from "./types";
|
||||
import { getSvgPathFromStroke, sceneCoordsToViewportCoords } from "./utils";
|
||||
import type App from "./components/App";
|
||||
import { SVG_NS } from "./constants";
|
||||
|
|
|
@ -7,9 +7,7 @@ import {
|
|||
EXPORT_SCALES,
|
||||
THEME,
|
||||
} from "./constants";
|
||||
import { t } from "./i18n";
|
||||
import { AppState, NormalizedZoomValue } from "./types";
|
||||
import { getDateTime } from "./utils";
|
||||
import type { AppState, NormalizedZoomValue } from "./types";
|
||||
|
||||
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
||||
? devicePixelRatio
|
||||
|
@ -65,7 +63,7 @@ export const getDefaultAppState = (): Omit<
|
|||
isRotating: false,
|
||||
lastPointerDownWith: "mouse",
|
||||
multiElement: null,
|
||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||
name: null,
|
||||
contextMenu: null,
|
||||
openMenu: null,
|
||||
openPopup: null,
|
||||
|
|
1525
packages/excalidraw/change.ts
Normal file
1525
packages/excalidraw/change.ts
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,9 +1,5 @@
|
|||
import {
|
||||
Spreadsheet,
|
||||
tryParseCells,
|
||||
tryParseNumber,
|
||||
VALID_SPREADSHEET,
|
||||
} from "./charts";
|
||||
import type { Spreadsheet } from "./charts";
|
||||
import { tryParseCells, tryParseNumber, VALID_SPREADSHEET } from "./charts";
|
||||
|
||||
describe("charts", () => {
|
||||
describe("tryParseNumber", () => {
|
||||
|
|
|
@ -9,9 +9,9 @@ import {
|
|||
VERTICAL_ALIGN,
|
||||
} from "./constants";
|
||||
import { newElement, newLinearElement, newTextElement } from "./element";
|
||||
import { NonDeletedExcalidrawElement } from "./element/types";
|
||||
import type { NonDeletedExcalidrawElement } from "./element/types";
|
||||
import { randomId } from "./random";
|
||||
import { AppState } from "./types";
|
||||
import type { AppState } from "./types";
|
||||
import { selectSubtype } from "./element/subtypes";
|
||||
|
||||
export type ChartElements = readonly NonDeletedExcalidrawElement[];
|
||||
|
|
|
@ -1,3 +1,18 @@
|
|||
import {
|
||||
COLOR_CHARCOAL_BLACK,
|
||||
COLOR_VOICE_CALL,
|
||||
COLOR_WHITE,
|
||||
THEME,
|
||||
} from "./constants";
|
||||
import { roundRect } from "./renderer/roundRect";
|
||||
import type { InteractiveCanvasRenderConfig } from "./scene/types";
|
||||
import type {
|
||||
Collaborator,
|
||||
InteractiveCanvasAppState,
|
||||
SocketId,
|
||||
} from "./types";
|
||||
import { UserIdleState } from "./types";
|
||||
|
||||
function hashToInteger(id: string) {
|
||||
let hash = 0;
|
||||
if (id.length === 0) {
|
||||
|
@ -11,14 +26,12 @@ function hashToInteger(id: string) {
|
|||
}
|
||||
|
||||
export const getClientColor = (
|
||||
/**
|
||||
* any uniquely identifying key, such as user id or socket id
|
||||
*/
|
||||
id: string,
|
||||
socketId: SocketId,
|
||||
collaborator: Collaborator | undefined,
|
||||
) => {
|
||||
// to get more even distribution in case `id` is not uniformly distributed to
|
||||
// begin with, we hash it
|
||||
const hash = Math.abs(hashToInteger(id));
|
||||
const hash = Math.abs(hashToInteger(collaborator?.id || socketId));
|
||||
// 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;
|
||||
|
@ -38,3 +51,209 @@ export const getNameInitial = (name?: string | null) => {
|
|||
firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?"
|
||||
).toUpperCase();
|
||||
};
|
||||
|
||||
export const renderRemoteCursors = ({
|
||||
context,
|
||||
renderConfig,
|
||||
appState,
|
||||
normalizedWidth,
|
||||
normalizedHeight,
|
||||
}: {
|
||||
context: CanvasRenderingContext2D;
|
||||
renderConfig: InteractiveCanvasRenderConfig;
|
||||
appState: InteractiveCanvasAppState;
|
||||
normalizedWidth: number;
|
||||
normalizedHeight: number;
|
||||
}) => {
|
||||
// Paint remote pointers
|
||||
for (const [socketId, pointer] of renderConfig.remotePointerViewportCoords) {
|
||||
let { x, y } = pointer;
|
||||
|
||||
const collaborator = appState.collaborators.get(socketId);
|
||||
|
||||
x -= appState.offsetLeft;
|
||||
y -= appState.offsetTop;
|
||||
|
||||
const width = 11;
|
||||
const height = 14;
|
||||
|
||||
const isOutOfBounds =
|
||||
x < 0 ||
|
||||
x > normalizedWidth - width ||
|
||||
y < 0 ||
|
||||
y > normalizedHeight - height;
|
||||
|
||||
x = Math.max(x, 0);
|
||||
x = Math.min(x, normalizedWidth - width);
|
||||
y = Math.max(y, 0);
|
||||
y = Math.min(y, normalizedHeight - height);
|
||||
|
||||
const background = getClientColor(socketId, collaborator);
|
||||
|
||||
context.save();
|
||||
context.strokeStyle = background;
|
||||
context.fillStyle = background;
|
||||
|
||||
const userState = renderConfig.remotePointerUserStates.get(socketId);
|
||||
const isInactive =
|
||||
isOutOfBounds ||
|
||||
userState === UserIdleState.IDLE ||
|
||||
userState === UserIdleState.AWAY;
|
||||
|
||||
if (isInactive) {
|
||||
context.globalAlpha = 0.3;
|
||||
}
|
||||
|
||||
if (renderConfig.remotePointerButton.get(socketId) === "down") {
|
||||
context.beginPath();
|
||||
context.arc(x, y, 15, 0, 2 * Math.PI, false);
|
||||
context.lineWidth = 3;
|
||||
context.strokeStyle = "#ffffff88";
|
||||
context.stroke();
|
||||
context.closePath();
|
||||
|
||||
context.beginPath();
|
||||
context.arc(x, y, 15, 0, 2 * Math.PI, false);
|
||||
context.lineWidth = 1;
|
||||
context.strokeStyle = background;
|
||||
context.stroke();
|
||||
context.closePath();
|
||||
}
|
||||
|
||||
// TODO remove the dark theme color after we stop inverting canvas colors
|
||||
const IS_SPEAKING_COLOR =
|
||||
appState.theme === THEME.DARK ? "#2f6330" : COLOR_VOICE_CALL;
|
||||
|
||||
const isSpeaking = collaborator?.isSpeaking;
|
||||
|
||||
if (isSpeaking) {
|
||||
// cursor outline for currently speaking user
|
||||
context.fillStyle = IS_SPEAKING_COLOR;
|
||||
context.strokeStyle = IS_SPEAKING_COLOR;
|
||||
context.lineWidth = 10;
|
||||
context.lineJoin = "round";
|
||||
context.beginPath();
|
||||
context.moveTo(x, y);
|
||||
context.lineTo(x + 0, y + 14);
|
||||
context.lineTo(x + 4, y + 9);
|
||||
context.lineTo(x + 11, y + 8);
|
||||
context.closePath();
|
||||
context.stroke();
|
||||
context.fill();
|
||||
}
|
||||
|
||||
// Background (white outline) for arrow
|
||||
context.fillStyle = COLOR_WHITE;
|
||||
context.strokeStyle = COLOR_WHITE;
|
||||
context.lineWidth = 6;
|
||||
context.lineJoin = "round";
|
||||
context.beginPath();
|
||||
context.moveTo(x, y);
|
||||
context.lineTo(x + 0, y + 14);
|
||||
context.lineTo(x + 4, y + 9);
|
||||
context.lineTo(x + 11, y + 8);
|
||||
context.closePath();
|
||||
context.stroke();
|
||||
context.fill();
|
||||
|
||||
// Arrow
|
||||
context.fillStyle = background;
|
||||
context.strokeStyle = background;
|
||||
context.lineWidth = 2;
|
||||
context.lineJoin = "round";
|
||||
context.beginPath();
|
||||
if (isInactive) {
|
||||
context.moveTo(x - 1, y - 1);
|
||||
context.lineTo(x - 1, y + 15);
|
||||
context.lineTo(x + 5, y + 10);
|
||||
context.lineTo(x + 12, y + 9);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
} else {
|
||||
context.moveTo(x, y);
|
||||
context.lineTo(x + 0, y + 14);
|
||||
context.lineTo(x + 4, y + 9);
|
||||
context.lineTo(x + 11, y + 8);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
const username = renderConfig.remotePointerUsernames.get(socketId) || "";
|
||||
|
||||
if (!isOutOfBounds && username) {
|
||||
context.font = "600 12px sans-serif"; // font has to be set before context.measureText()
|
||||
|
||||
const offsetX = (isSpeaking ? x + 0 : x) + width / 2;
|
||||
const offsetY = (isSpeaking ? y + 0 : y) + height + 2;
|
||||
const paddingHorizontal = 5;
|
||||
const paddingVertical = 3;
|
||||
const measure = context.measureText(username);
|
||||
const measureHeight =
|
||||
measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
|
||||
const finalHeight = Math.max(measureHeight, 12);
|
||||
|
||||
const boxX = offsetX - 1;
|
||||
const boxY = offsetY - 1;
|
||||
const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2;
|
||||
const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2;
|
||||
if (context.roundRect) {
|
||||
context.beginPath();
|
||||
context.roundRect(boxX, boxY, boxWidth, boxHeight, 8);
|
||||
context.fillStyle = background;
|
||||
context.fill();
|
||||
context.strokeStyle = COLOR_WHITE;
|
||||
context.stroke();
|
||||
|
||||
if (isSpeaking) {
|
||||
context.beginPath();
|
||||
context.roundRect(boxX - 2, boxY - 2, boxWidth + 4, boxHeight + 4, 8);
|
||||
context.strokeStyle = IS_SPEAKING_COLOR;
|
||||
context.stroke();
|
||||
}
|
||||
} else {
|
||||
roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, COLOR_WHITE);
|
||||
}
|
||||
context.fillStyle = COLOR_CHARCOAL_BLACK;
|
||||
|
||||
context.fillText(
|
||||
username,
|
||||
offsetX + paddingHorizontal + 1,
|
||||
offsetY +
|
||||
paddingVertical +
|
||||
measure.actualBoundingBoxAscent +
|
||||
Math.floor((finalHeight - measureHeight) / 2) +
|
||||
2,
|
||||
);
|
||||
|
||||
// draw three vertical bars signalling someone is speaking
|
||||
if (isSpeaking) {
|
||||
context.fillStyle = IS_SPEAKING_COLOR;
|
||||
const barheight = 8;
|
||||
const margin = 8;
|
||||
const gap = 5;
|
||||
context.fillRect(
|
||||
boxX + boxWidth + margin,
|
||||
boxY + (boxHeight / 2 - barheight / 2),
|
||||
2,
|
||||
barheight,
|
||||
);
|
||||
context.fillRect(
|
||||
boxX + boxWidth + margin + gap,
|
||||
boxY + (boxHeight / 2 - (barheight * 2) / 2),
|
||||
2,
|
||||
barheight * 2,
|
||||
);
|
||||
context.fillRect(
|
||||
boxX + boxWidth + margin + gap * 2,
|
||||
boxY + (boxHeight / 2 - barheight / 2),
|
||||
2,
|
||||
barheight,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
context.restore();
|
||||
context.closePath();
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import {
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import { AppState, BinaryFiles } from "./types";
|
||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import type { AppState, BinaryFiles } from "./types";
|
||||
import type { Spreadsheet } from "./charts";
|
||||
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import {
|
||||
ALLOWED_PASTE_MIME_TYPES,
|
||||
EXPORT_DATA_TYPES,
|
||||
|
@ -16,8 +17,7 @@ import {
|
|||
import { deepCopyElement } from "./element/newElement";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import { getContainingFrame } from "./frame";
|
||||
import { isMemberOf, isPromiseLike } from "./utils";
|
||||
import { t } from "./i18n";
|
||||
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
|
||||
|
||||
type ElementsClipboard = {
|
||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||
|
@ -126,6 +126,7 @@ export const serializeAsClipboardJSON = ({
|
|||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles | null;
|
||||
}) => {
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const framesToCopy = new Set(
|
||||
elements.filter((element) => isFrameLikeElement(element)),
|
||||
);
|
||||
|
@ -152,8 +153,8 @@ export const serializeAsClipboardJSON = ({
|
|||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
elements: elements.map((element) => {
|
||||
if (
|
||||
getContainingFrame(element) &&
|
||||
!framesToCopy.has(getContainingFrame(element)!)
|
||||
getContainingFrame(element, elementsMap) &&
|
||||
!framesToCopy.has(getContainingFrame(element, elementsMap)!)
|
||||
) {
|
||||
const copiedElement = deepCopyElement(element);
|
||||
mutateElement(copiedElement, {
|
||||
|
@ -439,7 +440,7 @@ export const copyTextToSystemClipboard = async (
|
|||
|
||||
// (3) if that fails, use document.execCommand
|
||||
if (!copyTextViaExecCommand(text)) {
|
||||
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
||||
throw new Error("Error copying to clipboard.");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import oc from "open-color";
|
||||
import { Merge } from "./utility-types";
|
||||
import type { Merge } from "./utility-types";
|
||||
|
||||
// FIXME can't put to utils.ts rn because of circular dependency
|
||||
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
font-size: 0.875rem !important;
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
|
||||
svg {
|
||||
width: var(--lg-icon-size) !important;
|
||||
height: var(--lg-icon-size) !important;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { useState } from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import {
|
||||
import type { ActionManager } from "../actions/manager";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawElementType,
|
||||
NonDeletedElementsMap,
|
||||
NonDeletedSceneElementsMap,
|
||||
|
@ -16,14 +17,18 @@ import {
|
|||
hasStrokeWidth,
|
||||
} from "../scene";
|
||||
import { SHAPES } from "../shapes";
|
||||
import { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
|
||||
import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
|
||||
import { capitalizeString, isTransparent } from "../utils";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { SubtypeShapeActions } from "./Subtypes";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { hasBoundTextElement, isTextElement } from "../element/typeChecks";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "../element/typeChecks";
|
||||
import clsx from "clsx";
|
||||
import { actionToggleZenMode } from "../actions";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
@ -46,6 +51,40 @@ import {
|
|||
import { KEYS } from "../keys";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
|
||||
export const canChangeStrokeColor = (
|
||||
appState: UIAppState,
|
||||
targetElements: ExcalidrawElement[],
|
||||
) => {
|
||||
let commonSelectedType: ExcalidrawElementType | null =
|
||||
targetElements[0]?.type || null;
|
||||
|
||||
for (const element of targetElements) {
|
||||
if (element.type !== commonSelectedType) {
|
||||
commonSelectedType = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
(hasStrokeColor(appState.activeTool.type) &&
|
||||
appState.activeTool.type !== "image" &&
|
||||
commonSelectedType !== "image" &&
|
||||
commonSelectedType !== "frame" &&
|
||||
commonSelectedType !== "magicframe") ||
|
||||
targetElements.some((element) => hasStrokeColor(element.type))
|
||||
);
|
||||
};
|
||||
|
||||
export const canChangeBackgroundColor = (
|
||||
appState: UIAppState,
|
||||
targetElements: ExcalidrawElement[],
|
||||
) => {
|
||||
return (
|
||||
hasBackground(appState.activeTool.type) ||
|
||||
targetElements.some((element) => hasBackground(element.type))
|
||||
);
|
||||
};
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
elementsMap,
|
||||
|
@ -76,35 +115,22 @@ export const SelectedShapeActions = ({
|
|||
(element) =>
|
||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||
);
|
||||
const showChangeBackgroundIcons =
|
||||
hasBackground(appState.activeTool.type) ||
|
||||
targetElements.some((element) => hasBackground(element.type));
|
||||
|
||||
const showLinkIcon =
|
||||
targetElements.length === 1 || isSingleElementBoundContainer;
|
||||
|
||||
let commonSelectedType: ExcalidrawElementType | null =
|
||||
targetElements[0]?.type || null;
|
||||
|
||||
for (const element of targetElements) {
|
||||
if (element.type !== commonSelectedType) {
|
||||
commonSelectedType = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const showLineEditorAction =
|
||||
!appState.editingLinearElement &&
|
||||
targetElements.length === 1 &&
|
||||
isLinearElement(targetElements[0]);
|
||||
|
||||
return (
|
||||
<div className="panelColumn">
|
||||
<div>
|
||||
{((hasStrokeColor(appState.activeTool.type) &&
|
||||
appState.activeTool.type !== "image" &&
|
||||
commonSelectedType !== "image" &&
|
||||
commonSelectedType !== "frame" &&
|
||||
commonSelectedType !== "magicframe") ||
|
||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||
{canChangeStrokeColor(appState, targetElements) &&
|
||||
renderAction("changeStrokeColor")}
|
||||
</div>
|
||||
{showChangeBackgroundIcons && (
|
||||
{canChangeBackgroundColor(appState, targetElements) && (
|
||||
<div>{renderAction("changeBackgroundColor")}</div>
|
||||
)}
|
||||
<SubtypeShapeActions elements={targetElements} />
|
||||
|
@ -158,8 +184,8 @@ export const SelectedShapeActions = ({
|
|||
<div className="buttonList">
|
||||
{renderAction("sendToBack")}
|
||||
{renderAction("sendBackward")}
|
||||
{renderAction("bringToFront")}
|
||||
{renderAction("bringForward")}
|
||||
{renderAction("bringToFront")}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
|
@ -214,6 +240,7 @@ export const SelectedShapeActions = ({
|
|||
{renderAction("group")}
|
||||
{renderAction("ungroup")}
|
||||
{showLinkIcon && renderAction("hyperlink")}
|
||||
{showLineEditorAction && renderAction("toggleLinearEditor")}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
@ -308,6 +335,25 @@ export const ShapesSwitcher = ({
|
|||
title={t("toolBar.extraTools")}
|
||||
>
|
||||
{extraToolsIcon}
|
||||
{app.props.aiEnabled !== false && (
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
marginLeft: "auto",
|
||||
padding: "2px 4px",
|
||||
borderRadius: 6,
|
||||
fontSize: 8,
|
||||
fontFamily: "Cascadia, monospace",
|
||||
position: "absolute",
|
||||
background: "var(--color-promo)",
|
||||
color: "var(--color-surface-lowest)",
|
||||
bottom: 3,
|
||||
right: 4,
|
||||
}}
|
||||
>
|
||||
AI
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -9,8 +9,7 @@ type AvatarProps = {
|
|||
color: string;
|
||||
name: string;
|
||||
src?: string;
|
||||
isBeingFollowed?: boolean;
|
||||
isCurrentUser: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Avatar = ({
|
||||
|
@ -18,22 +17,14 @@ export const Avatar = ({
|
|||
onClick,
|
||||
name,
|
||||
src,
|
||||
isBeingFollowed,
|
||||
isCurrentUser,
|
||||
className,
|
||||
}: AvatarProps) => {
|
||||
const shortName = getNameInitial(name);
|
||||
const [error, setError] = useState(false);
|
||||
const loadImg = !error && src;
|
||||
const style = loadImg ? undefined : { background: color };
|
||||
return (
|
||||
<div
|
||||
className={clsx("Avatar", {
|
||||
"Avatar--is-followed": isBeingFollowed,
|
||||
"Avatar--is-current-user": isCurrentUser,
|
||||
})}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={clsx("Avatar", className)} style={style} onClick={onClick}>
|
||||
{loadImg ? (
|
||||
<img
|
||||
className="Avatar-img"
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getColor } from "./ColorPicker";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
ColorPickerType,
|
||||
activeColorPickerSectionAtom,
|
||||
} from "./colorPickerUtils";
|
||||
import type { ColorPickerType } from "./colorPickerUtils";
|
||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||
import { eyeDropperIcon } from "../icons";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import { KEYS } from "../../keys";
|
||||
|
|
|
@ -1,16 +1,15 @@
|
|||
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { AppState } from "../../types";
|
||||
import type { ExcalidrawElement } from "../../element/types";
|
||||
import type { AppState } from "../../types";
|
||||
import { TopPicks } from "./TopPicks";
|
||||
import { Picker } from "./Picker";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
activeColorPickerSectionAtom,
|
||||
ColorPickerType,
|
||||
} from "./colorPickerUtils";
|
||||
import type { ColorPickerType } from "./colorPickerUtils";
|
||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||
import { useDevice, useExcalidrawContainer } from "../App";
|
||||
import { ColorTuple, COLOR_PALETTE, ColorPaletteCustom } from "../../colors";
|
||||
import type { ColorTuple, ColorPaletteCustom } from "../../colors";
|
||||
import { COLOR_PALETTE } from "../../colors";
|
||||
import PickerHeading from "./PickerHeading";
|
||||
import { t } from "../../i18n";
|
||||
import clsx from "clsx";
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import type { ExcalidrawElement } from "../../element/types";
|
||||
import { ShadeList } from "./ShadeList";
|
||||
|
||||
import PickerColorList from "./PickerColorList";
|
||||
|
@ -9,15 +9,15 @@ import { useAtom } from "jotai";
|
|||
import { CustomColorList } from "./CustomColorList";
|
||||
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
|
||||
import PickerHeading from "./PickerHeading";
|
||||
import type { ColorPickerType } from "./colorPickerUtils";
|
||||
import {
|
||||
ColorPickerType,
|
||||
activeColorPickerSectionAtom,
|
||||
getColorNameAndShadeFromColor,
|
||||
getMostUsedCustomColors,
|
||||
isCustomColor,
|
||||
} from "./colorPickerUtils";
|
||||
import type { ColorPaletteCustom } from "../../colors";
|
||||
import {
|
||||
ColorPaletteCustom,
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
||||
} from "../../colors";
|
||||
|
|
|
@ -7,8 +7,9 @@ import {
|
|||
getColorNameAndShadeFromColor,
|
||||
} from "./colorPickerUtils";
|
||||
import HotkeyLabel from "./HotkeyLabel";
|
||||
import { ColorPaletteCustom } from "../../colors";
|
||||
import { TranslationKeys, t } from "../../i18n";
|
||||
import type { ColorPaletteCustom } from "../../colors";
|
||||
import type { TranslationKeys } from "../../i18n";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
interface PickerColorListProps {
|
||||
palette: ColorPaletteCustom;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const PickerHeading = ({ children }: { children: ReactNode }) => (
|
||||
<div className="color-picker__heading">{children}</div>
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from "./colorPickerUtils";
|
||||
import HotkeyLabel from "./HotkeyLabel";
|
||||
import { t } from "../../i18n";
|
||||
import { ColorPaletteCustom } from "../../colors";
|
||||
import type { ColorPaletteCustom } from "../../colors";
|
||||
|
||||
interface ShadeListProps {
|
||||
hex: string;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import clsx from "clsx";
|
||||
import { ColorPickerType } from "./colorPickerUtils";
|
||||
import type { ColorPickerType } from "./colorPickerUtils";
|
||||
import {
|
||||
DEFAULT_CANVAS_BACKGROUND_PICKS,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
import { ExcalidrawElement } from "../../element/types";
|
||||
import type { ExcalidrawElement } from "../../element/types";
|
||||
import { atom } from "jotai";
|
||||
import {
|
||||
ColorPickerColor,
|
||||
ColorPaletteCustom,
|
||||
MAX_CUSTOM_COLORS_USED_IN_CANVAS,
|
||||
} from "../../colors";
|
||||
import type { ColorPickerColor, ColorPaletteCustom } from "../../colors";
|
||||
import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "../../colors";
|
||||
|
||||
export const getColorNameAndShadeFromColor = ({
|
||||
palette,
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
import { KEYS } from "../../keys";
|
||||
import {
|
||||
import type {
|
||||
ColorPickerColor,
|
||||
ColorPalette,
|
||||
ColorPaletteCustom,
|
||||
COLORS_PER_ROW,
|
||||
COLOR_PALETTE,
|
||||
} from "../../colors";
|
||||
import { ValueOf } from "../../utility-types";
|
||||
import { COLORS_PER_ROW, COLOR_PALETTE } from "../../colors";
|
||||
import type { ValueOf } from "../../utility-types";
|
||||
import type { ActiveColorPickerSectionAtomType } from "./colorPickerUtils";
|
||||
import {
|
||||
ActiveColorPickerSectionAtomType,
|
||||
colorPickerHotkeyBindings,
|
||||
getColorNameAndShadeFromColor,
|
||||
} from "./colorPickerUtils";
|
||||
|
|
|
@ -0,0 +1,137 @@
|
|||
@import "../../css/variables.module.scss";
|
||||
|
||||
$verticalBreakpoint: 861px;
|
||||
|
||||
.excalidraw {
|
||||
.command-palette-dialog {
|
||||
user-select: none;
|
||||
|
||||
.Modal__content {
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
|
||||
@media screen and (min-width: $verticalBreakpoint) {
|
||||
max-height: 750px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.Island {
|
||||
height: 100%;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.Dialog__content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.shortcuts-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
gap: 0.25rem;
|
||||
|
||||
.shortcut-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.shortcut-plus {
|
||||
margin: 0px 4px;
|
||||
}
|
||||
|
||||
.shortcut-key {
|
||||
padding: 0px 4px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--color-primary-light);
|
||||
}
|
||||
|
||||
.shortcut-desc {
|
||||
margin-left: 4px;
|
||||
color: var(--color-gray-50);
|
||||
}
|
||||
}
|
||||
|
||||
.commands {
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
margin-top: 12px;
|
||||
color: var(--popup-text-color);
|
||||
user-select: none;
|
||||
|
||||
.command-category {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 0px;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.command-category-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.command-item {
|
||||
color: var(--popup-text-color);
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: var(--border-radius-lg);
|
||||
cursor: pointer;
|
||||
|
||||
&:active {
|
||||
background-color: var(--color-surface-low);
|
||||
}
|
||||
|
||||
.name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.item-selected {
|
||||
background-color: var(--color-surface-mid);
|
||||
}
|
||||
|
||||
.item-disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.no-match {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
}
|
934
packages/excalidraw/components/CommandPalette/CommandPalette.tsx
Normal file
934
packages/excalidraw/components/CommandPalette/CommandPalette.tsx
Normal file
|
@ -0,0 +1,934 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
useApp,
|
||||
useAppProps,
|
||||
useExcalidrawActionManager,
|
||||
useExcalidrawSetAppState,
|
||||
} from "../App";
|
||||
import { KEYS } from "../../keys";
|
||||
import { Dialog } from "../Dialog";
|
||||
import { TextField } from "../TextField";
|
||||
import clsx from "clsx";
|
||||
import { getSelectedElements } from "../../scene";
|
||||
import type { Action } from "../../actions/types";
|
||||
import type { TranslationKeys } from "../../i18n";
|
||||
import { t } from "../../i18n";
|
||||
import type { ShortcutName } from "../../actions/shortcuts";
|
||||
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
|
||||
import { DEFAULT_SIDEBAR, EVENT } from "../../constants";
|
||||
import {
|
||||
LockedIcon,
|
||||
UnlockedIcon,
|
||||
clockIcon,
|
||||
searchIcon,
|
||||
boltIcon,
|
||||
bucketFillIcon,
|
||||
ExportImageIcon,
|
||||
mermaidLogoIcon,
|
||||
brainIconThin,
|
||||
LibraryIcon,
|
||||
} from "../icons";
|
||||
import fuzzy from "fuzzy";
|
||||
import { useUIAppState } from "../../context/ui-appState";
|
||||
import type { AppProps, AppState, UIAppState } from "../../types";
|
||||
import {
|
||||
capitalizeString,
|
||||
getShortcutKey,
|
||||
isWritableElement,
|
||||
} from "../../utils";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { deburr } from "../../deburr";
|
||||
import type { MarkRequired } from "../../utility-types";
|
||||
import { InlineIcon } from "../InlineIcon";
|
||||
import { SHAPES } from "../../shapes";
|
||||
import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
|
||||
import { useStableCallback } from "../../hooks/useStableCallback";
|
||||
import { actionClearCanvas, actionLink } from "../../actions";
|
||||
import { jotaiStore } from "../../jotai";
|
||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||
import type { CommandPaletteItem } from "./types";
|
||||
import * as defaultItems from "./defaultCommandPaletteItems";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { useStable } from "../../hooks/useStable";
|
||||
|
||||
import "./CommandPalette.scss";
|
||||
|
||||
const lastUsedPaletteItem = atom<CommandPaletteItem | null>(null);
|
||||
|
||||
export const DEFAULT_CATEGORIES = {
|
||||
app: "App",
|
||||
export: "Export",
|
||||
tools: "Tools",
|
||||
editor: "Editor",
|
||||
elements: "Elements",
|
||||
links: "Links",
|
||||
};
|
||||
|
||||
const getCategoryOrder = (category: string) => {
|
||||
switch (category) {
|
||||
case DEFAULT_CATEGORIES.app:
|
||||
return 1;
|
||||
case DEFAULT_CATEGORIES.export:
|
||||
return 2;
|
||||
case DEFAULT_CATEGORIES.editor:
|
||||
return 3;
|
||||
case DEFAULT_CATEGORIES.tools:
|
||||
return 4;
|
||||
case DEFAULT_CATEGORIES.elements:
|
||||
return 5;
|
||||
case DEFAULT_CATEGORIES.links:
|
||||
return 6;
|
||||
default:
|
||||
return 10;
|
||||
}
|
||||
};
|
||||
|
||||
const CommandShortcutHint = ({
|
||||
shortcut,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
shortcut: string;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
const shortcuts = shortcut.replace("++", "+$").split("+");
|
||||
|
||||
return (
|
||||
<div className={clsx("shortcut", className)}>
|
||||
{shortcuts.map((item, idx) => {
|
||||
return (
|
||||
<div className="shortcut-wrapper" key={item}>
|
||||
<div className="shortcut-key">{item === "$" ? "+" : item}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="shortcut-desc">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const isCommandPaletteToggleShortcut = (event: KeyboardEvent) => {
|
||||
return (
|
||||
!event.altKey &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
((event.shiftKey && event.key.toLowerCase() === KEYS.P) ||
|
||||
event.key === KEYS.SLASH)
|
||||
);
|
||||
};
|
||||
|
||||
type CommandPaletteProps = {
|
||||
customCommandPaletteItems?: CommandPaletteItem[];
|
||||
};
|
||||
|
||||
export const CommandPalette = Object.assign(
|
||||
(props: CommandPaletteProps) => {
|
||||
const uiAppState = useUIAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
useEffect(() => {
|
||||
const commandPaletteShortcut = (event: KeyboardEvent) => {
|
||||
if (isCommandPaletteToggleShortcut(event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setAppState((appState) => {
|
||||
const nextState =
|
||||
appState.openDialog?.name === "commandPalette"
|
||||
? null
|
||||
: ({ name: "commandPalette" } as const);
|
||||
|
||||
if (nextState) {
|
||||
trackEvent("command_palette", "open", "shortcut");
|
||||
}
|
||||
|
||||
return {
|
||||
openDialog: nextState,
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener(EVENT.KEYDOWN, commandPaletteShortcut, {
|
||||
capture: true,
|
||||
});
|
||||
return () =>
|
||||
window.removeEventListener(EVENT.KEYDOWN, commandPaletteShortcut, {
|
||||
capture: true,
|
||||
});
|
||||
}, [setAppState]);
|
||||
|
||||
if (uiAppState.openDialog?.name !== "commandPalette") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <CommandPaletteInner {...props} />;
|
||||
},
|
||||
{
|
||||
defaultItems,
|
||||
},
|
||||
);
|
||||
|
||||
function CommandPaletteInner({
|
||||
customCommandPaletteItems,
|
||||
}: CommandPaletteProps) {
|
||||
const app = useApp();
|
||||
const uiAppState = useUIAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const appProps = useAppProps();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
const [lastUsed, setLastUsed] = useAtom(lastUsedPaletteItem);
|
||||
const [allCommands, setAllCommands] = useState<
|
||||
MarkRequired<CommandPaletteItem, "haystack" | "order">[] | null
|
||||
>(null);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const stableDeps = useStable({
|
||||
uiAppState,
|
||||
customCommandPaletteItems,
|
||||
appProps,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// these props change often and we don't want them to re-run the effect
|
||||
// which would renew `allCommands`, cascading down and resetting state.
|
||||
//
|
||||
// This means that the commands won't update on appState/appProps changes
|
||||
// while the command palette is open
|
||||
const { uiAppState, customCommandPaletteItems, appProps } = stableDeps;
|
||||
|
||||
const getActionLabel = (action: Action) => {
|
||||
let label = "";
|
||||
if (action.label) {
|
||||
if (typeof action.label === "function") {
|
||||
label = t(
|
||||
action.label(
|
||||
app.scene.getNonDeletedElements(),
|
||||
uiAppState as AppState,
|
||||
app,
|
||||
) as unknown as TranslationKeys,
|
||||
);
|
||||
} else {
|
||||
label = t(action.label as unknown as TranslationKeys);
|
||||
}
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
const getActionIcon = (action: Action) => {
|
||||
if (typeof action.icon === "function") {
|
||||
return action.icon(uiAppState, app.scene.getNonDeletedElements());
|
||||
}
|
||||
return action.icon;
|
||||
};
|
||||
|
||||
let commandsFromActions: CommandPaletteItem[] = [];
|
||||
|
||||
const actionToCommand = (
|
||||
action: Action,
|
||||
category: string,
|
||||
transformer?: (
|
||||
command: CommandPaletteItem,
|
||||
action: Action,
|
||||
) => CommandPaletteItem,
|
||||
): CommandPaletteItem => {
|
||||
const command: CommandPaletteItem = {
|
||||
label: getActionLabel(action),
|
||||
icon: getActionIcon(action),
|
||||
category,
|
||||
shortcut: getShortcutFromShortcutName(action.name as ShortcutName),
|
||||
keywords: action.keywords,
|
||||
predicate: action.predicate,
|
||||
viewMode: action.viewMode,
|
||||
perform: () => {
|
||||
actionManager.executeAction(action, "commandPalette");
|
||||
},
|
||||
};
|
||||
|
||||
return transformer ? transformer(command, action) : command;
|
||||
};
|
||||
|
||||
if (uiAppState && app.scene && actionManager) {
|
||||
const elementsCommands: CommandPaletteItem[] = [
|
||||
actionManager.actions.group,
|
||||
actionManager.actions.ungroup,
|
||||
actionManager.actions.cut,
|
||||
actionManager.actions.copy,
|
||||
actionManager.actions.deleteSelectedElements,
|
||||
actionManager.actions.copyStyles,
|
||||
actionManager.actions.pasteStyles,
|
||||
actionManager.actions.bringToFront,
|
||||
actionManager.actions.bringForward,
|
||||
actionManager.actions.sendBackward,
|
||||
actionManager.actions.sendToBack,
|
||||
actionManager.actions.alignTop,
|
||||
actionManager.actions.alignBottom,
|
||||
actionManager.actions.alignLeft,
|
||||
actionManager.actions.alignRight,
|
||||
actionManager.actions.alignVerticallyCentered,
|
||||
actionManager.actions.alignHorizontallyCentered,
|
||||
actionManager.actions.duplicateSelection,
|
||||
actionManager.actions.flipHorizontal,
|
||||
actionManager.actions.flipVertical,
|
||||
actionManager.actions.zoomToFitSelection,
|
||||
actionManager.actions.zoomToFitSelectionInViewport,
|
||||
actionManager.actions.increaseFontSize,
|
||||
actionManager.actions.decreaseFontSize,
|
||||
actionManager.actions.toggleLinearEditor,
|
||||
actionLink,
|
||||
].map((action: Action) =>
|
||||
actionToCommand(
|
||||
action,
|
||||
DEFAULT_CATEGORIES.elements,
|
||||
(command, action) => ({
|
||||
...command,
|
||||
predicate: action.predicate
|
||||
? action.predicate
|
||||
: (elements, appState, appProps, app) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
elements,
|
||||
appState,
|
||||
);
|
||||
return selectedElements.length > 0;
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const toolCommands: CommandPaletteItem[] = [
|
||||
actionManager.actions.toggleHandTool,
|
||||
actionManager.actions.setFrameAsActiveTool,
|
||||
].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.tools));
|
||||
|
||||
const editorCommands: CommandPaletteItem[] = [
|
||||
actionManager.actions.undo,
|
||||
actionManager.actions.redo,
|
||||
actionManager.actions.zoomIn,
|
||||
actionManager.actions.zoomOut,
|
||||
actionManager.actions.resetZoom,
|
||||
actionManager.actions.zoomToFit,
|
||||
actionManager.actions.zenMode,
|
||||
actionManager.actions.viewMode,
|
||||
actionManager.actions.gridMode,
|
||||
actionManager.actions.objectsSnapMode,
|
||||
actionManager.actions.toggleShortcuts,
|
||||
actionManager.actions.selectAll,
|
||||
actionManager.actions.toggleElementLock,
|
||||
actionManager.actions.unlockAllElements,
|
||||
actionManager.actions.stats,
|
||||
].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.editor));
|
||||
|
||||
const exportCommands: CommandPaletteItem[] = [
|
||||
actionManager.actions.saveToActiveFile,
|
||||
actionManager.actions.saveFileToDisk,
|
||||
actionManager.actions.copyAsPng,
|
||||
actionManager.actions.copyAsSvg,
|
||||
].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.export));
|
||||
|
||||
commandsFromActions = [
|
||||
...elementsCommands,
|
||||
...editorCommands,
|
||||
{
|
||||
label: getActionLabel(actionClearCanvas),
|
||||
icon: getActionIcon(actionClearCanvas),
|
||||
shortcut: getShortcutFromShortcutName(
|
||||
actionClearCanvas.name as ShortcutName,
|
||||
),
|
||||
category: DEFAULT_CATEGORIES.editor,
|
||||
keywords: ["delete", "destroy"],
|
||||
viewMode: false,
|
||||
perform: () => {
|
||||
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("buttons.exportImage"),
|
||||
category: DEFAULT_CATEGORIES.export,
|
||||
icon: ExportImageIcon,
|
||||
shortcut: getShortcutFromShortcutName("imageExport"),
|
||||
keywords: [
|
||||
"export",
|
||||
"image",
|
||||
"png",
|
||||
"jpeg",
|
||||
"svg",
|
||||
"clipboard",
|
||||
"picture",
|
||||
],
|
||||
perform: () => {
|
||||
setAppState({ openDialog: { name: "imageExport" } });
|
||||
},
|
||||
},
|
||||
...exportCommands,
|
||||
];
|
||||
|
||||
const additionalCommands: CommandPaletteItem[] = [
|
||||
{
|
||||
label: t("toolBar.library"),
|
||||
category: DEFAULT_CATEGORIES.app,
|
||||
icon: LibraryIcon,
|
||||
viewMode: false,
|
||||
perform: () => {
|
||||
if (uiAppState.openSidebar) {
|
||||
setAppState({
|
||||
openSidebar: null,
|
||||
});
|
||||
} else {
|
||||
setAppState({
|
||||
openSidebar: {
|
||||
name: DEFAULT_SIDEBAR.name,
|
||||
tab: DEFAULT_SIDEBAR.defaultTab,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("labels.changeStroke"),
|
||||
keywords: ["color", "outline"],
|
||||
category: DEFAULT_CATEGORIES.elements,
|
||||
icon: bucketFillIcon,
|
||||
viewMode: false,
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return (
|
||||
selectedElements.length > 0 &&
|
||||
canChangeStrokeColor(appState, selectedElements)
|
||||
);
|
||||
},
|
||||
perform: () => {
|
||||
setAppState((prevState) => ({
|
||||
openMenu: prevState.openMenu === "shape" ? null : "shape",
|
||||
openPopup: "elementStroke",
|
||||
}));
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("labels.changeBackground"),
|
||||
keywords: ["color", "fill"],
|
||||
icon: bucketFillIcon,
|
||||
category: DEFAULT_CATEGORIES.elements,
|
||||
viewMode: false,
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return (
|
||||
selectedElements.length > 0 &&
|
||||
canChangeBackgroundColor(appState, selectedElements)
|
||||
);
|
||||
},
|
||||
perform: () => {
|
||||
setAppState((prevState) => ({
|
||||
openMenu: prevState.openMenu === "shape" ? null : "shape",
|
||||
openPopup: "elementBackground",
|
||||
}));
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("labels.canvasBackground"),
|
||||
keywords: ["color"],
|
||||
icon: bucketFillIcon,
|
||||
category: DEFAULT_CATEGORIES.editor,
|
||||
viewMode: false,
|
||||
perform: () => {
|
||||
setAppState((prevState) => ({
|
||||
openMenu: prevState.openMenu === "canvas" ? null : "canvas",
|
||||
openPopup: "canvasBackground",
|
||||
}));
|
||||
},
|
||||
},
|
||||
...SHAPES.reduce((acc: CommandPaletteItem[], shape) => {
|
||||
const { value, icon, key, numericKey } = shape;
|
||||
|
||||
if (
|
||||
appProps.UIOptions.tools?.[
|
||||
value as Extract<
|
||||
typeof value,
|
||||
keyof AppProps["UIOptions"]["tools"]
|
||||
>
|
||||
] === false
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const letter =
|
||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter || numericKey;
|
||||
|
||||
const command: CommandPaletteItem = {
|
||||
label: t(`toolBar.${value}`),
|
||||
category: DEFAULT_CATEGORIES.tools,
|
||||
shortcut,
|
||||
icon,
|
||||
keywords: ["toolbar"],
|
||||
viewMode: false,
|
||||
perform: ({ event }) => {
|
||||
if (value === "image") {
|
||||
app.setActiveTool({
|
||||
type: value,
|
||||
insertOnCanvasDirectly: event.type === EVENT.KEYDOWN,
|
||||
});
|
||||
} else {
|
||||
app.setActiveTool({ type: value });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
acc.push(command);
|
||||
|
||||
return acc;
|
||||
}, []),
|
||||
...toolCommands,
|
||||
{
|
||||
label: t("toolBar.lock"),
|
||||
category: DEFAULT_CATEGORIES.tools,
|
||||
icon: uiAppState.activeTool.locked ? LockedIcon : UnlockedIcon,
|
||||
shortcut: KEYS.Q.toLocaleUpperCase(),
|
||||
viewMode: false,
|
||||
perform: () => {
|
||||
app.toggleLock();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: `${t("labels.textToDiagram")}...`,
|
||||
category: DEFAULT_CATEGORIES.tools,
|
||||
icon: brainIconThin,
|
||||
viewMode: false,
|
||||
predicate: appProps.aiEnabled,
|
||||
perform: () => {
|
||||
setAppState((state) => ({
|
||||
...state,
|
||||
openDialog: {
|
||||
name: "ttd",
|
||||
tab: "text-to-diagram",
|
||||
},
|
||||
}));
|
||||
},
|
||||
},
|
||||
{
|
||||
label: `${t("toolBar.mermaidToExcalidraw")}...`,
|
||||
category: DEFAULT_CATEGORIES.tools,
|
||||
icon: mermaidLogoIcon,
|
||||
viewMode: false,
|
||||
predicate: appProps.aiEnabled,
|
||||
perform: () => {
|
||||
setAppState((state) => ({
|
||||
...state,
|
||||
openDialog: {
|
||||
name: "ttd",
|
||||
tab: "mermaid",
|
||||
},
|
||||
}));
|
||||
},
|
||||
},
|
||||
// {
|
||||
// label: `${t("toolBar.magicframe")}...`,
|
||||
// category: DEFAULT_CATEGORIES.tools,
|
||||
// icon: MagicIconThin,
|
||||
// viewMode: false,
|
||||
// predicate: appProps.aiEnabled,
|
||||
// perform: () => {
|
||||
// app.onMagicframeToolSelect();
|
||||
// },
|
||||
// },
|
||||
];
|
||||
|
||||
const allCommands = [
|
||||
...commandsFromActions,
|
||||
...additionalCommands,
|
||||
...(customCommandPaletteItems || []),
|
||||
].map((command) => {
|
||||
return {
|
||||
...command,
|
||||
icon: command.icon || boltIcon,
|
||||
order: command.order ?? getCategoryOrder(command.category),
|
||||
haystack: `${deburr(command.label)} ${
|
||||
command.keywords?.join(" ") || ""
|
||||
}`,
|
||||
};
|
||||
});
|
||||
|
||||
setAllCommands(allCommands);
|
||||
setLastUsed(
|
||||
allCommands.find((command) => command.label === lastUsed?.label) ??
|
||||
null,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
stableDeps,
|
||||
app,
|
||||
actionManager,
|
||||
setAllCommands,
|
||||
lastUsed?.label,
|
||||
setLastUsed,
|
||||
setAppState,
|
||||
]);
|
||||
|
||||
const [commandSearch, setCommandSearch] = useState("");
|
||||
const [currentCommand, setCurrentCommand] =
|
||||
useState<CommandPaletteItem | null>(null);
|
||||
const [commandsByCategory, setCommandsByCategory] = useState<
|
||||
Record<string, CommandPaletteItem[]>
|
||||
>({});
|
||||
|
||||
const closeCommandPalette = (cb?: () => void) => {
|
||||
setAppState(
|
||||
{
|
||||
openDialog: null,
|
||||
},
|
||||
cb,
|
||||
);
|
||||
setCommandSearch("");
|
||||
};
|
||||
|
||||
const executeCommand = (
|
||||
command: CommandPaletteItem,
|
||||
event: React.MouseEvent | React.KeyboardEvent | KeyboardEvent,
|
||||
) => {
|
||||
if (uiAppState.openDialog?.name === "commandPalette") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
document.body.classList.add("excalidraw-animations-disabled");
|
||||
closeCommandPalette(() => {
|
||||
command.perform({ actionManager, event });
|
||||
setLastUsed(command);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
document.body.classList.remove("excalidraw-animations-disabled");
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isCommandAvailable = useStableCallback(
|
||||
(command: CommandPaletteItem) => {
|
||||
if (command.viewMode === false && uiAppState.viewModeEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof command.predicate === "function"
|
||||
? command.predicate(
|
||||
app.scene.getNonDeletedElements(),
|
||||
uiAppState as AppState,
|
||||
appProps,
|
||||
app,
|
||||
)
|
||||
: command.predicate === undefined || command.predicate;
|
||||
},
|
||||
);
|
||||
|
||||
const handleKeyDown = useStableCallback((event: KeyboardEvent) => {
|
||||
const ignoreAlphanumerics =
|
||||
isWritableElement(event.target) ||
|
||||
isCommandPaletteToggleShortcut(event) ||
|
||||
event.key === KEYS.ESCAPE;
|
||||
|
||||
if (
|
||||
ignoreAlphanumerics &&
|
||||
event.key !== KEYS.ARROW_UP &&
|
||||
event.key !== KEYS.ARROW_DOWN &&
|
||||
event.key !== KEYS.ENTER
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingCommands = Object.values(commandsByCategory).flat();
|
||||
const shouldConsiderLastUsed =
|
||||
lastUsed && !commandSearch && isCommandAvailable(lastUsed);
|
||||
|
||||
if (event.key === KEYS.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
const index = matchingCommands.findIndex(
|
||||
(item) => item.label === currentCommand?.label,
|
||||
);
|
||||
|
||||
if (shouldConsiderLastUsed) {
|
||||
if (index === 0) {
|
||||
setCurrentCommand(lastUsed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentCommand === lastUsed) {
|
||||
const nextItem = matchingCommands[matchingCommands.length - 1];
|
||||
if (nextItem) {
|
||||
setCurrentCommand(nextItem);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let nextIndex;
|
||||
|
||||
if (index === -1) {
|
||||
nextIndex = matchingCommands.length - 1;
|
||||
} else {
|
||||
nextIndex =
|
||||
index === 0
|
||||
? matchingCommands.length - 1
|
||||
: (index - 1) % matchingCommands.length;
|
||||
}
|
||||
|
||||
const nextItem = matchingCommands[nextIndex];
|
||||
if (nextItem) {
|
||||
setCurrentCommand(nextItem);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === KEYS.ARROW_DOWN) {
|
||||
event.preventDefault();
|
||||
const index = matchingCommands.findIndex(
|
||||
(item) => item.label === currentCommand?.label,
|
||||
);
|
||||
|
||||
if (shouldConsiderLastUsed) {
|
||||
if (!currentCommand || index === matchingCommands.length - 1) {
|
||||
setCurrentCommand(lastUsed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentCommand === lastUsed) {
|
||||
const nextItem = matchingCommands[0];
|
||||
if (nextItem) {
|
||||
setCurrentCommand(nextItem);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const nextIndex = (index + 1) % matchingCommands.length;
|
||||
const nextItem = matchingCommands[nextIndex];
|
||||
if (nextItem) {
|
||||
setCurrentCommand(nextItem);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === KEYS.ENTER) {
|
||||
if (currentCommand) {
|
||||
setTimeout(() => {
|
||||
executeCommand(currentCommand, event);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (ignoreAlphanumerics) {
|
||||
return;
|
||||
}
|
||||
|
||||
// prevent regular editor shortcuts
|
||||
event.stopPropagation();
|
||||
|
||||
// if alphanumeric keypress and we're not inside the input, focus it
|
||||
if (/^[a-zA-Z0-9]$/.test(event.key)) {
|
||||
inputRef?.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener(EVENT.KEYDOWN, handleKeyDown, {
|
||||
capture: true,
|
||||
});
|
||||
return () =>
|
||||
window.removeEventListener(EVENT.KEYDOWN, handleKeyDown, {
|
||||
capture: true,
|
||||
});
|
||||
}, [handleKeyDown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allCommands) {
|
||||
return;
|
||||
}
|
||||
|
||||
const getNextCommandsByCategory = (commands: CommandPaletteItem[]) => {
|
||||
const nextCommandsByCategory: Record<string, CommandPaletteItem[]> = {};
|
||||
for (const command of commands) {
|
||||
if (nextCommandsByCategory[command.category]) {
|
||||
nextCommandsByCategory[command.category].push(command);
|
||||
} else {
|
||||
nextCommandsByCategory[command.category] = [command];
|
||||
}
|
||||
}
|
||||
|
||||
return nextCommandsByCategory;
|
||||
};
|
||||
|
||||
let matchingCommands = allCommands
|
||||
.filter(isCommandAvailable)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
const showLastUsed =
|
||||
!commandSearch && lastUsed && isCommandAvailable(lastUsed);
|
||||
|
||||
if (!commandSearch) {
|
||||
setCommandsByCategory(
|
||||
getNextCommandsByCategory(
|
||||
showLastUsed
|
||||
? matchingCommands.filter(
|
||||
(command) => command.label !== lastUsed?.label,
|
||||
)
|
||||
: matchingCommands,
|
||||
),
|
||||
);
|
||||
setCurrentCommand(showLastUsed ? lastUsed : matchingCommands[0] || null);
|
||||
return;
|
||||
}
|
||||
|
||||
const _query = deburr(commandSearch.replace(/[<>-_| ]/g, ""));
|
||||
matchingCommands = fuzzy
|
||||
.filter(_query, matchingCommands, {
|
||||
extract: (command) => command.haystack,
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((item) => item.original);
|
||||
|
||||
setCommandsByCategory(getNextCommandsByCategory(matchingCommands));
|
||||
setCurrentCommand(matchingCommands[0] ?? null);
|
||||
}, [commandSearch, allCommands, isCommandAvailable, lastUsed]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onCloseRequest={() => closeCommandPalette()}
|
||||
closeOnClickOutside
|
||||
title={false}
|
||||
size={720}
|
||||
autofocus
|
||||
className="command-palette-dialog"
|
||||
>
|
||||
<TextField
|
||||
value={commandSearch}
|
||||
placeholder={t("commandPalette.search.placeholder")}
|
||||
onChange={(value) => {
|
||||
setCommandSearch(value);
|
||||
}}
|
||||
selectOnRender
|
||||
ref={inputRef}
|
||||
/>
|
||||
|
||||
{!app.device.viewport.isMobile && (
|
||||
<div className="shortcuts-wrapper">
|
||||
<CommandShortcutHint shortcut="↑↓">
|
||||
{t("commandPalette.shortcuts.select")}
|
||||
</CommandShortcutHint>
|
||||
<CommandShortcutHint shortcut="↵">
|
||||
{t("commandPalette.shortcuts.confirm")}
|
||||
</CommandShortcutHint>
|
||||
<CommandShortcutHint shortcut={getShortcutKey("Esc")}>
|
||||
{t("commandPalette.shortcuts.close")}
|
||||
</CommandShortcutHint>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="commands">
|
||||
{lastUsed && !commandSearch && (
|
||||
<div className="command-category">
|
||||
<div className="command-category-title">
|
||||
{t("commandPalette.recents")}
|
||||
<div
|
||||
className="icon"
|
||||
style={{
|
||||
marginLeft: "6px",
|
||||
}}
|
||||
>
|
||||
{clockIcon}
|
||||
</div>
|
||||
</div>
|
||||
<CommandItem
|
||||
command={lastUsed}
|
||||
isSelected={lastUsed.label === currentCommand?.label}
|
||||
onClick={(event) => executeCommand(lastUsed, event)}
|
||||
disabled={!isCommandAvailable(lastUsed)}
|
||||
onMouseMove={() => setCurrentCommand(lastUsed)}
|
||||
showShortcut={!app.device.viewport.isMobile}
|
||||
appState={uiAppState}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(commandsByCategory).length > 0 ? (
|
||||
Object.keys(commandsByCategory).map((category, idx) => {
|
||||
return (
|
||||
<div className="command-category" key={category}>
|
||||
<div className="command-category-title">{category}</div>
|
||||
{commandsByCategory[category].map((command) => (
|
||||
<CommandItem
|
||||
key={command.label}
|
||||
command={command}
|
||||
isSelected={command.label === currentCommand?.label}
|
||||
onClick={(event) => executeCommand(command, event)}
|
||||
onMouseMove={() => setCurrentCommand(command)}
|
||||
showShortcut={!app.device.viewport.isMobile}
|
||||
appState={uiAppState}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : allCommands ? (
|
||||
<div className="no-match">
|
||||
<div className="icon">{searchIcon}</div>{" "}
|
||||
{t("commandPalette.search.noMatch")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const CommandItem = ({
|
||||
command,
|
||||
isSelected,
|
||||
disabled,
|
||||
onMouseMove,
|
||||
onClick,
|
||||
showShortcut,
|
||||
appState,
|
||||
}: {
|
||||
command: CommandPaletteItem;
|
||||
isSelected: boolean;
|
||||
disabled?: boolean;
|
||||
onMouseMove: () => void;
|
||||
onClick: (event: React.MouseEvent) => void;
|
||||
showShortcut: boolean;
|
||||
appState: UIAppState;
|
||||
}) => {
|
||||
const noop = () => {};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("command-item", {
|
||||
"item-selected": isSelected,
|
||||
"item-disabled": disabled,
|
||||
})}
|
||||
ref={(ref) => {
|
||||
if (isSelected && !disabled) {
|
||||
ref?.scrollIntoView?.({
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClick={disabled ? noop : onClick}
|
||||
onMouseMove={disabled ? noop : onMouseMove}
|
||||
title={disabled ? t("commandPalette.itemNotAvailable") : ""}
|
||||
>
|
||||
<div className="name">
|
||||
{command.icon && (
|
||||
<InlineIcon
|
||||
icon={
|
||||
typeof command.icon === "function"
|
||||
? command.icon(appState)
|
||||
: command.icon
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{command.label}
|
||||
</div>
|
||||
{showShortcut && command.shortcut && (
|
||||
<CommandShortcutHint shortcut={command.shortcut} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
import { actionToggleTheme } from "../../actions";
|
||||
import type { CommandPaletteItem } from "./types";
|
||||
|
||||
export const toggleTheme: CommandPaletteItem = {
|
||||
...actionToggleTheme,
|
||||
category: "App",
|
||||
label: "Toggle theme",
|
||||
perform: ({ actionManager }) => {
|
||||
actionManager.executeAction(actionToggleTheme, "commandPalette");
|
||||
},
|
||||
};
|
26
packages/excalidraw/components/CommandPalette/types.ts
Normal file
26
packages/excalidraw/components/CommandPalette/types.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import type { ActionManager } from "../../actions/manager";
|
||||
import type { Action } from "../../actions/types";
|
||||
import type { UIAppState } from "../../types";
|
||||
|
||||
export type CommandPaletteItem = {
|
||||
label: string;
|
||||
/** additional keywords to match against
|
||||
* (appended to haystack, not displayed) */
|
||||
keywords?: string[];
|
||||
/**
|
||||
* string we should match against when searching
|
||||
* (deburred name + keywords)
|
||||
*/
|
||||
haystack?: string;
|
||||
icon?: React.ReactNode | ((appState: UIAppState) => React.ReactNode);
|
||||
category: string;
|
||||
order?: number;
|
||||
predicate?: boolean | Action["predicate"];
|
||||
shortcut?: string;
|
||||
/** if false, command will not show while in view mode */
|
||||
viewMode?: boolean;
|
||||
perform: (data: {
|
||||
actionManager: ActionManager;
|
||||
event: React.MouseEvent | React.KeyboardEvent | KeyboardEvent;
|
||||
}) => void;
|
||||
};
|
|
@ -1,5 +1,6 @@
|
|||
import { t } from "../i18n";
|
||||
import { Dialog, DialogProps } from "./Dialog";
|
||||
import type { DialogProps } from "./Dialog";
|
||||
import { Dialog } from "./Dialog";
|
||||
|
||||
import "./ConfirmDialog.scss";
|
||||
import DialogActionButton from "./DialogActionButton";
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
import clsx from "clsx";
|
||||
import { Popover } from "./Popover";
|
||||
import { t, TranslationKeys } from "../i18n";
|
||||
import type { TranslationKeys } from "../i18n";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import "./ContextMenu.scss";
|
||||
import {
|
||||
getShortcutFromShortcutName,
|
||||
ShortcutName,
|
||||
} from "../actions/shortcuts";
|
||||
import { Action } from "../actions/types";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import type { ShortcutName } from "../actions/shortcuts";
|
||||
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||
import type { Action } from "../actions/types";
|
||||
import type { ActionManager } from "../actions/manager";
|
||||
import { useExcalidrawAppState, useExcalidrawElements } from "./App";
|
||||
import React from "react";
|
||||
|
||||
|
@ -78,17 +77,17 @@ export const ContextMenu = React.memo(
|
|||
|
||||
const actionName = item.name;
|
||||
let label = "";
|
||||
if (item.contextItemLabel) {
|
||||
if (typeof item.contextItemLabel === "function") {
|
||||
if (item.label) {
|
||||
if (typeof item.label === "function") {
|
||||
label = t(
|
||||
item.contextItemLabel(
|
||||
item.label(
|
||||
elements,
|
||||
appState,
|
||||
actionManager.app,
|
||||
) as unknown as TranslationKeys,
|
||||
);
|
||||
} else {
|
||||
label = t(item.contextItemLabel as unknown as TranslationKeys);
|
||||
label = t(item.label as unknown as TranslationKeys);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import "./ToolIcon.scss";
|
|||
import { t } from "../i18n";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { THEME } from "../constants";
|
||||
import { Theme } from "../element/types";
|
||||
import type { Theme } from "../element/types";
|
||||
|
||||
// We chose to use only explicit toggle and not a third option for system value,
|
||||
// but this could be added in the future.
|
||||
|
@ -14,7 +14,9 @@ export const DarkModeToggle = (props: {
|
|||
}) => {
|
||||
const title =
|
||||
props.title ||
|
||||
(props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode"));
|
||||
(props.value === THEME.DARK
|
||||
? t("buttons.lightMode")
|
||||
: t("buttons.darkMode"));
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
|
|
|
@ -3,12 +3,12 @@ import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants";
|
|||
import { useTunnels } from "../context/tunnels";
|
||||
import { useUIAppState } from "../context/ui-appState";
|
||||
import { t } from "../i18n";
|
||||
import { MarkOptional, Merge } from "../utility-types";
|
||||
import type { MarkOptional, Merge } from "../utility-types";
|
||||
import { composeEventHandlers } from "../utils";
|
||||
import { useExcalidrawSetAppState } from "./App";
|
||||
import { withInternalFallback } from "./hoc/withInternalFallback";
|
||||
import { LibraryMenu } from "./LibraryMenu";
|
||||
import { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
|
||||
import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
|
||||
import { Sidebar } from "./Sidebar/Sidebar";
|
||||
|
||||
const DefaultSidebarTrigger = withInternalFallback(
|
||||
|
|
|
@ -37,6 +37,12 @@
|
|||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
& + .Dialog__content {
|
||||
--offset: 28px;
|
||||
height: calc(100% - var(--offset)) !important;
|
||||
margin-top: var(--offset) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.Dialog--fullscreen {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import clsx from "clsx";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
import {
|
||||
useExcalidrawContainer,
|
||||
useDevice,
|
||||
|
@ -9,13 +8,14 @@ import {
|
|||
} from "./App";
|
||||
import { KEYS } from "../keys";
|
||||
import "./Dialog.scss";
|
||||
import { back, CloseIcon } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import { Modal } from "./Modal";
|
||||
import { queryFocusableElements } from "../utils";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { t } from "../i18n";
|
||||
import { CloseIcon } from "./icons";
|
||||
|
||||
export type DialogSize = number | "small" | "regular" | "wide" | undefined;
|
||||
|
||||
|
@ -58,10 +58,12 @@ export const Dialog = (props: DialogProps) => {
|
|||
|
||||
const focusableElements = queryFocusableElements(islandNode);
|
||||
|
||||
if (focusableElements.length > 0 && props.autofocus !== false) {
|
||||
// If there's an element other than close, focus it.
|
||||
(focusableElements[1] || focusableElements[0]).focus();
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (focusableElements.length > 0 && props.autofocus !== false) {
|
||||
// If there's an element other than close, focus it.
|
||||
(focusableElements[1] || focusableElements[0]).focus();
|
||||
}
|
||||
});
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === KEYS.TAB) {
|
||||
|
@ -115,14 +117,16 @@ export const Dialog = (props: DialogProps) => {
|
|||
<span className="Dialog__titleContent">{props.title}</span>
|
||||
</h2>
|
||||
)}
|
||||
<button
|
||||
className="Dialog__close"
|
||||
onClick={onClose}
|
||||
title={t("buttons.close")}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{isFullscreen ? back : CloseIcon}
|
||||
</button>
|
||||
{isFullscreen && (
|
||||
<button
|
||||
className="Dialog__close"
|
||||
onClick={onClose}
|
||||
title={t("buttons.close")}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{CloseIcon}
|
||||
</button>
|
||||
)}
|
||||
<div className="Dialog__content">{props.children}</div>
|
||||
</Island>
|
||||
</Modal>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import clsx from "clsx";
|
||||
import { ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import "./DialogActionButton.scss";
|
||||
import Spinner from "./Spinner";
|
||||
|
||||
|
|
|
@ -12,8 +12,8 @@ import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App";
|
|||
import { useStable } from "../hooks/useStable";
|
||||
|
||||
import "./EyeDropper.scss";
|
||||
import { ColorPickerType } from "./ColorPicker/colorPickerUtils";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import type { ColorPickerType } from "./ColorPicker/colorPickerUtils";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
|
||||
export type EyeDropperProperties = {
|
||||
keepOpenOnAlt: boolean;
|
||||
|
|
|
@ -10,6 +10,10 @@
|
|||
background-color: var(--back-color);
|
||||
border-color: var(--border-color);
|
||||
|
||||
&:hover {
|
||||
transition: all 150ms ease-out;
|
||||
}
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--color-surface-lowest);
|
||||
position: absolute;
|
||||
|
@ -203,8 +207,6 @@
|
|||
|
||||
user-select: none;
|
||||
|
||||
transition: all 150ms ease-out;
|
||||
|
||||
&--size-large {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { UserToFollow } from "../../types";
|
||||
import type { UserToFollow } from "../../types";
|
||||
import { CloseIcon } from "../icons";
|
||||
import "./FollowMode.scss";
|
||||
|
||||
|
|
|
@ -4,9 +4,10 @@ import { KEYS } from "../keys";
|
|||
import { Dialog } from "./Dialog";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import "./HelpDialog.scss";
|
||||
import { ExternalLinkIcon } from "./icons";
|
||||
import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons";
|
||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||
import { isDarwin, isFirefox, isWindows } from "../constants";
|
||||
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||
|
||||
const Header = () => (
|
||||
<div className="HelpDialog__header">
|
||||
|
@ -16,8 +17,8 @@ const Header = () => (
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("helpDialog.documentation")}
|
||||
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
|
||||
{t("helpDialog.documentation")}
|
||||
</a>
|
||||
<a
|
||||
className="HelpDialog__btn"
|
||||
|
@ -25,8 +26,8 @@ const Header = () => (
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("helpDialog.blog")}
|
||||
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
|
||||
{t("helpDialog.blog")}
|
||||
</a>
|
||||
<a
|
||||
className="HelpDialog__btn"
|
||||
|
@ -34,8 +35,17 @@ const Header = () => (
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="HelpDialog__link-icon">{GithubIcon}</div>
|
||||
{t("helpDialog.github")}
|
||||
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
|
||||
</a>
|
||||
<a
|
||||
className="HelpDialog__btn"
|
||||
href="https://youtube.com/@excalidraw"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="HelpDialog__link-icon">{youtubeIcon}</div>
|
||||
YouTube
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
@ -263,7 +273,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||
shortcuts={[getShortcutKey("Alt+S")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.showGrid")}
|
||||
label={t("labels.toggleGrid")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
|
||||
/>
|
||||
<Shortcut
|
||||
|
@ -278,6 +288,17 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||
label={t("stats.title")}
|
||||
shortcuts={[getShortcutKey("Alt+/")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("commandPalette.title")}
|
||||
shortcuts={
|
||||
isFirefox
|
||||
? [getShortcutFromShortcutName("commandPalette")]
|
||||
: [
|
||||
getShortcutFromShortcutName("commandPalette"),
|
||||
getShortcutFromShortcutName("commandPalette", 1),
|
||||
]
|
||||
}
|
||||
/>
|
||||
</ShortcutIsland>
|
||||
<ShortcutIsland
|
||||
className="HelpDialog__island--editor"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { t } from "../i18n";
|
||||
import { AppClassProperties, Device, UIAppState } from "../types";
|
||||
import type { AppClassProperties, Device, UIAppState } from "../types";
|
||||
import {
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
|
|
|
@ -20,7 +20,7 @@ import {
|
|||
|
||||
import { canvasToBlob } from "../data/blob";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import type { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { exportToCanvas } from "../../utils/export";
|
||||
|
@ -32,7 +32,6 @@ import { Switch } from "./Switch";
|
|||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
import "./ImageExportDialog.scss";
|
||||
import { useAppProps } from "./App";
|
||||
import { FilledButton } from "./FilledButton";
|
||||
import { cloneJSON } from "../utils";
|
||||
import { prepareElementsForExport } from "../data";
|
||||
|
@ -58,6 +57,7 @@ type ImageExportModalProps = {
|
|||
files: BinaryFiles;
|
||||
actionManager: ActionManager;
|
||||
onExportImage: AppClassProperties["onExportImage"];
|
||||
name: string;
|
||||
};
|
||||
|
||||
const ImageExportModal = ({
|
||||
|
@ -66,14 +66,14 @@ const ImageExportModal = ({
|
|||
files,
|
||||
actionManager,
|
||||
onExportImage,
|
||||
name,
|
||||
}: ImageExportModalProps) => {
|
||||
const hasSelection = isSomeElementSelected(
|
||||
elementsSnapshot,
|
||||
appStateSnapshot,
|
||||
);
|
||||
|
||||
const appProps = useAppProps();
|
||||
const [projectName, setProjectName] = useState(appStateSnapshot.name);
|
||||
const [projectName, setProjectName] = useState(name);
|
||||
const [exportSelectionOnly, setExportSelectionOnly] = useState(hasSelection);
|
||||
const [exportWithBackground, setExportWithBackground] = useState(
|
||||
appStateSnapshot.exportBackground,
|
||||
|
@ -124,9 +124,16 @@ const ImageExportModal = ({
|
|||
setRenderError(null);
|
||||
// if converting to blob fails, there's some problem that will
|
||||
// likely prevent preview and export (e.g. canvas too big)
|
||||
return canvasToBlob(canvas).then(() => {
|
||||
previewNode.replaceChildren(canvas);
|
||||
});
|
||||
return canvasToBlob(canvas)
|
||||
.then(() => {
|
||||
previewNode.replaceChildren(canvas);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
||||
throw new Error(t("canvasError.canvasTooBig"));
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
|
@ -158,10 +165,6 @@ const ImageExportModal = ({
|
|||
className="TextInput"
|
||||
value={projectName}
|
||||
style={{ width: "30ch" }}
|
||||
disabled={
|
||||
typeof appProps.name !== "undefined" ||
|
||||
appStateSnapshot.viewModeEnabled
|
||||
}
|
||||
onChange={(event) => {
|
||||
setProjectName(event.target.value);
|
||||
actionManager.executeAction(
|
||||
|
@ -347,6 +350,7 @@ export const ImageExportDialog = ({
|
|||
actionManager,
|
||||
onExportImage,
|
||||
onCloseRequest,
|
||||
name,
|
||||
}: {
|
||||
appState: UIAppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
|
@ -354,6 +358,7 @@ export const ImageExportDialog = ({
|
|||
actionManager: ActionManager;
|
||||
onExportImage: AppClassProperties["onExportImage"];
|
||||
onCloseRequest: () => void;
|
||||
name: string;
|
||||
}) => {
|
||||
// we need to take a snapshot so that the exported state can't be modified
|
||||
// while the dialog is open
|
||||
|
@ -372,6 +377,7 @@ export const ImageExportDialog = ({
|
|||
files={files}
|
||||
actionManager={actionManager}
|
||||
onExportImage={onExportImage}
|
||||
name={name}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import { defaultLang, Language, languages, setLanguage } from "../i18n";
|
||||
import { Theme } from "../element/types";
|
||||
import type { Language } from "../i18n";
|
||||
import { defaultLang, languages, setLanguage } from "../i18n";
|
||||
import type { Theme } from "../element/types";
|
||||
|
||||
interface Props {
|
||||
langCode: Language["code"];
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export const InlineIcon = ({ icon }: { icon: JSX.Element }) => {
|
||||
export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import React from "react";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import type { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { ExportOpts, BinaryFiles, UIAppState } from "../types";
|
||||
import type { ExportOpts, BinaryFiles, UIAppState } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { exportToFileIcon, LinkIcon } from "./icons";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
@ -12,7 +12,7 @@ import { Card } from "./Card";
|
|||
import "./ExportDialog.scss";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import type { ActionManager } from "../actions/manager";
|
||||
import { getFrame } from "../utils";
|
||||
|
||||
export type ExportCB = (
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import "./ToolIcon.scss";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ToolButtonSize } from "./ToolButton";
|
||||
import type { ToolButtonSize } from "./ToolButton";
|
||||
import { laserPointerToolIcon } from "./icons";
|
||||
|
||||
type LaserPointerIconProps = {
|
||||
|
|
|
@ -19,7 +19,14 @@
|
|||
|
||||
&__top-right {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
pointer-events: none !important;
|
||||
|
||||
& > * {
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import type { ActionManager } from "../actions/manager";
|
||||
import {
|
||||
CLASSES,
|
||||
DEFAULT_SIDEBAR,
|
||||
|
@ -8,10 +8,11 @@ import {
|
|||
TOOL_TYPE,
|
||||
} from "../constants";
|
||||
import { showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { Language, t } from "../i18n";
|
||||
import type { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import type { Language } from "../i18n";
|
||||
import { t } from "../i18n";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import {
|
||||
import type {
|
||||
AppProps,
|
||||
AppState,
|
||||
ExcalidrawProps,
|
||||
|
@ -196,6 +197,7 @@ const LayerUI = ({
|
|||
actionManager={actionManager}
|
||||
onExportImage={onExportImage}
|
||||
onCloseRequest={() => setAppState({ openDialog: null })}
|
||||
name={app.getName()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import React, { useState, useCallback, useMemo, useRef } from "react";
|
||||
import Library, {
|
||||
import type Library from "../data/library";
|
||||
import {
|
||||
distributeLibraryItemsOnSquareGrid,
|
||||
libraryItemsAtom,
|
||||
} from "../data/library";
|
||||
import { t } from "../i18n";
|
||||
import { randomId } from "../random";
|
||||
import {
|
||||
import type {
|
||||
LibraryItems,
|
||||
LibraryItem,
|
||||
ExcalidrawProps,
|
||||
|
@ -28,7 +29,7 @@ import { useUIAppState } from "../context/ui-appState";
|
|||
import "./LibraryMenu.scss";
|
||||
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
||||
import { isShallowEqual } from "../utils";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import type { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
||||
|
||||
export const isLibraryMenuOpenAtom = atom(false);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { VERSIONS } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { ExcalidrawProps, UIAppState } from "../types";
|
||||
import type { ExcalidrawProps, UIAppState } from "../types";
|
||||
|
||||
const LibraryMenuBrowseButton = ({
|
||||
theme,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ExcalidrawProps, UIAppState } from "../types";
|
||||
import type { ExcalidrawProps, UIAppState } from "../types";
|
||||
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
|
||||
import clsx from "clsx";
|
||||
|
||||
|
|
|
@ -2,10 +2,11 @@ import { useCallback, useState } from "react";
|
|||
import { t } from "../i18n";
|
||||
import Trans from "./Trans";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { LibraryItem, LibraryItems, UIAppState } from "../types";
|
||||
import type { LibraryItem, LibraryItems, UIAppState } from "../types";
|
||||
import { useApp, useExcalidrawSetAppState } from "./App";
|
||||
import { saveLibraryAsJSON } from "../data/json";
|
||||
import Library, { libraryItemsAtom } from "../data/library";
|
||||
import type Library from "../data/library";
|
||||
import { libraryItemsAtom } from "../data/library";
|
||||
import {
|
||||
DotsIcon,
|
||||
ExportIcon,
|
||||
|
|
|
@ -7,7 +7,7 @@ import React, {
|
|||
} from "react";
|
||||
import { serializeLibraryAsJSON } from "../data/json";
|
||||
import { t } from "../i18n";
|
||||
import {
|
||||
import type {
|
||||
ExcalidrawProps,
|
||||
LibraryItem,
|
||||
LibraryItems,
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import React, { memo, ReactNode, useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import React, { memo, useEffect, useState } from "react";
|
||||
import { EmptyLibraryUnit, LibraryUnit } from "./LibraryUnit";
|
||||
import { LibraryItem } from "../types";
|
||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { SvgCache } from "../hooks/useLibraryItemSvg";
|
||||
import type { LibraryItem } from "../types";
|
||||
import type { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import type { SvgCache } from "../hooks/useLibraryItemSvg";
|
||||
import { useTransition } from "../hooks/useTransition";
|
||||
|
||||
type LibraryOrPendingItem = (
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import clsx from "clsx";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { useDevice } from "./App";
|
||||
import { LibraryItem } from "../types";
|
||||
import type { LibraryItem } from "../types";
|
||||
import "./LibraryUnit.scss";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
import { PlusIcon } from "./icons";
|
||||
import { SvgCache, useLibraryItemSvg } from "../hooks/useLibraryItemSvg";
|
||||
import type { SvgCache } from "../hooks/useLibraryItemSvg";
|
||||
import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg";
|
||||
|
||||
export const LibraryUnit = memo(
|
||||
({
|
||||
|
|
|
@ -3,7 +3,7 @@ import { useState, useEffect } from "react";
|
|||
import Spinner from "./Spinner";
|
||||
import clsx from "clsx";
|
||||
import { THEME } from "../constants";
|
||||
import { Theme } from "../element/types";
|
||||
import type { Theme } from "../element/types";
|
||||
|
||||
export const LoadingMessage: React.FC<{ delay?: number; theme?: Theme }> = ({
|
||||
delay,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import "./ToolIcon.scss";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ToolButtonSize } from "./ToolButton";
|
||||
import type { ToolButtonSize } from "./ToolButton";
|
||||
import { LockedIcon, UnlockedIcon } from "./icons";
|
||||
|
||||
type LockIconProps = {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import "./ToolIcon.scss";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ToolButtonSize } from "./ToolButton";
|
||||
import type { ToolButtonSize } from "./ToolButton";
|
||||
|
||||
const DEFAULT_SIZE: ToolButtonSize = "small";
|
||||
|
||||
|
|
|
@ -53,8 +53,8 @@ export const MagicSettings = (props: {
|
|||
marginLeft: "1rem",
|
||||
fontSize: 14,
|
||||
borderRadius: "12px",
|
||||
color: "#000",
|
||||
background: "pink",
|
||||
background: "var(--color-promo)",
|
||||
color: "var(--color-surface-lowest)",
|
||||
}}
|
||||
>
|
||||
Experimental
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import {
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppProps,
|
||||
AppState,
|
||||
|
@ -7,11 +7,11 @@ import {
|
|||
ExcalidrawProps,
|
||||
UIAppState,
|
||||
} from "../types";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import type { ActionManager } from "../actions/manager";
|
||||
import { t } from "../i18n";
|
||||
import Stack from "./Stack";
|
||||
import { showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import type { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
import { Island } from "./Island";
|
||||
import { HintViewer } from "./HintViewer";
|
||||
|
|
|
@ -23,6 +23,20 @@
|
|||
|
||||
.Island {
|
||||
padding: 2.5rem;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&.animations-disabled {
|
||||
.Modal__background {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.Modal__content {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -35,7 +49,7 @@
|
|||
z-index: 1;
|
||||
background-color: rgba(#121212, 0.2);
|
||||
|
||||
animation: Modal__background__fade-in 0.125s linear forwards;
|
||||
animation: Modal__background__fade-in 0.1s linear forwards;
|
||||
}
|
||||
|
||||
.Modal__content {
|
||||
|
@ -47,7 +61,8 @@
|
|||
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
animation: Modal__content_fade-in 0.1s ease-out 0.05s forwards;
|
||||
animation: Modal__content_fade-in 0.025s ease-out 0s forwards;
|
||||
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
|
||||
|
@ -56,7 +71,7 @@
|
|||
|
||||
border: 1px solid var(--dialog-border-color);
|
||||
box-shadow: var(--modal-shadow);
|
||||
border-radius: 6px;
|
||||
border-radius: 0.75rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
|
|
|
@ -3,8 +3,9 @@ import "./Modal.scss";
|
|||
import { createPortal } from "react-dom";
|
||||
import clsx from "clsx";
|
||||
import { KEYS } from "../keys";
|
||||
import { AppState } from "../types";
|
||||
import type { AppState } from "../types";
|
||||
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
|
||||
import { useRef } from "react";
|
||||
|
||||
export const Modal: React.FC<{
|
||||
className?: string;
|
||||
|
@ -20,6 +21,10 @@ export const Modal: React.FC<{
|
|||
className: "excalidraw-modal-container",
|
||||
});
|
||||
|
||||
const animationsDisabledRef = useRef(
|
||||
document.body.classList.contains("excalidraw-animations-disabled"),
|
||||
);
|
||||
|
||||
if (!modalRoot) {
|
||||
return null;
|
||||
}
|
||||
|
@ -34,7 +39,9 @@ export const Modal: React.FC<{
|
|||
|
||||
return createPortal(
|
||||
<div
|
||||
className={clsx("Modal", props.className)}
|
||||
className={clsx("Modal", props.className, {
|
||||
"animations-disabled": animationsDisabledRef.current,
|
||||
})}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onKeyDown={handleKeydown}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { atom } from "jotai";
|
||||
import { jotaiStore } from "../../jotai";
|
||||
import React from "react";
|
||||
import type React from "react";
|
||||
|
||||
export type OverwriteConfirmState =
|
||||
| {
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import oc from "open-color";
|
||||
import React, { useLayoutEffect, useRef, useState } from "react";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { ChartElements, renderSpreadsheet, Spreadsheet } from "../charts";
|
||||
import { ChartType, ElementsMap } from "../element/types";
|
||||
import type { ChartElements, Spreadsheet } from "../charts";
|
||||
import { renderSpreadsheet } from "../charts";
|
||||
import type { ChartType, ElementsMap } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { UIAppState } from "../types";
|
||||
import type { UIAppState } from "../types";
|
||||
import { useApp } from "./App";
|
||||
import { Dialog } from "./Dialog";
|
||||
|
||||
|
@ -58,7 +59,11 @@ const ChartPreviewBtn = (props: {
|
|||
elements.forEach(
|
||||
(el) =>
|
||||
isTextElement(el) &&
|
||||
redrawTextBoundingBox(el, getContainerElement(el, elementsMap)),
|
||||
redrawTextBoundingBox(
|
||||
el,
|
||||
getContainerElement(el, elementsMap),
|
||||
elementsMap,
|
||||
),
|
||||
);
|
||||
setChartElements(elements);
|
||||
},
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import "./ToolIcon.scss";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ToolButtonSize } from "./ToolButton";
|
||||
import type { ToolButtonSize } from "./ToolButton";
|
||||
import { PenModeIcon } from "./icons";
|
||||
|
||||
type PenModeIconProps = {
|
||||
|
|
|
@ -11,7 +11,6 @@ type Props = {
|
|||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
isNameEditable: boolean;
|
||||
ignoreFocus?: boolean;
|
||||
};
|
||||
|
||||
|
@ -42,23 +41,17 @@ export const ProjectName = (props: Props) => {
|
|||
return (
|
||||
<div className="ProjectName">
|
||||
<label className="ProjectName-label" htmlFor="filename">
|
||||
{`${props.label}${props.isNameEditable ? "" : ":"}`}
|
||||
{`${props.label}:`}
|
||||
</label>
|
||||
{props.isNameEditable ? (
|
||||
<input
|
||||
type="text"
|
||||
className="TextInput"
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
id={`${id}-filename`}
|
||||
value={fileName}
|
||||
onChange={(event) => setFileName(event.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<span className="TextInput TextInput--readonly" id={`${id}-filename`}>
|
||||
{props.value}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
className="TextInput"
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
id={`${id}-filename`}
|
||||
value={fileName}
|
||||
onChange={(event) => setFileName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import OpenColor from "open-color";
|
||||
|
||||
import { Dialog } from "./Dialog";
|
||||
import { t } from "../i18n";
|
||||
import Trans from "./Trans";
|
||||
|
||||
import { LibraryItems, LibraryItem, UIAppState } from "../types";
|
||||
import type { LibraryItems, LibraryItem, UIAppState } from "../types";
|
||||
import { exportToCanvas, exportToSvg } from "../../utils/export";
|
||||
import {
|
||||
EDITOR_LS_KEYS,
|
||||
|
@ -14,7 +15,7 @@ import {
|
|||
MIME_TYPES,
|
||||
VERSIONS,
|
||||
} from "../constants";
|
||||
import { ExportedLibraryData } from "../data/types";
|
||||
import type { ExportedLibraryData } from "../data/types";
|
||||
import { canvasToBlob, resizeImageFile } from "../data/blob";
|
||||
import { chunk } from "../utils";
|
||||
import DialogActionButton from "./DialogActionButton";
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue