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
bff220e0f5
666 changed files with 15238 additions and 13351 deletions
2
packages/excalidraw/.gitignore
vendored
Normal file
2
packages/excalidraw/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
node_modules
|
||||
types
|
16
packages/excalidraw/.size-limit.json
Normal file
16
packages/excalidraw/.size-limit.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
[
|
||||
{
|
||||
"path": "dist/excalidraw.production.min.js",
|
||||
"limit": "340 kB"
|
||||
},
|
||||
{
|
||||
"path": "dist/excalidraw-assets/locales",
|
||||
"name": "dist/excalidraw-assets/locales",
|
||||
"limit": "290 kB"
|
||||
},
|
||||
{
|
||||
"path": "dist/excalidraw-assets/vendor-*.js",
|
||||
"name": "dist/excalidraw-assets/vendor*.js",
|
||||
"limit": "900 kB"
|
||||
}
|
||||
]
|
1700
packages/excalidraw/CHANGELOG.md
Normal file
1700
packages/excalidraw/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load diff
49
packages/excalidraw/README.md
Normal file
49
packages/excalidraw/README.md
Normal file
|
@ -0,0 +1,49 @@
|
|||
# Excalidraw
|
||||
|
||||
**Excalidraw** is exported as a component to directly embed in your projects.
|
||||
|
||||
## Installation
|
||||
|
||||
You can use `npm`
|
||||
|
||||
```bash
|
||||
npm install react react-dom @excalidraw/excalidraw
|
||||
```
|
||||
|
||||
or via `yarn`
|
||||
|
||||
```bash
|
||||
yarn add react react-dom @excalidraw/excalidraw
|
||||
```
|
||||
|
||||
After installation you will see a folder `excalidraw-assets` and `excalidraw-assets-dev` in `dist` directory which contains the assets needed for this app in prod and dev mode respectively.
|
||||
|
||||
Move the folder `excalidraw-assets` and `excalidraw-assets-dev` to the path where your assets are served.
|
||||
|
||||
By default it will try to load the files from [`https://unpkg.com/@excalidraw/excalidraw/dist/`](https://unpkg.com/@excalidraw/excalidraw/dist)
|
||||
|
||||
If you want to load assets from a different path you can set a variable `window.EXCALIDRAW_ASSET_PATH` depending on environment (for example if you have different URL's for dev and prod) to the url from where you want to load the assets.
|
||||
|
||||
#### Note
|
||||
|
||||
**If you don't want to wait for the next stable release and try out the unreleased changes you can use `@excalidraw/excalidraw@next`.**
|
||||
|
||||
## Dimensions of Excalidraw
|
||||
|
||||
Excalidraw takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions.
|
||||
|
||||
### Demo
|
||||
|
||||
[Try here](https://codesandbox.io/s/excalidraw-ehlz3).
|
||||
|
||||
## Integration
|
||||
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration)
|
||||
|
||||
## API
|
||||
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api)
|
||||
|
||||
## Contributing
|
||||
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing)
|
62
packages/excalidraw/actions/actionAddToLibrary.ts
Normal file
62
packages/excalidraw/actions/actionAddToLibrary.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { register } from "./register";
|
||||
import { deepCopyElement } from "../element/newElement";
|
||||
import { randomId } from "../random";
|
||||
import { t } from "../i18n";
|
||||
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
||||
for (const type of LIBRARY_DISABLED_TYPES) {
|
||||
if (selectedElements.some((element) => element.type === type)) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return app.library
|
||||
.getLatestLibrary()
|
||||
.then((items) => {
|
||||
return app.library.setLibrary([
|
||||
{
|
||||
id: randomId(),
|
||||
status: "unpublished",
|
||||
elements: selectedElements.map(deepCopyElement),
|
||||
created: Date.now(),
|
||||
},
|
||||
...items,
|
||||
]);
|
||||
})
|
||||
.then(() => {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
toast: { message: t("toast.addedToLibrary") },
|
||||
},
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
contextItemLabel: "labels.addToLibrary",
|
||||
});
|
236
packages/excalidraw/actions/actionAlign.tsx
Normal file
236
packages/excalidraw/actions/actionAlign.tsx
Normal file
|
@ -0,0 +1,236 @@
|
|||
import { alignElements, Alignment } from "../align";
|
||||
import {
|
||||
AlignBottomIcon,
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
AlignTopIcon,
|
||||
CenterHorizontallyIcon,
|
||||
CenterVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { 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 { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const alignActionsPredicate = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
_: unknown,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return (
|
||||
selectedElements.length > 1 &&
|
||||
// TODO enable aligning frames when implemented properly
|
||||
!selectedElements.some((el) => isFrameLikeElement(el))
|
||||
);
|
||||
};
|
||||
|
||||
const alignSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
app: AppClassProperties,
|
||||
alignment: Alignment,
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
const elementsMap = arrayToMap(elements);
|
||||
|
||||
const updatedElements = alignElements(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
alignment,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
return updateFrameMembershipOfSelectedElements(
|
||||
elements.map((element) => updatedElementsMap.get(element.id) || element),
|
||||
appState,
|
||||
app,
|
||||
);
|
||||
};
|
||||
|
||||
export const actionAlignTop = register({
|
||||
name: "alignTop",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, app, {
|
||||
position: "start",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
type="button"
|
||||
icon={AlignTopIcon}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.alignTop")} — ${getShortcutKey(
|
||||
"CtrlOrCmd+Shift+Up",
|
||||
)}`}
|
||||
aria-label={t("labels.alignTop")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionAlignBottom = register({
|
||||
name: "alignBottom",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, app, {
|
||||
position: "end",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
type="button"
|
||||
icon={AlignBottomIcon}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.alignBottom")} — ${getShortcutKey(
|
||||
"CtrlOrCmd+Shift+Down",
|
||||
)}`}
|
||||
aria-label={t("labels.alignBottom")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionAlignLeft = register({
|
||||
name: "alignLeft",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, app, {
|
||||
position: "start",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
type="button"
|
||||
icon={AlignLeftIcon}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.alignLeft")} — ${getShortcutKey(
|
||||
"CtrlOrCmd+Shift+Left",
|
||||
)}`}
|
||||
aria-label={t("labels.alignLeft")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionAlignRight = register({
|
||||
name: "alignRight",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, app, {
|
||||
position: "end",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
type="button"
|
||||
icon={AlignRightIcon}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.alignRight")} — ${getShortcutKey(
|
||||
"CtrlOrCmd+Shift+Right",
|
||||
)}`}
|
||||
aria-label={t("labels.alignRight")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionAlignVerticallyCentered = register({
|
||||
name: "alignVerticallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, app, {
|
||||
position: "center",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
type="button"
|
||||
icon={CenterVerticallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
title={t("labels.centerVertically")}
|
||||
aria-label={t("labels.centerVertically")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionAlignHorizontallyCentered = register({
|
||||
name: "alignHorizontallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, app, {
|
||||
position: "center",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
type="button"
|
||||
icon={CenterHorizontallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
title={t("labels.centerHorizontally")}
|
||||
aria-label={t("labels.centerHorizontally")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
/>
|
||||
),
|
||||
});
|
309
packages/excalidraw/actions/actionBoundText.tsx
Normal file
309
packages/excalidraw/actions/actionBoundText.tsx
Normal file
|
@ -0,0 +1,309 @@
|
|||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
ROUNDNESS,
|
||||
VERTICAL_ALIGN,
|
||||
TEXT_ALIGN,
|
||||
} from "../constants";
|
||||
import { isTextElement, newElement } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import {
|
||||
computeBoundTextPosition,
|
||||
computeContainerDimensionForBoundText,
|
||||
getBoundTextElement,
|
||||
measureTextElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
getOriginalContainerHeightFromCache,
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "../element/containerCache";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isTextBindableContainer,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "../element/typeChecks";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionUnbindText = register({
|
||||
name: "unbindText",
|
||||
contextItemLabel: "labels.unbindText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
return selectedElements.some((element) => hasBoundTextElement(element));
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
selectedElements.forEach((element) => {
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement) {
|
||||
const { width, height, baseline } = measureTextElement(
|
||||
boundTextElement,
|
||||
{
|
||||
text: boundTextElement.originalText,
|
||||
},
|
||||
);
|
||||
const originalContainerHeight = getOriginalContainerHeightFromCache(
|
||||
element.id,
|
||||
);
|
||||
resetOriginalContainerCache(element.id);
|
||||
const { x, y } = computeBoundTextPosition(element, boundTextElement);
|
||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||
containerId: null,
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
text: boundTextElement.originalText,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
mutateElement(element, {
|
||||
boundElements: element.boundElements?.filter(
|
||||
(ele) => ele.id !== boundTextElement.id,
|
||||
),
|
||||
height: originalContainerHeight
|
||||
? originalContainerHeight
|
||||
: element.height,
|
||||
});
|
||||
}
|
||||
});
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const actionBindText = register({
|
||||
name: "bindText",
|
||||
contextItemLabel: "labels.bindText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
if (selectedElements.length === 2) {
|
||||
const textElement =
|
||||
isTextElement(selectedElements[0]) ||
|
||||
isTextElement(selectedElements[1]);
|
||||
|
||||
let bindingContainer;
|
||||
if (isTextBindableContainer(selectedElements[0])) {
|
||||
bindingContainer = selectedElements[0];
|
||||
} else if (isTextBindableContainer(selectedElements[1])) {
|
||||
bindingContainer = selectedElements[1];
|
||||
}
|
||||
if (
|
||||
textElement &&
|
||||
bindingContainer &&
|
||||
getBoundTextElement(
|
||||
bindingContainer,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
) === null
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
let textElement: ExcalidrawTextElement;
|
||||
let container: ExcalidrawTextContainer;
|
||||
|
||||
if (
|
||||
isTextElement(selectedElements[0]) &&
|
||||
isTextBindableContainer(selectedElements[1])
|
||||
) {
|
||||
textElement = selectedElements[0];
|
||||
container = selectedElements[1];
|
||||
} else {
|
||||
textElement = selectedElements[1] as ExcalidrawTextElement;
|
||||
container = selectedElements[0] as ExcalidrawTextContainer;
|
||||
}
|
||||
mutateElement(textElement, {
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
});
|
||||
mutateElement(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
});
|
||||
const originalContainerHeight = container.height;
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
// overwritting the cache with original container height so
|
||||
// it can be restored when unbind
|
||||
updateOriginalContainerCache(container.id, originalContainerHeight);
|
||||
|
||||
return {
|
||||
elements: pushTextAboveContainer(elements, container, textElement),
|
||||
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const pushTextAboveContainer = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
container: ExcalidrawElement,
|
||||
textElement: ExcalidrawTextElement,
|
||||
) => {
|
||||
const updatedElements = elements.slice();
|
||||
const textElementIndex = updatedElements.findIndex(
|
||||
(ele) => ele.id === textElement.id,
|
||||
);
|
||||
updatedElements.splice(textElementIndex, 1);
|
||||
|
||||
const containerIndex = updatedElements.findIndex(
|
||||
(ele) => ele.id === container.id,
|
||||
);
|
||||
updatedElements.splice(containerIndex + 1, 0, textElement);
|
||||
return updatedElements;
|
||||
};
|
||||
|
||||
const pushContainerBelowText = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
container: ExcalidrawElement,
|
||||
textElement: ExcalidrawTextElement,
|
||||
) => {
|
||||
const updatedElements = elements.slice();
|
||||
const containerIndex = updatedElements.findIndex(
|
||||
(ele) => ele.id === container.id,
|
||||
);
|
||||
updatedElements.splice(containerIndex, 1);
|
||||
|
||||
const textElementIndex = updatedElements.findIndex(
|
||||
(ele) => ele.id === textElement.id,
|
||||
);
|
||||
updatedElements.splice(textElementIndex, 0, container);
|
||||
return updatedElements;
|
||||
};
|
||||
|
||||
export const actionWrapTextInContainer = register({
|
||||
name: "wrapTextInContainer",
|
||||
contextItemLabel: "labels.createContainerFromText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
const areTextElements = selectedElements.every((el) => isTextElement(el));
|
||||
return selectedElements.length > 0 && areTextElements;
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
let updatedElements: readonly ExcalidrawElement[] = elements.slice();
|
||||
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
|
||||
|
||||
for (const textElement of selectedElements) {
|
||||
if (isTextElement(textElement)) {
|
||||
const container = newElement({
|
||||
type: "rectangle",
|
||||
backgroundColor: appState.currentItemBackgroundColor,
|
||||
boundElements: [
|
||||
...(textElement.boundElements || []),
|
||||
{ id: textElement.id, type: "text" },
|
||||
],
|
||||
angle: textElement.angle,
|
||||
fillStyle: appState.currentItemFillStyle,
|
||||
strokeColor: appState.currentItemStrokeColor,
|
||||
roughness: appState.currentItemRoughness,
|
||||
strokeWidth: appState.currentItemStrokeWidth,
|
||||
strokeStyle: appState.currentItemStrokeStyle,
|
||||
roundness:
|
||||
appState.currentItemRoundness === "round"
|
||||
? {
|
||||
type: isUsingAdaptiveRadius("rectangle")
|
||||
? ROUNDNESS.ADAPTIVE_RADIUS
|
||||
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||
}
|
||||
: null,
|
||||
opacity: 100,
|
||||
locked: false,
|
||||
x: textElement.x - BOUND_TEXT_PADDING,
|
||||
y: textElement.y - BOUND_TEXT_PADDING,
|
||||
width: computeContainerDimensionForBoundText(
|
||||
textElement.width,
|
||||
"rectangle",
|
||||
),
|
||||
height: computeContainerDimensionForBoundText(
|
||||
textElement.height,
|
||||
"rectangle",
|
||||
),
|
||||
groupIds: textElement.groupIds,
|
||||
frameId: textElement.frameId,
|
||||
});
|
||||
|
||||
// update bindings
|
||||
if (textElement.boundElements?.length) {
|
||||
const linearElementIds = textElement.boundElements
|
||||
.filter((ele) => ele.type === "arrow")
|
||||
.map((el) => el.id);
|
||||
const linearElements = updatedElements.filter((ele) =>
|
||||
linearElementIds.includes(ele.id),
|
||||
) as ExcalidrawLinearElement[];
|
||||
linearElements.forEach((ele) => {
|
||||
let startBinding = ele.startBinding;
|
||||
let endBinding = ele.endBinding;
|
||||
|
||||
if (startBinding?.elementId === textElement.id) {
|
||||
startBinding = {
|
||||
...startBinding,
|
||||
elementId: container.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (endBinding?.elementId === textElement.id) {
|
||||
endBinding = { ...endBinding, elementId: container.id };
|
||||
}
|
||||
|
||||
if (startBinding || endBinding) {
|
||||
mutateElement(ele, { startBinding, endBinding }, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
mutateElement(
|
||||
textElement,
|
||||
{
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
boundElements: null,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
},
|
||||
false,
|
||||
);
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
|
||||
updatedElements = pushContainerBelowText(
|
||||
[...updatedElements, container],
|
||||
container,
|
||||
textElement,
|
||||
);
|
||||
containerIds[container.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
elements: updatedElements,
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: containerIds,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
});
|
480
packages/excalidraw/actions/actionCanvas.tsx
Normal file
480
packages/excalidraw/actions/actionCanvas.tsx
Normal file
|
@ -0,0 +1,480 @@
|
|||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||
import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
|
||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
import { 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 { getShortcutKey, updateActiveTool } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import {
|
||||
getDefaultAppState,
|
||||
isEraserActive,
|
||||
isHandToolActive,
|
||||
} from "../appState";
|
||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||
import { SceneBounds } from "../element/bounds";
|
||||
import { setCursor } from "../cursor";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
trackEvent: false,
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
!!app.props.UIOptions.canvasActions.changeViewBackgroundColor &&
|
||||
!appState.viewModeEnabled
|
||||
);
|
||||
},
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, ...value },
|
||||
commitToHistory: !!value.viewBackgroundColor,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
||||
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
|
||||
return (
|
||||
<ColorPicker
|
||||
palette={null}
|
||||
topPicks={DEFAULT_CANVAS_BACKGROUND_PICKS}
|
||||
label={t("labels.canvasBackground")}
|
||||
type="canvasBackground"
|
||||
color={appState.viewBackgroundColor}
|
||||
onChange={(color) => updateData({ viewBackgroundColor: color })}
|
||||
data-testid="canvas-background-picker"
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
updateData={updateData}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionClearCanvas = register({
|
||||
name: "clearCanvas",
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
!!app.props.UIOptions.canvasActions.clearCanvas &&
|
||||
!appState.viewModeEnabled
|
||||
);
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
app.imageCache.clear();
|
||||
return {
|
||||
elements: elements.map((element) =>
|
||||
newElementWith(element, { isDeleted: true }),
|
||||
),
|
||||
appState: {
|
||||
...getDefaultAppState(),
|
||||
files: {},
|
||||
theme: appState.theme,
|
||||
penMode: appState.penMode,
|
||||
penDetected: appState.penDetected,
|
||||
exportBackground: appState.exportBackground,
|
||||
exportEmbedScene: appState.exportEmbedScene,
|
||||
gridSize: appState.gridSize,
|
||||
showStats: appState.showStats,
|
||||
pasteDialog: appState.pasteDialog,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
? { ...appState.activeTool, type: "selection" }
|
||||
: appState.activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const actionZoomIn = register({
|
||||
name: "zoomIn",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...getStateForZoom(
|
||||
{
|
||||
viewportX: appState.width / 2 + appState.offsetLeft,
|
||||
viewportY: appState.height / 2 + appState.offsetTop,
|
||||
nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
||||
},
|
||||
appState,
|
||||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
className="zoom-in-button zoom-button"
|
||||
icon={ZoomInIcon}
|
||||
title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
|
||||
aria-label={t("buttons.zoomIn")}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
(event.code === CODES.EQUAL || event.code === CODES.NUM_ADD) &&
|
||||
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
||||
});
|
||||
|
||||
export const actionZoomOut = register({
|
||||
name: "zoomOut",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...getStateForZoom(
|
||||
{
|
||||
viewportX: appState.width / 2 + appState.offsetLeft,
|
||||
viewportY: appState.height / 2 + appState.offsetTop,
|
||||
nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
||||
},
|
||||
appState,
|
||||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
className="zoom-out-button zoom-button"
|
||||
icon={ZoomOutIcon}
|
||||
title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
|
||||
aria-label={t("buttons.zoomOut")}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
(event.code === CODES.MINUS || event.code === CODES.NUM_SUBTRACT) &&
|
||||
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
||||
});
|
||||
|
||||
export const actionResetZoom = register({
|
||||
name: "resetZoom",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...getStateForZoom(
|
||||
{
|
||||
viewportX: appState.width / 2 + appState.offsetLeft,
|
||||
viewportY: appState.height / 2 + appState.offsetTop,
|
||||
nextZoom: getNormalizedZoom(1),
|
||||
},
|
||||
appState,
|
||||
),
|
||||
userToFollow: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}>
|
||||
<ToolButton
|
||||
type="button"
|
||||
className="reset-zoom-button zoom-button"
|
||||
title={t("buttons.resetZoom")}
|
||||
aria-label={t("buttons.resetZoom")}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
>
|
||||
{(appState.zoom.value * 100).toFixed(0)}%
|
||||
</ToolButton>
|
||||
</Tooltip>
|
||||
),
|
||||
keyTest: (event) =>
|
||||
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
||||
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
||||
});
|
||||
|
||||
const zoomValueToFitBoundsOnViewport = (
|
||||
bounds: SceneBounds,
|
||||
viewportDimensions: { width: number; height: number },
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = bounds;
|
||||
const commonBoundsWidth = x2 - x1;
|
||||
const zoomValueForWidth = viewportDimensions.width / commonBoundsWidth;
|
||||
const commonBoundsHeight = y2 - y1;
|
||||
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
|
||||
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
|
||||
const zoomAdjustedToSteps =
|
||||
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
|
||||
const clampedZoomValueToFitElements = Math.min(
|
||||
Math.max(zoomAdjustedToSteps, MIN_ZOOM),
|
||||
1,
|
||||
);
|
||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||
};
|
||||
|
||||
export const zoomToFitBounds = ({
|
||||
bounds,
|
||||
appState,
|
||||
fitToViewport = false,
|
||||
viewportZoomFactor = 0.7,
|
||||
}: {
|
||||
bounds: SceneBounds;
|
||||
appState: Readonly<AppState>;
|
||||
/** whether to fit content to viewport (beyond >100%) */
|
||||
fitToViewport: boolean;
|
||||
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||
viewportZoomFactor?: number;
|
||||
}) => {
|
||||
const [x1, y1, x2, y2] = bounds;
|
||||
const centerX = (x1 + x2) / 2;
|
||||
const centerY = (y1 + y2) / 2;
|
||||
|
||||
let newZoomValue;
|
||||
let scrollX;
|
||||
let scrollY;
|
||||
|
||||
if (fitToViewport) {
|
||||
const commonBoundsWidth = x2 - x1;
|
||||
const commonBoundsHeight = y2 - y1;
|
||||
|
||||
newZoomValue =
|
||||
Math.min(
|
||||
appState.width / commonBoundsWidth,
|
||||
appState.height / commonBoundsHeight,
|
||||
) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
|
||||
|
||||
// Apply clamping to newZoomValue to be between 10% and 3000%
|
||||
newZoomValue = Math.min(
|
||||
Math.max(newZoomValue, 0.1),
|
||||
30.0,
|
||||
) as NormalizedZoomValue;
|
||||
|
||||
scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
|
||||
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
|
||||
} else {
|
||||
newZoomValue = zoomValueToFitBoundsOnViewport(bounds, {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
});
|
||||
|
||||
const centerScroll = centerScrollOn({
|
||||
scenePoint: { x: centerX, y: centerY },
|
||||
viewportDimensions: {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
},
|
||||
zoom: { value: newZoomValue },
|
||||
});
|
||||
|
||||
scrollX = centerScroll.scrollX;
|
||||
scrollY = centerScroll.scrollY;
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
scrollX,
|
||||
scrollY,
|
||||
zoom: { value: newZoomValue },
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const zoomToFit = ({
|
||||
targetElements,
|
||||
appState,
|
||||
fitToViewport,
|
||||
viewportZoomFactor,
|
||||
}: {
|
||||
targetElements: readonly ExcalidrawElement[];
|
||||
appState: Readonly<AppState>;
|
||||
/** whether to fit content to viewport (beyond >100%) */
|
||||
fitToViewport: boolean;
|
||||
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||
viewportZoomFactor?: number;
|
||||
}) => {
|
||||
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
|
||||
|
||||
return zoomToFitBounds({
|
||||
bounds: commonBounds,
|
||||
appState,
|
||||
fitToViewport,
|
||||
viewportZoomFactor,
|
||||
});
|
||||
};
|
||||
|
||||
// Note, this action differs from actionZoomToFitSelection in that it doesn't
|
||||
// zoom beyond 100%. In other words, if the content is smaller than viewport
|
||||
// size, it won't be zoomed in.
|
||||
export const actionZoomToFitSelectionInViewport = register({
|
||||
name: "zoomToFitSelectionInViewport",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return zoomToFit({
|
||||
targetElements: selectedElements.length ? selectedElements : elements,
|
||||
appState: {
|
||||
...appState,
|
||||
userToFollow: null,
|
||||
},
|
||||
fitToViewport: false,
|
||||
});
|
||||
},
|
||||
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
|
||||
// TBD on how proceed
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.TWO &&
|
||||
event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionZoomToFitSelection = register({
|
||||
name: "zoomToFitSelection",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return zoomToFit({
|
||||
targetElements: selectedElements.length ? selectedElements : elements,
|
||||
appState: {
|
||||
...appState,
|
||||
userToFollow: null,
|
||||
},
|
||||
fitToViewport: true,
|
||||
});
|
||||
},
|
||||
// NOTE this action should use shift-2 per figma, alas
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.THREE &&
|
||||
event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionZoomToFit = register({
|
||||
name: "zoomToFit",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) =>
|
||||
zoomToFit({
|
||||
targetElements: elements,
|
||||
appState: {
|
||||
...appState,
|
||||
userToFollow: null,
|
||||
},
|
||||
fitToViewport: false,
|
||||
}),
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.ONE &&
|
||||
event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionToggleTheme = register({
|
||||
name: "toggleTheme",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
theme:
|
||||
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return !!app.props.UIOptions.canvasActions.toggleTheme;
|
||||
},
|
||||
});
|
||||
|
||||
export const actionToggleEraserTool = register({
|
||||
name: "toggleEraserTool",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
if (isEraserActive(appState)) {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: "selection",
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: "eraser",
|
||||
lastActiveToolBeforeEraser: appState.activeTool,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.E,
|
||||
});
|
||||
|
||||
export const actionToggleHandTool = register({
|
||||
name: "toggleHandTool",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
if (isHandToolActive(appState)) {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: "selection",
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: "hand",
|
||||
lastActiveToolBeforeEraser: appState.activeTool,
|
||||
});
|
||||
setCursor(app.interactiveCanvas, CURSOR_TYPE.GRAB);
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
!event.altKey && !event[KEYS.CTRL_OR_CMD] && event.key === KEYS.H,
|
||||
});
|
256
packages/excalidraw/actions/actionClipboard.tsx
Normal file
256
packages/excalidraw/actions/actionClipboard.tsx
Normal file
|
@ -0,0 +1,256 @@
|
|||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import {
|
||||
copyTextToSystemClipboard,
|
||||
copyToClipboard,
|
||||
createPasteEvent,
|
||||
probablySupportsClipboardBlob,
|
||||
probablySupportsClipboardWriteText,
|
||||
readSystemClipboard,
|
||||
} from "../clipboard";
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||
import { isTextElement } from "../element";
|
||||
import { t } from "../i18n";
|
||||
import { isFirefox } from "../constants";
|
||||
|
||||
export const actionCopy = register({
|
||||
name: "copy",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
|
||||
const elementsToCopy = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await copyToClipboard(elementsToCopy, app.files, event);
|
||||
} catch (error: any) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.copy",
|
||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||
keyTest: undefined,
|
||||
});
|
||||
|
||||
export const actionPaste = register({
|
||||
name: "paste",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, data, app) => {
|
||||
let types;
|
||||
try {
|
||||
types = await readSystemClipboard();
|
||||
} catch (error: any) {
|
||||
if (error.name === "AbortError" || error.name === "NotAllowedError") {
|
||||
// user probably aborted the action. Though not 100% sure, it's best
|
||||
// to not annoy them with an error message.
|
||||
return false;
|
||||
}
|
||||
|
||||
console.error(`actionPaste ${error.name}: ${error.message}`);
|
||||
|
||||
if (isFirefox) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("hints.firefox_clipboard_write"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("errors.asyncPasteFailedOnRead"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
app.pasteFromClipboard(createPasteEvent({ types }));
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t("errors.asyncPasteFailedOnParse"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.paste",
|
||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||
keyTest: undefined,
|
||||
});
|
||||
|
||||
export const actionCut = register({
|
||||
name: "cut",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, event: ClipboardEvent | null, app) => {
|
||||
actionCopy.perform(elements, appState, event, app);
|
||||
return actionDeleteSelected.perform(elements, appState);
|
||||
},
|
||||
contextItemLabel: "labels.cut",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
|
||||
});
|
||||
|
||||
export const actionCopyAsSvg = register({
|
||||
name: "copyAsSvg",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||
elements,
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
|
||||
try {
|
||||
await exportCanvas(
|
||||
"clipboard-svg",
|
||||
exportedElements,
|
||||
appState,
|
||||
app.files,
|
||||
{
|
||||
...appState,
|
||||
exportingFrame,
|
||||
},
|
||||
);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
predicate: (elements) => {
|
||||
return probablySupportsClipboardWriteText && elements.length > 0;
|
||||
},
|
||||
contextItemLabel: "labels.copyAsSvg",
|
||||
});
|
||||
|
||||
export const actionCopyAsPng = register({
|
||||
name: "copyAsPng",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
||||
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||
elements,
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
try {
|
||||
await exportCanvas("clipboard", exportedElements, appState, app.files, {
|
||||
...appState,
|
||||
exportingFrame,
|
||||
});
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
toast: {
|
||||
message: t("toast.copyToClipboardAsPng", {
|
||||
exportSelection: selectedElements.length
|
||||
? t("toast.selection")
|
||||
: t("toast.canvas"),
|
||||
exportColorScheme: appState.exportWithDarkMode
|
||||
? t("buttons.darkMode")
|
||||
: t("buttons.lightMode"),
|
||||
}),
|
||||
},
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
predicate: (elements) => {
|
||||
return probablySupportsClipboardBlob && elements.length > 0;
|
||||
},
|
||||
contextItemLabel: "labels.copyAsPng",
|
||||
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
||||
});
|
||||
|
||||
export const copyText = register({
|
||||
name: "copyText",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
|
||||
const text = selectedElements
|
||||
.reduce((acc: string[], element) => {
|
||||
if (isTextElement(element)) {
|
||||
acc.push(element.text);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.join("\n\n");
|
||||
copyTextToSystemClipboard(text);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
predicate: (elements, appState, _, app) => {
|
||||
return (
|
||||
probablySupportsClipboardWriteText &&
|
||||
app.scene
|
||||
.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
})
|
||||
.some(isTextElement)
|
||||
);
|
||||
},
|
||||
contextItemLabel: "labels.copyText",
|
||||
});
|
184
packages/excalidraw/actions/actionDeleteSelected.tsx
Normal file
184
packages/excalidraw/actions/actionDeleteSelected.tsx
Normal file
|
@ -0,0 +1,184 @@
|
|||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { KEYS } from "../keys";
|
||||
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 { newElementWith } from "../element/mutateElement";
|
||||
import { getElementsInGroup } from "../groups";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { TrashIcon } from "../components/icons";
|
||||
|
||||
const deleteSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const framesToBeDeleted = new Set(
|
||||
getSelectedElements(
|
||||
elements.filter((el) => isFrameLikeElement(el)),
|
||||
appState,
|
||||
).map((el) => el.id),
|
||||
);
|
||||
|
||||
return {
|
||||
elements: elements.map((el) => {
|
||||
if (appState.selectedElementIds[el.id]) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
|
||||
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
|
||||
if (
|
||||
isBoundToContainer(el) &&
|
||||
appState.selectedElementIds[el.containerId]
|
||||
) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
return el;
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const handleGroupEditingState = (
|
||||
appState: AppState,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): AppState => {
|
||||
if (appState.editingGroupId) {
|
||||
const siblingElements = getElementsInGroup(
|
||||
getNonDeletedElements(elements),
|
||||
appState.editingGroupId!,
|
||||
);
|
||||
if (siblingElements.length) {
|
||||
return {
|
||||
...appState,
|
||||
selectedElementIds: { [siblingElements[0].id]: true },
|
||||
};
|
||||
}
|
||||
}
|
||||
return appState;
|
||||
};
|
||||
|
||||
export const actionDeleteSelected = register({
|
||||
name: "deleteSelectedElements",
|
||||
trackEvent: { category: "element", action: "delete" },
|
||||
perform: (elements, appState) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const {
|
||||
elementId,
|
||||
selectedPointsIndices,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
} = appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
// case: no point selected → do nothing, as deleting the whole element
|
||||
// is most likely a mistake, where you wanted to delete a specific point
|
||||
// but failed to select it (or you thought it's selected, while it was
|
||||
// only in a hover state)
|
||||
if (selectedPointsIndices == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// case: deleting last remaining point
|
||||
if (element.points.length < 2) {
|
||||
const nextElements = elements.map((el) => {
|
||||
if (el.id === element.id) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
return el;
|
||||
});
|
||||
const nextAppState = handleGroupEditingState(appState, nextElements);
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...nextAppState,
|
||||
editingLinearElement: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
|
||||
// We cannot do this inside `movePoint` because it is also called
|
||||
// when deleting the uncommitted point (which hasn't caused any binding)
|
||||
const binding = {
|
||||
startBindingElement: selectedPointsIndices?.includes(0)
|
||||
? null
|
||||
: startBindingElement,
|
||||
endBindingElement: selectedPointsIndices?.includes(
|
||||
element.points.length - 1,
|
||||
)
|
||||
? null
|
||||
: endBindingElement,
|
||||
};
|
||||
|
||||
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
...binding,
|
||||
selectedPointsIndices:
|
||||
selectedPointsIndices?.[0] > 0
|
||||
? [selectedPointsIndices[0] - 1]
|
||||
: [0],
|
||||
},
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
}
|
||||
let { elements: nextElements, appState: nextAppState } =
|
||||
deleteSelectedElements(elements, appState);
|
||||
fixBindingsAfterDeletion(
|
||||
nextElements,
|
||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||
);
|
||||
|
||||
nextAppState = handleGroupEditingState(nextAppState, nextElements);
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...nextAppState,
|
||||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
||||
multiElement: null,
|
||||
activeEmbeddable: null,
|
||||
},
|
||||
commitToHistory: isSomeElementSelected(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
),
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.delete",
|
||||
keyTest: (event, appState, elements) =>
|
||||
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
|
||||
!event[KEYS.CTRL_OR_CMD],
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={TrashIcon}
|
||||
title={t("labels.delete")}
|
||||
aria-label={t("labels.delete")}
|
||||
onClick={() => updateData(null)}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
/>
|
||||
),
|
||||
});
|
106
packages/excalidraw/actions/actionDistribute.tsx
Normal file
106
packages/excalidraw/actions/actionDistribute.tsx
Normal file
|
@ -0,0 +1,106 @@
|
|||
import {
|
||||
DistributeHorizontallyIcon,
|
||||
DistributeVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { distributeElements, Distribution } from "../distribute";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { 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 { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return (
|
||||
selectedElements.length > 1 &&
|
||||
// TODO enable distributing frames when implemented properly
|
||||
!selectedElements.some((el) => isFrameLikeElement(el))
|
||||
);
|
||||
};
|
||||
|
||||
const distributeSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
app: AppClassProperties,
|
||||
distribution: Distribution,
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
const updatedElements = distributeElements(
|
||||
selectedElements,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
distribution,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
return updateFrameMembershipOfSelectedElements(
|
||||
elements.map((element) => updatedElementsMap.get(element.id) || element),
|
||||
appState,
|
||||
app,
|
||||
);
|
||||
};
|
||||
|
||||
export const distributeHorizontally = register({
|
||||
name: "distributeHorizontally",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
elements: distributeSelectedElements(elements, appState, app, {
|
||||
space: "between",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(appState, app)}
|
||||
type="button"
|
||||
icon={DistributeHorizontallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.distributeHorizontally")} — ${getShortcutKey(
|
||||
"Alt+H",
|
||||
)}`}
|
||||
aria-label={t("labels.distributeHorizontally")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const distributeVertically = register({
|
||||
name: "distributeVertically",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
elements: distributeSelectedElements(elements, appState, app, {
|
||||
space: "between",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(appState, app)}
|
||||
type="button"
|
||||
icon={DistributeVerticallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.distributeVertically")} — ${getShortcutKey("Alt+V")}`}
|
||||
aria-label={t("labels.distributeVertically")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
/>
|
||||
),
|
||||
});
|
283
packages/excalidraw/actions/actionDuplicateSelection.tsx
Normal file
283
packages/excalidraw/actions/actionDuplicateSelection.tsx
Normal file
|
@ -0,0 +1,283 @@
|
|||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { duplicateElement, getNonDeletedElements } from "../element";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import {
|
||||
selectGroupsForSelectedElements,
|
||||
getSelectedGroupForElement,
|
||||
getElementsInGroup,
|
||||
} from "../groups";
|
||||
import { AppState } from "../types";
|
||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||
import { ActionResult } from "./types";
|
||||
import { GRID_SIZE } from "../constants";
|
||||
import {
|
||||
bindTextToShapeAfterDuplication,
|
||||
getBoundTextElement,
|
||||
} from "../element/textElement";
|
||||
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
||||
import { normalizeElementOrder } from "../element/sortElements";
|
||||
import { DuplicateIcon } from "../components/icons";
|
||||
import {
|
||||
bindElementsToFramesAfterDuplication,
|
||||
getFrameChildren,
|
||||
} from "../frame";
|
||||
import {
|
||||
excludeElementsInFramesFromSelection,
|
||||
getSelectedElements,
|
||||
} from "../scene/selection";
|
||||
|
||||
export const actionDuplicateSelection = register({
|
||||
name: "duplicateSelection",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
// duplicate selected point(s) if editing a line
|
||||
if (appState.editingLinearElement) {
|
||||
const ret = LinearElementEditor.duplicateSelectedPoints(appState);
|
||||
|
||||
if (!ret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: ret.appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...duplicateElements(elements, appState),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.duplicateSelection",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={DuplicateIcon}
|
||||
title={`${t("labels.duplicateSelection")} — ${getShortcutKey(
|
||||
"CtrlOrCmd+D",
|
||||
)}`}
|
||||
aria-label={t("labels.duplicateSelection")}
|
||||
onClick={() => updateData(null)}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
const duplicateElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
): Partial<ActionResult> => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// step (1)
|
||||
|
||||
const sortedElements = normalizeElementOrder(elements);
|
||||
const groupIdMap = new Map();
|
||||
const newElements: ExcalidrawElement[] = [];
|
||||
const oldElements: ExcalidrawElement[] = [];
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
|
||||
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
|
||||
const newElement = duplicateElement(
|
||||
appState.editingGroupId,
|
||||
groupIdMap,
|
||||
element,
|
||||
{
|
||||
x: element.x + GRID_SIZE / 2,
|
||||
y: element.y + GRID_SIZE / 2,
|
||||
},
|
||||
);
|
||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||
oldElements.push(element);
|
||||
newElements.push(newElement);
|
||||
return newElement;
|
||||
};
|
||||
|
||||
const idsOfElementsToDuplicate = arrayToMap(
|
||||
getSelectedElements(sortedElements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Ids of elements that have already been processed so we don't push them
|
||||
// into the array twice if we end up backtracking when retrieving
|
||||
// discontiguous group of elements (can happen due to a bug, or in edge
|
||||
// cases such as a group containing deleted elements which were not selected).
|
||||
//
|
||||
// This is not enough to prevent duplicates, so we do a second loop afterwards
|
||||
// to remove them.
|
||||
//
|
||||
// For convenience we mark even the newly created ones even though we don't
|
||||
// loop over them.
|
||||
const processedIds = new Map<ExcalidrawElement["id"], true>();
|
||||
|
||||
const markAsProcessed = (elements: ExcalidrawElement[]) => {
|
||||
for (const element of elements) {
|
||||
processedIds.set(element.id, true);
|
||||
}
|
||||
return elements;
|
||||
};
|
||||
|
||||
const elementsWithClones: ExcalidrawElement[] = [];
|
||||
|
||||
let index = -1;
|
||||
|
||||
while (++index < sortedElements.length) {
|
||||
const element = sortedElements[index];
|
||||
|
||||
if (processedIds.get(element.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, arrayToMap(elements));
|
||||
const isElementAFrameLike = isFrameLikeElement(element);
|
||||
|
||||
if (idsOfElementsToDuplicate.get(element.id)) {
|
||||
// if a group or a container/bound-text or frame, duplicate atomically
|
||||
if (element.groupIds.length || boundTextElement || isElementAFrameLike) {
|
||||
const groupId = getSelectedGroupForElement(appState, element);
|
||||
if (groupId) {
|
||||
// TODO:
|
||||
// remove `.flatMap...`
|
||||
// if the elements in a frame are grouped when the frame is grouped
|
||||
const groupElements = getElementsInGroup(
|
||||
sortedElements,
|
||||
groupId,
|
||||
).flatMap((element) =>
|
||||
isFrameLikeElement(element)
|
||||
? [...getFrameChildren(elements, element.id), element]
|
||||
: [element],
|
||||
);
|
||||
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([
|
||||
...groupElements,
|
||||
...groupElements.map((element) =>
|
||||
duplicateAndOffsetElement(element),
|
||||
),
|
||||
]),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (boundTextElement) {
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([
|
||||
element,
|
||||
boundTextElement,
|
||||
duplicateAndOffsetElement(element),
|
||||
duplicateAndOffsetElement(boundTextElement),
|
||||
]),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (isElementAFrameLike) {
|
||||
const elementsInFrame = getFrameChildren(sortedElements, element.id);
|
||||
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([
|
||||
...elementsInFrame,
|
||||
element,
|
||||
...elementsInFrame.map((e) => duplicateAndOffsetElement(e)),
|
||||
duplicateAndOffsetElement(element),
|
||||
]),
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// since elements in frames have a lower z-index than the frame itself,
|
||||
// they will be looped first and if their frames are selected as well,
|
||||
// they will have been copied along with the frame atomically in the
|
||||
// above branch, so we must skip those elements here
|
||||
//
|
||||
// now, for elements do not belong any frames or elements whose frames
|
||||
// are selected (or elements that are left out from the above
|
||||
// steps for whatever reason) we (should at least) duplicate them here
|
||||
if (!element.frameId || !idsOfElementsToDuplicate.has(element.frameId)) {
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
elementsWithClones.push(...markAsProcessed([element]));
|
||||
}
|
||||
}
|
||||
|
||||
// step (2)
|
||||
|
||||
// second pass to remove duplicates. We loop from the end as it's likelier
|
||||
// that the last elements are in the correct order (contiguous or otherwise).
|
||||
// Thus we need to reverse as the last step (3).
|
||||
|
||||
const finalElementsReversed: ExcalidrawElement[] = [];
|
||||
|
||||
const finalElementIds = new Map<ExcalidrawElement["id"], true>();
|
||||
index = elementsWithClones.length;
|
||||
|
||||
while (--index >= 0) {
|
||||
const element = elementsWithClones[index];
|
||||
if (!finalElementIds.get(element.id)) {
|
||||
finalElementIds.set(element.id, true);
|
||||
finalElementsReversed.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
// step (3)
|
||||
|
||||
const finalElements = finalElementsReversed.reverse();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bindTextToShapeAfterDuplication(
|
||||
elementsWithClones,
|
||||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
fixBindingsAfterDuplication(
|
||||
elementsWithClones,
|
||||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
bindElementsToFramesAfterDuplication(
|
||||
finalElements,
|
||||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
|
||||
const nextElementsToSelect =
|
||||
excludeElementsInFramesFromSelection(newElements);
|
||||
|
||||
return {
|
||||
elements: finalElements,
|
||||
appState: {
|
||||
...appState,
|
||||
...selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: appState.editingGroupId,
|
||||
selectedElementIds: nextElementsToSelect.reduce(
|
||||
(acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (!isBoundToContainer(element)) {
|
||||
acc[element.id] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
),
|
||||
},
|
||||
getNonDeletedElements(finalElements),
|
||||
appState,
|
||||
null,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
68
packages/excalidraw/actions/actionElementLock.test.tsx
Normal file
68
packages/excalidraw/actions/actionElementLock.test.tsx
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { Excalidraw } from "../index";
|
||||
import { queryByTestId, fireEvent } from "@testing-library/react";
|
||||
import { render } from "../tests/test-utils";
|
||||
import { Pointer, UI } from "../tests/helpers/ui";
|
||||
import { API } from "../tests/helpers/api";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
describe("element locking", () => {
|
||||
it("should not show unlockAllElements action in contextMenu if no elements locked", async () => {
|
||||
await render(<Excalidraw />);
|
||||
|
||||
mouse.rightClickAt(0, 0);
|
||||
|
||||
const item = queryByTestId(UI.queryContextMenu()!, "unlockAllElements");
|
||||
expect(item).toBe(null);
|
||||
});
|
||||
|
||||
it("should unlock all elements and select them when using unlockAllElements action in contextMenu", async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
elements: [
|
||||
API.createElement({
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
locked: true,
|
||||
}),
|
||||
API.createElement({
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
locked: true,
|
||||
}),
|
||||
API.createElement({
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
locked: false,
|
||||
}),
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
mouse.rightClickAt(0, 0);
|
||||
|
||||
expect(Object.keys(h.state.selectedElementIds).length).toBe(0);
|
||||
expect(h.elements.map((el) => el.locked)).toEqual([true, true, false]);
|
||||
|
||||
const item = queryByTestId(UI.queryContextMenu()!, "unlockAllElements");
|
||||
expect(item).not.toBe(null);
|
||||
|
||||
fireEvent.click(item!.querySelector("button")!);
|
||||
|
||||
expect(h.elements.map((el) => el.locked)).toEqual([false, false, false]);
|
||||
// should select the unlocked elements
|
||||
expect(h.state.selectedElementIds).toEqual({
|
||||
[h.elements[0].id]: true,
|
||||
[h.elements[1].id]: true,
|
||||
});
|
||||
});
|
||||
});
|
105
packages/excalidraw/actions/actionElementLock.ts
Normal file
105
packages/excalidraw/actions/actionElementLock.ts
Normal file
|
@ -0,0 +1,105 @@
|
|||
import { newElementWith } from "../element/mutateElement";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { KEYS } from "../keys";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.every((el) => !el.locked);
|
||||
|
||||
export const actionToggleElementLock = register({
|
||||
name: "toggleElementLock",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return !selectedElements.some(
|
||||
(element) => element.locked && element.frameId,
|
||||
);
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
||||
if (!selectedElements.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextLockState = shouldLock(selectedElements);
|
||||
const selectedElementsMap = arrayToMap(selectedElements);
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (!selectedElementsMap.has(element.id)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return newElementWith(element, { locked: nextLockState });
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedLinearElement: nextLockState
|
||||
? null
|
||||
: appState.selectedLinearElement,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
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 &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: false,
|
||||
}).length > 0
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionUnlockAllElements = register({
|
||||
name: "unlockAllElements",
|
||||
trackEvent: { category: "canvas" },
|
||||
viewMode: false,
|
||||
predicate: (elements) => {
|
||||
return elements.some((element) => element.locked);
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const lockedElements = elements.filter((el) => el.locked);
|
||||
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (element.locked) {
|
||||
return newElementWith(element, { locked: false });
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: Object.fromEntries(
|
||||
lockedElements.map((el) => [el.id, true]),
|
||||
),
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.elementLock.unlockAll",
|
||||
});
|
292
packages/excalidraw/actions/actionExport.tsx
Normal file
292
packages/excalidraw/actions/actionExport.tsx
Normal file
|
@ -0,0 +1,292 @@
|
|||
import { questionCircle, saveAs } from "../components/icons";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
import { resaveAsImageWithScene } from "../data/resave";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "../components/App";
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { CheckboxItem } from "../components/CheckboxItem";
|
||||
import { getExportSize } from "../scene/export";
|
||||
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
|
||||
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 "../components/ToolIcon.scss";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
trackEvent: false,
|
||||
perform: (_elements, appState, value) => {
|
||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, appProps, data }) => (
|
||||
<ProjectName
|
||||
label={t("labels.fileTitle")}
|
||||
value={appState.name || "Unnamed"}
|
||||
onChange={(name: string) => updateData(name)}
|
||||
isNameEditable={
|
||||
typeof appProps.name === "undefined" && !appState.viewModeEnabled
|
||||
}
|
||||
ignoreFocus={data?.ignoreFocus ?? false}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionChangeExportScale = register({
|
||||
name: "changeExportScale",
|
||||
trackEvent: { category: "export", action: "scale" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportScale: value },
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements: allElements, appState, updateData }) => {
|
||||
const elements = getNonDeletedElements(allElements);
|
||||
const exportSelected = isSomeElementSelected(elements, appState);
|
||||
const exportedElements = exportSelected
|
||||
? getSelectedElements(elements, appState)
|
||||
: elements;
|
||||
|
||||
return (
|
||||
<>
|
||||
{EXPORT_SCALES.map((s) => {
|
||||
const [width, height] = getExportSize(
|
||||
exportedElements,
|
||||
DEFAULT_EXPORT_PADDING,
|
||||
s,
|
||||
);
|
||||
|
||||
const scaleButtonTitle = `${t(
|
||||
"imageExportDialog.label.scale",
|
||||
)} ${s}x (${width}x${height})`;
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
key={s}
|
||||
size="small"
|
||||
type="radio"
|
||||
icon={`${s}x`}
|
||||
name="export-canvas-scale"
|
||||
title={scaleButtonTitle}
|
||||
aria-label={scaleButtonTitle}
|
||||
id="export-canvas-scale"
|
||||
checked={s === appState.exportScale}
|
||||
onChange={() => updateData(s)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeExportBackground = register({
|
||||
name: "changeExportBackground",
|
||||
trackEvent: { category: "export", action: "toggleBackground" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportBackground: value },
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<CheckboxItem
|
||||
checked={appState.exportBackground}
|
||||
onChange={(checked) => updateData(checked)}
|
||||
>
|
||||
{t("imageExportDialog.label.withBackground")}
|
||||
</CheckboxItem>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionChangeExportEmbedScene = register({
|
||||
name: "changeExportEmbedScene",
|
||||
trackEvent: { category: "export", action: "embedScene" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportEmbedScene: value },
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<CheckboxItem
|
||||
checked={appState.exportEmbedScene}
|
||||
onChange={(checked) => updateData(checked)}
|
||||
>
|
||||
{t("imageExportDialog.label.embedScene")}
|
||||
<Tooltip label={t("imageExportDialog.tooltip.embedScene")} long={true}>
|
||||
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
|
||||
</Tooltip>
|
||||
</CheckboxItem>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
trackEvent: { category: "export" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
!!app.props.UIOptions.canvasActions.saveToActiveFile &&
|
||||
!!appState.fileHandle &&
|
||||
!appState.viewModeEnabled
|
||||
);
|
||||
},
|
||||
perform: async (elements, appState, value, app) => {
|
||||
const fileHandleExists = !!appState.fileHandle;
|
||||
|
||||
try {
|
||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||
? await resaveAsImageWithScene(elements, appState, app.files)
|
||||
: await saveAsJSON(elements, appState, app.files);
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
fileHandle,
|
||||
toast: fileHandleExists
|
||||
? {
|
||||
message: fileHandle?.name
|
||||
? t("toast.fileSavedToFilename").replace(
|
||||
"{filename}",
|
||||
`"${fileHandle.name}"`,
|
||||
)
|
||||
: t("toast.fileSaved"),
|
||||
}
|
||||
: null,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error?.name !== "AbortError") {
|
||||
console.error(error);
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { commitToHistory: false };
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
||||
});
|
||||
|
||||
export const actionSaveFileToDisk = register({
|
||||
name: "saveFileToDisk",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, value, app) => {
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(
|
||||
elements,
|
||||
{
|
||||
...appState,
|
||||
fileHandle: null,
|
||||
},
|
||||
app.files,
|
||||
);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
openDialog: null,
|
||||
fileHandle,
|
||||
toast: { message: t("toast.fileSaved") },
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error?.name !== "AbortError") {
|
||||
console.error(error);
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { commitToHistory: false };
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD],
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={saveAs}
|
||||
title={t("buttons.saveAs")}
|
||||
aria-label={t("buttons.saveAs")}
|
||||
showAriaLabel={useDevice().editor.isMobile}
|
||||
hidden={!nativeFileSystemSupported}
|
||||
onClick={() => updateData(null)}
|
||||
data-testid="save-as-button"
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionLoadScene = register({
|
||||
name: "loadScene",
|
||||
trackEvent: { category: "export" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
!!app.props.UIOptions.canvasActions.loadScene && !appState.viewModeEnabled
|
||||
);
|
||||
},
|
||||
perform: async (elements, appState, _, app) => {
|
||||
try {
|
||||
const {
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
files,
|
||||
} = await loadFromJSON(appState, elements);
|
||||
return {
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
files,
|
||||
commitToHistory: true,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
console.warn(error);
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
elements,
|
||||
appState: { ...appState, errorMessage: error.message },
|
||||
files: app.files,
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
|
||||
});
|
||||
|
||||
export const actionExportWithDarkMode = register({
|
||||
name: "exportWithDarkMode",
|
||||
trackEvent: { category: "export", action: "toggleTheme" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportWithDarkMode: value },
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginTop: "-45px",
|
||||
marginBottom: "10px",
|
||||
}}
|
||||
>
|
||||
<DarkModeToggle
|
||||
value={appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT}
|
||||
onChange={(theme: Theme) => {
|
||||
updateData(theme === THEME.DARK);
|
||||
}}
|
||||
title={t("imageExportDialog.label.darkMode")}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
});
|
213
packages/excalidraw/actions/actionFinalize.tsx
Normal file
213
packages/excalidraw/actions/actionFinalize.tsx
Normal file
|
@ -0,0 +1,213 @@
|
|||
import { KEYS } from "../keys";
|
||||
import { isInvisiblySmallElement } from "../element";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { done } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
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 { resetCursor } from "../cursor";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
trackEvent: false,
|
||||
perform: (
|
||||
elements,
|
||||
appState,
|
||||
_,
|
||||
{ interactiveCanvas, focusContainer, scene },
|
||||
) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const { elementId, startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
|
||||
if (element) {
|
||||
if (isBindingElement(element)) {
|
||||
bindOrUnbindLinearElement(
|
||||
element,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
);
|
||||
}
|
||||
return {
|
||||
elements:
|
||||
element.points.length < 2 || isInvisiblySmallElement(element)
|
||||
? elements.filter((el) => el.id !== element.id)
|
||||
: undefined,
|
||||
appState: {
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
editingLinearElement: null,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let newElements = elements;
|
||||
|
||||
const pendingImageElement =
|
||||
appState.pendingImageElementId &&
|
||||
scene.getElement(appState.pendingImageElementId);
|
||||
|
||||
if (pendingImageElement) {
|
||||
mutateElement(pendingImageElement, { isDeleted: true }, false);
|
||||
}
|
||||
|
||||
if (window.document.activeElement instanceof HTMLElement) {
|
||||
focusContainer();
|
||||
}
|
||||
|
||||
const multiPointElement = appState.multiElement
|
||||
? appState.multiElement
|
||||
: appState.editingElement?.type === "freedraw"
|
||||
? appState.editingElement
|
||||
: null;
|
||||
|
||||
if (multiPointElement) {
|
||||
// pen and mouse have hover
|
||||
if (
|
||||
multiPointElement.type !== "freedraw" &&
|
||||
appState.lastPointerDownWith !== "touch"
|
||||
) {
|
||||
const { points, lastCommittedPoint } = multiPointElement;
|
||||
if (
|
||||
!lastCommittedPoint ||
|
||||
points[points.length - 1] !== lastCommittedPoint
|
||||
) {
|
||||
mutateElement(multiPointElement, {
|
||||
points: multiPointElement.points.slice(0, -1),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (isInvisiblySmallElement(multiPointElement)) {
|
||||
newElements = newElements.filter(
|
||||
(el) => el.id !== multiPointElement.id,
|
||||
);
|
||||
}
|
||||
|
||||
// If the multi point line closes the loop,
|
||||
// set the last point to first point.
|
||||
// This ensures that loop remains closed at different scales.
|
||||
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
|
||||
if (
|
||||
multiPointElement.type === "line" ||
|
||||
multiPointElement.type === "freedraw"
|
||||
) {
|
||||
if (isLoop) {
|
||||
const linePoints = multiPointElement.points;
|
||||
const firstPoint = linePoints[0];
|
||||
mutateElement(multiPointElement, {
|
||||
points: linePoints.map((point, index) =>
|
||||
index === linePoints.length - 1
|
||||
? ([firstPoint[0], firstPoint[1]] as const)
|
||||
: point,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isBindingElement(multiPointElement) &&
|
||||
!isLoop &&
|
||||
multiPointElement.points.length > 1
|
||||
) {
|
||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
multiPointElement,
|
||||
-1,
|
||||
);
|
||||
maybeBindLinearElement(
|
||||
multiPointElement,
|
||||
appState,
|
||||
Scene.getScene(multiPointElement)!,
|
||||
{ x, y },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw") ||
|
||||
!multiPointElement
|
||||
) {
|
||||
resetCursor(interactiveCanvas);
|
||||
}
|
||||
|
||||
let activeTool: AppState["activeTool"];
|
||||
if (appState.activeTool.type === "eraser") {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: "selection",
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: "selection",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
elements: newElements,
|
||||
appState: {
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
activeTool:
|
||||
(appState.activeTool.locked ||
|
||||
appState.activeTool.type === "freedraw") &&
|
||||
multiPointElement
|
||||
? appState.activeTool
|
||||
: activeTool,
|
||||
activeEmbeddable: null,
|
||||
draggingElement: null,
|
||||
multiElement: null,
|
||||
editingElement: null,
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
selectedElementIds:
|
||||
multiPointElement &&
|
||||
!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw"
|
||||
? {
|
||||
...appState.selectedElementIds,
|
||||
[multiPointElement.id]: true,
|
||||
}
|
||||
: appState.selectedElementIds,
|
||||
// To select the linear element when user has finished mutipoint editing
|
||||
selectedLinearElement:
|
||||
multiPointElement && isLinearElement(multiPointElement)
|
||||
? new LinearElementEditor(multiPointElement, scene)
|
||||
: appState.selectedLinearElement,
|
||||
pendingImageElementId: null,
|
||||
},
|
||||
commitToHistory: appState.activeTool.type === "freedraw",
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
(event.key === KEYS.ESCAPE &&
|
||||
(appState.editingLinearElement !== null ||
|
||||
(!appState.draggingElement && appState.multiElement === null))) ||
|
||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||
appState.multiElement !== null),
|
||||
PanelComponent: ({ appState, updateData, data }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={done}
|
||||
title={t("buttons.done")}
|
||||
aria-label={t("buttons.done")}
|
||||
onClick={updateData}
|
||||
visible={appState.multiElement != null}
|
||||
size={data?.size || "medium"}
|
||||
/>
|
||||
),
|
||||
});
|
121
packages/excalidraw/actions/actionFlip.ts
Normal file
121
packages/excalidraw/actions/actionFlip.ts
Normal file
|
@ -0,0 +1,121 @@
|
|||
import { register } from "./register";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
NonDeleted,
|
||||
NonDeletedElementsMap,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../element/types";
|
||||
import { resizeMultipleElements } from "../element/resizeElements";
|
||||
import { AppState } from "../types";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getCommonBoundingBox } from "../element/bounds";
|
||||
import {
|
||||
bindOrUnbindSelectedElements,
|
||||
isBindingEnabled,
|
||||
unbindLinearElements,
|
||||
} from "../element/binding";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
|
||||
export const actionFlipHorizontal = register({
|
||||
name: "flipHorizontal",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
elements: updateFrameMembershipOfSelectedElements(
|
||||
flipSelectedElements(
|
||||
elements,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
appState,
|
||||
"horizontal",
|
||||
),
|
||||
appState,
|
||||
app,
|
||||
),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.shiftKey && event.code === CODES.H,
|
||||
contextItemLabel: "labels.flipHorizontal",
|
||||
});
|
||||
|
||||
export const actionFlipVertical = register({
|
||||
name: "flipVertical",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
elements: updateFrameMembershipOfSelectedElements(
|
||||
flipSelectedElements(
|
||||
elements,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
appState,
|
||||
"vertical",
|
||||
),
|
||||
appState,
|
||||
app,
|
||||
),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
|
||||
contextItemLabel: "labels.flipVertical",
|
||||
});
|
||||
|
||||
const flipSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap,
|
||||
appState: Readonly<AppState>,
|
||||
flipDirection: "horizontal" | "vertical",
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
},
|
||||
);
|
||||
|
||||
const updatedElements = flipElements(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
appState,
|
||||
flipDirection,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
return elements.map(
|
||||
(element) => updatedElementsMap.get(element.id) || element,
|
||||
);
|
||||
};
|
||||
|
||||
const flipElements = (
|
||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap,
|
||||
appState: AppState,
|
||||
flipDirection: "horizontal" | "vertical",
|
||||
): ExcalidrawElement[] => {
|
||||
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
|
||||
|
||||
resizeMultipleElements(
|
||||
elementsMap,
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
"nw",
|
||||
true,
|
||||
flipDirection === "horizontal" ? maxX : minX,
|
||||
flipDirection === "horizontal" ? minY : maxY,
|
||||
);
|
||||
|
||||
(isBindingEnabled(appState)
|
||||
? bindOrUnbindSelectedElements
|
||||
: unbindLinearElements)(selectedElements);
|
||||
|
||||
return selectedElements;
|
||||
};
|
138
packages/excalidraw/actions/actionFrame.ts
Normal file
138
packages/excalidraw/actions/actionFrame.ts
Normal file
|
@ -0,0 +1,138 @@
|
|||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { removeAllElementsFromFrame } from "../frame";
|
||||
import { getFrameChildren } from "../frame";
|
||||
import { KEYS } from "../keys";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { register } from "./register";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
|
||||
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
return (
|
||||
selectedElements.length === 1 && isFrameLikeElement(selectedElements[0])
|
||||
);
|
||||
};
|
||||
|
||||
export const actionSelectAllElementsInFrame = register({
|
||||
name: "selectAllElementsInFrame",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElement =
|
||||
app.scene.getSelectedElements(appState).at(0) || null;
|
||||
|
||||
if (isFrameLikeElement(selectedElement)) {
|
||||
const elementsInFrame = getFrameChildren(
|
||||
getNonDeletedElements(elements),
|
||||
selectedElement.id,
|
||||
).filter((element) => !(element.type === "text" && element.containerId));
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: elementsInFrame.reduce((acc, element) => {
|
||||
acc[element.id] = true;
|
||||
return acc;
|
||||
}, {} as Record<ExcalidrawElement["id"], true>),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.selectAllElementsInFrame",
|
||||
predicate: (elements, appState, _, app) =>
|
||||
isSingleFrameSelected(appState, app),
|
||||
});
|
||||
|
||||
export const actionRemoveAllElementsFromFrame = register({
|
||||
name: "removeAllElementsFromFrame",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElement =
|
||||
app.scene.getSelectedElements(appState).at(0) || null;
|
||||
|
||||
if (isFrameLikeElement(selectedElement)) {
|
||||
return {
|
||||
elements: removeAllElementsFromFrame(elements, selectedElement),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {
|
||||
[selectedElement.id]: true,
|
||||
},
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.removeAllElementsFromFrame",
|
||||
predicate: (elements, appState, _, app) =>
|
||||
isSingleFrameSelected(appState, app),
|
||||
});
|
||||
|
||||
export const actionupdateFrameRendering = register({
|
||||
name: "updateFrameRendering",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
frameRendering: {
|
||||
...appState.frameRendering,
|
||||
enabled: !appState.frameRendering.enabled,
|
||||
},
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.updateFrameRendering",
|
||||
checked: (appState: AppState) => appState.frameRendering.enabled,
|
||||
});
|
||||
|
||||
export const actionSetFrameAsActiveTool = register({
|
||||
name: "setFrameAsActiveTool",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
setCursorForShape(app.interactiveCanvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
activeTool: updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
}),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey &&
|
||||
event.key.toLocaleLowerCase() === KEYS.F,
|
||||
});
|
273
packages/excalidraw/actions/actionGroup.tsx
Normal file
273
packages/excalidraw/actions/actionGroup.tsx
Normal file
|
@ -0,0 +1,273 @@
|
|||
import { KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { UngroupIcon, GroupIcon } from "../components/icons";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import {
|
||||
getSelectedGroupIds,
|
||||
selectGroup,
|
||||
selectGroupsForSelectedElements,
|
||||
getElementsInGroup,
|
||||
addToGroup,
|
||||
removeFromSelectedGroups,
|
||||
isElementInGroup,
|
||||
} from "../groups";
|
||||
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 { isBoundToContainer } from "../element/typeChecks";
|
||||
import {
|
||||
getElementsInResizingFrame,
|
||||
getFrameLikeElements,
|
||||
groupByFrameLikes,
|
||||
removeElementsFromFrame,
|
||||
replaceAllElementsInFrame,
|
||||
} from "../frame";
|
||||
|
||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||
if (elements.length >= 2) {
|
||||
const groupIds = elements[0].groupIds;
|
||||
for (const groupId of groupIds) {
|
||||
if (
|
||||
elements.reduce(
|
||||
(acc, element) => acc && isElementInGroup(element, groupId),
|
||||
true,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const enableActionGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
return (
|
||||
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
||||
);
|
||||
};
|
||||
|
||||
export const actionGroup = register({
|
||||
name: "group",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
if (selectedElements.length < 2) {
|
||||
// nothing to group
|
||||
return { appState, elements, commitToHistory: false };
|
||||
}
|
||||
// if everything is already grouped into 1 group, there is nothing to do
|
||||
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||
if (selectedGroupIds.length === 1) {
|
||||
const selectedGroupId = selectedGroupIds[0];
|
||||
const elementIdsInGroup = new Set(
|
||||
getElementsInGroup(elements, selectedGroupId).map(
|
||||
(element) => element.id,
|
||||
),
|
||||
);
|
||||
const selectedElementIds = new Set(
|
||||
selectedElements.map((element) => element.id),
|
||||
);
|
||||
const combinedSet = new Set([
|
||||
...Array.from(elementIdsInGroup),
|
||||
...Array.from(selectedElementIds),
|
||||
]);
|
||||
if (combinedSet.size === elementIdsInGroup.size) {
|
||||
// no incremental ids in the selected ids
|
||||
return { appState, elements, commitToHistory: false };
|
||||
}
|
||||
}
|
||||
|
||||
let nextElements = [...elements];
|
||||
|
||||
// this includes the case where we are grouping elements inside a frame
|
||||
// and elements outside that frame
|
||||
const groupingElementsFromDifferentFrames =
|
||||
new Set(selectedElements.map((element) => element.frameId)).size > 1;
|
||||
// when it happens, we want to remove elements that are in the frame
|
||||
// and are going to be grouped from the frame (mouthful, I know)
|
||||
if (groupingElementsFromDifferentFrames) {
|
||||
const frameElementsMap = groupByFrameLikes(selectedElements);
|
||||
|
||||
frameElementsMap.forEach((elementsInFrame, frameId) => {
|
||||
removeElementsFromFrame(
|
||||
elementsInFrame,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const newGroupId = randomId();
|
||||
const selectElementIds = arrayToMap(selectedElements);
|
||||
|
||||
nextElements = nextElements.map((element) => {
|
||||
if (!selectElementIds.get(element.id)) {
|
||||
return element;
|
||||
}
|
||||
return newElementWith(element, {
|
||||
groupIds: addToGroup(
|
||||
element.groupIds,
|
||||
newGroupId,
|
||||
appState.editingGroupId,
|
||||
),
|
||||
});
|
||||
});
|
||||
// keep the z order within the group the same, but move them
|
||||
// to the z order of the highest element in the layer stack
|
||||
const elementsInGroup = getElementsInGroup(nextElements, newGroupId);
|
||||
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
||||
const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup);
|
||||
const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
|
||||
const elementsBeforeGroup = nextElements
|
||||
.slice(0, lastGroupElementIndex)
|
||||
.filter(
|
||||
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
|
||||
);
|
||||
nextElements = [
|
||||
...elementsBeforeGroup,
|
||||
...elementsInGroup,
|
||||
...elementsAfterGroup,
|
||||
];
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...selectGroup(
|
||||
newGroupId,
|
||||
{ ...appState, selectedGroupIds: {} },
|
||||
getNonDeletedElements(nextElements),
|
||||
),
|
||||
},
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.group",
|
||||
predicate: (elements, appState, _, app) =>
|
||||
enableActionGroup(elements, appState, app),
|
||||
keyTest: (event) =>
|
||||
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState, app)}
|
||||
type="button"
|
||||
icon={<GroupIcon theme={appState.theme} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.group")} — ${getShortcutKey("CtrlOrCmd+G")}`}
|
||||
aria-label={t("labels.group")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
></ToolButton>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionUngroup = register({
|
||||
name: "ungroup",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const groupIds = getSelectedGroupIds(appState);
|
||||
if (groupIds.length === 0) {
|
||||
return { appState, elements, commitToHistory: false };
|
||||
}
|
||||
|
||||
let nextElements = [...elements];
|
||||
|
||||
const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
|
||||
nextElements = nextElements.map((element) => {
|
||||
if (isBoundToContainer(element)) {
|
||||
boundTextElementIds.push(element.id);
|
||||
}
|
||||
const nextGroupIds = removeFromSelectedGroups(
|
||||
element.groupIds,
|
||||
appState.selectedGroupIds,
|
||||
);
|
||||
if (nextGroupIds.length === element.groupIds.length) {
|
||||
return element;
|
||||
}
|
||||
return newElementWith(element, {
|
||||
groupIds: nextGroupIds,
|
||||
});
|
||||
});
|
||||
|
||||
const updateAppState = selectGroupsForSelectedElements(
|
||||
appState,
|
||||
getNonDeletedElements(nextElements),
|
||||
appState,
|
||||
null,
|
||||
);
|
||||
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
const selectedElementFrameIds = new Set(
|
||||
selectedElements
|
||||
.filter((element) => element.frameId)
|
||||
.map((element) => element.frameId!),
|
||||
);
|
||||
|
||||
const targetFrames = getFrameLikeElements(elements).filter((frame) =>
|
||||
selectedElementFrameIds.has(frame.id),
|
||||
);
|
||||
|
||||
targetFrames.forEach((frame) => {
|
||||
if (frame) {
|
||||
nextElements = replaceAllElementsInFrame(
|
||||
nextElements,
|
||||
getElementsInResizingFrame(nextElements, frame, appState),
|
||||
frame,
|
||||
app,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// remove binded text elements from selection
|
||||
updateAppState.selectedElementIds = Object.entries(
|
||||
updateAppState.selectedElementIds,
|
||||
).reduce(
|
||||
(acc: { [key: ExcalidrawElement["id"]]: true }, [id, selected]) => {
|
||||
if (selected && !boundTextElementIds.includes(id)) {
|
||||
acc[id] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return {
|
||||
appState: { ...appState, ...updateAppState },
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
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 }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
hidden={getSelectedGroupIds(appState).length === 0}
|
||||
icon={<UngroupIcon theme={appState.theme} />}
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.ungroup")} — ${getShortcutKey("CtrlOrCmd+Shift+G")}`}
|
||||
aria-label={t("labels.ungroup")}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
></ToolButton>
|
||||
),
|
||||
});
|
105
packages/excalidraw/actions/actionHistory.tsx
Normal file
105
packages/excalidraw/actions/actionHistory.tsx
Normal file
|
@ -0,0 +1,105 @@
|
|||
import { 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 { KEYS } from "../keys";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { isWindows } from "../constants";
|
||||
|
||||
const writeData = (
|
||||
prevElements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
updater: () => HistoryEntry | null,
|
||||
): ActionResult => {
|
||||
const commitToHistory = false;
|
||||
if (
|
||||
!appState.multiElement &&
|
||||
!appState.resizingElement &&
|
||||
!appState.editingElement &&
|
||||
!appState.draggingElement
|
||||
) {
|
||||
const data = updater();
|
||||
if (data === null) {
|
||||
return { commitToHistory };
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: { ...appState, ...data.appState },
|
||||
commitToHistory,
|
||||
syncHistory: true,
|
||||
};
|
||||
}
|
||||
return { commitToHistory };
|
||||
};
|
||||
|
||||
type ActionCreator = (history: History) => Action;
|
||||
|
||||
export const createUndoAction: ActionCreator = (history) => ({
|
||||
name: "undo",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.undoOnce()),
|
||||
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,
|
||||
});
|
||||
|
||||
export const createRedoAction: ActionCreator = (history) => ({
|
||||
name: "redo",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.redoOnce()),
|
||||
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,
|
||||
});
|
45
packages/excalidraw/actions/actionLinearEditor.ts
Normal file
45
packages/excalidraw/actions/actionLinearEditor.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
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/actionMenu.tsx
Normal file
76
packages/excalidraw/actions/actionMenu.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { HamburgerMenuIcon, 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";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
name: "toggleCanvasMenu",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_, appState) => ({
|
||||
appState: {
|
||||
...appState,
|
||||
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
||||
},
|
||||
commitToHistory: false,
|
||||
}),
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={HamburgerMenuIcon}
|
||||
aria-label={t("buttons.menu")}
|
||||
onClick={updateData}
|
||||
selected={appState.openMenu === "canvas"}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionToggleEditMenu = register({
|
||||
name: "toggleEditMenu",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_elements, appState) => ({
|
||||
appState: {
|
||||
...appState,
|
||||
openMenu: appState.openMenu === "shape" ? null : "shape",
|
||||
},
|
||||
commitToHistory: false,
|
||||
}),
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
visible={showSelectedShapeActions(
|
||||
appState,
|
||||
getNonDeletedElements(elements),
|
||||
)}
|
||||
type="button"
|
||||
icon={palette}
|
||||
aria-label={t("buttons.edit")}
|
||||
onClick={updateData}
|
||||
selected={appState.openMenu === "shape"}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionShortcuts = register({
|
||||
name: "toggleShortcuts",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "menu", action: "toggleHelpDialog" },
|
||||
perform: (_elements, appState, _, { focusContainer }) => {
|
||||
if (appState.openDialog?.name === "help") {
|
||||
focusContainer();
|
||||
}
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
openDialog:
|
||||
appState.openDialog?.name === "help"
|
||||
? null
|
||||
: {
|
||||
name: "help",
|
||||
},
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
||||
});
|
85
packages/excalidraw/actions/actionNavigate.tsx
Normal file
85
packages/excalidraw/actions/actionNavigate.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import { getClientColor } from "../clients";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { GoToCollaboratorComponentProps } from "../components/UserList";
|
||||
import { eyeIcon } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { Collaborator } from "../types";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionGoToCollaborator = register({
|
||||
name: "goToCollaborator",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "collab" },
|
||||
perform: (_elements, appState, collaborator: Collaborator) => {
|
||||
if (
|
||||
!collaborator.socketId ||
|
||||
appState.userToFollow?.socketId === collaborator.socketId ||
|
||||
collaborator.isCurrentUser
|
||||
) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
userToFollow: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
userToFollow: {
|
||||
socketId: collaborator.socketId,
|
||||
username: collaborator.username || "",
|
||||
},
|
||||
// Close mobile menu
|
||||
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, data, appState }) => {
|
||||
const { clientId, collaborator, withName, isBeingFollowed } =
|
||||
data as GoToCollaboratorComponentProps;
|
||||
|
||||
const background = getClientColor(clientId);
|
||||
|
||||
return withName ? (
|
||||
<div
|
||||
className="dropdown-menu-item dropdown-menu-item-base UserList__collaborator"
|
||||
onClick={() => updateData<Collaborator>(collaborator)}
|
||||
>
|
||||
<Avatar
|
||||
color={background}
|
||||
onClick={() => {}}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
isBeingFollowed={isBeingFollowed}
|
||||
isCurrentUser={collaborator.isCurrentUser === true}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<Avatar
|
||||
color={background}
|
||||
onClick={() => {
|
||||
updateData(collaborator);
|
||||
}}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
isBeingFollowed={isBeingFollowed}
|
||||
isCurrentUser={collaborator.isCurrentUser === true}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
167
packages/excalidraw/actions/actionProperties.test.tsx
Normal file
167
packages/excalidraw/actions/actionProperties.test.tsx
Normal file
|
@ -0,0 +1,167 @@
|
|||
import { Excalidraw } from "../index";
|
||||
import { queryByTestId } from "@testing-library/react";
|
||||
import { render } from "../tests/test-utils";
|
||||
import { UI } from "../tests/helpers/ui";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors";
|
||||
import { FONT_FAMILY, STROKE_WIDTH } from "../constants";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("element locking", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
describe("properties when tool selected", () => {
|
||||
it("should show active background top picks", () => {
|
||||
UI.clickTool("rectangle");
|
||||
|
||||
const color = DEFAULT_ELEMENT_BACKGROUND_PICKS[1];
|
||||
|
||||
// just in case we change it in the future
|
||||
expect(color).not.toBe(COLOR_PALETTE.transparent);
|
||||
|
||||
h.setState({
|
||||
currentItemBackgroundColor: color,
|
||||
});
|
||||
const activeColor = queryByTestId(
|
||||
document.body,
|
||||
`color-top-pick-${color}`,
|
||||
);
|
||||
expect(activeColor).toHaveClass("active");
|
||||
});
|
||||
|
||||
it("should show fill style when background non-transparent", () => {
|
||||
UI.clickTool("rectangle");
|
||||
|
||||
const color = DEFAULT_ELEMENT_BACKGROUND_PICKS[1];
|
||||
|
||||
// just in case we change it in the future
|
||||
expect(color).not.toBe(COLOR_PALETTE.transparent);
|
||||
|
||||
h.setState({
|
||||
currentItemBackgroundColor: color,
|
||||
currentItemFillStyle: "hachure",
|
||||
});
|
||||
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
|
||||
|
||||
expect(hachureFillButton).toHaveClass("active");
|
||||
h.setState({
|
||||
currentItemFillStyle: "solid",
|
||||
});
|
||||
const solidFillStyle = queryByTestId(document.body, `fill-solid`);
|
||||
expect(solidFillStyle).toHaveClass("active");
|
||||
});
|
||||
|
||||
it("should not show fill style when background transparent", () => {
|
||||
UI.clickTool("rectangle");
|
||||
|
||||
h.setState({
|
||||
currentItemBackgroundColor: COLOR_PALETTE.transparent,
|
||||
currentItemFillStyle: "hachure",
|
||||
});
|
||||
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
|
||||
|
||||
expect(hachureFillButton).toBe(null);
|
||||
});
|
||||
|
||||
it("should show horizontal text align for text tool", () => {
|
||||
UI.clickTool("text");
|
||||
|
||||
h.setState({
|
||||
currentItemTextAlign: "right",
|
||||
});
|
||||
|
||||
const centerTextAlign = queryByTestId(document.body, `align-right`);
|
||||
expect(centerTextAlign).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe("properties when elements selected", () => {
|
||||
it("should show active styles when single element selected", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
backgroundColor: "red",
|
||||
fillStyle: "cross-hatch",
|
||||
});
|
||||
h.elements = [rect];
|
||||
API.setSelectedElements([rect]);
|
||||
|
||||
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
|
||||
expect(crossHatchButton).toHaveClass("active");
|
||||
});
|
||||
|
||||
it("should not show fill style selected element's background is transparent", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
backgroundColor: COLOR_PALETTE.transparent,
|
||||
fillStyle: "cross-hatch",
|
||||
});
|
||||
h.elements = [rect];
|
||||
API.setSelectedElements([rect]);
|
||||
|
||||
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
|
||||
expect(crossHatchButton).toBe(null);
|
||||
});
|
||||
|
||||
it("should highlight common stroke width of selected elements", () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
strokeWidth: STROKE_WIDTH.thin,
|
||||
});
|
||||
const rect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
strokeWidth: STROKE_WIDTH.thin,
|
||||
});
|
||||
h.elements = [rect1, rect2];
|
||||
API.setSelectedElements([rect1, rect2]);
|
||||
|
||||
const thinStrokeWidthButton = queryByTestId(
|
||||
document.body,
|
||||
`strokeWidth-thin`,
|
||||
);
|
||||
expect(thinStrokeWidthButton).toBeChecked();
|
||||
});
|
||||
|
||||
it("should not highlight any stroke width button if no common style", () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
strokeWidth: STROKE_WIDTH.thin,
|
||||
});
|
||||
const rect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
strokeWidth: STROKE_WIDTH.bold,
|
||||
});
|
||||
h.elements = [rect1, rect2];
|
||||
API.setSelectedElements([rect1, rect2]);
|
||||
|
||||
expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null);
|
||||
expect(
|
||||
queryByTestId(document.body, `strokeWidth-thin`),
|
||||
).not.toBeChecked();
|
||||
expect(
|
||||
queryByTestId(document.body, `strokeWidth-bold`),
|
||||
).not.toBeChecked();
|
||||
expect(
|
||||
queryByTestId(document.body, `strokeWidth-extraBold`),
|
||||
).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should show properties of different element types when selected", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
strokeWidth: STROKE_WIDTH.bold,
|
||||
});
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
fontFamily: FONT_FAMILY.Cascadia,
|
||||
});
|
||||
h.elements = [rect, text];
|
||||
API.setSelectedElements([rect, text]);
|
||||
|
||||
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
|
||||
expect(queryByTestId(document.body, `font-family-code`)).toBeChecked();
|
||||
});
|
||||
});
|
||||
});
|
1208
packages/excalidraw/actions/actionProperties.tsx
Normal file
1208
packages/excalidraw/actions/actionProperties.tsx
Normal file
File diff suppressed because it is too large
Load diff
54
packages/excalidraw/actions/actionSelectAll.ts
Normal file
54
packages/excalidraw/actions/actionSelectAll.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { selectGroupsForSelectedElements } from "../groups";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, value, app) => {
|
||||
if (appState.editingLinearElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectedElementIds = excludeElementsInFramesFromSelection(
|
||||
elements.filter(
|
||||
(element) =>
|
||||
!element.isDeleted &&
|
||||
!(isTextElement(element) && element.containerId) &&
|
||||
!element.locked,
|
||||
),
|
||||
).reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
map[element.id] = true;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: null,
|
||||
selectedElementIds,
|
||||
},
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
app,
|
||||
),
|
||||
selectedLinearElement:
|
||||
// single linear element selected
|
||||
Object.keys(selectedElementIds).length === 1 &&
|
||||
isLinearElement(elements[0])
|
||||
? new LinearElementEditor(elements[0], app.scene)
|
||||
: null,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.selectAll",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
||||
});
|
161
packages/excalidraw/actions/actionStyles.ts
Normal file
161
packages/excalidraw/actions/actionStyles.ts
Normal file
|
@ -0,0 +1,161 @@
|
|||
import {
|
||||
isTextElement,
|
||||
isExcalidrawElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
} from "../constants";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
canApplyRoundnessTypeToElement,
|
||||
getDefaultRoundnessTypeForElement,
|
||||
isFrameLikeElement,
|
||||
isArrowElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { ExcalidrawTextElement } from "../element/types";
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
export let copiedStyles: string = "{}";
|
||||
|
||||
export const actionCopyStyles = register({
|
||||
name: "copyStyles",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, formData, app) => {
|
||||
const elementsCopied = [];
|
||||
const element = elements.find((el) => appState.selectedElementIds[el.id]);
|
||||
elementsCopied.push(element);
|
||||
if (element && hasBoundTextElement(element)) {
|
||||
const boundTextElement = getBoundTextElement(
|
||||
element,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
elementsCopied.push(boundTextElement);
|
||||
}
|
||||
if (element) {
|
||||
copiedStyles = JSON.stringify(elementsCopied);
|
||||
}
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
toast: { message: t("toast.copyStyles") },
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.copyStyles",
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
||||
});
|
||||
|
||||
export const actionPasteStyles = register({
|
||||
name: "pasteStyles",
|
||||
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 };
|
||||
}
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
const selectedElementIds = selectedElements.map((element) => element.id);
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (selectedElementIds.includes(element.id)) {
|
||||
let elementStylesToCopyFrom = pastedElement;
|
||||
if (isTextElement(element) && element.containerId) {
|
||||
elementStylesToCopyFrom = boundTextElement;
|
||||
}
|
||||
if (!elementStylesToCopyFrom) {
|
||||
return element;
|
||||
}
|
||||
let newElement = newElementWith(element, {
|
||||
backgroundColor: elementStylesToCopyFrom?.backgroundColor,
|
||||
strokeWidth: elementStylesToCopyFrom?.strokeWidth,
|
||||
strokeColor: elementStylesToCopyFrom?.strokeColor,
|
||||
strokeStyle: elementStylesToCopyFrom?.strokeStyle,
|
||||
fillStyle: elementStylesToCopyFrom?.fillStyle,
|
||||
opacity: elementStylesToCopyFrom?.opacity,
|
||||
roughness: elementStylesToCopyFrom?.roughness,
|
||||
roundness: elementStylesToCopyFrom.roundness
|
||||
? canApplyRoundnessTypeToElement(
|
||||
elementStylesToCopyFrom.roundness.type,
|
||||
element,
|
||||
)
|
||||
? elementStylesToCopyFrom.roundness
|
||||
: getDefaultRoundnessTypeForElement(element)
|
||||
: null,
|
||||
});
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
const fontSize =
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).fontSize ||
|
||||
DEFAULT_FONT_SIZE;
|
||||
const fontFamily =
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).fontFamily ||
|
||||
DEFAULT_FONT_FAMILY;
|
||||
newElement = newElementWith(newElement, {
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign:
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).textAlign ||
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
lineHeight:
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
|
||||
getDefaultLineHeight(fontFamily),
|
||||
});
|
||||
let container = null;
|
||||
if (newElement.containerId) {
|
||||
container =
|
||||
selectedElements.find(
|
||||
(element) =>
|
||||
isTextElement(newElement) &&
|
||||
element.id === newElement.containerId,
|
||||
) || null;
|
||||
}
|
||||
redrawTextBoundingBox(newElement, container);
|
||||
}
|
||||
|
||||
if (
|
||||
newElement.type === "arrow" &&
|
||||
isArrowElement(elementStylesToCopyFrom)
|
||||
) {
|
||||
newElement = newElementWith(newElement, {
|
||||
startArrowhead: elementStylesToCopyFrom.startArrowhead,
|
||||
endArrowhead: elementStylesToCopyFrom.endArrowhead,
|
||||
});
|
||||
}
|
||||
|
||||
if (isFrameLikeElement(element)) {
|
||||
newElement = newElementWith(newElement, {
|
||||
roundness: null,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
}
|
||||
|
||||
return newElement;
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.pasteStyles",
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
||||
});
|
29
packages/excalidraw/actions/actionToggleGridMode.tsx
Normal file
29
packages/excalidraw/actions/actionToggleGridMode.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { GRID_SIZE } from "../constants";
|
||||
import { AppState } from "../types";
|
||||
|
||||
export const actionToggleGridMode = register({
|
||||
name: "gridMode",
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.gridSize,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
gridSize: this.checked!(appState) ? null : GRID_SIZE,
|
||||
objectsSnapModeEnabled: false,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
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,
|
||||
});
|
28
packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx
Normal file
28
packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleObjectsSnapMode = register({
|
||||
name: "objectsSnapMode",
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.objectsSnapModeEnabled,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
objectsSnapModeEnabled: !this.checked!(appState),
|
||||
gridSize: null,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
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,
|
||||
});
|
21
packages/excalidraw/actions/actionToggleStats.tsx
Normal file
21
packages/excalidraw/actions/actionToggleStats.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { register } from "./register";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
|
||||
export const actionToggleStats = register({
|
||||
name: "stats",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "menu" },
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
showStats: !this.checked!(appState),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.showStats,
|
||||
contextItemLabel: "stats.title",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
||||
});
|
27
packages/excalidraw/actions/actionToggleViewMode.tsx
Normal file
27
packages/excalidraw/actions/actionToggleViewMode.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleViewMode = register({
|
||||
name: "viewMode",
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.viewModeEnabled,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
viewModeEnabled: !this.checked!(appState),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
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,
|
||||
});
|
27
packages/excalidraw/actions/actionToggleZenMode.tsx
Normal file
27
packages/excalidraw/actions/actionToggleZenMode.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleZenMode = register({
|
||||
name: "zenMode",
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.zenModeEnabled,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
zenModeEnabled: !this.checked!(appState),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
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,
|
||||
});
|
145
packages/excalidraw/actions/actionZindex.tsx
Normal file
145
packages/excalidraw/actions/actionZindex.tsx
Normal file
|
@ -0,0 +1,145 @@
|
|||
import React from "react";
|
||||
import {
|
||||
moveOneLeft,
|
||||
moveOneRight,
|
||||
moveAllLeft,
|
||||
moveAllRight,
|
||||
} from "../zindex";
|
||||
import { KEYS, CODES } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import {
|
||||
BringForwardIcon,
|
||||
BringToFrontIcon,
|
||||
SendBackwardIcon,
|
||||
SendToBackIcon,
|
||||
} from "../components/icons";
|
||||
import { isDarwin } from "../constants";
|
||||
|
||||
export const actionSendBackward = register({
|
||||
name: "sendBackward",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveOneLeft(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.sendBackward",
|
||||
keyPriority: 40,
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
!event.shiftKey &&
|
||||
event.code === CODES.BRACKET_LEFT,
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="zIndexButton"
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.sendBackward")} — ${getShortcutKey("CtrlOrCmd+[")}`}
|
||||
>
|
||||
{SendBackwardIcon}
|
||||
</button>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionBringForward = register({
|
||||
name: "bringForward",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveOneRight(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.bringForward",
|
||||
keyPriority: 40,
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
!event.shiftKey &&
|
||||
event.code === CODES.BRACKET_RIGHT,
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="zIndexButton"
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.bringForward")} — ${getShortcutKey("CtrlOrCmd+]")}`}
|
||||
>
|
||||
{BringForwardIcon}
|
||||
</button>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionSendToBack = register({
|
||||
name: "sendToBack",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveAllLeft(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.sendToBack",
|
||||
keyTest: (event) =>
|
||||
isDarwin
|
||||
? event[KEYS.CTRL_OR_CMD] &&
|
||||
event.altKey &&
|
||||
event.code === CODES.BRACKET_LEFT
|
||||
: event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
event.code === CODES.BRACKET_LEFT,
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="zIndexButton"
|
||||
onClick={() => updateData(null)}
|
||||
title={`${t("labels.sendToBack")} — ${
|
||||
isDarwin
|
||||
? getShortcutKey("CtrlOrCmd+Alt+[")
|
||||
: getShortcutKey("CtrlOrCmd+Shift+[")
|
||||
}`}
|
||||
>
|
||||
{SendToBackIcon}
|
||||
</button>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionBringToFront = register({
|
||||
name: "bringToFront",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveAllRight(elements, appState),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.bringToFront",
|
||||
keyTest: (event) =>
|
||||
isDarwin
|
||||
? event[KEYS.CTRL_OR_CMD] &&
|
||||
event.altKey &&
|
||||
event.code === CODES.BRACKET_RIGHT
|
||||
: event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
event.code === CODES.BRACKET_RIGHT,
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="zIndexButton"
|
||||
onClick={(event) => updateData(null)}
|
||||
title={`${t("labels.bringToFront")} — ${
|
||||
isDarwin
|
||||
? getShortcutKey("CtrlOrCmd+Alt+]")
|
||||
: getShortcutKey("CtrlOrCmd+Shift+]")
|
||||
}`}
|
||||
>
|
||||
{BringToFrontIcon}
|
||||
</button>
|
||||
),
|
||||
});
|
88
packages/excalidraw/actions/index.ts
Normal file
88
packages/excalidraw/actions/index.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
export { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
export {
|
||||
actionBringForward,
|
||||
actionBringToFront,
|
||||
actionSendBackward,
|
||||
actionSendToBack,
|
||||
} from "./actionZindex";
|
||||
export { actionSelectAll } from "./actionSelectAll";
|
||||
export { actionDuplicateSelection } from "./actionDuplicateSelection";
|
||||
export {
|
||||
actionChangeStrokeColor,
|
||||
actionChangeBackgroundColor,
|
||||
actionChangeStrokeWidth,
|
||||
actionChangeFillStyle,
|
||||
actionChangeSloppiness,
|
||||
actionChangeOpacity,
|
||||
actionChangeFontSize,
|
||||
actionChangeFontFamily,
|
||||
actionChangeTextAlign,
|
||||
actionChangeVerticalAlign,
|
||||
} from "./actionProperties";
|
||||
|
||||
export {
|
||||
actionChangeViewBackgroundColor,
|
||||
actionClearCanvas,
|
||||
actionZoomIn,
|
||||
actionZoomOut,
|
||||
actionResetZoom,
|
||||
actionZoomToFit,
|
||||
actionToggleTheme,
|
||||
} from "./actionCanvas";
|
||||
|
||||
export { actionFinalize } from "./actionFinalize";
|
||||
|
||||
export {
|
||||
actionChangeProjectName,
|
||||
actionChangeExportBackground,
|
||||
actionSaveToActiveFile,
|
||||
actionSaveFileToDisk,
|
||||
actionLoadScene,
|
||||
} from "./actionExport";
|
||||
|
||||
export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
|
||||
export {
|
||||
actionToggleCanvasMenu,
|
||||
actionToggleEditMenu,
|
||||
actionShortcuts,
|
||||
} from "./actionMenu";
|
||||
|
||||
export { actionGroup, actionUngroup } from "./actionGroup";
|
||||
|
||||
export { actionGoToCollaborator } from "./actionNavigate";
|
||||
|
||||
export { actionAddToLibrary } from "./actionAddToLibrary";
|
||||
|
||||
export {
|
||||
actionAlignTop,
|
||||
actionAlignBottom,
|
||||
actionAlignLeft,
|
||||
actionAlignRight,
|
||||
actionAlignVerticallyCentered,
|
||||
actionAlignHorizontallyCentered,
|
||||
} from "./actionAlign";
|
||||
|
||||
export {
|
||||
distributeHorizontally,
|
||||
distributeVertically,
|
||||
} from "./actionDistribute";
|
||||
|
||||
export { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
|
||||
|
||||
export {
|
||||
actionCopy,
|
||||
actionCut,
|
||||
actionCopyAsPng,
|
||||
actionCopyAsSvg,
|
||||
copyText,
|
||||
} from "./actionClipboard";
|
||||
|
||||
export { actionToggleGridMode } from "./actionToggleGridMode";
|
||||
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||
export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
|
||||
|
||||
export { actionToggleStats } from "./actionToggleStats";
|
||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
export { actionLink } from "../element/Hyperlink";
|
||||
export { actionToggleElementLock } from "./actionElementLock";
|
||||
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
243
packages/excalidraw/actions/manager.tsx
Normal file
243
packages/excalidraw/actions/manager.tsx
Normal file
|
@ -0,0 +1,243 @@
|
|||
import React from "react";
|
||||
import {
|
||||
Action,
|
||||
UpdaterFn,
|
||||
ActionName,
|
||||
ActionResult,
|
||||
PanelComponentProps,
|
||||
ActionSource,
|
||||
ActionPredicateFn,
|
||||
} from "./types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { isPromiseLike } from "../utils";
|
||||
|
||||
const trackAction = (
|
||||
action: Action,
|
||||
source: ActionSource,
|
||||
appState: Readonly<AppState>,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
app: AppClassProperties,
|
||||
value: any,
|
||||
) => {
|
||||
if (action.trackEvent) {
|
||||
try {
|
||||
if (typeof action.trackEvent === "object") {
|
||||
const shouldTrack = action.trackEvent.predicate
|
||||
? action.trackEvent.predicate(appState, elements, value)
|
||||
: true;
|
||||
if (shouldTrack) {
|
||||
trackEvent(
|
||||
action.trackEvent.category,
|
||||
action.trackEvent.action || action.name,
|
||||
`${source} (${app.device.editor.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("error while logging action:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export class ActionManager {
|
||||
actions = {} as Record<ActionName, Action>;
|
||||
actionPredicates = [] as ActionPredicateFn[];
|
||||
|
||||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
||||
|
||||
getAppState: () => Readonly<AppState>;
|
||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
||||
app: AppClassProperties;
|
||||
|
||||
constructor(
|
||||
updater: UpdaterFn,
|
||||
getAppState: () => AppState,
|
||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
||||
app: AppClassProperties,
|
||||
) {
|
||||
this.updater = (actionResult) => {
|
||||
if (isPromiseLike(actionResult)) {
|
||||
actionResult.then((actionResult) => {
|
||||
return updater(actionResult);
|
||||
});
|
||||
} else {
|
||||
return updater(actionResult);
|
||||
}
|
||||
};
|
||||
this.getAppState = getAppState;
|
||||
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
registerActionPredicate(predicate: ActionPredicateFn) {
|
||||
if (!this.actionPredicates.includes(predicate)) {
|
||||
this.actionPredicates.push(predicate);
|
||||
}
|
||||
}
|
||||
|
||||
filterActions(
|
||||
filter: ActionPredicateFn,
|
||||
opts?: {
|
||||
elements?: readonly ExcalidrawElement[];
|
||||
data?: Record<string, any>;
|
||||
},
|
||||
): Action[] {
|
||||
// For testing
|
||||
if (this === undefined) {
|
||||
return [];
|
||||
}
|
||||
const elements = opts?.elements ?? this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const data = opts?.data;
|
||||
|
||||
const actions: Action[] = [];
|
||||
for (const key in this.actions) {
|
||||
const action = this.actions[key as ActionName];
|
||||
if (filter(action, elements, appState, this.app, data)) {
|
||||
actions.push(action);
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
registerAction(action: Action) {
|
||||
this.actions[action.name] = action;
|
||||
}
|
||||
|
||||
registerAll(actions: readonly Action[]) {
|
||||
actions.forEach((action) => this.registerAction(action));
|
||||
}
|
||||
|
||||
handleKeyDown(event: React.KeyboardEvent | KeyboardEvent) {
|
||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||
const data = Object.values(this.actions)
|
||||
.sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
|
||||
.filter(
|
||||
(action) =>
|
||||
(action.name in canvasActions
|
||||
? canvasActions[action.name as keyof typeof canvasActions]
|
||||
: this.isActionEnabled(action, { noPredicates: true })) &&
|
||||
action.keyTest &&
|
||||
action.keyTest(
|
||||
event,
|
||||
this.getAppState(),
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.app,
|
||||
),
|
||||
);
|
||||
|
||||
if (data.length !== 1) {
|
||||
if (data.length > 1) {
|
||||
console.warn("Canceling as multiple actions match this shortcut", data);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const action = data[0];
|
||||
|
||||
if (this.getAppState().viewModeEnabled && action.viewMode !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const value = null;
|
||||
|
||||
trackAction(action, "keyboard", appState, elements, this.app, null);
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.updater(data[0].perform(elements, appState, value, this.app));
|
||||
return true;
|
||||
}
|
||||
|
||||
executeAction<T extends Action>(
|
||||
action: T,
|
||||
source: ActionSource = "api",
|
||||
value: Parameters<T["perform"]>[2] = null,
|
||||
) {
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
|
||||
trackAction(action, source, appState, elements, this.app, value);
|
||||
|
||||
this.updater(action.perform(elements, appState, value, this.app));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param data additional data sent to the PanelComponent
|
||||
*/
|
||||
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
|
||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||
|
||||
if (
|
||||
this.actions[name] &&
|
||||
"PanelComponent" in this.actions[name] &&
|
||||
(name in canvasActions
|
||||
? canvasActions[name as keyof typeof canvasActions]
|
||||
: this.isActionEnabled(this.actions[name], { noPredicates: true }))
|
||||
) {
|
||||
const action = this.actions[name];
|
||||
const PanelComponent = action.PanelComponent!;
|
||||
PanelComponent.displayName = "PanelComponent";
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const updateData = (formState?: any) => {
|
||||
trackAction(action, "ui", appState, elements, this.app, formState);
|
||||
|
||||
this.updater(
|
||||
action.perform(
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.getAppState(),
|
||||
formState,
|
||||
this.app,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelComponent
|
||||
key={name}
|
||||
elements={this.getElementsIncludingDeleted()}
|
||||
appState={this.getAppState()}
|
||||
updateData={updateData}
|
||||
appProps={this.app.props}
|
||||
app={this.app}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
isActionEnabled = (
|
||||
action: Action,
|
||||
opts?: {
|
||||
elements?: readonly ExcalidrawElement[];
|
||||
data?: Record<string, any>;
|
||||
noPredicates?: boolean;
|
||||
},
|
||||
): boolean => {
|
||||
const elements = opts?.elements ?? this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const data = opts?.data;
|
||||
|
||||
if (
|
||||
!opts?.noPredicates &&
|
||||
action.predicate &&
|
||||
!action.predicate(elements, appState, this.app.props, this.app, data)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let enabled = true;
|
||||
this.actionPredicates.forEach((fn) => {
|
||||
if (!fn(action, elements, appState, this.app, data)) {
|
||||
enabled = false;
|
||||
}
|
||||
});
|
||||
return enabled;
|
||||
};
|
||||
}
|
10
packages/excalidraw/actions/register.ts
Normal file
10
packages/excalidraw/actions/register.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { Action } from "./types";
|
||||
|
||||
export let actions: readonly Action[] = [];
|
||||
|
||||
export const register = <T extends Action>(action: T) => {
|
||||
actions = actions.concat(action);
|
||||
return action as T & {
|
||||
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
|
||||
};
|
||||
};
|
102
packages/excalidraw/actions/shortcuts.ts
Normal file
102
packages/excalidraw/actions/shortcuts.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
import { isDarwin } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { SubtypeOf } from "../utility-types";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { ActionName, CustomActionName } from "./types";
|
||||
|
||||
export type ShortcutName =
|
||||
| SubtypeOf<
|
||||
ActionName,
|
||||
| CustomActionName
|
||||
| "toggleTheme"
|
||||
| "loadScene"
|
||||
| "clearCanvas"
|
||||
| "cut"
|
||||
| "copy"
|
||||
| "paste"
|
||||
| "copyStyles"
|
||||
| "pasteStyles"
|
||||
| "selectAll"
|
||||
| "deleteSelectedElements"
|
||||
| "duplicateSelection"
|
||||
| "sendBackward"
|
||||
| "bringForward"
|
||||
| "sendToBack"
|
||||
| "bringToFront"
|
||||
| "copyAsPng"
|
||||
| "copyAsSvg"
|
||||
| "group"
|
||||
| "ungroup"
|
||||
| "gridMode"
|
||||
| "zenMode"
|
||||
| "objectsSnapMode"
|
||||
| "stats"
|
||||
| "addToLibrary"
|
||||
| "viewMode"
|
||||
| "flipHorizontal"
|
||||
| "flipVertical"
|
||||
| "hyperlink"
|
||||
| "toggleElementLock"
|
||||
>
|
||||
| "saveScene"
|
||||
| "imageExport";
|
||||
|
||||
export const registerCustomShortcuts = (
|
||||
shortcuts: Record<CustomActionName, string[]>,
|
||||
) => {
|
||||
for (const key in shortcuts) {
|
||||
const shortcut = key as CustomActionName;
|
||||
shortcutMap[shortcut] = shortcuts[shortcut];
|
||||
}
|
||||
};
|
||||
|
||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
toggleTheme: [getShortcutKey("Shift+Alt+D")],
|
||||
saveScene: [getShortcutKey("CtrlOrCmd+S")],
|
||||
loadScene: [getShortcutKey("CtrlOrCmd+O")],
|
||||
clearCanvas: [getShortcutKey("CtrlOrCmd+Delete")],
|
||||
imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")],
|
||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
||||
copy: [getShortcutKey("CtrlOrCmd+C")],
|
||||
paste: [getShortcutKey("CtrlOrCmd+V")],
|
||||
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
|
||||
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
|
||||
selectAll: [getShortcutKey("CtrlOrCmd+A")],
|
||||
deleteSelectedElements: [getShortcutKey("Delete")],
|
||||
duplicateSelection: [
|
||||
getShortcutKey("CtrlOrCmd+D"),
|
||||
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
|
||||
],
|
||||
sendBackward: [getShortcutKey("CtrlOrCmd+[")],
|
||||
bringForward: [getShortcutKey("CtrlOrCmd+]")],
|
||||
sendToBack: [
|
||||
isDarwin
|
||||
? getShortcutKey("CtrlOrCmd+Alt+[")
|
||||
: getShortcutKey("CtrlOrCmd+Shift+["),
|
||||
],
|
||||
bringToFront: [
|
||||
isDarwin
|
||||
? getShortcutKey("CtrlOrCmd+Alt+]")
|
||||
: getShortcutKey("CtrlOrCmd+Shift+]"),
|
||||
],
|
||||
copyAsPng: [getShortcutKey("Shift+Alt+C")],
|
||||
copyAsSvg: [],
|
||||
group: [getShortcutKey("CtrlOrCmd+G")],
|
||||
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
||||
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
||||
zenMode: [getShortcutKey("Alt+Z")],
|
||||
objectsSnapMode: [getShortcutKey("Alt+S")],
|
||||
stats: [getShortcutKey("Alt+/")],
|
||||
addToLibrary: [],
|
||||
flipHorizontal: [getShortcutKey("Shift+H")],
|
||||
flipVertical: [getShortcutKey("Shift+V")],
|
||||
viewMode: [getShortcutKey("Alt+R")],
|
||||
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
|
||||
toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
||||
const shortcuts = shortcutMap[name];
|
||||
// if multiple shortcuts available, take the first one
|
||||
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
||||
};
|
200
packages/excalidraw/actions/types.ts
Normal file
200
packages/excalidraw/actions/types.ts
Normal file
|
@ -0,0 +1,200 @@
|
|||
import React from "react";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
ExcalidrawProps,
|
||||
BinaryFiles,
|
||||
} from "../types";
|
||||
import { MarkOptional } from "../utility-types";
|
||||
|
||||
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
|
||||
|
||||
/** if false, the action should be prevented */
|
||||
export type ActionResult =
|
||||
| {
|
||||
elements?: readonly ExcalidrawElement[] | null;
|
||||
appState?: MarkOptional<
|
||||
AppState,
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
> | null;
|
||||
files?: BinaryFiles | null;
|
||||
commitToHistory: boolean;
|
||||
syncHistory?: boolean;
|
||||
replaceFiles?: boolean;
|
||||
}
|
||||
| false;
|
||||
|
||||
type ActionFn = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
formData: any,
|
||||
app: AppClassProperties,
|
||||
) => ActionResult | Promise<ActionResult>;
|
||||
|
||||
// Return `true` *unless* `Action` should be disabled
|
||||
// given `elements`, `appState`, and optionally `data`.
|
||||
export type ActionPredicateFn = (
|
||||
action: Action,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
app: AppClassProperties,
|
||||
data?: Record<string, any>,
|
||||
) => boolean;
|
||||
|
||||
export type UpdaterFn = (res: ActionResult) => void;
|
||||
export type ActionFilterFn = (action: Action) => void;
|
||||
|
||||
export const makeCustomActionName = (name: string) =>
|
||||
`custom.${name}` as CustomActionName;
|
||||
export type CustomActionName = `custom.${string}`;
|
||||
export type ActionName =
|
||||
| CustomActionName
|
||||
| "copy"
|
||||
| "cut"
|
||||
| "paste"
|
||||
| "copyAsPng"
|
||||
| "copyAsSvg"
|
||||
| "copyText"
|
||||
| "sendBackward"
|
||||
| "bringForward"
|
||||
| "sendToBack"
|
||||
| "bringToFront"
|
||||
| "copyStyles"
|
||||
| "selectAll"
|
||||
| "pasteStyles"
|
||||
| "gridMode"
|
||||
| "zenMode"
|
||||
| "objectsSnapMode"
|
||||
| "stats"
|
||||
| "changeStrokeColor"
|
||||
| "changeBackgroundColor"
|
||||
| "changeFillStyle"
|
||||
| "changeStrokeWidth"
|
||||
| "changeStrokeShape"
|
||||
| "changeSloppiness"
|
||||
| "changeStrokeStyle"
|
||||
| "changeArrowhead"
|
||||
| "changeOpacity"
|
||||
| "changeFontSize"
|
||||
| "toggleCanvasMenu"
|
||||
| "toggleEditMenu"
|
||||
| "undo"
|
||||
| "redo"
|
||||
| "finalize"
|
||||
| "changeProjectName"
|
||||
| "changeExportBackground"
|
||||
| "changeExportEmbedScene"
|
||||
| "changeExportScale"
|
||||
| "saveToActiveFile"
|
||||
| "saveFileToDisk"
|
||||
| "loadScene"
|
||||
| "duplicateSelection"
|
||||
| "deleteSelectedElements"
|
||||
| "changeViewBackgroundColor"
|
||||
| "clearCanvas"
|
||||
| "zoomIn"
|
||||
| "zoomOut"
|
||||
| "resetZoom"
|
||||
| "zoomToFit"
|
||||
| "zoomToFitSelection"
|
||||
| "zoomToFitSelectionInViewport"
|
||||
| "changeFontFamily"
|
||||
| "changeTextAlign"
|
||||
| "changeVerticalAlign"
|
||||
| "toggleFullScreen"
|
||||
| "toggleShortcuts"
|
||||
| "group"
|
||||
| "ungroup"
|
||||
| "goToCollaborator"
|
||||
| "addToLibrary"
|
||||
| "changeRoundness"
|
||||
| "alignTop"
|
||||
| "alignBottom"
|
||||
| "alignLeft"
|
||||
| "alignRight"
|
||||
| "alignVerticallyCentered"
|
||||
| "alignHorizontallyCentered"
|
||||
| "distributeHorizontally"
|
||||
| "distributeVertically"
|
||||
| "flipHorizontal"
|
||||
| "flipVertical"
|
||||
| "viewMode"
|
||||
| "exportWithDarkMode"
|
||||
| "toggleTheme"
|
||||
| "increaseFontSize"
|
||||
| "decreaseFontSize"
|
||||
| "unbindText"
|
||||
| "hyperlink"
|
||||
| "bindText"
|
||||
| "unlockAllElements"
|
||||
| "toggleElementLock"
|
||||
| "toggleLinearEditor"
|
||||
| "toggleEraserTool"
|
||||
| "toggleHandTool"
|
||||
| "selectAllElementsInFrame"
|
||||
| "removeAllElementsFromFrame"
|
||||
| "updateFrameRendering"
|
||||
| "setFrameAsActiveTool"
|
||||
| "setEmbeddableAsActiveTool"
|
||||
| "createContainerFromText"
|
||||
| "wrapTextInContainer";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
updateData: <T = any>(formData?: T) => void;
|
||||
appProps: ExcalidrawProps;
|
||||
data?: Record<string, any>;
|
||||
app: AppClassProperties;
|
||||
};
|
||||
|
||||
export interface Action {
|
||||
name: ActionName;
|
||||
PanelComponent?: React.FC<PanelComponentProps>;
|
||||
perform: ActionFn;
|
||||
keyPriority?: number;
|
||||
keyTest?: (
|
||||
event: React.KeyboardEvent | KeyboardEvent,
|
||||
appState: AppState,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
app: AppClassProperties,
|
||||
) => boolean;
|
||||
contextItemLabel?:
|
||||
| string
|
||||
| ((
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
app: AppClassProperties,
|
||||
) => string);
|
||||
predicate?: (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
appProps: ExcalidrawProps,
|
||||
app: AppClassProperties,
|
||||
data?: Record<string, any>,
|
||||
) => boolean;
|
||||
checked?: (appState: Readonly<AppState>) => boolean;
|
||||
trackEvent:
|
||||
| false
|
||||
| {
|
||||
category:
|
||||
| "toolbar"
|
||||
| "element"
|
||||
| "canvas"
|
||||
| "export"
|
||||
| "history"
|
||||
| "menu"
|
||||
| "collab"
|
||||
| "hyperlink";
|
||||
action?: string;
|
||||
predicate?: (
|
||||
appState: Readonly<AppState>,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
value: any,
|
||||
) => boolean;
|
||||
};
|
||||
/** if set to `true`, allow action to be performed in viewMode.
|
||||
* Defaults to `false` */
|
||||
viewMode?: boolean;
|
||||
}
|
65
packages/excalidraw/align.ts
Normal file
65
packages/excalidraw/align.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { ElementsMap, ExcalidrawElement } from "./element/types";
|
||||
import { newElementWith } from "./element/mutateElement";
|
||||
import { BoundingBox, getCommonBoundingBox } from "./element/bounds";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
|
||||
export interface Alignment {
|
||||
position: "start" | "center" | "end";
|
||||
axis: "x" | "y";
|
||||
}
|
||||
|
||||
export const alignElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
alignment: Alignment,
|
||||
): ExcalidrawElement[] => {
|
||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
);
|
||||
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
||||
|
||||
return groups.flatMap((group) => {
|
||||
const translation = calculateTranslation(
|
||||
group,
|
||||
selectionBoundingBox,
|
||||
alignment,
|
||||
);
|
||||
return group.map((element) =>
|
||||
newElementWith(element, {
|
||||
x: element.x + translation.x,
|
||||
y: element.y + translation.y,
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const calculateTranslation = (
|
||||
group: ExcalidrawElement[],
|
||||
selectionBoundingBox: BoundingBox,
|
||||
{ axis, position }: Alignment,
|
||||
): { x: number; y: number } => {
|
||||
const groupBoundingBox = getCommonBoundingBox(group);
|
||||
|
||||
const [min, max]: ["minX" | "minY", "maxX" | "maxY"] =
|
||||
axis === "x" ? ["minX", "maxX"] : ["minY", "maxY"];
|
||||
|
||||
const noTranslation = { x: 0, y: 0 };
|
||||
if (position === "start") {
|
||||
return {
|
||||
...noTranslation,
|
||||
[axis]: selectionBoundingBox[min] - groupBoundingBox[min],
|
||||
};
|
||||
} else if (position === "end") {
|
||||
return {
|
||||
...noTranslation,
|
||||
[axis]: selectionBoundingBox[max] - groupBoundingBox[max],
|
||||
};
|
||||
} // else if (position === "center") {
|
||||
return {
|
||||
...noTranslation,
|
||||
[axis]:
|
||||
(selectionBoundingBox[min] + selectionBoundingBox[max]) / 2 -
|
||||
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
|
||||
};
|
||||
};
|
40
packages/excalidraw/analytics.ts
Normal file
40
packages/excalidraw/analytics.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
// 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[];
|
||||
|
||||
export const trackEvent = (
|
||||
category: string,
|
||||
action: string,
|
||||
label?: string,
|
||||
value?: number,
|
||||
) => {
|
||||
try {
|
||||
// prettier-ignore
|
||||
if (
|
||||
typeof window === "undefined"
|
||||
|| import.meta.env.VITE_WORKER_ID
|
||||
// comment out to debug locally
|
||||
|| import.meta.env.PROD
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!import.meta.env.PROD) {
|
||||
console.info("trackEvent", { category, action, label, value });
|
||||
}
|
||||
|
||||
if (window.sa_event) {
|
||||
window.sa_event(action, {
|
||||
category,
|
||||
label,
|
||||
value,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("error during analytics", error);
|
||||
}
|
||||
};
|
148
packages/excalidraw/animated-trail.ts
Normal file
148
packages/excalidraw/animated-trail.ts
Normal file
|
@ -0,0 +1,148 @@
|
|||
import { LaserPointer, LaserPointerOptions } from "@excalidraw/laser-pointer";
|
||||
import { AnimationFrameHandler } from "./animation-frame-handler";
|
||||
import { AppState } from "./types";
|
||||
import { getSvgPathFromStroke, sceneCoordsToViewportCoords } from "./utils";
|
||||
import type App from "./components/App";
|
||||
import { SVG_NS } from "./constants";
|
||||
|
||||
export interface Trail {
|
||||
start(container: SVGSVGElement): void;
|
||||
stop(): void;
|
||||
|
||||
startPath(x: number, y: number): void;
|
||||
addPointToPath(x: number, y: number): void;
|
||||
endPath(): void;
|
||||
}
|
||||
|
||||
export interface AnimatedTrailOptions {
|
||||
fill: (trail: AnimatedTrail) => string;
|
||||
}
|
||||
|
||||
export class AnimatedTrail implements Trail {
|
||||
private currentTrail?: LaserPointer;
|
||||
private pastTrails: LaserPointer[] = [];
|
||||
|
||||
private container?: SVGSVGElement;
|
||||
private trailElement: SVGPathElement;
|
||||
|
||||
constructor(
|
||||
private animationFrameHandler: AnimationFrameHandler,
|
||||
private app: App,
|
||||
private options: Partial<LaserPointerOptions> &
|
||||
Partial<AnimatedTrailOptions>,
|
||||
) {
|
||||
this.animationFrameHandler.register(this, this.onFrame.bind(this));
|
||||
|
||||
this.trailElement = document.createElementNS(SVG_NS, "path");
|
||||
}
|
||||
|
||||
get hasCurrentTrail() {
|
||||
return !!this.currentTrail;
|
||||
}
|
||||
|
||||
hasLastPoint(x: number, y: number) {
|
||||
if (this.currentTrail) {
|
||||
const len = this.currentTrail.originalPoints.length;
|
||||
return (
|
||||
this.currentTrail.originalPoints[len - 1][0] === x &&
|
||||
this.currentTrail.originalPoints[len - 1][1] === y
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
start(container?: SVGSVGElement) {
|
||||
if (container) {
|
||||
this.container = container;
|
||||
}
|
||||
|
||||
if (this.trailElement.parentNode !== this.container && this.container) {
|
||||
this.container.appendChild(this.trailElement);
|
||||
}
|
||||
|
||||
this.animationFrameHandler.start(this);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.animationFrameHandler.stop(this);
|
||||
|
||||
if (this.trailElement.parentNode === this.container) {
|
||||
this.container?.removeChild(this.trailElement);
|
||||
}
|
||||
}
|
||||
|
||||
startPath(x: number, y: number) {
|
||||
this.currentTrail = new LaserPointer(this.options);
|
||||
|
||||
this.currentTrail.addPoint([x, y, performance.now()]);
|
||||
|
||||
this.update();
|
||||
}
|
||||
|
||||
addPointToPath(x: number, y: number) {
|
||||
if (this.currentTrail) {
|
||||
this.currentTrail.addPoint([x, y, performance.now()]);
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
endPath() {
|
||||
if (this.currentTrail) {
|
||||
this.currentTrail.close();
|
||||
this.currentTrail.options.keepHead = false;
|
||||
this.pastTrails.push(this.currentTrail);
|
||||
this.currentTrail = undefined;
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
private update() {
|
||||
this.start();
|
||||
}
|
||||
|
||||
private onFrame() {
|
||||
const paths: string[] = [];
|
||||
|
||||
for (const trail of this.pastTrails) {
|
||||
paths.push(this.drawTrail(trail, this.app.state));
|
||||
}
|
||||
|
||||
if (this.currentTrail) {
|
||||
const currentPath = this.drawTrail(this.currentTrail, this.app.state);
|
||||
|
||||
paths.push(currentPath);
|
||||
}
|
||||
|
||||
this.pastTrails = this.pastTrails.filter((trail) => {
|
||||
return trail.getStrokeOutline().length !== 0;
|
||||
});
|
||||
|
||||
if (paths.length === 0) {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
const svgPaths = paths.join(" ").trim();
|
||||
|
||||
this.trailElement.setAttribute("d", svgPaths);
|
||||
this.trailElement.setAttribute(
|
||||
"fill",
|
||||
(this.options.fill ?? (() => "black"))(this),
|
||||
);
|
||||
}
|
||||
|
||||
private drawTrail(trail: LaserPointer, state: AppState): string {
|
||||
const stroke = trail
|
||||
.getStrokeOutline(trail.options.size / state.zoom.value)
|
||||
.map(([x, y]) => {
|
||||
const result = sceneCoordsToViewportCoords(
|
||||
{ sceneX: x, sceneY: y },
|
||||
state,
|
||||
);
|
||||
|
||||
return [result.x, result.y];
|
||||
});
|
||||
|
||||
return getSvgPathFromStroke(stroke, true);
|
||||
}
|
||||
}
|
79
packages/excalidraw/animation-frame-handler.ts
Normal file
79
packages/excalidraw/animation-frame-handler.ts
Normal file
|
@ -0,0 +1,79 @@
|
|||
export type AnimationCallback = (timestamp: number) => void | boolean;
|
||||
|
||||
export type AnimationTarget = {
|
||||
callback: AnimationCallback;
|
||||
stopped: boolean;
|
||||
};
|
||||
|
||||
export class AnimationFrameHandler {
|
||||
private targets = new WeakMap<object, AnimationTarget>();
|
||||
private rafIds = new WeakMap<object, number>();
|
||||
|
||||
register(key: object, callback: AnimationCallback) {
|
||||
this.targets.set(key, { callback, stopped: true });
|
||||
}
|
||||
|
||||
start(key: object) {
|
||||
const target = this.targets.get(key);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.rafIds.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.targets.set(key, { ...target, stopped: false });
|
||||
this.scheduleFrame(key);
|
||||
}
|
||||
|
||||
stop(key: object) {
|
||||
const target = this.targets.get(key);
|
||||
if (target && !target.stopped) {
|
||||
this.targets.set(key, { ...target, stopped: true });
|
||||
}
|
||||
|
||||
this.cancelFrame(key);
|
||||
}
|
||||
|
||||
private constructFrame(key: object): FrameRequestCallback {
|
||||
return (timestamp: number) => {
|
||||
const target = this.targets.get(key);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldAbort = this.onFrame(target, timestamp);
|
||||
|
||||
if (!target.stopped && !shouldAbort) {
|
||||
this.scheduleFrame(key);
|
||||
} else {
|
||||
this.cancelFrame(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleFrame(key: object) {
|
||||
const rafId = requestAnimationFrame(this.constructFrame(key));
|
||||
|
||||
this.rafIds.set(key, rafId);
|
||||
}
|
||||
|
||||
private cancelFrame(key: object) {
|
||||
if (this.rafIds.has(key)) {
|
||||
const rafId = this.rafIds.get(key)!;
|
||||
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
|
||||
this.rafIds.delete(key);
|
||||
}
|
||||
|
||||
private onFrame(target: AnimationTarget, timestamp: number): boolean {
|
||||
const shouldAbort = target.callback(timestamp);
|
||||
|
||||
return shouldAbort ?? false;
|
||||
}
|
||||
}
|
274
packages/excalidraw/appState.ts
Normal file
274
packages/excalidraw/appState.ts
Normal file
|
@ -0,0 +1,274 @@
|
|||
import { COLOR_PALETTE } from "./colors";
|
||||
import {
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
EXPORT_SCALES,
|
||||
THEME,
|
||||
} from "./constants";
|
||||
import { t } from "./i18n";
|
||||
import { AppState, NormalizedZoomValue } from "./types";
|
||||
import { getDateTime } from "./utils";
|
||||
|
||||
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
||||
? devicePixelRatio
|
||||
: 1;
|
||||
|
||||
export const getDefaultAppState = (): Omit<
|
||||
AppState,
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
> => {
|
||||
return {
|
||||
showWelcomeScreen: false,
|
||||
theme: THEME.LIGHT,
|
||||
collaborators: new Map(),
|
||||
currentChartType: "bar",
|
||||
currentItemBackgroundColor: DEFAULT_ELEMENT_PROPS.backgroundColor,
|
||||
currentItemEndArrowhead: "arrow",
|
||||
currentItemFillStyle: DEFAULT_ELEMENT_PROPS.fillStyle,
|
||||
currentItemFontFamily: DEFAULT_FONT_FAMILY,
|
||||
currentItemFontSize: DEFAULT_FONT_SIZE,
|
||||
currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity,
|
||||
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
|
||||
currentItemStartArrowhead: null,
|
||||
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||
currentItemRoundness: "round",
|
||||
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||
cursorButton: "up",
|
||||
activeEmbeddable: null,
|
||||
draggingElement: null,
|
||||
editingElement: null,
|
||||
editingGroupId: null,
|
||||
editingLinearElement: null,
|
||||
activeTool: {
|
||||
type: "selection",
|
||||
customType: null,
|
||||
locked: DEFAULT_ELEMENT_PROPS.locked,
|
||||
lastActiveTool: null,
|
||||
},
|
||||
penMode: false,
|
||||
penDetected: false,
|
||||
errorMessage: null,
|
||||
exportBackground: true,
|
||||
exportScale: defaultExportScale,
|
||||
exportEmbedScene: false,
|
||||
exportWithDarkMode: false,
|
||||
fileHandle: null,
|
||||
gridSize: null,
|
||||
isBindingEnabled: true,
|
||||
defaultSidebarDockedPreference: false,
|
||||
isLoading: false,
|
||||
isResizing: false,
|
||||
isRotating: false,
|
||||
lastPointerDownWith: "mouse",
|
||||
multiElement: null,
|
||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||
contextMenu: null,
|
||||
openMenu: null,
|
||||
openPopup: null,
|
||||
openSidebar: null,
|
||||
openDialog: null,
|
||||
pasteDialog: { shown: false, data: null },
|
||||
previousSelectedElementIds: {},
|
||||
resizingElement: null,
|
||||
scrolledOutside: false,
|
||||
scrollX: 0,
|
||||
scrollY: 0,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
selectedElementsAreBeingDragged: false,
|
||||
selectionElement: null,
|
||||
shouldCacheIgnoreZoom: false,
|
||||
showStats: false,
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
frameRendering: { enabled: true, clip: true, name: true, outline: true },
|
||||
frameToHighlight: null,
|
||||
editingFrame: null,
|
||||
elementsToHighlight: null,
|
||||
toast: null,
|
||||
viewBackgroundColor: COLOR_PALETTE.white,
|
||||
zenModeEnabled: false,
|
||||
zoom: {
|
||||
value: 1 as NormalizedZoomValue,
|
||||
},
|
||||
viewModeEnabled: false,
|
||||
pendingImageElementId: null,
|
||||
showHyperlinkPopup: false,
|
||||
selectedLinearElement: null,
|
||||
snapLines: [],
|
||||
originSnapOffset: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
objectsSnapModeEnabled: false,
|
||||
userToFollow: null,
|
||||
followedBy: new Set(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Config containing all AppState keys. Used to determine whether given state
|
||||
* prop should be stripped when exporting to given storage type.
|
||||
*/
|
||||
const APP_STATE_STORAGE_CONF = (<
|
||||
Values extends {
|
||||
/** whether to keep when storing to browser storage (localStorage/IDB) */
|
||||
browser: boolean;
|
||||
/** whether to keep when exporting to file/database */
|
||||
export: boolean;
|
||||
/** server (shareLink/collab/...) */
|
||||
server: boolean;
|
||||
},
|
||||
T extends Record<keyof AppState, Values>,
|
||||
>(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) =>
|
||||
config)({
|
||||
showWelcomeScreen: { browser: true, export: false, server: false },
|
||||
theme: { browser: true, export: false, server: false },
|
||||
collaborators: { browser: false, export: false, server: false },
|
||||
currentChartType: { browser: true, export: false, server: false },
|
||||
currentItemBackgroundColor: { browser: true, export: false, server: false },
|
||||
currentItemEndArrowhead: { browser: true, export: false, server: false },
|
||||
currentItemFillStyle: { browser: true, export: false, server: false },
|
||||
currentItemFontFamily: { browser: true, export: false, server: false },
|
||||
currentItemFontSize: { browser: true, export: false, server: false },
|
||||
currentItemRoundness: {
|
||||
browser: true,
|
||||
export: false,
|
||||
server: false,
|
||||
},
|
||||
currentItemOpacity: { browser: true, export: false, server: false },
|
||||
currentItemRoughness: { browser: true, export: false, server: false },
|
||||
currentItemStartArrowhead: { browser: true, export: false, server: false },
|
||||
currentItemStrokeColor: { browser: true, export: false, server: false },
|
||||
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||
cursorButton: { browser: true, export: false, server: false },
|
||||
activeEmbeddable: { browser: false, export: false, server: false },
|
||||
draggingElement: { browser: false, export: false, server: false },
|
||||
editingElement: { browser: false, export: false, server: false },
|
||||
editingGroupId: { browser: true, export: false, server: false },
|
||||
editingLinearElement: { browser: false, export: false, server: false },
|
||||
activeTool: { browser: true, export: false, server: false },
|
||||
activeSubtypes: { browser: true, export: false, server: false },
|
||||
customData: { browser: true, export: false, server: false },
|
||||
penMode: { browser: true, export: false, server: false },
|
||||
penDetected: { browser: true, export: false, server: false },
|
||||
errorMessage: { browser: false, export: false, server: false },
|
||||
exportBackground: { browser: true, export: false, server: false },
|
||||
exportEmbedScene: { browser: true, export: false, server: false },
|
||||
exportScale: { browser: true, export: false, server: false },
|
||||
exportWithDarkMode: { browser: true, export: false, server: false },
|
||||
fileHandle: { browser: false, export: false, server: false },
|
||||
gridSize: { browser: true, export: true, server: true },
|
||||
height: { browser: false, export: false, server: false },
|
||||
isBindingEnabled: { browser: false, export: false, server: false },
|
||||
defaultSidebarDockedPreference: {
|
||||
browser: true,
|
||||
export: false,
|
||||
server: false,
|
||||
},
|
||||
isLoading: { browser: false, export: false, server: false },
|
||||
isResizing: { browser: false, export: false, server: false },
|
||||
isRotating: { browser: false, export: false, server: false },
|
||||
lastPointerDownWith: { browser: true, export: false, server: false },
|
||||
multiElement: { browser: false, export: false, server: false },
|
||||
name: { browser: true, export: false, server: false },
|
||||
offsetLeft: { browser: false, export: false, server: false },
|
||||
offsetTop: { browser: false, export: false, server: false },
|
||||
contextMenu: { browser: false, export: false, server: false },
|
||||
openMenu: { browser: true, export: false, server: false },
|
||||
openPopup: { browser: false, export: false, server: false },
|
||||
openSidebar: { browser: true, export: false, server: false },
|
||||
openDialog: { browser: false, export: false, server: false },
|
||||
pasteDialog: { browser: false, export: false, server: false },
|
||||
previousSelectedElementIds: { browser: true, export: false, server: false },
|
||||
resizingElement: { browser: false, export: false, server: false },
|
||||
scrolledOutside: { browser: true, export: false, server: false },
|
||||
scrollX: { browser: true, export: false, server: false },
|
||||
scrollY: { browser: true, export: false, server: false },
|
||||
selectedElementIds: { browser: true, export: false, server: false },
|
||||
selectedGroupIds: { browser: true, export: false, server: false },
|
||||
selectedElementsAreBeingDragged: {
|
||||
browser: false,
|
||||
export: false,
|
||||
server: false,
|
||||
},
|
||||
selectionElement: { browser: false, export: false, server: false },
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||
showStats: { browser: true, export: false, server: false },
|
||||
startBoundElement: { browser: false, export: false, server: false },
|
||||
suggestedBindings: { browser: false, export: false, server: false },
|
||||
frameRendering: { browser: false, export: false, server: false },
|
||||
frameToHighlight: { browser: false, export: false, server: false },
|
||||
editingFrame: { browser: false, export: false, server: false },
|
||||
elementsToHighlight: { browser: false, export: false, server: false },
|
||||
toast: { browser: false, export: false, server: false },
|
||||
viewBackgroundColor: { browser: true, export: true, server: true },
|
||||
width: { browser: false, export: false, server: false },
|
||||
zenModeEnabled: { browser: true, export: false, server: false },
|
||||
zoom: { browser: true, export: false, server: false },
|
||||
viewModeEnabled: { browser: false, export: false, server: false },
|
||||
pendingImageElementId: { browser: false, export: false, server: false },
|
||||
showHyperlinkPopup: { browser: false, export: false, server: false },
|
||||
selectedLinearElement: { browser: true, export: false, server: false },
|
||||
snapLines: { browser: false, export: false, server: false },
|
||||
originSnapOffset: { browser: false, export: false, server: false },
|
||||
objectsSnapModeEnabled: { browser: true, export: false, server: false },
|
||||
userToFollow: { browser: false, export: false, server: false },
|
||||
followedBy: { browser: false, export: false, server: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
ExportType extends "export" | "browser" | "server",
|
||||
>(
|
||||
appState: Partial<AppState>,
|
||||
exportType: ExportType,
|
||||
) => {
|
||||
type ExportableKeys = {
|
||||
[K in keyof typeof APP_STATE_STORAGE_CONF]: typeof APP_STATE_STORAGE_CONF[K][ExportType] extends true
|
||||
? K
|
||||
: never;
|
||||
}[keyof typeof APP_STATE_STORAGE_CONF];
|
||||
const stateForExport = {} as { [K in ExportableKeys]?: typeof appState[K] };
|
||||
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
||||
const propConfig = APP_STATE_STORAGE_CONF[key];
|
||||
if (propConfig?.[exportType]) {
|
||||
const nextValue = appState[key];
|
||||
|
||||
// https://github.com/microsoft/TypeScript/issues/31445
|
||||
(stateForExport as any)[key] = nextValue;
|
||||
}
|
||||
}
|
||||
return stateForExport;
|
||||
};
|
||||
|
||||
export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
|
||||
return _clearAppStateForStorage(appState, "browser");
|
||||
};
|
||||
|
||||
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
||||
return _clearAppStateForStorage(appState, "export");
|
||||
};
|
||||
|
||||
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
|
||||
return _clearAppStateForStorage(appState, "server");
|
||||
};
|
||||
|
||||
export const isEraserActive = ({
|
||||
activeTool,
|
||||
}: {
|
||||
activeTool: AppState["activeTool"];
|
||||
}) => activeTool.type === "eraser";
|
||||
|
||||
export const isHandToolActive = ({
|
||||
activeTool,
|
||||
}: {
|
||||
activeTool: AppState["activeTool"];
|
||||
}) => {
|
||||
return activeTool.type === "hand";
|
||||
};
|
121
packages/excalidraw/charts.test.ts
Normal file
121
packages/excalidraw/charts.test.ts
Normal file
|
@ -0,0 +1,121 @@
|
|||
import {
|
||||
Spreadsheet,
|
||||
tryParseCells,
|
||||
tryParseNumber,
|
||||
VALID_SPREADSHEET,
|
||||
} from "./charts";
|
||||
|
||||
describe("charts", () => {
|
||||
describe("tryParseNumber", () => {
|
||||
it.each<[string, number]>([
|
||||
["1", 1],
|
||||
["0", 0],
|
||||
["-1", -1],
|
||||
["0.1", 0.1],
|
||||
[".1", 0.1],
|
||||
["1.", 1],
|
||||
["424.", 424],
|
||||
["$1", 1],
|
||||
["-.1", -0.1],
|
||||
["-$1", -1],
|
||||
["$-1", -1],
|
||||
])("should correctly identify %s as numbers", (given, expected) => {
|
||||
expect(tryParseNumber(given)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each<[string]>([["a"], ["$"], ["$a"], ["-$a"]])(
|
||||
"should correctly identify %s as not a number",
|
||||
(given) => {
|
||||
expect(tryParseNumber(given)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("tryParseCells", () => {
|
||||
it("Successfully parses a spreadsheet", () => {
|
||||
const spreadsheet = [
|
||||
["time", "value"],
|
||||
["01:00", "61"],
|
||||
["02:00", "-60"],
|
||||
["03:00", "85"],
|
||||
["04:00", "-67"],
|
||||
["05:00", "54"],
|
||||
["06:00", "95"],
|
||||
];
|
||||
|
||||
const result = tryParseCells(spreadsheet);
|
||||
|
||||
expect(result.type).toBe(VALID_SPREADSHEET);
|
||||
|
||||
const { title, labels, values } = (
|
||||
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
|
||||
).spreadsheet;
|
||||
|
||||
expect(title).toEqual("value");
|
||||
expect(labels).toEqual([
|
||||
"01:00",
|
||||
"02:00",
|
||||
"03:00",
|
||||
"04:00",
|
||||
"05:00",
|
||||
"06:00",
|
||||
]);
|
||||
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
|
||||
});
|
||||
|
||||
it("Uses the second column as the label if it is not a number", () => {
|
||||
const spreadsheet = [
|
||||
["time", "value"],
|
||||
["01:00", "61"],
|
||||
["02:00", "-60"],
|
||||
["03:00", "85"],
|
||||
["04:00", "-67"],
|
||||
["05:00", "54"],
|
||||
["06:00", "95"],
|
||||
];
|
||||
|
||||
const result = tryParseCells(spreadsheet);
|
||||
|
||||
expect(result.type).toBe(VALID_SPREADSHEET);
|
||||
|
||||
const { title, labels, values } = (
|
||||
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
|
||||
).spreadsheet;
|
||||
|
||||
expect(title).toEqual("value");
|
||||
expect(labels).toEqual([
|
||||
"01:00",
|
||||
"02:00",
|
||||
"03:00",
|
||||
"04:00",
|
||||
"05:00",
|
||||
"06:00",
|
||||
]);
|
||||
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
|
||||
});
|
||||
|
||||
it("treats the first column as labels if both columns are numbers", () => {
|
||||
const spreadsheet = [
|
||||
["time", "value"],
|
||||
["01", "61"],
|
||||
["02", "-60"],
|
||||
["03", "85"],
|
||||
["04", "-67"],
|
||||
["05", "54"],
|
||||
["06", "95"],
|
||||
];
|
||||
|
||||
const result = tryParseCells(spreadsheet);
|
||||
|
||||
expect(result.type).toBe(VALID_SPREADSHEET);
|
||||
|
||||
const { title, labels, values } = (
|
||||
result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
|
||||
).spreadsheet;
|
||||
|
||||
expect(title).toEqual("value");
|
||||
expect(labels).toEqual(["01", "02", "03", "04", "05", "06"]);
|
||||
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
|
||||
});
|
||||
});
|
||||
});
|
513
packages/excalidraw/charts.ts
Normal file
513
packages/excalidraw/charts.ts
Normal file
|
@ -0,0 +1,513 @@
|
|||
import {
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_CHART_COLOR_INDEX,
|
||||
getAllColorsSpecificShade,
|
||||
} from "./colors";
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
VERTICAL_ALIGN,
|
||||
} from "./constants";
|
||||
import { newElement, newLinearElement, newTextElement } from "./element";
|
||||
import { NonDeletedExcalidrawElement } from "./element/types";
|
||||
import { randomId } from "./random";
|
||||
import { AppState } from "./types";
|
||||
import { selectSubtype } from "./element/subtypes";
|
||||
|
||||
export type ChartElements = readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
const BAR_WIDTH = 32;
|
||||
const BAR_GAP = 12;
|
||||
const BAR_HEIGHT = 256;
|
||||
const GRID_OPACITY = 50;
|
||||
|
||||
export interface Spreadsheet {
|
||||
title: string | null;
|
||||
labels: string[] | null;
|
||||
values: number[];
|
||||
activeSubtypes?: AppState["activeSubtypes"];
|
||||
customData?: AppState["customData"];
|
||||
}
|
||||
|
||||
export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
|
||||
export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
|
||||
|
||||
type ParseSpreadsheetResult =
|
||||
| { type: typeof NOT_SPREADSHEET; reason: string }
|
||||
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
||||
|
||||
/**
|
||||
* @private exported for testing
|
||||
*/
|
||||
export const tryParseNumber = (s: string): number | null => {
|
||||
const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
|
||||
};
|
||||
|
||||
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
||||
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
|
||||
|
||||
/**
|
||||
* @private exported for testing
|
||||
*/
|
||||
export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
||||
const numCols = cells[0].length;
|
||||
|
||||
if (numCols > 2) {
|
||||
return { type: NOT_SPREADSHEET, reason: "More than 2 columns" };
|
||||
}
|
||||
|
||||
if (numCols === 1) {
|
||||
if (!isNumericColumn(cells, 0)) {
|
||||
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||
}
|
||||
|
||||
const hasHeader = tryParseNumber(cells[0][0]) === null;
|
||||
const values = (hasHeader ? cells.slice(1) : cells).map((line) =>
|
||||
tryParseNumber(line[0]),
|
||||
);
|
||||
|
||||
if (values.length < 2) {
|
||||
return { type: NOT_SPREADSHEET, reason: "Less than two rows" };
|
||||
}
|
||||
|
||||
return {
|
||||
type: VALID_SPREADSHEET,
|
||||
spreadsheet: {
|
||||
title: hasHeader ? cells[0][0] : null,
|
||||
labels: null,
|
||||
values: values as number[],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const labelColumnNumeric = isNumericColumn(cells, 0);
|
||||
const valueColumnNumeric = isNumericColumn(cells, 1);
|
||||
|
||||
if (!labelColumnNumeric && !valueColumnNumeric) {
|
||||
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||
}
|
||||
|
||||
const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric
|
||||
? [0, 1]
|
||||
: [1, 0];
|
||||
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
|
||||
const rows = hasHeader ? cells.slice(1) : cells;
|
||||
|
||||
if (rows.length < 2) {
|
||||
return { type: NOT_SPREADSHEET, reason: "Less than 2 rows" };
|
||||
}
|
||||
|
||||
return {
|
||||
type: VALID_SPREADSHEET,
|
||||
spreadsheet: {
|
||||
title: hasHeader ? cells[0][valueColumnIndex] : null,
|
||||
labels: rows.map((row) => row[labelColumnIndex]),
|
||||
values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const transposeCells = (cells: string[][]) => {
|
||||
const nextCells: string[][] = [];
|
||||
for (let col = 0; col < cells[0].length; col++) {
|
||||
const nextCellRow: string[] = [];
|
||||
for (let row = 0; row < cells.length; row++) {
|
||||
nextCellRow.push(cells[row][col]);
|
||||
}
|
||||
nextCells.push(nextCellRow);
|
||||
}
|
||||
return nextCells;
|
||||
};
|
||||
|
||||
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
||||
// Copy/paste from excel, spreadsheets, tsv, csv.
|
||||
// For now we only accept 2 columns with an optional header
|
||||
|
||||
// Check for tab separated values
|
||||
let lines = text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => line.trim().split("\t"));
|
||||
|
||||
// Check for comma separated files
|
||||
if (lines.length && lines[0].length !== 2) {
|
||||
lines = text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => line.trim().split(","));
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return { type: NOT_SPREADSHEET, reason: "No values" };
|
||||
}
|
||||
|
||||
const numColsFirstLine = lines[0].length;
|
||||
const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
|
||||
|
||||
if (!isSpreadsheet) {
|
||||
return {
|
||||
type: NOT_SPREADSHEET,
|
||||
reason: "All rows don't have same number of columns",
|
||||
};
|
||||
}
|
||||
|
||||
const result = tryParseCells(lines);
|
||||
if (result.type !== VALID_SPREADSHEET) {
|
||||
const transposedResults = tryParseCells(transposeCells(lines));
|
||||
if (transposedResults.type === VALID_SPREADSHEET) {
|
||||
return transposedResults;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const bgColors = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX);
|
||||
|
||||
// Put all the common properties here so when the whole chart is selected
|
||||
// the properties dialog shows the correct selected values
|
||||
const commonProps = {
|
||||
fillStyle: "hachure",
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
opacity: 100,
|
||||
roughness: 1,
|
||||
strokeColor: COLOR_PALETTE.black,
|
||||
roundness: null,
|
||||
strokeStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
locked: false,
|
||||
} as const;
|
||||
|
||||
const getChartDimensions = (spreadsheet: Spreadsheet) => {
|
||||
const chartWidth =
|
||||
(BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
|
||||
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
|
||||
return { chartWidth, chartHeight };
|
||||
};
|
||||
|
||||
const chartXLabels = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
groupId: string,
|
||||
backgroundColor: string,
|
||||
): ChartElements => {
|
||||
const custom = selectSubtype(spreadsheet, "text");
|
||||
return (
|
||||
spreadsheet.labels?.map((label, index) => {
|
||||
return newTextElement({
|
||||
groupIds: [groupId],
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
text:
|
||||
label.length > 8 && custom.subtype === undefined
|
||||
? `${label.slice(0, 5)}...`
|
||||
: label,
|
||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
||||
y: y + BAR_GAP / 2,
|
||||
width: BAR_WIDTH,
|
||||
angle: 5.87,
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
verticalAlign: "top",
|
||||
...custom,
|
||||
});
|
||||
}) || []
|
||||
);
|
||||
};
|
||||
|
||||
const chartYLabels = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
groupId: string,
|
||||
backgroundColor: string,
|
||||
): ChartElements => {
|
||||
const minYLabel = newTextElement({
|
||||
groupIds: [groupId],
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
x: x - BAR_GAP,
|
||||
y: y - BAR_GAP,
|
||||
text: "0",
|
||||
textAlign: "right",
|
||||
...selectSubtype(spreadsheet, "text"),
|
||||
});
|
||||
|
||||
const maxYLabel = newTextElement({
|
||||
groupIds: [groupId],
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
x: x - BAR_GAP,
|
||||
y: y - BAR_HEIGHT - minYLabel.height / 2,
|
||||
text: Math.max(...spreadsheet.values).toLocaleString(),
|
||||
textAlign: "right",
|
||||
...selectSubtype(spreadsheet, "text"),
|
||||
});
|
||||
|
||||
return [minYLabel, maxYLabel];
|
||||
};
|
||||
|
||||
const chartLines = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
groupId: string,
|
||||
backgroundColor: string,
|
||||
): ChartElements => {
|
||||
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
|
||||
const xLine = newLinearElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x,
|
||||
y,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
width: chartWidth,
|
||||
points: [
|
||||
[0, 0],
|
||||
[chartWidth, 0],
|
||||
],
|
||||
...selectSubtype(spreadsheet, "line"),
|
||||
});
|
||||
|
||||
const yLine = newLinearElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x,
|
||||
y,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
height: chartHeight,
|
||||
points: [
|
||||
[0, 0],
|
||||
[0, -chartHeight],
|
||||
],
|
||||
...selectSubtype(spreadsheet, "line"),
|
||||
});
|
||||
|
||||
const maxLine = newLinearElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x,
|
||||
y: y - BAR_HEIGHT - BAR_GAP,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
strokeStyle: "dotted",
|
||||
width: chartWidth,
|
||||
opacity: GRID_OPACITY,
|
||||
points: [
|
||||
[0, 0],
|
||||
[chartWidth, 0],
|
||||
],
|
||||
...selectSubtype(spreadsheet, "line"),
|
||||
});
|
||||
|
||||
return [xLine, yLine, maxLine];
|
||||
};
|
||||
|
||||
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
|
||||
const chartBaseElements = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
groupId: string,
|
||||
backgroundColor: string,
|
||||
debug?: boolean,
|
||||
): ChartElements => {
|
||||
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
|
||||
|
||||
const title = spreadsheet.title
|
||||
? newTextElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
text: spreadsheet.title,
|
||||
x: x + chartWidth / 2,
|
||||
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
|
||||
roundness: null,
|
||||
textAlign: "center",
|
||||
...selectSubtype(spreadsheet, "text"),
|
||||
})
|
||||
: null;
|
||||
|
||||
const debugRect = debug
|
||||
? newElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "rectangle",
|
||||
x,
|
||||
y: y - chartHeight,
|
||||
width: chartWidth,
|
||||
height: chartHeight,
|
||||
strokeColor: COLOR_PALETTE.black,
|
||||
fillStyle: "solid",
|
||||
opacity: 6,
|
||||
...selectSubtype(spreadsheet, "rectangle"),
|
||||
})
|
||||
: null;
|
||||
|
||||
return [
|
||||
...(debugRect ? [debugRect] : []),
|
||||
...(title ? [title] : []),
|
||||
...chartXLabels(spreadsheet, x, y, groupId, backgroundColor),
|
||||
...chartYLabels(spreadsheet, x, y, groupId, backgroundColor),
|
||||
...chartLines(spreadsheet, x, y, groupId, backgroundColor),
|
||||
];
|
||||
};
|
||||
|
||||
const chartTypeBar = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
): ChartElements => {
|
||||
const max = Math.max(...spreadsheet.values);
|
||||
const groupId = randomId();
|
||||
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
|
||||
|
||||
const bars = spreadsheet.values.map((value, index) => {
|
||||
const barHeight = (value / max) * BAR_HEIGHT;
|
||||
return newElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "rectangle",
|
||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
|
||||
y: y - barHeight - BAR_GAP,
|
||||
width: BAR_WIDTH,
|
||||
height: barHeight,
|
||||
...selectSubtype(spreadsheet, "rectangle"),
|
||||
});
|
||||
});
|
||||
|
||||
return [
|
||||
...bars,
|
||||
...chartBaseElements(
|
||||
spreadsheet,
|
||||
x,
|
||||
y,
|
||||
groupId,
|
||||
backgroundColor,
|
||||
import.meta.env.DEV,
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
const chartTypeLine = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
): ChartElements => {
|
||||
const max = Math.max(...spreadsheet.values);
|
||||
const groupId = randomId();
|
||||
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
|
||||
|
||||
let index = 0;
|
||||
const points = [];
|
||||
for (const value of spreadsheet.values) {
|
||||
const cx = index * (BAR_WIDTH + BAR_GAP);
|
||||
const cy = -(value / max) * BAR_HEIGHT;
|
||||
points.push([cx, cy]);
|
||||
index++;
|
||||
}
|
||||
|
||||
const maxX = Math.max(...points.map((element) => element[0]));
|
||||
const maxY = Math.max(...points.map((element) => element[1]));
|
||||
const minX = Math.min(...points.map((element) => element[0]));
|
||||
const minY = Math.min(...points.map((element) => element[1]));
|
||||
|
||||
const line = newLinearElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x: x + BAR_GAP + BAR_WIDTH / 2,
|
||||
y: y - BAR_GAP,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
height: maxY - minY,
|
||||
width: maxX - minX,
|
||||
strokeWidth: 2,
|
||||
points: points as any,
|
||||
...selectSubtype(spreadsheet, "line"),
|
||||
});
|
||||
|
||||
const dots = spreadsheet.values.map((value, index) => {
|
||||
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
|
||||
const cy = -(value / max) * BAR_HEIGHT + BAR_GAP / 2;
|
||||
return newElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
type: "ellipse",
|
||||
x: x + cx + BAR_WIDTH / 2,
|
||||
y: y + cy - BAR_GAP * 2,
|
||||
width: BAR_GAP,
|
||||
height: BAR_GAP,
|
||||
...selectSubtype(spreadsheet, "ellipse"),
|
||||
});
|
||||
});
|
||||
|
||||
const lines = spreadsheet.values.map((value, index) => {
|
||||
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
|
||||
const cy = (value / max) * BAR_HEIGHT + BAR_GAP / 2 + BAR_GAP;
|
||||
return newLinearElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
|
||||
y: y - cy,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
height: cy,
|
||||
strokeStyle: "dotted",
|
||||
opacity: GRID_OPACITY,
|
||||
points: [
|
||||
[0, 0],
|
||||
[0, cy],
|
||||
],
|
||||
...selectSubtype(spreadsheet, "line"),
|
||||
});
|
||||
});
|
||||
|
||||
return [
|
||||
...chartBaseElements(
|
||||
spreadsheet,
|
||||
x,
|
||||
y,
|
||||
groupId,
|
||||
backgroundColor,
|
||||
import.meta.env.DEV,
|
||||
),
|
||||
line,
|
||||
...lines,
|
||||
...dots,
|
||||
];
|
||||
};
|
||||
|
||||
export const renderSpreadsheet = (
|
||||
chartType: string,
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
): ChartElements => {
|
||||
if (chartType === "line") {
|
||||
return chartTypeLine(spreadsheet, x, y);
|
||||
}
|
||||
return chartTypeBar(spreadsheet, x, y);
|
||||
};
|
40
packages/excalidraw/clients.ts
Normal file
40
packages/excalidraw/clients.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
function hashToInteger(id: string) {
|
||||
let hash = 0;
|
||||
if (id.length === 0) {
|
||||
return hash;
|
||||
}
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
const char = id.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
export const getClientColor = (
|
||||
/**
|
||||
* any uniquely identifying key, such as user id or socket id
|
||||
*/
|
||||
id: string,
|
||||
) => {
|
||||
// to get more even distribution in case `id` is not uniformly distributed to
|
||||
// begin with, we hash it
|
||||
const hash = Math.abs(hashToInteger(id));
|
||||
// we want to get a multiple of 10 number in the range of 0-360 (in other
|
||||
// words a hue value of step size 10). There are 37 such values including 0.
|
||||
const hue = (hash % 37) * 10;
|
||||
const saturation = 100;
|
||||
const lightness = 83;
|
||||
|
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
};
|
||||
|
||||
/**
|
||||
* returns first char, capitalized
|
||||
*/
|
||||
export const getNameInitial = (name?: string | null) => {
|
||||
// first char can be a surrogate pair, hence using codePointAt
|
||||
const firstCodePoint = name?.trim()?.codePointAt(0);
|
||||
return (
|
||||
firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?"
|
||||
).toUpperCase();
|
||||
};
|
196
packages/excalidraw/clipboard.test.ts
Normal file
196
packages/excalidraw/clipboard.test.ts
Normal file
|
@ -0,0 +1,196 @@
|
|||
import {
|
||||
createPasteEvent,
|
||||
parseClipboard,
|
||||
serializeAsClipboardJSON,
|
||||
} from "./clipboard";
|
||||
import { API } from "./tests/helpers/api";
|
||||
|
||||
describe("parseClipboard()", () => {
|
||||
it("should parse JSON as plaintext if not excalidraw-api/clipboard data", async () => {
|
||||
let text;
|
||||
let clipboardData;
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
text = "123";
|
||||
clipboardData = await parseClipboard(
|
||||
createPasteEvent({ types: { "text/plain": text } }),
|
||||
);
|
||||
expect(clipboardData.text).toBe(text);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
text = "[123]";
|
||||
clipboardData = await parseClipboard(
|
||||
createPasteEvent({ types: { "text/plain": text } }),
|
||||
);
|
||||
expect(clipboardData.text).toBe(text);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
text = JSON.stringify({ val: 42 });
|
||||
clipboardData = await parseClipboard(
|
||||
createPasteEvent({ types: { "text/plain": text } }),
|
||||
);
|
||||
expect(clipboardData.text).toBe(text);
|
||||
});
|
||||
|
||||
it("should parse valid excalidraw JSON if inside text/plain", async () => {
|
||||
const rect = API.createElement({ type: "rectangle" });
|
||||
|
||||
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||
const clipboardData = await parseClipboard(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/plain": json,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(clipboardData.elements).toEqual([rect]);
|
||||
});
|
||||
|
||||
it("should parse valid excalidraw JSON if inside text/html", async () => {
|
||||
const rect = API.createElement({ type: "rectangle" });
|
||||
|
||||
let json;
|
||||
let clipboardData;
|
||||
// -------------------------------------------------------------------------
|
||||
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||
clipboardData = await parseClipboard(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/html": json,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(clipboardData.elements).toEqual([rect]);
|
||||
// -------------------------------------------------------------------------
|
||||
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||
clipboardData = await parseClipboard(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/html": `<div> ${json}</div>`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(clipboardData.elements).toEqual([rect]);
|
||||
// -------------------------------------------------------------------------
|
||||
});
|
||||
|
||||
it("should parse <image> `src` urls out of text/html", async () => {
|
||||
let clipboardData;
|
||||
// -------------------------------------------------------------------------
|
||||
clipboardData = await parseClipboard(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/html": `<img src="https://example.com/image.png" />`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(clipboardData.mixedContent).toEqual([
|
||||
{
|
||||
type: "imageUrl",
|
||||
value: "https://example.com/image.png",
|
||||
},
|
||||
]);
|
||||
// -------------------------------------------------------------------------
|
||||
clipboardData = await parseClipboard(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(clipboardData.mixedContent).toEqual([
|
||||
{
|
||||
type: "imageUrl",
|
||||
value: "https://example.com/image.png",
|
||||
},
|
||||
{
|
||||
type: "imageUrl",
|
||||
value: "https://example.com/image2.png",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
|
||||
const clipboardData = await parseClipboard(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(clipboardData.mixedContent).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
// trimmed
|
||||
value: "hello",
|
||||
},
|
||||
{
|
||||
type: "imageUrl",
|
||||
value: "https://example.com/image.png",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
value: "my friend!",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should parse spreadsheet from either text/plain and text/html", async () => {
|
||||
let clipboardData;
|
||||
// -------------------------------------------------------------------------
|
||||
clipboardData = await parseClipboard(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/plain": `a b
|
||||
1 2
|
||||
4 5
|
||||
7 10`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(clipboardData.spreadsheet).toEqual({
|
||||
title: "b",
|
||||
labels: ["1", "4", "7"],
|
||||
values: [2, 5, 10],
|
||||
});
|
||||
// -------------------------------------------------------------------------
|
||||
clipboardData = await parseClipboard(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/html": `a b
|
||||
1 2
|
||||
4 5
|
||||
7 10`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(clipboardData.spreadsheet).toEqual({
|
||||
title: "b",
|
||||
labels: ["1", "4", "7"],
|
||||
values: [2, 5, 10],
|
||||
});
|
||||
// -------------------------------------------------------------------------
|
||||
clipboardData = await parseClipboard(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/html": `<html>
|
||||
<body>
|
||||
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"a"}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"b"}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":10}">10</td></tr></tbody></table><!--EndFragment-->
|
||||
</body>
|
||||
</html>`,
|
||||
"text/plain": `a b
|
||||
1 2
|
||||
4 5
|
||||
7 10`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(clipboardData.spreadsheet).toEqual({
|
||||
title: "b",
|
||||
labels: ["1", "4", "7"],
|
||||
values: [2, 5, 10],
|
||||
});
|
||||
});
|
||||
});
|
487
packages/excalidraw/clipboard.ts
Normal file
487
packages/excalidraw/clipboard.ts
Normal file
|
@ -0,0 +1,487 @@
|
|||
import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import { AppState, BinaryFiles } from "./types";
|
||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import {
|
||||
ALLOWED_PASTE_MIME_TYPES,
|
||||
EXPORT_DATA_TYPES,
|
||||
MIME_TYPES,
|
||||
} from "./constants";
|
||||
import {
|
||||
isFrameLikeElement,
|
||||
isInitializedImageElement,
|
||||
} from "./element/typeChecks";
|
||||
import { deepCopyElement } from "./element/newElement";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import { getContainingFrame } from "./frame";
|
||||
import { isMemberOf, isPromiseLike } from "./utils";
|
||||
import { t } from "./i18n";
|
||||
|
||||
type ElementsClipboard = {
|
||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles | undefined;
|
||||
};
|
||||
|
||||
export type PastedMixedContent = { type: "text" | "imageUrl"; value: string }[];
|
||||
|
||||
export interface ClipboardData {
|
||||
spreadsheet?: Spreadsheet;
|
||||
elements?: readonly ExcalidrawElement[];
|
||||
files?: BinaryFiles;
|
||||
text?: string;
|
||||
mixedContent?: PastedMixedContent;
|
||||
errorMessage?: string;
|
||||
programmaticAPI?: boolean;
|
||||
}
|
||||
|
||||
type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
|
||||
|
||||
type ParsedClipboardEvent =
|
||||
| { type: "text"; value: string }
|
||||
| { type: "mixedContent"; value: PastedMixedContent };
|
||||
|
||||
export const probablySupportsClipboardReadText =
|
||||
"clipboard" in navigator && "readText" in navigator.clipboard;
|
||||
|
||||
export const probablySupportsClipboardWriteText =
|
||||
"clipboard" in navigator && "writeText" in navigator.clipboard;
|
||||
|
||||
export const probablySupportsClipboardBlob =
|
||||
"clipboard" in navigator &&
|
||||
"write" in navigator.clipboard &&
|
||||
"ClipboardItem" in window &&
|
||||
"toBlob" in HTMLCanvasElement.prototype;
|
||||
|
||||
const clipboardContainsElements = (
|
||||
contents: any,
|
||||
): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
|
||||
if (
|
||||
[
|
||||
EXPORT_DATA_TYPES.excalidraw,
|
||||
EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
EXPORT_DATA_TYPES.excalidrawClipboardWithAPI,
|
||||
].includes(contents?.type) &&
|
||||
Array.isArray(contents.elements)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const createPasteEvent = ({
|
||||
types,
|
||||
files,
|
||||
}: {
|
||||
types?: { [key in AllowedPasteMimeTypes]?: string };
|
||||
files?: File[];
|
||||
}) => {
|
||||
if (!types && !files) {
|
||||
console.warn("createPasteEvent: no types or files provided");
|
||||
}
|
||||
|
||||
const event = new ClipboardEvent("paste", {
|
||||
clipboardData: new DataTransfer(),
|
||||
});
|
||||
|
||||
if (types) {
|
||||
for (const [type, value] of Object.entries(types)) {
|
||||
try {
|
||||
event.clipboardData?.setData(type, value);
|
||||
if (event.clipboardData?.getData(type) !== value) {
|
||||
throw new Error(`Failed to set "${type}" as clipboardData item`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (files) {
|
||||
let idx = -1;
|
||||
for (const file of files) {
|
||||
idx++;
|
||||
try {
|
||||
event.clipboardData?.items.add(file);
|
||||
if (event.clipboardData?.files[idx] !== file) {
|
||||
throw new Error(
|
||||
`Failed to set file "${file.name}" as clipboardData item`,
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return event;
|
||||
};
|
||||
|
||||
export const serializeAsClipboardJSON = ({
|
||||
elements,
|
||||
files,
|
||||
}: {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles | null;
|
||||
}) => {
|
||||
const framesToCopy = new Set(
|
||||
elements.filter((element) => isFrameLikeElement(element)),
|
||||
);
|
||||
let foundFile = false;
|
||||
|
||||
const _files = elements.reduce((acc, element) => {
|
||||
if (isInitializedImageElement(element)) {
|
||||
foundFile = true;
|
||||
if (files && files[element.fileId]) {
|
||||
acc[element.fileId] = files[element.fileId];
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, {} as BinaryFiles);
|
||||
|
||||
if (foundFile && !files) {
|
||||
console.warn(
|
||||
"copyToClipboard: attempting to file element(s) without providing associated `files` object.",
|
||||
);
|
||||
}
|
||||
|
||||
// select bound text elements when copying
|
||||
const contents: ElementsClipboard = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
elements: elements.map((element) => {
|
||||
if (
|
||||
getContainingFrame(element) &&
|
||||
!framesToCopy.has(getContainingFrame(element)!)
|
||||
) {
|
||||
const copiedElement = deepCopyElement(element);
|
||||
mutateElement(copiedElement, {
|
||||
frameId: null,
|
||||
});
|
||||
return copiedElement;
|
||||
}
|
||||
|
||||
return element;
|
||||
}),
|
||||
files: files ? _files : undefined,
|
||||
};
|
||||
|
||||
return JSON.stringify(contents);
|
||||
};
|
||||
|
||||
export const copyToClipboard = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
files: BinaryFiles | null,
|
||||
/** supply if available to make the operation more certain to succeed */
|
||||
clipboardEvent?: ClipboardEvent | null,
|
||||
) => {
|
||||
await copyTextToSystemClipboard(
|
||||
serializeAsClipboardJSON({ elements, files }),
|
||||
clipboardEvent,
|
||||
);
|
||||
};
|
||||
|
||||
const parsePotentialSpreadsheet = (
|
||||
text: string,
|
||||
): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => {
|
||||
const result = tryParseSpreadsheet(text);
|
||||
if (result.type === VALID_SPREADSHEET) {
|
||||
return { spreadsheet: result.spreadsheet };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** internal, specific to parsing paste events. Do not reuse. */
|
||||
function parseHTMLTree(el: ChildNode) {
|
||||
let result: PastedMixedContent = [];
|
||||
for (const node of el.childNodes) {
|
||||
if (node.nodeType === 3) {
|
||||
const text = node.textContent?.trim();
|
||||
if (text) {
|
||||
result.push({ type: "text", value: text });
|
||||
}
|
||||
} else if (node instanceof HTMLImageElement) {
|
||||
const url = node.getAttribute("src");
|
||||
if (url && url.startsWith("http")) {
|
||||
result.push({ type: "imageUrl", value: url });
|
||||
}
|
||||
} else {
|
||||
result = result.concat(parseHTMLTree(node));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const maybeParseHTMLPaste = (
|
||||
event: ClipboardEvent,
|
||||
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
||||
const html = event.clipboardData?.getData("text/html");
|
||||
|
||||
if (!html) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
|
||||
const content = parseHTMLTree(doc.body);
|
||||
|
||||
if (content.length) {
|
||||
return { type: "mixedContent", value: content };
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`error in parseHTMLFromPaste: ${error.message}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const readSystemClipboard = async () => {
|
||||
const types: { [key in AllowedPasteMimeTypes]?: string } = {};
|
||||
|
||||
try {
|
||||
if (navigator.clipboard?.readText) {
|
||||
return { "text/plain": await navigator.clipboard?.readText() };
|
||||
}
|
||||
} catch (error: any) {
|
||||
// @ts-ignore
|
||||
if (navigator.clipboard?.read) {
|
||||
console.warn(
|
||||
`navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let clipboardItems: ClipboardItems;
|
||||
|
||||
try {
|
||||
clipboardItems = await navigator.clipboard?.read();
|
||||
} catch (error: any) {
|
||||
if (error.name === "DataError") {
|
||||
console.warn(
|
||||
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
|
||||
);
|
||||
return types;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const item of clipboardItems) {
|
||||
for (const type of item.types) {
|
||||
if (!isMemberOf(ALLOWED_PASTE_MIME_TYPES, type)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
types[type] = await (await item.getType(type)).text();
|
||||
} catch (error: any) {
|
||||
console.warn(
|
||||
`Cannot retrieve ${type} from clipboardItem: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(types).length === 0) {
|
||||
console.warn("No clipboard data found from clipboard.read().");
|
||||
return types;
|
||||
}
|
||||
|
||||
return types;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses "paste" ClipboardEvent.
|
||||
*/
|
||||
const parseClipboardEvent = async (
|
||||
event: ClipboardEvent,
|
||||
isPlainPaste = false,
|
||||
): Promise<ParsedClipboardEvent> => {
|
||||
try {
|
||||
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
|
||||
|
||||
if (mixedContent) {
|
||||
if (mixedContent.value.every((item) => item.type === "text")) {
|
||||
return {
|
||||
type: "text",
|
||||
value:
|
||||
event.clipboardData?.getData("text/plain") ||
|
||||
mixedContent.value
|
||||
.map((item) => item.value)
|
||||
.join("\n")
|
||||
.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
return mixedContent;
|
||||
}
|
||||
|
||||
const text = event.clipboardData?.getData("text/plain");
|
||||
|
||||
return { type: "text", value: (text || "").trim() };
|
||||
} catch {
|
||||
return { type: "text", value: "" };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts to parse clipboard. Prefers system clipboard.
|
||||
*/
|
||||
export const parseClipboard = async (
|
||||
event: ClipboardEvent,
|
||||
isPlainPaste = false,
|
||||
appState?: AppState,
|
||||
): Promise<ClipboardData> => {
|
||||
const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
|
||||
|
||||
if (parsedEventData.type === "mixedContent") {
|
||||
return {
|
||||
mixedContent: parsedEventData.value,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// if system clipboard contains spreadsheet, use it even though it's
|
||||
// technically possible it's staler than in-app clipboard
|
||||
const spreadsheetResult =
|
||||
!isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value);
|
||||
|
||||
if (spreadsheetResult) {
|
||||
if ("spreadsheet" in spreadsheetResult) {
|
||||
spreadsheetResult.spreadsheet.activeSubtypes = appState?.activeSubtypes;
|
||||
spreadsheetResult.spreadsheet.customData = appState?.customData;
|
||||
}
|
||||
return spreadsheetResult;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
try {
|
||||
const systemClipboardData = JSON.parse(parsedEventData.value);
|
||||
const programmaticAPI =
|
||||
systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
|
||||
if (clipboardContainsElements(systemClipboardData)) {
|
||||
return {
|
||||
elements: systemClipboardData.elements,
|
||||
files: systemClipboardData.files,
|
||||
text: isPlainPaste
|
||||
? JSON.stringify(systemClipboardData.elements, null, 2)
|
||||
: undefined,
|
||||
programmaticAPI,
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return { text: parsedEventData.value };
|
||||
};
|
||||
|
||||
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
|
||||
try {
|
||||
// in Safari so far we need to construct the ClipboardItem synchronously
|
||||
// (i.e. in the same tick) otherwise browser will complain for lack of
|
||||
// user intent. Using a Promise ClipboardItem constructor solves this.
|
||||
// https://bugs.webkit.org/show_bug.cgi?id=222262
|
||||
//
|
||||
// Note that Firefox (and potentially others) seems to support Promise
|
||||
// ClipboardItem constructor, but throws on an unrelated MIME type error.
|
||||
// So we need to await this and fallback to awaiting the blob if applicable.
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({
|
||||
[MIME_TYPES.png]: blob,
|
||||
}),
|
||||
]);
|
||||
} catch (error: any) {
|
||||
// if we're using a Promise ClipboardItem, let's try constructing
|
||||
// with resolution value instead
|
||||
if (isPromiseLike(blob)) {
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({
|
||||
[MIME_TYPES.png]: await blob,
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const copyTextToSystemClipboard = async (
|
||||
text: string | null,
|
||||
clipboardEvent?: ClipboardEvent | null,
|
||||
) => {
|
||||
// (1) first try using Async Clipboard API
|
||||
if (probablySupportsClipboardWriteText) {
|
||||
try {
|
||||
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
|
||||
// not focused
|
||||
await navigator.clipboard.writeText(text || "");
|
||||
return;
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// (2) if fails and we have access to ClipboardEvent, use plain old setData()
|
||||
try {
|
||||
if (clipboardEvent) {
|
||||
clipboardEvent.clipboardData?.setData("text/plain", text || "");
|
||||
if (clipboardEvent.clipboardData?.getData("text/plain") !== text) {
|
||||
throw new Error("Failed to setData on clipboardEvent");
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
// (3) if that fails, use document.execCommand
|
||||
if (!copyTextViaExecCommand(text)) {
|
||||
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
|
||||
const copyTextViaExecCommand = (text: string | null) => {
|
||||
// execCommand doesn't allow copying empty strings, so if we're
|
||||
// clearing clipboard using this API, we must copy at least an empty char
|
||||
if (!text) {
|
||||
text = " ";
|
||||
}
|
||||
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
|
||||
textarea.style.border = "0";
|
||||
textarea.style.padding = "0";
|
||||
textarea.style.margin = "0";
|
||||
textarea.style.position = "absolute";
|
||||
textarea.style[isRTL ? "right" : "left"] = "-9999px";
|
||||
const yPosition = window.pageYOffset || document.documentElement.scrollTop;
|
||||
textarea.style.top = `${yPosition}px`;
|
||||
// Prevent zooming on iOS
|
||||
textarea.style.fontSize = "12pt";
|
||||
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.value = text;
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
let success = false;
|
||||
|
||||
try {
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
|
||||
success = document.execCommand("copy");
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
textarea.remove();
|
||||
|
||||
return success;
|
||||
};
|
170
packages/excalidraw/colors.ts
Normal file
170
packages/excalidraw/colors.ts
Normal file
|
@ -0,0 +1,170 @@
|
|||
import oc from "open-color";
|
||||
import { 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)[]>(
|
||||
source: R,
|
||||
keys: K,
|
||||
) => {
|
||||
return keys.reduce((acc, key: K[number]) => {
|
||||
if (key in source) {
|
||||
acc[key] = source[key];
|
||||
}
|
||||
return acc;
|
||||
}, {} as Pick<R, K[number]>) as Pick<R, K[number]>;
|
||||
};
|
||||
|
||||
export type ColorPickerColor =
|
||||
| Exclude<keyof oc, "indigo" | "lime">
|
||||
| "transparent"
|
||||
| "bronze";
|
||||
export type ColorTuple = readonly [string, string, string, string, string];
|
||||
export type ColorPalette = Merge<
|
||||
Record<ColorPickerColor, ColorTuple>,
|
||||
{ black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" }
|
||||
>;
|
||||
|
||||
// used general type instead of specific type (ColorPalette) to support custom colors
|
||||
export type ColorPaletteCustom = { [key: string]: ColorTuple | string };
|
||||
export type ColorShadesIndexes = [number, number, number, number, number];
|
||||
|
||||
export const MAX_CUSTOM_COLORS_USED_IN_CANVAS = 5;
|
||||
export const COLORS_PER_ROW = 5;
|
||||
|
||||
export const DEFAULT_CHART_COLOR_INDEX = 4;
|
||||
|
||||
export const DEFAULT_ELEMENT_STROKE_COLOR_INDEX = 4;
|
||||
export const DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX = 1;
|
||||
export const ELEMENTS_PALETTE_SHADE_INDEXES = [0, 2, 4, 6, 8] as const;
|
||||
export const CANVAS_PALETTE_SHADE_INDEXES = [0, 1, 2, 3, 4] as const;
|
||||
|
||||
export const getSpecificColorShades = (
|
||||
color: Exclude<
|
||||
ColorPickerColor,
|
||||
"transparent" | "white" | "black" | "bronze"
|
||||
>,
|
||||
indexArr: Readonly<ColorShadesIndexes>,
|
||||
) => {
|
||||
return indexArr.map((index) => oc[color][index]) as any as ColorTuple;
|
||||
};
|
||||
|
||||
export const COLOR_PALETTE = {
|
||||
transparent: "transparent",
|
||||
black: "#1e1e1e",
|
||||
white: "#ffffff",
|
||||
// open-colors
|
||||
gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
red: getSpecificColorShades("red", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
pink: getSpecificColorShades("pink", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
grape: getSpecificColorShades("grape", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
violet: getSpecificColorShades("violet", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
blue: getSpecificColorShades("blue", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
cyan: getSpecificColorShades("cyan", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
teal: getSpecificColorShades("teal", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
green: getSpecificColorShades("green", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
yellow: getSpecificColorShades("yellow", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
orange: getSpecificColorShades("orange", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
// radix bronze shades 3,5,7,9,11
|
||||
bronze: ["#f8f1ee", "#eaddd7", "#d2bab0", "#a18072", "#846358"],
|
||||
} as ColorPalette;
|
||||
|
||||
const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
|
||||
"cyan",
|
||||
"blue",
|
||||
"violet",
|
||||
"grape",
|
||||
"pink",
|
||||
"green",
|
||||
"teal",
|
||||
"yellow",
|
||||
"orange",
|
||||
"red",
|
||||
]);
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// quick picks defaults
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// ORDER matters for positioning in quick picker
|
||||
export const DEFAULT_ELEMENT_STROKE_PICKS = [
|
||||
COLOR_PALETTE.black,
|
||||
COLOR_PALETTE.red[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
|
||||
COLOR_PALETTE.green[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
|
||||
COLOR_PALETTE.blue[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
|
||||
COLOR_PALETTE.yellow[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
|
||||
] as ColorTuple;
|
||||
|
||||
// ORDER matters for positioning in quick picker
|
||||
export const DEFAULT_ELEMENT_BACKGROUND_PICKS = [
|
||||
COLOR_PALETTE.transparent,
|
||||
COLOR_PALETTE.red[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
|
||||
COLOR_PALETTE.green[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
|
||||
COLOR_PALETTE.blue[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
|
||||
COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
|
||||
] as ColorTuple;
|
||||
|
||||
// ORDER matters for positioning in quick picker
|
||||
export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
|
||||
COLOR_PALETTE.white,
|
||||
// radix slate2
|
||||
"#f8f9fa",
|
||||
// radix blue2
|
||||
"#f5faff",
|
||||
// radix yellow2
|
||||
"#fffce8",
|
||||
// radix bronze2
|
||||
"#fdf8f6",
|
||||
] as ColorTuple;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// palette defaults
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const DEFAULT_ELEMENT_STROKE_COLOR_PALETTE = {
|
||||
// 1st row
|
||||
transparent: COLOR_PALETTE.transparent,
|
||||
white: COLOR_PALETTE.white,
|
||||
gray: COLOR_PALETTE.gray,
|
||||
black: COLOR_PALETTE.black,
|
||||
bronze: COLOR_PALETTE.bronze,
|
||||
// rest
|
||||
...COMMON_ELEMENT_SHADES,
|
||||
} as const;
|
||||
|
||||
// ORDER matters for positioning in pallete (5x3 grid)s
|
||||
export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
|
||||
transparent: COLOR_PALETTE.transparent,
|
||||
white: COLOR_PALETTE.white,
|
||||
gray: COLOR_PALETTE.gray,
|
||||
black: COLOR_PALETTE.black,
|
||||
bronze: COLOR_PALETTE.bronze,
|
||||
|
||||
...COMMON_ELEMENT_SHADES,
|
||||
} as const;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!!
|
||||
export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
|
||||
[
|
||||
// 2nd row
|
||||
COLOR_PALETTE.cyan[index],
|
||||
COLOR_PALETTE.blue[index],
|
||||
COLOR_PALETTE.violet[index],
|
||||
COLOR_PALETTE.grape[index],
|
||||
COLOR_PALETTE.pink[index],
|
||||
|
||||
// 3rd row
|
||||
COLOR_PALETTE.green[index],
|
||||
COLOR_PALETTE.teal[index],
|
||||
COLOR_PALETTE.yellow[index],
|
||||
COLOR_PALETTE.orange[index],
|
||||
COLOR_PALETTE.red[index],
|
||||
] as const;
|
||||
|
||||
export const rgbToHex = (r: number, g: number, b: number) =>
|
||||
`#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
92
packages/excalidraw/components/Actions.scss
Normal file
92
packages/excalidraw/components/Actions.scss
Normal file
|
@ -0,0 +1,92 @@
|
|||
.zoom-actions,
|
||||
.undo-redo-buttons {
|
||||
background-color: var(--island-bg-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
||||
}
|
||||
|
||||
.zoom-button,
|
||||
.undo-redo-buttons button {
|
||||
border-radius: 0 !important;
|
||||
background-color: var(--color-surface-low) !important;
|
||||
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;
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.reset-zoom-button {
|
||||
border-left: 0 !important;
|
||||
border-right: 0 !important;
|
||||
padding: 0 0.625rem !important;
|
||||
width: 3.75rem !important;
|
||||
justify-content: center;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
.zoom-out-button {
|
||||
border-top-left-radius: var(--border-radius-lg) !important;
|
||||
border-bottom-left-radius: var(--border-radius-lg) !important;
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
border-top-right-radius: 0 !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.zoom-in-button {
|
||||
border-top-right-radius: var(--border-radius-lg) !important;
|
||||
border-bottom-right-radius: var(--border-radius-lg) !important;
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
border-top-left-radius: 0 !important;
|
||||
border-bottom-left-radius: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.undo-redo-buttons {
|
||||
.undo-button-container button {
|
||||
border-top-left-radius: var(--border-radius-lg) !important;
|
||||
border-bottom-left-radius: var(--border-radius-lg) !important;
|
||||
border-right: 0 !important;
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
border-top-right-radius: 0 !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.redo-button-container button {
|
||||
border-top-right-radius: var(--border-radius-lg) !important;
|
||||
border-bottom-right-radius: var(--border-radius-lg) !important;
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
border-top-left-radius: 0 !important;
|
||||
border-bottom-left-radius: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
446
packages/excalidraw/components/Actions.tsx
Normal file
446
packages/excalidraw/components/Actions.tsx
Normal file
|
@ -0,0 +1,446 @@
|
|||
import { useState } from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import {
|
||||
ExcalidrawElementType,
|
||||
NonDeletedElementsMap,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "./App";
|
||||
import {
|
||||
canChangeRoundness,
|
||||
canHaveArrowheads,
|
||||
getTargetElements,
|
||||
hasBackground,
|
||||
hasStrokeStyle,
|
||||
hasStrokeWidth,
|
||||
} from "../scene";
|
||||
import { SHAPES } from "../shapes";
|
||||
import { 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 clsx from "clsx";
|
||||
import { actionToggleZenMode } from "../actions";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import {
|
||||
shouldAllowVerticalAlign,
|
||||
suppportsHorizontalAlign,
|
||||
} from "../element/textElement";
|
||||
|
||||
import "./Actions.scss";
|
||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||
import {
|
||||
EmbedIcon,
|
||||
extraToolsIcon,
|
||||
frameToolIcon,
|
||||
mermaidLogoIcon,
|
||||
laserPointerToolIcon,
|
||||
OpenAIIcon,
|
||||
MagicIcon,
|
||||
} from "./icons";
|
||||
import { KEYS } from "../keys";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
elementsMap,
|
||||
renderAction,
|
||||
}: {
|
||||
appState: UIAppState;
|
||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
|
||||
renderAction: ActionManager["renderAction"];
|
||||
}) => {
|
||||
const targetElements = getTargetElements(elementsMap, appState);
|
||||
|
||||
let isSingleElementBoundContainer = false;
|
||||
if (
|
||||
targetElements.length === 2 &&
|
||||
(hasBoundTextElement(targetElements[0]) ||
|
||||
hasBoundTextElement(targetElements[1]))
|
||||
) {
|
||||
isSingleElementBoundContainer = true;
|
||||
}
|
||||
const isEditing = Boolean(appState.editingElement);
|
||||
const device = useDevice();
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
const showFillIcons =
|
||||
(hasBackground(appState.activeTool.type) &&
|
||||
!isTransparent(appState.currentItemBackgroundColor)) ||
|
||||
targetElements.some(
|
||||
(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;
|
||||
}
|
||||
}
|
||||
|
||||
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))) &&
|
||||
renderAction("changeStrokeColor")}
|
||||
</div>
|
||||
{showChangeBackgroundIcons && (
|
||||
<div>{renderAction("changeBackgroundColor")}</div>
|
||||
)}
|
||||
<SubtypeShapeActions elements={targetElements} />
|
||||
{showFillIcons && renderAction("changeFillStyle")}
|
||||
|
||||
{(hasStrokeWidth(appState.activeTool.type) ||
|
||||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
||||
renderAction("changeStrokeWidth")}
|
||||
|
||||
{(appState.activeTool.type === "freedraw" ||
|
||||
targetElements.some((element) => element.type === "freedraw")) &&
|
||||
renderAction("changeStrokeShape")}
|
||||
|
||||
{(hasStrokeStyle(appState.activeTool.type) ||
|
||||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeStrokeStyle")}
|
||||
{renderAction("changeSloppiness")}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(canChangeRoundness(appState.activeTool.type) ||
|
||||
targetElements.some((element) => canChangeRoundness(element.type))) && (
|
||||
<>{renderAction("changeRoundness")}</>
|
||||
)}
|
||||
|
||||
{(appState.activeTool.type === "text" ||
|
||||
targetElements.some(isTextElement)) && (
|
||||
<>
|
||||
{renderAction("changeFontSize")}
|
||||
|
||||
{renderAction("changeFontFamily")}
|
||||
|
||||
{(appState.activeTool.type === "text" ||
|
||||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
|
||||
renderAction("changeTextAlign")}
|
||||
</>
|
||||
)}
|
||||
|
||||
{shouldAllowVerticalAlign(targetElements, elementsMap) &&
|
||||
renderAction("changeVerticalAlign")}
|
||||
{(canHaveArrowheads(appState.activeTool.type) ||
|
||||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
||||
<>{renderAction("changeArrowhead")}</>
|
||||
)}
|
||||
|
||||
{renderAction("changeOpacity")}
|
||||
|
||||
<fieldset>
|
||||
<legend>{t("labels.layers")}</legend>
|
||||
<div className="buttonList">
|
||||
{renderAction("sendToBack")}
|
||||
{renderAction("sendBackward")}
|
||||
{renderAction("bringToFront")}
|
||||
{renderAction("bringForward")}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{targetElements.length > 1 && !isSingleElementBoundContainer && (
|
||||
<fieldset>
|
||||
<legend>{t("labels.align")}</legend>
|
||||
<div className="buttonList">
|
||||
{
|
||||
// swap this order for RTL so the button positions always match their action
|
||||
// (i.e. the leftmost button aligns left)
|
||||
}
|
||||
{isRTL ? (
|
||||
<>
|
||||
{renderAction("alignRight")}
|
||||
{renderAction("alignHorizontallyCentered")}
|
||||
{renderAction("alignLeft")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{renderAction("alignLeft")}
|
||||
{renderAction("alignHorizontallyCentered")}
|
||||
{renderAction("alignRight")}
|
||||
</>
|
||||
)}
|
||||
{targetElements.length > 2 &&
|
||||
renderAction("distributeHorizontally")}
|
||||
{/* breaks the row ˇˇ */}
|
||||
<div style={{ flexBasis: "100%", height: 0 }} />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: ".5rem",
|
||||
marginTop: "-0.5rem",
|
||||
}}
|
||||
>
|
||||
{renderAction("alignTop")}
|
||||
{renderAction("alignVerticallyCentered")}
|
||||
{renderAction("alignBottom")}
|
||||
{targetElements.length > 2 &&
|
||||
renderAction("distributeVertically")}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
{!isEditing && targetElements.length > 0 && (
|
||||
<fieldset>
|
||||
<legend>{t("labels.actions")}</legend>
|
||||
<div className="buttonList">
|
||||
{!device.editor.isMobile && renderAction("duplicateSelection")}
|
||||
{!device.editor.isMobile && renderAction("deleteSelectedElements")}
|
||||
{renderAction("group")}
|
||||
{renderAction("ungroup")}
|
||||
{showLinkIcon && renderAction("hyperlink")}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ShapesSwitcher = ({
|
||||
activeTool,
|
||||
appState,
|
||||
app,
|
||||
UIOptions,
|
||||
}: {
|
||||
activeTool: UIAppState["activeTool"];
|
||||
appState: UIAppState;
|
||||
app: AppClassProperties;
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
}) => {
|
||||
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
|
||||
|
||||
const frameToolSelected = activeTool.type === "frame";
|
||||
const laserToolSelected = activeTool.type === "laser";
|
||||
const embeddableToolSelected = activeTool.type === "embeddable";
|
||||
|
||||
const { TTDDialogTriggerTunnel } = useTunnels();
|
||||
|
||||
return (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
if (
|
||||
UIOptions.tools?.[
|
||||
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]>
|
||||
] === false
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter =
|
||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||
: `${numericKey}`;
|
||||
return (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable })}
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={activeTool.type === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={numericKey || letter}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={`toolbar-${value}`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
app.togglePenMode(true);
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
if (value === "image") {
|
||||
app.setActiveTool({
|
||||
type: value,
|
||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
||||
});
|
||||
} else {
|
||||
app.setActiveTool({ type: value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="App-toolbar__divider" />
|
||||
|
||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||
<DropdownMenu.Trigger
|
||||
className={clsx("App-toolbar__extra-tools-trigger", {
|
||||
"App-toolbar__extra-tools-trigger--selected":
|
||||
frameToolSelected ||
|
||||
embeddableToolSelected ||
|
||||
// in collab we're already highlighting the laser button
|
||||
// outside toolbar, so let's not highlight extra-tools button
|
||||
// on top of it
|
||||
(laserToolSelected && !app.props.isCollaborating),
|
||||
})}
|
||||
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
|
||||
title={t("toolBar.extraTools")}
|
||||
>
|
||||
{extraToolsIcon}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
|
||||
onSelect={() => setIsExtraToolsMenuOpen(false)}
|
||||
className="App-toolbar__extra-tools-dropdown"
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "frame" })}
|
||||
icon={frameToolIcon}
|
||||
shortcut={KEYS.F.toLocaleUpperCase()}
|
||||
data-testid="toolbar-frame"
|
||||
selected={frameToolSelected}
|
||||
>
|
||||
{t("toolBar.frame")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "embeddable" })}
|
||||
icon={EmbedIcon}
|
||||
data-testid="toolbar-embeddable"
|
||||
selected={embeddableToolSelected}
|
||||
>
|
||||
{t("toolBar.embeddable")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "laser" })}
|
||||
icon={laserPointerToolIcon}
|
||||
data-testid="toolbar-laser"
|
||||
selected={laserToolSelected}
|
||||
shortcut={KEYS.K.toLocaleUpperCase()}
|
||||
>
|
||||
{t("toolBar.laser")}
|
||||
</DropdownMenu.Item>
|
||||
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
|
||||
Generate
|
||||
</div>
|
||||
{app.props.aiEnabled !== false && <TTDDialogTriggerTunnel.Out />}
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setOpenDialog({ name: "ttd", tab: "mermaid" })}
|
||||
icon={mermaidLogoIcon}
|
||||
data-testid="toolbar-embeddable"
|
||||
>
|
||||
{t("toolBar.mermaidToExcalidraw")}
|
||||
</DropdownMenu.Item>
|
||||
{app.props.aiEnabled !== false && (
|
||||
<>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.onMagicframeToolSelect()}
|
||||
icon={MagicIcon}
|
||||
data-testid="toolbar-magicframe"
|
||||
>
|
||||
{t("toolBar.magicframe")}
|
||||
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
trackEvent("ai", "open-settings", "d2c");
|
||||
app.setOpenDialog({
|
||||
name: "settings",
|
||||
source: "settings",
|
||||
tab: "diagram-to-code",
|
||||
});
|
||||
}}
|
||||
icon={OpenAIIcon}
|
||||
data-testid="toolbar-magicSettings"
|
||||
>
|
||||
{t("toolBar.magicSettings")}
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ZoomActions = ({
|
||||
renderAction,
|
||||
zoom,
|
||||
}: {
|
||||
renderAction: ActionManager["renderAction"];
|
||||
zoom: Zoom;
|
||||
}) => (
|
||||
<Stack.Col gap={1} className="zoom-actions">
|
||||
<Stack.Row align="center">
|
||||
{renderAction("zoomOut")}
|
||||
{renderAction("resetZoom")}
|
||||
{renderAction("zoomIn")}
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
);
|
||||
|
||||
export const UndoRedoActions = ({
|
||||
renderAction,
|
||||
className,
|
||||
}: {
|
||||
renderAction: ActionManager["renderAction"];
|
||||
className?: string;
|
||||
}) => (
|
||||
<div className={`undo-redo-buttons ${className}`}>
|
||||
<div className="undo-button-container">
|
||||
<Tooltip label={t("buttons.undo")}>{renderAction("undo")}</Tooltip>
|
||||
</div>
|
||||
<div className="redo-button-container">
|
||||
<Tooltip label={t("buttons.redo")}> {renderAction("redo")}</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ExitZenModeAction = ({
|
||||
actionManager,
|
||||
showExitZenModeBtn,
|
||||
}: {
|
||||
actionManager: ActionManager;
|
||||
showExitZenModeBtn: boolean;
|
||||
}) => (
|
||||
<button
|
||||
className={clsx("disable-zen-mode", {
|
||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||
})}
|
||||
onClick={() => actionManager.executeAction(actionToggleZenMode)}
|
||||
>
|
||||
{t("buttons.exitZenMode")}
|
||||
</button>
|
||||
);
|
||||
|
||||
export const FinalizeAction = ({
|
||||
renderAction,
|
||||
className,
|
||||
}: {
|
||||
renderAction: ActionManager["renderAction"];
|
||||
className?: string;
|
||||
}) => (
|
||||
<div className={`finalize-button ${className}`}>
|
||||
{renderAction("finalize", { size: "small" })}
|
||||
</div>
|
||||
);
|
37
packages/excalidraw/components/ActiveConfirmDialog.tsx
Normal file
37
packages/excalidraw/components/ActiveConfirmDialog.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { atom, useAtom } from "jotai";
|
||||
import { actionClearCanvas } from "../actions";
|
||||
import { t } from "../i18n";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { useExcalidrawActionManager } from "./App";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
|
||||
export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
|
||||
|
||||
export const ActiveConfirmDialog = () => {
|
||||
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
|
||||
activeConfirmDialogAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
if (!activeConfirmDialog) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activeConfirmDialog === "clearCanvas") {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
onConfirm={() => {
|
||||
actionManager.executeAction(actionClearCanvas);
|
||||
setActiveConfirmDialog(null);
|
||||
}}
|
||||
onCancel={() => setActiveConfirmDialog(null)}
|
||||
title={t("clearCanvasDialog.title")}
|
||||
>
|
||||
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
9628
packages/excalidraw/components/App.tsx
Normal file
9628
packages/excalidraw/components/App.tsx
Normal file
File diff suppressed because it is too large
Load diff
7
packages/excalidraw/components/Avatar.scss
Normal file
7
packages/excalidraw/components/Avatar.scss
Normal file
|
@ -0,0 +1,7 @@
|
|||
@import "../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.Avatar {
|
||||
@include avatarStyles;
|
||||
}
|
||||
}
|
50
packages/excalidraw/components/Avatar.tsx
Normal file
50
packages/excalidraw/components/Avatar.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import "./Avatar.scss";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { getNameInitial } from "../clients";
|
||||
import clsx from "clsx";
|
||||
|
||||
type AvatarProps = {
|
||||
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
color: string;
|
||||
name: string;
|
||||
src?: string;
|
||||
isBeingFollowed?: boolean;
|
||||
isCurrentUser: boolean;
|
||||
};
|
||||
|
||||
export const Avatar = ({
|
||||
color,
|
||||
onClick,
|
||||
name,
|
||||
src,
|
||||
isBeingFollowed,
|
||||
isCurrentUser,
|
||||
}: 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}
|
||||
>
|
||||
{loadImg ? (
|
||||
<img
|
||||
className="Avatar-img"
|
||||
src={src}
|
||||
alt={shortName}
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
) : (
|
||||
shortName
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
43
packages/excalidraw/components/BraveMeasureTextError.tsx
Normal file
43
packages/excalidraw/components/BraveMeasureTextError.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import Trans from "./Trans";
|
||||
|
||||
const BraveMeasureTextError = () => {
|
||||
return (
|
||||
<div data-testid="brave-measure-text-error">
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="errors.brave_measure_text_error.line1"
|
||||
bold={(el) => <span style={{ fontWeight: 600 }}>{el}</span>}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="errors.brave_measure_text_error.line2"
|
||||
bold={(el) => <span style={{ fontWeight: 600 }}>{el}</span>}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="errors.brave_measure_text_error.line3"
|
||||
link={(el) => (
|
||||
<a href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser">
|
||||
{el}
|
||||
</a>
|
||||
)}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="errors.brave_measure_text_error.line4"
|
||||
issueLink={(el) => (
|
||||
<a href="https://github.com/excalidraw/excalidraw/issues/new">
|
||||
{el}
|
||||
</a>
|
||||
)}
|
||||
discordLink={(el) => <a href="https://discord.gg/UexuTaE">{el}.</a>}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BraveMeasureTextError;
|
7
packages/excalidraw/components/Button.scss
Normal file
7
packages/excalidraw/components/Button.scss
Normal file
|
@ -0,0 +1,7 @@
|
|||
@import "../css/theme";
|
||||
|
||||
.excalidraw {
|
||||
.excalidraw-button {
|
||||
@include outlineButtonStyles;
|
||||
}
|
||||
}
|
44
packages/excalidraw/components/Button.tsx
Normal file
44
packages/excalidraw/components/Button.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { composeEventHandlers } from "../utils";
|
||||
import "./Button.scss";
|
||||
|
||||
interface ButtonProps
|
||||
extends React.DetailedHTMLProps<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
> {
|
||||
type?: "button" | "submit" | "reset";
|
||||
onSelect: () => any;
|
||||
/** whether button is in active state */
|
||||
selected?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic button component that follows Excalidraw's design system.
|
||||
* Style can be customised using `className` or `style` prop.
|
||||
* Accepts all props that a regular `button` element accepts.
|
||||
*/
|
||||
export const Button = ({
|
||||
type = "button",
|
||||
onSelect,
|
||||
selected,
|
||||
children,
|
||||
className = "",
|
||||
...rest
|
||||
}: ButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
onClick={composeEventHandlers(rest.onClick, (event) => {
|
||||
onSelect();
|
||||
})}
|
||||
type={type}
|
||||
className={clsx("excalidraw-button", className, { selected })}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
28
packages/excalidraw/components/ButtonIconCycle.tsx
Normal file
28
packages/excalidraw/components/ButtonIconCycle.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import clsx from "clsx";
|
||||
|
||||
export const ButtonIconCycle = <T extends any>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
group,
|
||||
}: {
|
||||
options: { value: T; text: string; icon: JSX.Element }[];
|
||||
value: T | null;
|
||||
onChange: (value: T) => void;
|
||||
group: string;
|
||||
}) => {
|
||||
const current = options.find((op) => op.value === value);
|
||||
|
||||
const cycle = () => {
|
||||
const index = options.indexOf(current!);
|
||||
const next = (index + 1) % options.length;
|
||||
onChange(options[next].value);
|
||||
};
|
||||
|
||||
return (
|
||||
<label key={group} className={clsx({ active: current!.value !== null })}>
|
||||
<input type="button" name={group} onClick={cycle} />
|
||||
{current!.icon}
|
||||
</label>
|
||||
);
|
||||
};
|
59
packages/excalidraw/components/ButtonIconSelect.tsx
Normal file
59
packages/excalidraw/components/ButtonIconSelect.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import clsx from "clsx";
|
||||
|
||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
||||
export const ButtonIconSelect = <T extends Object>(
|
||||
props: {
|
||||
options: {
|
||||
value: T;
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
testId?: string;
|
||||
/** if not supplied, defaults to value identity check */
|
||||
active?: boolean;
|
||||
}[];
|
||||
value: T | null;
|
||||
type?: "radio" | "button";
|
||||
} & (
|
||||
| { type?: "radio"; group: string; onChange: (value: T) => void }
|
||||
| {
|
||||
type: "button";
|
||||
onClick: (
|
||||
value: T,
|
||||
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
||||
) => void;
|
||||
}
|
||||
),
|
||||
) => (
|
||||
<div className="buttonList buttonListIcon">
|
||||
{props.options.map((option) =>
|
||||
props.type === "button" ? (
|
||||
<button
|
||||
key={option.text}
|
||||
onClick={(event) => props.onClick(option.value, event)}
|
||||
className={clsx({
|
||||
active: option.active ?? props.value === option.value,
|
||||
})}
|
||||
data-testid={option.testId}
|
||||
title={option.text}
|
||||
>
|
||||
{option.icon}
|
||||
</button>
|
||||
) : (
|
||||
<label
|
||||
key={option.text}
|
||||
className={clsx({ active: props.value === option.value })}
|
||||
title={option.text}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={props.group}
|
||||
onChange={() => props.onChange(option.value)}
|
||||
checked={props.value === option.value}
|
||||
data-testid={option.testId}
|
||||
/>
|
||||
{option.icon}
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
30
packages/excalidraw/components/ButtonSelect.tsx
Normal file
30
packages/excalidraw/components/ButtonSelect.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import clsx from "clsx";
|
||||
|
||||
export const ButtonSelect = <T extends Object>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
group,
|
||||
}: {
|
||||
options: { value: T; text: string }[];
|
||||
value: T | null;
|
||||
onChange: (value: T) => void;
|
||||
group: string;
|
||||
}) => (
|
||||
<div className="buttonList">
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.text}
|
||||
className={clsx({ active: value === option.value })}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={group}
|
||||
onChange={() => onChange(option.value)}
|
||||
checked={value === option.value}
|
||||
/>
|
||||
{option.text}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
57
packages/excalidraw/components/Card.scss
Normal file
57
packages/excalidraw/components/Card.scss
Normal file
|
@ -0,0 +1,57 @@
|
|||
@import "../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.Card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
max-width: 290px;
|
||||
|
||||
margin: 1em;
|
||||
|
||||
text-align: center;
|
||||
|
||||
.Card-icon {
|
||||
font-size: 2.6em;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
padding: 1.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--card-color);
|
||||
color: $oc-white;
|
||||
|
||||
svg {
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.Card-details {
|
||||
font-size: 0.96em;
|
||||
min-height: 90px;
|
||||
padding: 0 1em;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
& .Card-button.ToolIcon_type_button {
|
||||
height: 2.5rem;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.3em;
|
||||
background-color: var(--card-color);
|
||||
&:hover {
|
||||
background-color: var(--card-color-darker);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--card-color-darkest);
|
||||
}
|
||||
.ToolIcon__label {
|
||||
color: $oc-white;
|
||||
}
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
28
packages/excalidraw/components/Card.tsx
Normal file
28
packages/excalidraw/components/Card.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import OpenColor from "open-color";
|
||||
|
||||
import "./Card.scss";
|
||||
|
||||
export const Card: React.FC<{
|
||||
color: keyof OpenColor | "primary";
|
||||
children?: React.ReactNode;
|
||||
}> = ({ children, color }) => {
|
||||
return (
|
||||
<div
|
||||
className="Card"
|
||||
style={{
|
||||
["--card-color" as any]:
|
||||
color === "primary" ? "var(--color-primary)" : OpenColor[color][7],
|
||||
["--card-color-darker" as any]:
|
||||
color === "primary"
|
||||
? "var(--color-primary-darker)"
|
||||
: OpenColor[color][8],
|
||||
["--card-color-darkest" as any]:
|
||||
color === "primary"
|
||||
? "var(--color-primary-darkest)"
|
||||
: OpenColor[color][9],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
91
packages/excalidraw/components/CheckboxItem.scss
Normal file
91
packages/excalidraw/components/CheckboxItem.scss
Normal file
|
@ -0,0 +1,91 @@
|
|||
@import "../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.Checkbox {
|
||||
margin: 4px 0.3em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
|
||||
box-shadow: 0 0 0 2px #{$oc-blue-4};
|
||||
}
|
||||
|
||||
&:hover:not(.is-checked) .Checkbox-box:not(:focus) {
|
||||
svg {
|
||||
display: block;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
.Checkbox-box {
|
||||
box-shadow: 0 0 2px 1px inset #{$oc-blue-7} !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.Checkbox-box {
|
||||
background-color: fade-out($oc-blue-1, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-checked {
|
||||
.Checkbox-box {
|
||||
background-color: #{$oc-blue-1};
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
&:hover .Checkbox-box {
|
||||
background-color: #{$oc-blue-2};
|
||||
}
|
||||
}
|
||||
|
||||
.Checkbox-box {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
flex: 0 0 auto;
|
||||
|
||||
margin: 0 1em;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
box-shadow: 0 0 0 2px #{$oc-blue-7};
|
||||
background-color: transparent;
|
||||
border-radius: 4px;
|
||||
|
||||
color: #{$oc-blue-7};
|
||||
|
||||
border: 0;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 3px #{$oc-blue-7};
|
||||
}
|
||||
|
||||
svg {
|
||||
display: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke-width: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.Checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.excalidraw-tooltip-icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
}
|
31
packages/excalidraw/components/CheckboxItem.tsx
Normal file
31
packages/excalidraw/components/CheckboxItem.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { checkIcon } from "./icons";
|
||||
|
||||
import "./CheckboxItem.scss";
|
||||
|
||||
export const CheckboxItem: React.FC<{
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean, event: React.MouseEvent) => void;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}> = ({ children, checked, onChange, className }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("Checkbox", className, { "is-checked": checked })}
|
||||
onClick={(event) => {
|
||||
onChange(!checked, event);
|
||||
(
|
||||
(event.currentTarget as HTMLDivElement).querySelector(
|
||||
".Checkbox-box",
|
||||
) as HTMLButtonElement
|
||||
).focus();
|
||||
}}
|
||||
>
|
||||
<button className="Checkbox-box" role="checkbox" aria-checked={checked}>
|
||||
{checkIcon}
|
||||
</button>
|
||||
<div className="Checkbox-label">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
136
packages/excalidraw/components/ColorPicker/ColorInput.tsx
Normal file
136
packages/excalidraw/components/ColorPicker/ColorInput.tsx
Normal file
|
@ -0,0 +1,136 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getColor } from "./ColorPicker";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
ColorPickerType,
|
||||
activeColorPickerSectionAtom,
|
||||
} from "./colorPickerUtils";
|
||||
import { eyeDropperIcon } from "../icons";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import { KEYS } from "../../keys";
|
||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||
import clsx from "clsx";
|
||||
import { t } from "../../i18n";
|
||||
import { useDevice } from "../App";
|
||||
import { getShortcutKey } from "../../utils";
|
||||
|
||||
interface ColorInputProps {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
colorPickerType: ColorPickerType;
|
||||
}
|
||||
|
||||
export const ColorInput = ({
|
||||
color,
|
||||
onChange,
|
||||
label,
|
||||
colorPickerType,
|
||||
}: ColorInputProps) => {
|
||||
const device = useDevice();
|
||||
const [innerValue, setInnerValue] = useState(color);
|
||||
const [activeSection, setActiveColorPickerSection] = useAtom(
|
||||
activeColorPickerSectionAtom,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setInnerValue(color);
|
||||
}, [color]);
|
||||
|
||||
const changeColor = useCallback(
|
||||
(inputValue: string) => {
|
||||
const value = inputValue.toLowerCase();
|
||||
const color = getColor(value);
|
||||
|
||||
if (color) {
|
||||
onChange(color);
|
||||
}
|
||||
setInnerValue(value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const eyeDropperTriggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [activeSection]);
|
||||
|
||||
const [eyeDropperState, setEyeDropperState] = useAtom(
|
||||
activeEyeDropperAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setEyeDropperState(null);
|
||||
};
|
||||
}, [setEyeDropperState]);
|
||||
|
||||
return (
|
||||
<div className="color-picker__input-label">
|
||||
<div className="color-picker__input-hash">#</div>
|
||||
<input
|
||||
ref={activeSection === "hex" ? inputRef : undefined}
|
||||
style={{ border: 0, padding: 0 }}
|
||||
spellCheck={false}
|
||||
className="color-picker-input"
|
||||
aria-label={label}
|
||||
onChange={(event) => {
|
||||
changeColor(event.target.value);
|
||||
}}
|
||||
value={(innerValue || "").replace(/^#/, "")}
|
||||
onBlur={() => {
|
||||
setInnerValue(color);
|
||||
}}
|
||||
tabIndex={-1}
|
||||
onFocus={() => setActiveColorPickerSection("hex")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KEYS.TAB) {
|
||||
return;
|
||||
} else if (event.key === KEYS.ESCAPE) {
|
||||
eyeDropperTriggerRef.current?.focus();
|
||||
}
|
||||
event.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
{/* TODO reenable on mobile with a better UX */}
|
||||
{!device.editor.isMobile && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
width: "1px",
|
||||
height: "1.25rem",
|
||||
backgroundColor: "var(--default-border-color)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={eyeDropperTriggerRef}
|
||||
className={clsx("excalidraw-eye-dropper-trigger", {
|
||||
selected: eyeDropperState,
|
||||
})}
|
||||
onClick={() =>
|
||||
setEyeDropperState((s) =>
|
||||
s
|
||||
? null
|
||||
: {
|
||||
keepOpenOnAlt: false,
|
||||
onSelect: (color) => onChange(color),
|
||||
colorPickerType,
|
||||
},
|
||||
)
|
||||
}
|
||||
title={`${t(
|
||||
"labels.eyeDropper",
|
||||
)} — ${KEYS.I.toLocaleUpperCase()} or ${getShortcutKey("Alt")} `}
|
||||
>
|
||||
{eyeDropperIcon}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
441
packages/excalidraw/components/ColorPicker/ColorPicker.scss
Normal file
441
packages/excalidraw/components/ColorPicker/ColorPicker.scss
Normal file
|
@ -0,0 +1,441 @@
|
|||
@import "../../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.focus-visible-none {
|
||||
&:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker__heading {
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.color-picker-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 20px 1.625rem;
|
||||
padding: 0.25rem 0px;
|
||||
align-items: center;
|
||||
|
||||
@include isMobile {
|
||||
max-width: 175px;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker__top-picks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.color-picker__button {
|
||||
--radius: 0.25rem;
|
||||
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
border: 1px solid var(--color-gray-30);
|
||||
border-radius: var(--radius);
|
||||
filter: var(--theme-filter);
|
||||
background-color: var(--swatch-color);
|
||||
background-position: left center;
|
||||
position: relative;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:hover {
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
box-shadow: 0 0 0 1px var(--color-gray-30);
|
||||
border-radius: calc(var(--radius) + 1px);
|
||||
filter: var(--theme-filter);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
.color-picker__button-outline {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
box-shadow: 0 0 0 1px var(--color-primary-darkest);
|
||||
z-index: 1; // due hover state so this has preference
|
||||
border-radius: calc(var(--radius) + 1px);
|
||||
filter: var(--theme-filter);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
left: -4px;
|
||||
border: 3px solid var(--focus-highlight-color);
|
||||
border-radius: calc(var(--radius) + 1px);
|
||||
}
|
||||
|
||||
&.active {
|
||||
.color-picker__button-outline {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--large {
|
||||
--radius: 0.5rem;
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
}
|
||||
|
||||
&.is-transparent {
|
||||
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==");
|
||||
}
|
||||
|
||||
&--no-focus-visible {
|
||||
border: 0;
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.active-color {
|
||||
border-radius: calc(var(--radius) + 1px);
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker__button__hotkey-label {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
filter: none;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
background: var(--popup-bg-color);
|
||||
border: 0 solid transparentize($oc-white, 0.75);
|
||||
box-shadow: transparentize($oc-black, 0.75) 0 1px 4px;
|
||||
border-radius: 4px;
|
||||
position: absolute;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
left: -5.5px;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
right: -5.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-control-container {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
column-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.color-picker-control-container + .popover {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.color-picker-popover-container {
|
||||
margin-top: -0.25rem;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
margin-left: -3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-triangle {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 0 9px 10px;
|
||||
border-color: transparent transparent var(--popup-bg-color);
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
transform: rotate(270deg);
|
||||
left: -14px;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
transform: rotate(90deg);
|
||||
right: -14px;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-triangle-shadow {
|
||||
border-color: transparent transparent transparentize($oc-black, 0.9);
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
left: -14px;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
right: -16px;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.color-picker-content--default {
|
||||
padding: 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1.875rem);
|
||||
grid-gap: 0.25rem;
|
||||
border-radius: 4px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-content--canvas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.25rem;
|
||||
|
||||
&-title {
|
||||
color: $oc-gray-6;
|
||||
font-size: 12px;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
&-colors {
|
||||
padding: 0.5rem 0;
|
||||
|
||||
.color-picker-swatch {
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-content .color-input-container {
|
||||
grid-column: 1 / span 5;
|
||||
}
|
||||
|
||||
.color-picker-swatch {
|
||||
position: relative;
|
||||
height: 1.875rem;
|
||||
width: 1.875rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ddd;
|
||||
background-color: currentColor !important;
|
||||
filter: var(--theme-filter);
|
||||
|
||||
&:focus {
|
||||
/* TODO: only show the border when the color is too light to see as a shadow */
|
||||
box-shadow: 0 0 4px 1px currentColor;
|
||||
border-color: var(--select-highlight-color);
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-transparent {
|
||||
border-radius: 4px;
|
||||
box-shadow: transparentize($oc-black, 0.9) 0 0 0 1px inset;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.color-picker-transparent,
|
||||
.color-picker-label-swatch {
|
||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
||||
left center;
|
||||
}
|
||||
|
||||
.color-picker-hash {
|
||||
height: var(--default-button-size);
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-right: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
|
||||
border-right: 1px solid var(--default-border-color);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
color: var(--input-label-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.color-input-container {
|
||||
display: flex;
|
||||
|
||||
&:focus-within {
|
||||
box-shadow: 0 0 0 1px var(--color-primary-darkest);
|
||||
border-radius: var(--border-radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker__input-label {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
margin: 8px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus-within {
|
||||
box-shadow: 0 0 0 1px var(--color-primary-darkest);
|
||||
border-radius: var(--border-radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker__input-hash {
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.color-picker-input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
background-color: transparent;
|
||||
color: var(--text-primary-color);
|
||||
border: 0;
|
||||
outline: none;
|
||||
height: var(--default-button-size);
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-left: 0;
|
||||
letter-spacing: 0.4px;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
|
||||
border-left: 1px solid var(--default-border-color);
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
padding: 0.5rem;
|
||||
padding-left: 0.25rem;
|
||||
appearance: none;
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-label-swatch-container {
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
width: var(--default-button-size);
|
||||
height: var(--default-button-size);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.color-picker-label-swatch {
|
||||
@include outlineButtonStyles;
|
||||
background-color: var(--swatch-color) !important;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
filter: var(--theme-filter);
|
||||
border: 0 !important;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--swatch-color);
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-keybinding {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
font-size: 0.7em;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-type-canvasBackground .color-picker-keybinding {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.color-picker-type-elementBackground .color-picker-keybinding {
|
||||
color: $oc-white;
|
||||
}
|
||||
|
||||
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.color-picker-type-elementStroke .color-picker-keybinding {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
.color-picker-type-elementBackground .color-picker-keybinding {
|
||||
color: $oc-black;
|
||||
}
|
||||
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
|
||||
color: $oc-black;
|
||||
}
|
||||
}
|
||||
}
|
304
packages/excalidraw/components/ColorPicker/ColorPicker.tsx
Normal file
304
packages/excalidraw/components/ColorPicker/ColorPicker.tsx
Normal file
|
@ -0,0 +1,304 @@
|
|||
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { 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 { useDevice, useExcalidrawContainer } from "../App";
|
||||
import { ColorTuple, COLOR_PALETTE, ColorPaletteCustom } from "../../colors";
|
||||
import PickerHeading from "./PickerHeading";
|
||||
import { t } from "../../i18n";
|
||||
import clsx from "clsx";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import { ColorInput } from "./ColorInput";
|
||||
import { useRef } from "react";
|
||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||
|
||||
import "./ColorPicker.scss";
|
||||
|
||||
const isValidColor = (color: string) => {
|
||||
const style = new Option().style;
|
||||
style.color = color;
|
||||
return !!style.color;
|
||||
};
|
||||
|
||||
export const getColor = (color: string): string | null => {
|
||||
if (isTransparent(color)) {
|
||||
return color;
|
||||
}
|
||||
|
||||
// testing for `#` first fixes a bug on Electron (more specfically, an
|
||||
// Obsidian popout window), where a hex color without `#` is (incorrectly)
|
||||
// considered valid
|
||||
return isValidColor(`#${color}`)
|
||||
? `#${color}`
|
||||
: isValidColor(color)
|
||||
? color
|
||||
: null;
|
||||
};
|
||||
|
||||
interface ColorPickerProps {
|
||||
type: ColorPickerType;
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
palette?: ColorPaletteCustom | null;
|
||||
topPicks?: ColorTuple;
|
||||
updateData: (formData?: any) => void;
|
||||
}
|
||||
|
||||
const ColorPickerPopupContent = ({
|
||||
type,
|
||||
color,
|
||||
onChange,
|
||||
label,
|
||||
elements,
|
||||
palette = COLOR_PALETTE,
|
||||
updateData,
|
||||
}: Pick<
|
||||
ColorPickerProps,
|
||||
| "type"
|
||||
| "color"
|
||||
| "onChange"
|
||||
| "label"
|
||||
| "elements"
|
||||
| "palette"
|
||||
| "updateData"
|
||||
>) => {
|
||||
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
||||
|
||||
const [eyeDropperState, setEyeDropperState] = useAtom(
|
||||
activeEyeDropperAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
|
||||
const { container } = useExcalidrawContainer();
|
||||
const device = useDevice();
|
||||
|
||||
const colorInputJSX = (
|
||||
<div>
|
||||
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
|
||||
<ColorInput
|
||||
color={color}
|
||||
label={label}
|
||||
onChange={(color) => {
|
||||
onChange(color);
|
||||
}}
|
||||
colorPickerType={type}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const focusPickerContent = () => {
|
||||
popoverRef.current
|
||||
?.querySelector<HTMLDivElement>(".color-picker-content")
|
||||
?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover.Portal container={container}>
|
||||
<Popover.Content
|
||||
ref={popoverRef}
|
||||
className="focus-visible-none"
|
||||
data-prevent-outside-click
|
||||
onFocusOutside={(event) => {
|
||||
focusPickerContent();
|
||||
event.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (eyeDropperState) {
|
||||
// prevent from closing if we click outside the popover
|
||||
// while eyedropping (e.g. click when clicking the sidebar;
|
||||
// the eye-dropper-backdrop is prevented downstream)
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.stopPropagation();
|
||||
// prevents focusing the trigger
|
||||
e.preventDefault();
|
||||
|
||||
// return focus to excalidraw container unless
|
||||
// user focuses an interactive element, such as a button, or
|
||||
// enters the text editor by clicking on canvas with the text tool
|
||||
if (container && !isInteractive(document.activeElement)) {
|
||||
container.focus();
|
||||
}
|
||||
|
||||
updateData({ openPopup: null });
|
||||
setActiveColorPickerSection(null);
|
||||
}}
|
||||
side={
|
||||
device.editor.isMobile && !device.viewport.isLandscape
|
||||
? "bottom"
|
||||
: "right"
|
||||
}
|
||||
align={
|
||||
device.editor.isMobile && !device.viewport.isLandscape
|
||||
? "center"
|
||||
: "start"
|
||||
}
|
||||
alignOffset={-16}
|
||||
sideOffset={20}
|
||||
style={{
|
||||
zIndex: "var(--zIndex-layerUI)",
|
||||
backgroundColor: "var(--popup-bg-color)",
|
||||
maxWidth: "208px",
|
||||
maxHeight: window.innerHeight,
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
boxSizing: "border-box",
|
||||
overflowY: "auto",
|
||||
boxShadow:
|
||||
"0px 7px 14px rgba(0, 0, 0, 0.05), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 0.931014px rgba(0, 0, 0, 0.1702)",
|
||||
}}
|
||||
>
|
||||
{palette ? (
|
||||
<Picker
|
||||
palette={palette}
|
||||
color={color}
|
||||
onChange={(changedColor) => {
|
||||
onChange(changedColor);
|
||||
}}
|
||||
onEyeDropperToggle={(force) => {
|
||||
setEyeDropperState((state) => {
|
||||
if (force) {
|
||||
state = state || {
|
||||
keepOpenOnAlt: true,
|
||||
onSelect: onChange,
|
||||
colorPickerType: type,
|
||||
};
|
||||
state.keepOpenOnAlt = true;
|
||||
return state;
|
||||
}
|
||||
|
||||
return force === false || state
|
||||
? null
|
||||
: {
|
||||
keepOpenOnAlt: false,
|
||||
onSelect: onChange,
|
||||
colorPickerType: type,
|
||||
};
|
||||
});
|
||||
}}
|
||||
onEscape={(event) => {
|
||||
if (eyeDropperState) {
|
||||
setEyeDropperState(null);
|
||||
} else if (isWritableElement(event.target)) {
|
||||
focusPickerContent();
|
||||
} else {
|
||||
updateData({ openPopup: null });
|
||||
}
|
||||
}}
|
||||
label={label}
|
||||
type={type}
|
||||
elements={elements}
|
||||
updateData={updateData}
|
||||
>
|
||||
{colorInputJSX}
|
||||
</Picker>
|
||||
) : (
|
||||
colorInputJSX
|
||||
)}
|
||||
<Popover.Arrow
|
||||
width={20}
|
||||
height={10}
|
||||
style={{
|
||||
fill: "var(--popup-bg-color)",
|
||||
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
|
||||
}}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
);
|
||||
};
|
||||
|
||||
const ColorPickerTrigger = ({
|
||||
label,
|
||||
color,
|
||||
type,
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
type: ColorPickerType;
|
||||
}) => {
|
||||
return (
|
||||
<Popover.Trigger
|
||||
type="button"
|
||||
className={clsx("color-picker__button active-color", {
|
||||
"is-transparent": color === "transparent" || !color,
|
||||
})}
|
||||
aria-label={label}
|
||||
style={color ? { "--swatch-color": color } : undefined}
|
||||
title={
|
||||
type === "elementStroke"
|
||||
? t("labels.showStroke")
|
||||
: t("labels.showBackground")
|
||||
}
|
||||
>
|
||||
<div className="color-picker__button-outline" />
|
||||
</Popover.Trigger>
|
||||
);
|
||||
};
|
||||
|
||||
export const ColorPicker = ({
|
||||
type,
|
||||
color,
|
||||
onChange,
|
||||
label,
|
||||
elements,
|
||||
palette = COLOR_PALETTE,
|
||||
topPicks,
|
||||
updateData,
|
||||
appState,
|
||||
}: ColorPickerProps) => {
|
||||
return (
|
||||
<div>
|
||||
<div role="dialog" aria-modal="true" className="color-picker-container">
|
||||
<TopPicks
|
||||
activeColor={color}
|
||||
onChange={onChange}
|
||||
type={type}
|
||||
topPicks={topPicks}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: 1,
|
||||
height: "100%",
|
||||
backgroundColor: "var(--default-border-color)",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
/>
|
||||
<Popover.Root
|
||||
open={appState.openPopup === type}
|
||||
onOpenChange={(open) => {
|
||||
updateData({ openPopup: open ? type : null });
|
||||
}}
|
||||
>
|
||||
{/* serves as an active color indicator as well */}
|
||||
<ColorPickerTrigger color={color} label={label} type={type} />
|
||||
{/* popup content */}
|
||||
{appState.openPopup === type && (
|
||||
<ColorPickerPopupContent
|
||||
type={type}
|
||||
color={color}
|
||||
onChange={onChange}
|
||||
label={label}
|
||||
elements={elements}
|
||||
palette={palette}
|
||||
updateData={updateData}
|
||||
/>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,63 @@
|
|||
import clsx from "clsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||
import HotkeyLabel from "./HotkeyLabel";
|
||||
|
||||
interface CustomColorListProps {
|
||||
colors: string[];
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const CustomColorList = ({
|
||||
colors,
|
||||
color,
|
||||
onChange,
|
||||
label,
|
||||
}: CustomColorListProps) => {
|
||||
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
|
||||
activeColorPickerSectionAtom,
|
||||
);
|
||||
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (btnRef.current) {
|
||||
btnRef.current.focus();
|
||||
}
|
||||
}, [color, activeColorPickerSection]);
|
||||
|
||||
return (
|
||||
<div className="color-picker-content--default">
|
||||
{colors.map((c, i) => {
|
||||
return (
|
||||
<button
|
||||
ref={color === c ? btnRef : undefined}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
className={clsx(
|
||||
"color-picker__button color-picker__button--large",
|
||||
{
|
||||
active: color === c,
|
||||
"is-transparent": c === "transparent" || !c,
|
||||
},
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(c);
|
||||
setActiveColorPickerSection("custom");
|
||||
}}
|
||||
title={c}
|
||||
aria-label={label}
|
||||
style={{ "--swatch-color": c }}
|
||||
key={i}
|
||||
>
|
||||
<div className="color-picker__button-outline" />
|
||||
<HotkeyLabel color={c} keyLabel={i + 1} isCustomColor />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
29
packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx
Normal file
29
packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import React from "react";
|
||||
import { getContrastYIQ } from "./colorPickerUtils";
|
||||
|
||||
interface HotkeyLabelProps {
|
||||
color: string;
|
||||
keyLabel: string | number;
|
||||
isCustomColor?: boolean;
|
||||
isShade?: boolean;
|
||||
}
|
||||
const HotkeyLabel = ({
|
||||
color,
|
||||
keyLabel,
|
||||
isCustomColor = false,
|
||||
isShade = false,
|
||||
}: HotkeyLabelProps) => {
|
||||
return (
|
||||
<div
|
||||
className="color-picker__button__hotkey-label"
|
||||
style={{
|
||||
color: getContrastYIQ(color, isCustomColor),
|
||||
}}
|
||||
>
|
||||
{isShade && "⇧"}
|
||||
{keyLabel}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HotkeyLabel;
|
178
packages/excalidraw/components/ColorPicker/Picker.tsx
Normal file
178
packages/excalidraw/components/ColorPicker/Picker.tsx
Normal file
|
@ -0,0 +1,178 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { ShadeList } from "./ShadeList";
|
||||
|
||||
import PickerColorList from "./PickerColorList";
|
||||
import { useAtom } from "jotai";
|
||||
import { CustomColorList } from "./CustomColorList";
|
||||
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
|
||||
import PickerHeading from "./PickerHeading";
|
||||
import {
|
||||
ColorPickerType,
|
||||
activeColorPickerSectionAtom,
|
||||
getColorNameAndShadeFromColor,
|
||||
getMostUsedCustomColors,
|
||||
isCustomColor,
|
||||
} from "./colorPickerUtils";
|
||||
import {
|
||||
ColorPaletteCustom,
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
||||
} from "../../colors";
|
||||
import { KEYS } from "../../keys";
|
||||
import { EVENT } from "../../constants";
|
||||
|
||||
interface PickerProps {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
type: ColorPickerType;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
palette: ColorPaletteCustom;
|
||||
updateData: (formData?: any) => void;
|
||||
children?: React.ReactNode;
|
||||
onEyeDropperToggle: (force?: boolean) => void;
|
||||
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export const Picker = ({
|
||||
color,
|
||||
onChange,
|
||||
label,
|
||||
type,
|
||||
elements,
|
||||
palette,
|
||||
updateData,
|
||||
children,
|
||||
onEyeDropperToggle,
|
||||
onEscape,
|
||||
}: PickerProps) => {
|
||||
const [customColors] = React.useState(() => {
|
||||
if (type === "canvasBackground") {
|
||||
return [];
|
||||
}
|
||||
return getMostUsedCustomColors(elements, type, palette);
|
||||
});
|
||||
|
||||
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
|
||||
activeColorPickerSectionAtom,
|
||||
);
|
||||
|
||||
const colorObj = getColorNameAndShadeFromColor({
|
||||
color,
|
||||
palette,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeColorPickerSection) {
|
||||
const isCustom = isCustomColor({ color, palette });
|
||||
const isCustomButNotInList = isCustom && !customColors.includes(color);
|
||||
|
||||
setActiveColorPickerSection(
|
||||
isCustomButNotInList
|
||||
? "hex"
|
||||
: isCustom
|
||||
? "custom"
|
||||
: colorObj?.shade != null
|
||||
? "shades"
|
||||
: "baseColors",
|
||||
);
|
||||
}
|
||||
}, [
|
||||
activeColorPickerSection,
|
||||
color,
|
||||
palette,
|
||||
setActiveColorPickerSection,
|
||||
colorObj,
|
||||
customColors,
|
||||
]);
|
||||
|
||||
const [activeShade, setActiveShade] = useState(
|
||||
colorObj?.shade ??
|
||||
(type === "elementBackground"
|
||||
? DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX
|
||||
: DEFAULT_ELEMENT_STROKE_COLOR_INDEX),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (colorObj?.shade != null) {
|
||||
setActiveShade(colorObj.shade);
|
||||
}
|
||||
|
||||
const keyup = (event: KeyboardEvent) => {
|
||||
if (event.key === KEYS.ALT) {
|
||||
onEyeDropperToggle(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener(EVENT.KEYUP, keyup, { capture: true });
|
||||
return () => {
|
||||
document.removeEventListener(EVENT.KEYUP, keyup, { capture: true });
|
||||
};
|
||||
}, [colorObj, onEyeDropperToggle]);
|
||||
|
||||
const pickerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-modal="true" aria-label={t("labels.colorPicker")}>
|
||||
<div
|
||||
ref={pickerRef}
|
||||
onKeyDown={(event) => {
|
||||
const handled = colorPickerKeyNavHandler({
|
||||
event,
|
||||
activeColorPickerSection,
|
||||
palette,
|
||||
color,
|
||||
onChange,
|
||||
onEyeDropperToggle,
|
||||
customColors,
|
||||
setActiveColorPickerSection,
|
||||
updateData,
|
||||
activeShade,
|
||||
onEscape,
|
||||
});
|
||||
|
||||
if (handled) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className="color-picker-content"
|
||||
// to allow focusing by clicking but not by tabbing
|
||||
tabIndex={-1}
|
||||
>
|
||||
{!!customColors.length && (
|
||||
<div>
|
||||
<PickerHeading>
|
||||
{t("colorPicker.mostUsedCustomColors")}
|
||||
</PickerHeading>
|
||||
<CustomColorList
|
||||
colors={customColors}
|
||||
color={color}
|
||||
label={t("colorPicker.mostUsedCustomColors")}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<PickerHeading>{t("colorPicker.colors")}</PickerHeading>
|
||||
<PickerColorList
|
||||
color={color}
|
||||
label={label}
|
||||
palette={palette}
|
||||
onChange={onChange}
|
||||
activeShade={activeShade}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<PickerHeading>{t("colorPicker.shades")}</PickerHeading>
|
||||
<ShadeList hex={color} onChange={onChange} palette={palette} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,90 @@
|
|||
import clsx from "clsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
activeColorPickerSectionAtom,
|
||||
colorPickerHotkeyBindings,
|
||||
getColorNameAndShadeFromColor,
|
||||
} from "./colorPickerUtils";
|
||||
import HotkeyLabel from "./HotkeyLabel";
|
||||
import { ColorPaletteCustom } from "../../colors";
|
||||
import { TranslationKeys, t } from "../../i18n";
|
||||
|
||||
interface PickerColorListProps {
|
||||
palette: ColorPaletteCustom;
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
activeShade: number;
|
||||
}
|
||||
|
||||
const PickerColorList = ({
|
||||
palette,
|
||||
color,
|
||||
onChange,
|
||||
label,
|
||||
activeShade,
|
||||
}: PickerColorListProps) => {
|
||||
const colorObj = getColorNameAndShadeFromColor({
|
||||
color: color || "transparent",
|
||||
palette,
|
||||
});
|
||||
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
|
||||
activeColorPickerSectionAtom,
|
||||
);
|
||||
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (btnRef.current && activeColorPickerSection === "baseColors") {
|
||||
btnRef.current.focus();
|
||||
}
|
||||
}, [colorObj?.colorName, activeColorPickerSection]);
|
||||
|
||||
return (
|
||||
<div className="color-picker-content--default">
|
||||
{Object.entries(palette).map(([key, value], index) => {
|
||||
const color =
|
||||
(Array.isArray(value) ? value[activeShade] : value) || "transparent";
|
||||
|
||||
const keybinding = colorPickerHotkeyBindings[index];
|
||||
const label = t(
|
||||
`colors.${key.replace(/\d+/, "")}` as unknown as TranslationKeys,
|
||||
null,
|
||||
"",
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={colorObj?.colorName === key ? btnRef : undefined}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
className={clsx(
|
||||
"color-picker__button color-picker__button--large",
|
||||
{
|
||||
active: colorObj?.colorName === key,
|
||||
"is-transparent": color === "transparent" || !color,
|
||||
},
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(color);
|
||||
setActiveColorPickerSection("baseColors");
|
||||
}}
|
||||
title={`${label}${
|
||||
color.startsWith("#") ? ` ${color}` : ""
|
||||
} — ${keybinding}`}
|
||||
aria-label={`${label} — ${keybinding}`}
|
||||
style={color ? { "--swatch-color": color } : undefined}
|
||||
data-testid={`color-${key}`}
|
||||
key={key}
|
||||
>
|
||||
<div className="color-picker__button-outline" />
|
||||
<HotkeyLabel color={color} keyLabel={keybinding} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PickerColorList;
|
|
@ -0,0 +1,7 @@
|
|||
import { ReactNode } from "react";
|
||||
|
||||
const PickerHeading = ({ children }: { children: ReactNode }) => (
|
||||
<div className="color-picker__heading">{children}</div>
|
||||
);
|
||||
|
||||
export default PickerHeading;
|
105
packages/excalidraw/components/ColorPicker/ShadeList.tsx
Normal file
105
packages/excalidraw/components/ColorPicker/ShadeList.tsx
Normal file
|
@ -0,0 +1,105 @@
|
|||
import clsx from "clsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
activeColorPickerSectionAtom,
|
||||
getColorNameAndShadeFromColor,
|
||||
} from "./colorPickerUtils";
|
||||
import HotkeyLabel from "./HotkeyLabel";
|
||||
import { t } from "../../i18n";
|
||||
import { ColorPaletteCustom } from "../../colors";
|
||||
|
||||
interface ShadeListProps {
|
||||
hex: string;
|
||||
onChange: (color: string) => void;
|
||||
palette: ColorPaletteCustom;
|
||||
}
|
||||
|
||||
export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
|
||||
const colorObj = getColorNameAndShadeFromColor({
|
||||
color: hex || "transparent",
|
||||
palette,
|
||||
});
|
||||
|
||||
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
|
||||
activeColorPickerSectionAtom,
|
||||
);
|
||||
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (btnRef.current && activeColorPickerSection === "shades") {
|
||||
btnRef.current.focus();
|
||||
}
|
||||
}, [colorObj, activeColorPickerSection]);
|
||||
|
||||
if (colorObj) {
|
||||
const { colorName, shade } = colorObj;
|
||||
|
||||
const shades = palette[colorName];
|
||||
|
||||
if (Array.isArray(shades)) {
|
||||
return (
|
||||
<div className="color-picker-content--default shades">
|
||||
{shades.map((color, i) => (
|
||||
<button
|
||||
ref={
|
||||
i === shade && activeColorPickerSection === "shades"
|
||||
? btnRef
|
||||
: undefined
|
||||
}
|
||||
tabIndex={-1}
|
||||
key={i}
|
||||
type="button"
|
||||
className={clsx(
|
||||
"color-picker__button color-picker__button--large",
|
||||
{ active: i === shade },
|
||||
)}
|
||||
aria-label="Shade"
|
||||
title={`${colorName} - ${i + 1}`}
|
||||
style={color ? { "--swatch-color": color } : undefined}
|
||||
onClick={() => {
|
||||
onChange(color);
|
||||
setActiveColorPickerSection("shades");
|
||||
}}
|
||||
>
|
||||
<div className="color-picker__button-outline" />
|
||||
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="color-picker-content--default"
|
||||
style={{ position: "relative" }}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="color-picker__button color-picker__button--large color-picker__button--no-focus-visible"
|
||||
/>
|
||||
<div
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{t("colorPicker.noShades")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
65
packages/excalidraw/components/ColorPicker/TopPicks.tsx
Normal file
65
packages/excalidraw/components/ColorPicker/TopPicks.tsx
Normal file
|
@ -0,0 +1,65 @@
|
|||
import clsx from "clsx";
|
||||
import { ColorPickerType } from "./colorPickerUtils";
|
||||
import {
|
||||
DEFAULT_CANVAS_BACKGROUND_PICKS,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
DEFAULT_ELEMENT_STROKE_PICKS,
|
||||
} from "../../colors";
|
||||
|
||||
interface TopPicksProps {
|
||||
onChange: (color: string) => void;
|
||||
type: ColorPickerType;
|
||||
activeColor: string;
|
||||
topPicks?: readonly string[];
|
||||
}
|
||||
|
||||
export const TopPicks = ({
|
||||
onChange,
|
||||
type,
|
||||
activeColor,
|
||||
topPicks,
|
||||
}: TopPicksProps) => {
|
||||
let colors;
|
||||
if (type === "elementStroke") {
|
||||
colors = DEFAULT_ELEMENT_STROKE_PICKS;
|
||||
}
|
||||
|
||||
if (type === "elementBackground") {
|
||||
colors = DEFAULT_ELEMENT_BACKGROUND_PICKS;
|
||||
}
|
||||
|
||||
if (type === "canvasBackground") {
|
||||
colors = DEFAULT_CANVAS_BACKGROUND_PICKS;
|
||||
}
|
||||
|
||||
// this one can overwrite defaults
|
||||
if (topPicks) {
|
||||
colors = topPicks;
|
||||
}
|
||||
|
||||
if (!colors) {
|
||||
console.error("Invalid type for TopPicks");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="color-picker__top-picks">
|
||||
{colors.map((color: string) => (
|
||||
<button
|
||||
className={clsx("color-picker__button", {
|
||||
active: color === activeColor,
|
||||
"is-transparent": color === "transparent" || !color,
|
||||
})}
|
||||
style={{ "--swatch-color": color }}
|
||||
key={color}
|
||||
type="button"
|
||||
title={color}
|
||||
onClick={() => onChange(color)}
|
||||
data-testid={`color-top-pick-${color}`}
|
||||
>
|
||||
<div className="color-picker__button-outline" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
136
packages/excalidraw/components/ColorPicker/colorPickerUtils.ts
Normal file
136
packages/excalidraw/components/ColorPicker/colorPickerUtils.ts
Normal file
|
@ -0,0 +1,136 @@
|
|||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { atom } from "jotai";
|
||||
import {
|
||||
ColorPickerColor,
|
||||
ColorPaletteCustom,
|
||||
MAX_CUSTOM_COLORS_USED_IN_CANVAS,
|
||||
} from "../../colors";
|
||||
|
||||
export const getColorNameAndShadeFromColor = ({
|
||||
palette,
|
||||
color,
|
||||
}: {
|
||||
palette: ColorPaletteCustom;
|
||||
color: string;
|
||||
}): {
|
||||
colorName: ColorPickerColor;
|
||||
shade: number | null;
|
||||
} | null => {
|
||||
for (const [colorName, colorVal] of Object.entries(palette)) {
|
||||
if (Array.isArray(colorVal)) {
|
||||
const shade = colorVal.indexOf(color);
|
||||
if (shade > -1) {
|
||||
return { colorName: colorName as ColorPickerColor, shade };
|
||||
}
|
||||
} else if (colorVal === color) {
|
||||
return { colorName: colorName as ColorPickerColor, shade: null };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const colorPickerHotkeyBindings = [
|
||||
["q", "w", "e", "r", "t"],
|
||||
["a", "s", "d", "f", "g"],
|
||||
["z", "x", "c", "v", "b"],
|
||||
].flat();
|
||||
|
||||
export const isCustomColor = ({
|
||||
color,
|
||||
palette,
|
||||
}: {
|
||||
color: string;
|
||||
palette: ColorPaletteCustom;
|
||||
}) => {
|
||||
const paletteValues = Object.values(palette).flat();
|
||||
return !paletteValues.includes(color);
|
||||
};
|
||||
|
||||
export const getMostUsedCustomColors = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
type: "elementBackground" | "elementStroke",
|
||||
palette: ColorPaletteCustom,
|
||||
) => {
|
||||
const elementColorTypeMap = {
|
||||
elementBackground: "backgroundColor",
|
||||
elementStroke: "strokeColor",
|
||||
};
|
||||
|
||||
const colors = elements.filter((element) => {
|
||||
if (element.isDeleted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const color =
|
||||
element[elementColorTypeMap[type] as "backgroundColor" | "strokeColor"];
|
||||
|
||||
return isCustomColor({ color, palette });
|
||||
});
|
||||
|
||||
const colorCountMap = new Map<string, number>();
|
||||
colors.forEach((element) => {
|
||||
const color =
|
||||
element[elementColorTypeMap[type] as "backgroundColor" | "strokeColor"];
|
||||
if (colorCountMap.has(color)) {
|
||||
colorCountMap.set(color, colorCountMap.get(color)! + 1);
|
||||
} else {
|
||||
colorCountMap.set(color, 1);
|
||||
}
|
||||
});
|
||||
|
||||
return [...colorCountMap.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map((c) => c[0])
|
||||
.slice(0, MAX_CUSTOM_COLORS_USED_IN_CANVAS);
|
||||
};
|
||||
|
||||
export type ActiveColorPickerSectionAtomType =
|
||||
| "custom"
|
||||
| "baseColors"
|
||||
| "shades"
|
||||
| "hex"
|
||||
| null;
|
||||
export const activeColorPickerSectionAtom =
|
||||
atom<ActiveColorPickerSectionAtomType>(null);
|
||||
|
||||
const calculateContrast = (r: number, g: number, b: number) => {
|
||||
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
return yiq >= 160 ? "black" : "white";
|
||||
};
|
||||
|
||||
// inspiration from https://stackoverflow.com/a/11868398
|
||||
export const getContrastYIQ = (bgHex: string, isCustomColor: boolean) => {
|
||||
if (isCustomColor) {
|
||||
const style = new Option().style;
|
||||
style.color = bgHex;
|
||||
|
||||
if (style.color) {
|
||||
const rgb = style.color
|
||||
.replace(/^(rgb|rgba)\(/, "")
|
||||
.replace(/\)$/, "")
|
||||
.replace(/\s/g, "")
|
||||
.split(",");
|
||||
const r = parseInt(rgb[0]);
|
||||
const g = parseInt(rgb[1]);
|
||||
const b = parseInt(rgb[2]);
|
||||
|
||||
return calculateContrast(r, g, b);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: ? is this wanted?
|
||||
if (bgHex === "transparent") {
|
||||
return "black";
|
||||
}
|
||||
|
||||
const r = parseInt(bgHex.substring(1, 3), 16);
|
||||
const g = parseInt(bgHex.substring(3, 5), 16);
|
||||
const b = parseInt(bgHex.substring(5, 7), 16);
|
||||
|
||||
return calculateContrast(r, g, b);
|
||||
};
|
||||
|
||||
export type ColorPickerType =
|
||||
| "canvasBackground"
|
||||
| "elementBackground"
|
||||
| "elementStroke";
|
|
@ -0,0 +1,287 @@
|
|||
import { KEYS } from "../../keys";
|
||||
import {
|
||||
ColorPickerColor,
|
||||
ColorPalette,
|
||||
ColorPaletteCustom,
|
||||
COLORS_PER_ROW,
|
||||
COLOR_PALETTE,
|
||||
} from "../../colors";
|
||||
import { ValueOf } from "../../utility-types";
|
||||
import {
|
||||
ActiveColorPickerSectionAtomType,
|
||||
colorPickerHotkeyBindings,
|
||||
getColorNameAndShadeFromColor,
|
||||
} from "./colorPickerUtils";
|
||||
|
||||
const arrowHandler = (
|
||||
eventKey: string,
|
||||
currentIndex: number | null,
|
||||
length: number,
|
||||
) => {
|
||||
const rows = Math.ceil(length / COLORS_PER_ROW);
|
||||
|
||||
currentIndex = currentIndex ?? -1;
|
||||
|
||||
switch (eventKey) {
|
||||
case "ArrowLeft": {
|
||||
const prevIndex = currentIndex - 1;
|
||||
return prevIndex < 0 ? length - 1 : prevIndex;
|
||||
}
|
||||
case "ArrowRight": {
|
||||
return (currentIndex + 1) % length;
|
||||
}
|
||||
case "ArrowDown": {
|
||||
const nextIndex = currentIndex + COLORS_PER_ROW;
|
||||
return nextIndex >= length ? currentIndex % COLORS_PER_ROW : nextIndex;
|
||||
}
|
||||
case "ArrowUp": {
|
||||
const prevIndex = currentIndex - COLORS_PER_ROW;
|
||||
const newIndex =
|
||||
prevIndex < 0 ? COLORS_PER_ROW * rows + prevIndex : prevIndex;
|
||||
return newIndex >= length ? undefined : newIndex;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interface HotkeyHandlerProps {
|
||||
e: React.KeyboardEvent;
|
||||
colorObj: { colorName: ColorPickerColor; shade: number | null } | null;
|
||||
onChange: (color: string) => void;
|
||||
palette: ColorPaletteCustom;
|
||||
customColors: string[];
|
||||
setActiveColorPickerSection: (
|
||||
update: React.SetStateAction<ActiveColorPickerSectionAtomType>,
|
||||
) => void;
|
||||
activeShade: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if the event was handled
|
||||
*/
|
||||
const hotkeyHandler = ({
|
||||
e,
|
||||
colorObj,
|
||||
onChange,
|
||||
palette,
|
||||
customColors,
|
||||
setActiveColorPickerSection,
|
||||
activeShade,
|
||||
}: HotkeyHandlerProps): boolean => {
|
||||
if (colorObj?.shade != null) {
|
||||
// shift + numpad is extremely messed up on windows apparently
|
||||
if (
|
||||
["Digit1", "Digit2", "Digit3", "Digit4", "Digit5"].includes(e.code) &&
|
||||
e.shiftKey
|
||||
) {
|
||||
const newShade = Number(e.code.slice(-1)) - 1;
|
||||
onChange(palette[colorObj.colorName][newShade]);
|
||||
setActiveColorPickerSection("shades");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (["1", "2", "3", "4", "5"].includes(e.key)) {
|
||||
const c = customColors[Number(e.key) - 1];
|
||||
if (c) {
|
||||
onChange(customColors[Number(e.key) - 1]);
|
||||
setActiveColorPickerSection("custom");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (colorPickerHotkeyBindings.includes(e.key)) {
|
||||
const index = colorPickerHotkeyBindings.indexOf(e.key);
|
||||
const paletteKey = Object.keys(palette)[index] as keyof ColorPalette;
|
||||
const paletteValue = palette[paletteKey];
|
||||
const r = Array.isArray(paletteValue)
|
||||
? paletteValue[activeShade]
|
||||
: paletteValue;
|
||||
onChange(r);
|
||||
setActiveColorPickerSection("baseColors");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
interface ColorPickerKeyNavHandlerProps {
|
||||
event: React.KeyboardEvent;
|
||||
activeColorPickerSection: ActiveColorPickerSectionAtomType;
|
||||
palette: ColorPaletteCustom;
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
customColors: string[];
|
||||
setActiveColorPickerSection: (
|
||||
update: React.SetStateAction<ActiveColorPickerSectionAtomType>,
|
||||
) => void;
|
||||
updateData: (formData?: any) => void;
|
||||
activeShade: number;
|
||||
onEyeDropperToggle: (force?: boolean) => void;
|
||||
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if the event was handled
|
||||
*/
|
||||
export const colorPickerKeyNavHandler = ({
|
||||
event,
|
||||
activeColorPickerSection,
|
||||
palette,
|
||||
color,
|
||||
onChange,
|
||||
customColors,
|
||||
setActiveColorPickerSection,
|
||||
updateData,
|
||||
activeShade,
|
||||
onEyeDropperToggle,
|
||||
onEscape,
|
||||
}: ColorPickerKeyNavHandlerProps): boolean => {
|
||||
if (event[KEYS.CTRL_OR_CMD]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.key === KEYS.ESCAPE) {
|
||||
onEscape(event);
|
||||
return true;
|
||||
}
|
||||
|
||||
// checkt using `key` to ignore combos with Alt modifier
|
||||
if (event.key === KEYS.ALT) {
|
||||
onEyeDropperToggle(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KEYS.I) {
|
||||
onEyeDropperToggle();
|
||||
return true;
|
||||
}
|
||||
|
||||
const colorObj = getColorNameAndShadeFromColor({ color, palette });
|
||||
|
||||
if (event.key === KEYS.TAB) {
|
||||
const sectionsMap: Record<
|
||||
NonNullable<ActiveColorPickerSectionAtomType>,
|
||||
boolean
|
||||
> = {
|
||||
custom: !!customColors.length,
|
||||
baseColors: true,
|
||||
shades: colorObj?.shade != null,
|
||||
hex: true,
|
||||
};
|
||||
|
||||
const sections = Object.entries(sectionsMap).reduce((acc, [key, value]) => {
|
||||
if (value) {
|
||||
acc.push(key as ActiveColorPickerSectionAtomType);
|
||||
}
|
||||
return acc;
|
||||
}, [] as ActiveColorPickerSectionAtomType[]);
|
||||
|
||||
const activeSectionIndex = sections.indexOf(activeColorPickerSection);
|
||||
const indexOffset = event.shiftKey ? -1 : 1;
|
||||
const nextSectionIndex =
|
||||
activeSectionIndex + indexOffset > sections.length - 1
|
||||
? 0
|
||||
: activeSectionIndex + indexOffset < 0
|
||||
? sections.length - 1
|
||||
: activeSectionIndex + indexOffset;
|
||||
|
||||
const nextSection = sections[nextSectionIndex];
|
||||
|
||||
if (nextSection) {
|
||||
setActiveColorPickerSection(nextSection);
|
||||
}
|
||||
|
||||
if (nextSection === "custom") {
|
||||
onChange(customColors[0]);
|
||||
} else if (nextSection === "baseColors") {
|
||||
const baseColorName = (
|
||||
Object.entries(palette) as [string, ValueOf<ColorPalette>][]
|
||||
).find(([name, shades]) => {
|
||||
if (Array.isArray(shades)) {
|
||||
return shades.includes(color);
|
||||
} else if (shades === color) {
|
||||
return name;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!baseColorName) {
|
||||
onChange(COLOR_PALETTE.black);
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
hotkeyHandler({
|
||||
e: event,
|
||||
colorObj,
|
||||
onChange,
|
||||
palette,
|
||||
customColors,
|
||||
setActiveColorPickerSection,
|
||||
activeShade,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (activeColorPickerSection === "shades") {
|
||||
if (colorObj) {
|
||||
const { shade } = colorObj;
|
||||
const newShade = arrowHandler(event.key, shade, COLORS_PER_ROW);
|
||||
|
||||
if (newShade !== undefined) {
|
||||
onChange(palette[colorObj.colorName][newShade]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeColorPickerSection === "baseColors") {
|
||||
if (colorObj) {
|
||||
const { colorName } = colorObj;
|
||||
const colorNames = Object.keys(palette) as (keyof ColorPalette)[];
|
||||
const indexOfColorName = colorNames.indexOf(colorName);
|
||||
|
||||
const newColorIndex = arrowHandler(
|
||||
event.key,
|
||||
indexOfColorName,
|
||||
colorNames.length,
|
||||
);
|
||||
|
||||
if (newColorIndex !== undefined) {
|
||||
const newColorName = colorNames[newColorIndex];
|
||||
const newColorNameValue = palette[newColorName];
|
||||
|
||||
onChange(
|
||||
Array.isArray(newColorNameValue)
|
||||
? newColorNameValue[activeShade]
|
||||
: newColorNameValue,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeColorPickerSection === "custom") {
|
||||
const indexOfColor = customColors.indexOf(color);
|
||||
|
||||
const newColorIndex = arrowHandler(
|
||||
event.key,
|
||||
indexOfColor,
|
||||
customColors.length,
|
||||
);
|
||||
|
||||
if (newColorIndex !== undefined) {
|
||||
const newColor = customColors[newColorIndex];
|
||||
onChange(newColor);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
11
packages/excalidraw/components/ConfirmDialog.scss
Normal file
11
packages/excalidraw/components/ConfirmDialog.scss
Normal file
|
@ -0,0 +1,11 @@
|
|||
@import "../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.confirm-dialog {
|
||||
&-buttons {
|
||||
display: flex;
|
||||
column-gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
63
packages/excalidraw/components/ConfirmDialog.tsx
Normal file
63
packages/excalidraw/components/ConfirmDialog.tsx
Normal file
|
@ -0,0 +1,63 @@
|
|||
import { t } from "../i18n";
|
||||
import { Dialog, DialogProps } from "./Dialog";
|
||||
|
||||
import "./ConfirmDialog.scss";
|
||||
import DialogActionButton from "./DialogActionButton";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||
import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App";
|
||||
import { jotaiScope } from "../jotai";
|
||||
|
||||
interface Props extends Omit<DialogProps, "onCloseRequest"> {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
}
|
||||
const ConfirmDialog = (props: Props) => {
|
||||
const {
|
||||
onConfirm,
|
||||
onCancel,
|
||||
children,
|
||||
confirmText = t("buttons.confirm"),
|
||||
cancelText = t("buttons.cancel"),
|
||||
className = "",
|
||||
...rest
|
||||
} = props;
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
|
||||
const { container } = useExcalidrawContainer();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onCloseRequest={onCancel}
|
||||
size="small"
|
||||
{...rest}
|
||||
className={`confirm-dialog ${className}`}
|
||||
>
|
||||
{children}
|
||||
<div className="confirm-dialog-buttons">
|
||||
<DialogActionButton
|
||||
label={cancelText}
|
||||
onClick={() => {
|
||||
setAppState({ openMenu: null });
|
||||
setIsLibraryMenuOpen(false);
|
||||
onCancel();
|
||||
container?.focus();
|
||||
}}
|
||||
/>
|
||||
<DialogActionButton
|
||||
label={confirmText}
|
||||
onClick={() => {
|
||||
setAppState({ openMenu: null });
|
||||
setIsLibraryMenuOpen(false);
|
||||
onConfirm();
|
||||
container?.focus();
|
||||
}}
|
||||
actionType="danger"
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
export default ConfirmDialog;
|
98
packages/excalidraw/components/ContextMenu.scss
Normal file
98
packages/excalidraw/components/ContextMenu.scss
Normal file
|
@ -0,0 +1,98 @@
|
|||
@import "../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.context-menu {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 3px 10px transparentize($oc-black, 0.8);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
margin: -0.25rem 0 0 0.125rem;
|
||||
padding: 0.5rem 0;
|
||||
background-color: var(--popup-secondary-bg-color);
|
||||
border: 1px solid var(--button-gray-3);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.context-menu button {
|
||||
color: var(--popup-text-color);
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 9.5rem;
|
||||
margin: 0;
|
||||
padding: 0.25rem 1rem 0.25rem 1.25rem;
|
||||
text-align: start;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 0.2fr;
|
||||
align-items: center;
|
||||
|
||||
&.checkmark::before {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
margin-bottom: 1px;
|
||||
content: "\2713";
|
||||
}
|
||||
|
||||
&.dangerous {
|
||||
.context-menu-item__label {
|
||||
color: $oc-red-7;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-item__label {
|
||||
justify-self: start;
|
||||
margin-inline-end: 20px;
|
||||
}
|
||||
.context-menu-item__shortcut {
|
||||
justify-self: end;
|
||||
opacity: 0.6;
|
||||
font-family: inherit;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
color: var(--popup-bg-color);
|
||||
background-color: var(--select-highlight-color);
|
||||
|
||||
&.dangerous {
|
||||
.context-menu-item__label {
|
||||
color: var(--popup-bg-color);
|
||||
}
|
||||
background-color: $oc-red-6;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-item:focus {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
.context-menu-item {
|
||||
display: block;
|
||||
|
||||
.context-menu-item__label {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.context-menu-item__shortcut {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-item-separator {
|
||||
border: none;
|
||||
border-top: 1px solid $oc-gray-5;
|
||||
}
|
||||
}
|
128
packages/excalidraw/components/ContextMenu.tsx
Normal file
128
packages/excalidraw/components/ContextMenu.tsx
Normal file
|
@ -0,0 +1,128 @@
|
|||
import clsx from "clsx";
|
||||
import { Popover } from "./Popover";
|
||||
import { t, TranslationKeys } from "../i18n";
|
||||
|
||||
import "./ContextMenu.scss";
|
||||
import {
|
||||
getShortcutFromShortcutName,
|
||||
ShortcutName,
|
||||
} from "../actions/shortcuts";
|
||||
import { Action } from "../actions/types";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { useExcalidrawAppState, useExcalidrawElements } from "./App";
|
||||
import React from "react";
|
||||
|
||||
export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action;
|
||||
|
||||
export type ContextMenuItems = (ContextMenuItem | false | null | undefined)[];
|
||||
|
||||
type ContextMenuProps = {
|
||||
actionManager: ActionManager;
|
||||
items: ContextMenuItems;
|
||||
top: number;
|
||||
left: number;
|
||||
onClose: (callback?: () => void) => void;
|
||||
};
|
||||
|
||||
export const CONTEXT_MENU_SEPARATOR = "separator";
|
||||
|
||||
export const ContextMenu = React.memo(
|
||||
({ actionManager, items, top, left, onClose }: ContextMenuProps) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
const elements = useExcalidrawElements();
|
||||
|
||||
const filteredItems = items.reduce((acc: ContextMenuItem[], item) => {
|
||||
if (
|
||||
item &&
|
||||
(item === CONTEXT_MENU_SEPARATOR ||
|
||||
!item.predicate ||
|
||||
item.predicate(
|
||||
elements,
|
||||
appState,
|
||||
actionManager.app.props,
|
||||
actionManager.app,
|
||||
))
|
||||
) {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onCloseRequest={() => {
|
||||
onClose();
|
||||
}}
|
||||
top={top}
|
||||
left={left}
|
||||
fitInViewport={true}
|
||||
offsetLeft={appState.offsetLeft}
|
||||
offsetTop={appState.offsetTop}
|
||||
viewportWidth={appState.width}
|
||||
viewportHeight={appState.height}
|
||||
>
|
||||
<ul
|
||||
className="context-menu"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
{filteredItems.map((item, idx) => {
|
||||
if (item === CONTEXT_MENU_SEPARATOR) {
|
||||
if (
|
||||
!filteredItems[idx - 1] ||
|
||||
filteredItems[idx - 1] === CONTEXT_MENU_SEPARATOR
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return <hr key={idx} className="context-menu-item-separator" />;
|
||||
}
|
||||
|
||||
const actionName = item.name;
|
||||
let label = "";
|
||||
if (item.contextItemLabel) {
|
||||
if (typeof item.contextItemLabel === "function") {
|
||||
label = t(
|
||||
item.contextItemLabel(
|
||||
elements,
|
||||
appState,
|
||||
actionManager.app,
|
||||
) as unknown as TranslationKeys,
|
||||
);
|
||||
} else {
|
||||
label = t(item.contextItemLabel as unknown as TranslationKeys);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
key={idx}
|
||||
data-testid={actionName}
|
||||
onClick={() => {
|
||||
// we need update state before executing the action in case
|
||||
// the action uses the appState it's being passed (that still
|
||||
// contains a defined contextMenu) to return the next state.
|
||||
onClose(() => {
|
||||
actionManager.executeAction(item, "contextMenu");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className={clsx("context-menu-item", {
|
||||
dangerous: actionName === "deleteSelectedElements",
|
||||
checkmark: item.checked?.(appState),
|
||||
})}
|
||||
>
|
||||
<div className="context-menu-item__label">{label}</div>
|
||||
<kbd className="context-menu-item__shortcut">
|
||||
{actionName
|
||||
? getShortcutFromShortcutName(actionName as ShortcutName)
|
||||
: ""}
|
||||
</kbd>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
);
|
50
packages/excalidraw/components/DarkModeToggle.tsx
Normal file
50
packages/excalidraw/components/DarkModeToggle.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import "./ToolIcon.scss";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { THEME } from "../constants";
|
||||
import { 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.
|
||||
export const DarkModeToggle = (props: {
|
||||
value: Theme;
|
||||
onChange: (value: Theme) => void;
|
||||
title?: string;
|
||||
}) => {
|
||||
const title =
|
||||
props.title ||
|
||||
(props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode"));
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
type="icon"
|
||||
icon={props.value === THEME.LIGHT ? ICONS.MOON : ICONS.SUN}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={() =>
|
||||
props.onChange(props.value === THEME.DARK ? THEME.LIGHT : THEME.DARK)
|
||||
}
|
||||
data-testid="toggle-dark-mode"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ICONS = {
|
||||
SUN: (
|
||||
<svg width="512" height="512" className="rtl-mirror" viewBox="0 0 512 512">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M256 160c-52.9 0-96 43.1-96 96s43.1 96 96 96 96-43.1 96-96-43.1-96-96-96zm246.4 80.5l-94.7-47.3 33.5-100.4c4.5-13.6-8.4-26.5-21.9-21.9l-100.4 33.5-47.4-94.8c-6.4-12.8-24.6-12.8-31 0l-47.3 94.7L92.7 70.8c-13.6-4.5-26.5 8.4-21.9 21.9l33.5 100.4-94.7 47.4c-12.8 6.4-12.8 24.6 0 31l94.7 47.3-33.5 100.5c-4.5 13.6 8.4 26.5 21.9 21.9l100.4-33.5 47.3 94.7c6.4 12.8 24.6 12.8 31 0l47.3-94.7 100.4 33.5c13.6 4.5 26.5-8.4 21.9-21.9l-33.5-100.4 94.7-47.3c13-6.5 13-24.7.2-31.1zm-155.9 106c-49.9 49.9-131.1 49.9-181 0-49.9-49.9-49.9-131.1 0-181 49.9-49.9 131.1-49.9 181 0 49.9 49.9 49.9 131.1 0 181z"
|
||||
></path>
|
||||
</svg>
|
||||
),
|
||||
MOON: (
|
||||
<svg width="512" height="512" className="rtl-mirror" viewBox="0 0 512 512">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M283.211 512c78.962 0 151.079-35.925 198.857-94.792 7.068-8.708-.639-21.43-11.562-19.35-124.203 23.654-238.262-71.576-238.262-196.954 0-72.222 38.662-138.635 101.498-174.394 9.686-5.512 7.25-20.197-3.756-22.23A258.156 258.156 0 0 0 283.211 0c-141.309 0-256 114.511-256 256 0 141.309 114.511 256 256 256z"
|
||||
></path>
|
||||
</svg>
|
||||
),
|
||||
};
|
144
packages/excalidraw/components/DefaultSidebar.test.tsx
Normal file
144
packages/excalidraw/components/DefaultSidebar.test.tsx
Normal file
|
@ -0,0 +1,144 @@
|
|||
import React from "react";
|
||||
import { DEFAULT_SIDEBAR } from "../constants";
|
||||
import { DefaultSidebar } from "../index";
|
||||
import {
|
||||
fireEvent,
|
||||
waitFor,
|
||||
withExcalidrawDimensions,
|
||||
} from "../tests/test-utils";
|
||||
import {
|
||||
assertExcalidrawWithSidebar,
|
||||
assertSidebarDockButton,
|
||||
} from "./Sidebar/Sidebar.test";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("DefaultSidebar", () => {
|
||||
it("when `docked={undefined}` & `onDock={undefined}`, should allow docking", async () => {
|
||||
await assertExcalidrawWithSidebar(
|
||||
<DefaultSidebar />,
|
||||
DEFAULT_SIDEBAR.name,
|
||||
async () => {
|
||||
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||
|
||||
const { dockButton } = await assertSidebarDockButton(true);
|
||||
|
||||
fireEvent.click(dockButton);
|
||||
await waitFor(() => {
|
||||
expect(h.state.defaultSidebarDockedPreference).toBe(true);
|
||||
expect(dockButton).toHaveClass("selected");
|
||||
});
|
||||
|
||||
fireEvent.click(dockButton);
|
||||
await waitFor(() => {
|
||||
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||
expect(dockButton).not.toHaveClass("selected");
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("when `docked={undefined}` & `onDock`, should allow docking", async () => {
|
||||
await assertExcalidrawWithSidebar(
|
||||
<DefaultSidebar onDock={() => {}} />,
|
||||
DEFAULT_SIDEBAR.name,
|
||||
async () => {
|
||||
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||
|
||||
const { dockButton } = await assertSidebarDockButton(true);
|
||||
|
||||
fireEvent.click(dockButton);
|
||||
await waitFor(() => {
|
||||
expect(h.state.defaultSidebarDockedPreference).toBe(true);
|
||||
expect(dockButton).toHaveClass("selected");
|
||||
});
|
||||
|
||||
fireEvent.click(dockButton);
|
||||
await waitFor(() => {
|
||||
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||
expect(dockButton).not.toHaveClass("selected");
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("when `docked={true}` & `onDock`, should allow docking", async () => {
|
||||
await assertExcalidrawWithSidebar(
|
||||
<DefaultSidebar onDock={() => {}} />,
|
||||
DEFAULT_SIDEBAR.name,
|
||||
async () => {
|
||||
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||
|
||||
const { dockButton } = await assertSidebarDockButton(true);
|
||||
|
||||
fireEvent.click(dockButton);
|
||||
await waitFor(() => {
|
||||
expect(h.state.defaultSidebarDockedPreference).toBe(true);
|
||||
expect(dockButton).toHaveClass("selected");
|
||||
});
|
||||
|
||||
fireEvent.click(dockButton);
|
||||
await waitFor(() => {
|
||||
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||
expect(dockButton).not.toHaveClass("selected");
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("when `onDock={false}`, should disable docking", async () => {
|
||||
await assertExcalidrawWithSidebar(
|
||||
<DefaultSidebar onDock={false} />,
|
||||
DEFAULT_SIDEBAR.name,
|
||||
async () => {
|
||||
await withExcalidrawDimensions(
|
||||
{ width: 1920, height: 1080 },
|
||||
async () => {
|
||||
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||
|
||||
await assertSidebarDockButton(false);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("when `docked={true}` & `onDock={false}`, should force-dock sidebar", async () => {
|
||||
await assertExcalidrawWithSidebar(
|
||||
<DefaultSidebar docked onDock={false} />,
|
||||
DEFAULT_SIDEBAR.name,
|
||||
async () => {
|
||||
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||
|
||||
const { sidebar } = await assertSidebarDockButton(false);
|
||||
expect(sidebar).toHaveClass("sidebar--docked");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("when `docked={true}` & `onDock={undefined}`, should force-dock sidebar", async () => {
|
||||
await assertExcalidrawWithSidebar(
|
||||
<DefaultSidebar docked />,
|
||||
DEFAULT_SIDEBAR.name,
|
||||
async () => {
|
||||
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||
|
||||
const { sidebar } = await assertSidebarDockButton(false);
|
||||
expect(sidebar).toHaveClass("sidebar--docked");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("when `docked={false}` & `onDock={undefined}`, should force-undock sidebar", async () => {
|
||||
await assertExcalidrawWithSidebar(
|
||||
<DefaultSidebar docked={false} />,
|
||||
DEFAULT_SIDEBAR.name,
|
||||
async () => {
|
||||
expect(h.state.defaultSidebarDockedPreference).toBe(false);
|
||||
|
||||
const { sidebar } = await assertSidebarDockButton(false);
|
||||
expect(sidebar).not.toHaveClass("sidebar--docked");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
118
packages/excalidraw/components/DefaultSidebar.tsx
Normal file
118
packages/excalidraw/components/DefaultSidebar.tsx
Normal file
|
@ -0,0 +1,118 @@
|
|||
import clsx from "clsx";
|
||||
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 { composeEventHandlers } from "../utils";
|
||||
import { useExcalidrawSetAppState } from "./App";
|
||||
import { withInternalFallback } from "./hoc/withInternalFallback";
|
||||
import { LibraryMenu } from "./LibraryMenu";
|
||||
import { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
|
||||
import { Sidebar } from "./Sidebar/Sidebar";
|
||||
|
||||
const DefaultSidebarTrigger = withInternalFallback(
|
||||
"DefaultSidebarTrigger",
|
||||
(
|
||||
props: Omit<SidebarTriggerProps, "name"> &
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
) => {
|
||||
const { DefaultSidebarTriggerTunnel } = useTunnels();
|
||||
return (
|
||||
<DefaultSidebarTriggerTunnel.In>
|
||||
<Sidebar.Trigger
|
||||
{...props}
|
||||
className="default-sidebar-trigger"
|
||||
name={DEFAULT_SIDEBAR.name}
|
||||
/>
|
||||
</DefaultSidebarTriggerTunnel.In>
|
||||
);
|
||||
},
|
||||
);
|
||||
DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
|
||||
|
||||
const DefaultTabTriggers = ({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
|
||||
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
|
||||
return (
|
||||
<DefaultSidebarTabTriggersTunnel.In>
|
||||
<Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers>
|
||||
</DefaultSidebarTabTriggersTunnel.In>
|
||||
);
|
||||
};
|
||||
DefaultTabTriggers.displayName = "DefaultTabTriggers";
|
||||
|
||||
export const DefaultSidebar = Object.assign(
|
||||
withInternalFallback(
|
||||
"DefaultSidebar",
|
||||
({
|
||||
children,
|
||||
className,
|
||||
onDock,
|
||||
docked,
|
||||
...rest
|
||||
}: Merge<
|
||||
MarkOptional<Omit<SidebarProps, "name">, "children">,
|
||||
{
|
||||
/** pass `false` to disable docking */
|
||||
onDock?: SidebarProps["onDock"] | false;
|
||||
}
|
||||
>) => {
|
||||
const appState = useUIAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
{...rest}
|
||||
name="default"
|
||||
key="default"
|
||||
className={clsx("default-sidebar", className)}
|
||||
docked={docked ?? appState.defaultSidebarDockedPreference}
|
||||
onDock={
|
||||
// `onDock=false` disables docking.
|
||||
// if `docked` passed, but no onDock passed, disable manual docking.
|
||||
onDock === false || (!onDock && docked != null)
|
||||
? undefined
|
||||
: // compose to allow the host app to listen on default behavior
|
||||
composeEventHandlers(onDock, (docked) => {
|
||||
setAppState({ defaultSidebarDockedPreference: docked });
|
||||
})
|
||||
}
|
||||
>
|
||||
<Sidebar.Tabs>
|
||||
<Sidebar.Header>
|
||||
{rest.__fallback && (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--color-primary)",
|
||||
fontSize: "1.2em",
|
||||
fontWeight: "bold",
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
paddingRight: "1em",
|
||||
}}
|
||||
>
|
||||
{t("toolBar.library")}
|
||||
</div>
|
||||
)}
|
||||
<DefaultSidebarTabTriggersTunnel.Out />
|
||||
</Sidebar.Header>
|
||||
<Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
|
||||
<LibraryMenu />
|
||||
</Sidebar.Tab>
|
||||
{children}
|
||||
</Sidebar.Tabs>
|
||||
</Sidebar>
|
||||
);
|
||||
},
|
||||
),
|
||||
{
|
||||
Trigger: DefaultSidebarTrigger,
|
||||
TabTriggers: DefaultTabTriggers,
|
||||
},
|
||||
);
|
48
packages/excalidraw/components/Dialog.scss
Normal file
48
packages/excalidraw/components/Dialog.scss
Normal file
|
@ -0,0 +1,48 @@
|
|||
@import "../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.Dialog {
|
||||
user-select: text;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
.Dialog__title {
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
font-size: 1.25rem;
|
||||
border-bottom: 1px solid var(--dialog-border-color);
|
||||
padding: 0 0 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.Dialog__close {
|
||||
color: var(--color-gray-40);
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.5rem;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
line-height: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-gray-60);
|
||||
}
|
||||
&:active {
|
||||
color: var(--color-gray-40);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.Dialog--fullscreen {
|
||||
.Dialog__close {
|
||||
top: 1.25rem;
|
||||
right: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
130
packages/excalidraw/components/Dialog.tsx
Normal file
130
packages/excalidraw/components/Dialog.tsx
Normal file
|
@ -0,0 +1,130 @@
|
|||
import clsx from "clsx";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
import {
|
||||
useExcalidrawContainer,
|
||||
useDevice,
|
||||
useExcalidrawSetAppState,
|
||||
} 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";
|
||||
|
||||
export type DialogSize = number | "small" | "regular" | "wide" | undefined;
|
||||
|
||||
export interface DialogProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
size?: DialogSize;
|
||||
onCloseRequest(): void;
|
||||
title: React.ReactNode | false;
|
||||
autofocus?: boolean;
|
||||
closeOnClickOutside?: boolean;
|
||||
}
|
||||
|
||||
function getDialogSize(size: DialogSize): number {
|
||||
if (size && typeof size === "number") {
|
||||
return size;
|
||||
}
|
||||
|
||||
switch (size) {
|
||||
case "small":
|
||||
return 550;
|
||||
case "wide":
|
||||
return 1024;
|
||||
case "regular":
|
||||
default:
|
||||
return 800;
|
||||
}
|
||||
}
|
||||
|
||||
export const Dialog = (props: DialogProps) => {
|
||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||
const [lastActiveElement] = useState(document.activeElement);
|
||||
const { id } = useExcalidrawContainer();
|
||||
const isFullscreen = useDevice().viewport.isMobile;
|
||||
|
||||
useEffect(() => {
|
||||
if (!islandNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === KEYS.TAB) {
|
||||
const focusableElements = queryFocusableElements(islandNode);
|
||||
const { activeElement } = document;
|
||||
const currentIndex = focusableElements.findIndex(
|
||||
(element) => element === activeElement,
|
||||
);
|
||||
|
||||
if (currentIndex === 0 && event.shiftKey) {
|
||||
focusableElements[focusableElements.length - 1].focus();
|
||||
event.preventDefault();
|
||||
} else if (
|
||||
currentIndex === focusableElements.length - 1 &&
|
||||
!event.shiftKey
|
||||
) {
|
||||
focusableElements[0].focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
islandNode.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => islandNode.removeEventListener("keydown", handleKeyDown);
|
||||
}, [islandNode, props.autofocus]);
|
||||
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
|
||||
|
||||
const onClose = () => {
|
||||
setAppState({ openMenu: null });
|
||||
setIsLibraryMenuOpen(false);
|
||||
(lastActiveElement as HTMLElement).focus();
|
||||
props.onCloseRequest();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={clsx("Dialog", props.className, {
|
||||
"Dialog--fullscreen": isFullscreen,
|
||||
})}
|
||||
labelledBy="dialog-title"
|
||||
maxWidth={getDialogSize(props.size)}
|
||||
onCloseRequest={onClose}
|
||||
closeOnClickOutside={props.closeOnClickOutside}
|
||||
>
|
||||
<Island ref={setIslandNode}>
|
||||
{props.title && (
|
||||
<h2 id={`${id}-dialog-title`} className="Dialog__title">
|
||||
<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>
|
||||
<div className="Dialog__content">{props.children}</div>
|
||||
</Island>
|
||||
</Modal>
|
||||
);
|
||||
};
|
47
packages/excalidraw/components/DialogActionButton.scss
Normal file
47
packages/excalidraw/components/DialogActionButton.scss
Normal file
|
@ -0,0 +1,47 @@
|
|||
.excalidraw {
|
||||
.Dialog__action-button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
column-gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: 1px solid var(--default-border-color);
|
||||
background-color: transparent;
|
||||
height: 3rem;
|
||||
border-radius: var(--border-radius-lg);
|
||||
letter-spacing: 0.4px;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background-color: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
.Dialog__action-button--danger {
|
||||
color: var(--color-gray-100);
|
||||
}
|
||||
|
||||
.Dialog__action-button--primary {
|
||||
color: var(--color-gray-100);
|
||||
}
|
||||
}
|
||||
}
|
46
packages/excalidraw/components/DialogActionButton.tsx
Normal file
46
packages/excalidraw/components/DialogActionButton.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import clsx from "clsx";
|
||||
import { ReactNode } from "react";
|
||||
import "./DialogActionButton.scss";
|
||||
import Spinner from "./Spinner";
|
||||
|
||||
interface DialogActionButtonProps {
|
||||
label: string;
|
||||
children?: ReactNode;
|
||||
actionType?: "primary" | "danger";
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const DialogActionButton = ({
|
||||
label,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
actionType,
|
||||
type = "button",
|
||||
isLoading,
|
||||
...rest
|
||||
}: DialogActionButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
const cs = actionType ? `Dialog__action-button--${actionType}` : "";
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx("Dialog__action-button", cs, className)}
|
||||
type={type}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
{...rest}
|
||||
>
|
||||
{children && (
|
||||
<div style={isLoading ? { visibility: "hidden" } : {}}>{children}</div>
|
||||
)}
|
||||
<div style={isLoading ? { visibility: "hidden" } : {}}>{label}</div>
|
||||
{isLoading && (
|
||||
<div style={{ position: "absolute", inset: 0 }}>
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default DialogActionButton;
|
40
packages/excalidraw/components/ErrorDialog.tsx
Normal file
40
packages/excalidraw/components/ErrorDialog.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import React, { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { Dialog } from "./Dialog";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
|
||||
export const ErrorDialog = ({
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(!!children);
|
||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setModalIsShown(false);
|
||||
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
// TODO: Fix the A11y issues so this is never needed since we should always focus on last active element
|
||||
excalidrawContainer?.focus();
|
||||
}, [onClose, excalidrawContainer]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{modalIsShown && (
|
||||
<Dialog
|
||||
size="small"
|
||||
onCloseRequest={handleClose}
|
||||
title={t("errorDialog.title")}
|
||||
>
|
||||
<div style={{ whiteSpace: "pre-wrap" }}>{children}</div>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
73
packages/excalidraw/components/ExcalidrawLogo.scss
Normal file
73
packages/excalidraw/components/ExcalidrawLogo.scss
Normal file
|
@ -0,0 +1,73 @@
|
|||
.excalidraw {
|
||||
.ExcalidrawLogo {
|
||||
--logo-icon--xs: 2rem;
|
||||
--logo-text--xs: 1.5rem;
|
||||
|
||||
--logo-icon--small: 2.5rem;
|
||||
--logo-text--small: 1.75rem;
|
||||
|
||||
--logo-icon--normal: 3rem;
|
||||
--logo-text--normal: 2.2rem;
|
||||
|
||||
--logo-icon--large: 90px;
|
||||
--logo-text--large: 65px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ExcalidrawLogo-icon {
|
||||
width: auto;
|
||||
color: var(--color-logo-icon);
|
||||
}
|
||||
|
||||
.ExcalidrawLogo-text {
|
||||
margin-left: 0.75rem;
|
||||
width: auto;
|
||||
color: var(--color-logo-text);
|
||||
}
|
||||
|
||||
&.is-xs {
|
||||
.ExcalidrawLogo-icon {
|
||||
height: var(--logo-icon--xs);
|
||||
}
|
||||
|
||||
.ExcalidrawLogo-text {
|
||||
height: var(--logo-text--xs);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-small {
|
||||
.ExcalidrawLogo-icon {
|
||||
height: var(--logo-icon--small);
|
||||
}
|
||||
|
||||
.ExcalidrawLogo-text {
|
||||
height: var(--logo-text--small);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-normal {
|
||||
.ExcalidrawLogo-icon {
|
||||
height: var(--logo-icon--normal);
|
||||
}
|
||||
|
||||
.ExcalidrawLogo-text {
|
||||
height: var(--logo-text--normal);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-large {
|
||||
.ExcalidrawLogo-icon {
|
||||
height: var(--logo-icon--large);
|
||||
}
|
||||
|
||||
.ExcalidrawLogo-text {
|
||||
height: var(--logo-text--large);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
69
packages/excalidraw/components/ExcalidrawLogo.tsx
Normal file
69
packages/excalidraw/components/ExcalidrawLogo.tsx
Normal file
File diff suppressed because one or more lines are too long
129
packages/excalidraw/components/ExportDialog.scss
Normal file
129
packages/excalidraw/components/ExportDialog.scss
Normal file
|
@ -0,0 +1,129 @@
|
|||
@import "../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.ExportDialog__preview {
|
||||
--preview-padding: calc(var(--space-factor) * 4);
|
||||
|
||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
|
||||
left center;
|
||||
text-align: center;
|
||||
padding: var(--preview-padding);
|
||||
margin-bottom: calc(var(--space-factor) * 3);
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ExportDialog__preview canvas {
|
||||
max-width: calc(100% - var(--preview-padding) * 2);
|
||||
max-height: 25rem;
|
||||
}
|
||||
|
||||
&.theme--dark .ExportDialog__preview canvas {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.ExportDialog__actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
grid-gap: calc(var(--space-factor) * 2);
|
||||
align-items: top;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
.ExportDialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ExportDialog__actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ExportDialog__actions > * {
|
||||
margin-bottom: calc(var(--space-factor) * 3);
|
||||
}
|
||||
|
||||
.ExportDialog__preview canvas {
|
||||
max-height: 30vh;
|
||||
}
|
||||
|
||||
.ExportDialog__dialog,
|
||||
.ExportDialog__dialog .Island {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ExportDialog__dialog .Island {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.ExportDialog--json {
|
||||
.ExportDialog-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
justify-items: center;
|
||||
row-gap: 2em;
|
||||
|
||||
@media (max-width: 460px) {
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
.Card-details {
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.ProjectName {
|
||||
width: fit-content;
|
||||
margin: 1em auto;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
.TextInput {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.ProjectName-label {
|
||||
margin: 0.625em 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button.ExportDialog-imageExportButton {
|
||||
border: 0;
|
||||
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
margin: 0 0.2em;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
border-radius: 1rem;
|
||||
background-color: var(--button-color);
|
||||
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.28),
|
||||
0 6px 10px 0 rgba(0, 0, 0, 0.14);
|
||||
|
||||
font-family: Cascadia;
|
||||
font-size: 1.8em;
|
||||
color: $oc-white;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-color-darker);
|
||||
}
|
||||
&:active {
|
||||
background-color: var(--button-color-darkest);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 0.9em;
|
||||
}
|
||||
}
|
||||
}
|
48
packages/excalidraw/components/EyeDropper.scss
Normal file
48
packages/excalidraw/components/EyeDropper.scss
Normal file
|
@ -0,0 +1,48 @@
|
|||
.excalidraw {
|
||||
.excalidraw-eye-dropper-container,
|
||||
.excalidraw-eye-dropper-backdrop {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: var(--zIndex-eyeDropperBackdrop);
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.excalidraw-eye-dropper-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.excalidraw-eye-dropper-backdrop {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.excalidraw-eye-dropper-preview {
|
||||
pointer-events: none;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
position: fixed;
|
||||
z-index: var(--zIndex-eyeDropperPreview);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--default-border-color);
|
||||
filter: var(--theme-filter);
|
||||
}
|
||||
|
||||
.excalidraw-eye-dropper-trigger {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
margin-right: -4px;
|
||||
margin-left: -2px;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--icon-fill-color);
|
||||
|
||||
&:hover {
|
||||
background: var(--button-hover-bg);
|
||||
}
|
||||
&.selected {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
}
|
||||
}
|
235
packages/excalidraw/components/EyeDropper.tsx
Normal file
235
packages/excalidraw/components/EyeDropper.tsx
Normal file
|
@ -0,0 +1,235 @@
|
|||
import { atom } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { rgbToHex } from "../colors";
|
||||
import { EVENT } from "../constants";
|
||||
import { useUIAppState } from "../context/ui-appState";
|
||||
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
|
||||
import { useOutsideClick } from "../hooks/useOutsideClick";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App";
|
||||
import { useStable } from "../hooks/useStable";
|
||||
|
||||
import "./EyeDropper.scss";
|
||||
import { ColorPickerType } from "./ColorPicker/colorPickerUtils";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
|
||||
export type EyeDropperProperties = {
|
||||
keepOpenOnAlt: boolean;
|
||||
swapPreviewOnAlt?: boolean;
|
||||
/** called when user picks color (on pointerup) */
|
||||
onSelect: (color: string, event: PointerEvent) => void;
|
||||
/**
|
||||
* property of selected elements to update live when alt-dragging.
|
||||
* Supply `null` if not applicable (e.g. updating the canvas bg instead of
|
||||
* elements)
|
||||
**/
|
||||
colorPickerType: ColorPickerType;
|
||||
};
|
||||
|
||||
export const activeEyeDropperAtom = atom<null | EyeDropperProperties>(null);
|
||||
|
||||
export const EyeDropper: React.FC<{
|
||||
onCancel: () => void;
|
||||
onSelect: EyeDropperProperties["onSelect"];
|
||||
/** called when color changes, on pointerdown for preview */
|
||||
onChange: (
|
||||
type: ColorPickerType,
|
||||
color: string,
|
||||
selectedElements: ExcalidrawElement[],
|
||||
event: { altKey: boolean },
|
||||
) => void;
|
||||
colorPickerType: EyeDropperProperties["colorPickerType"];
|
||||
}> = ({ onCancel, onChange, onSelect, colorPickerType }) => {
|
||||
const eyeDropperContainer = useCreatePortalContainer({
|
||||
className: "excalidraw-eye-dropper-backdrop",
|
||||
parentSelector: ".excalidraw-eye-dropper-container",
|
||||
});
|
||||
const appState = useUIAppState();
|
||||
const elements = useExcalidrawElements();
|
||||
const app = useApp();
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
const stableProps = useStable({
|
||||
app,
|
||||
onCancel,
|
||||
onChange,
|
||||
onSelect,
|
||||
selectedElements,
|
||||
});
|
||||
|
||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||
|
||||
useEffect(() => {
|
||||
const colorPreviewDiv = ref.current;
|
||||
|
||||
if (!colorPreviewDiv || !app.canvas || !eyeDropperContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isHoldingPointerDown = false;
|
||||
|
||||
const ctx = app.canvas.getContext("2d")!;
|
||||
|
||||
const getCurrentColor = ({
|
||||
clientX,
|
||||
clientY,
|
||||
}: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}) => {
|
||||
const pixel = ctx.getImageData(
|
||||
(clientX - appState.offsetLeft) * window.devicePixelRatio,
|
||||
(clientY - appState.offsetTop) * window.devicePixelRatio,
|
||||
1,
|
||||
1,
|
||||
).data;
|
||||
|
||||
return rgbToHex(pixel[0], pixel[1], pixel[2]);
|
||||
};
|
||||
|
||||
const mouseMoveListener = ({
|
||||
clientX,
|
||||
clientY,
|
||||
altKey,
|
||||
}: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
altKey: boolean;
|
||||
}) => {
|
||||
// FIXME swap offset when the preview gets outside viewport
|
||||
colorPreviewDiv.style.top = `${clientY + 20}px`;
|
||||
colorPreviewDiv.style.left = `${clientX + 20}px`;
|
||||
|
||||
const currentColor = getCurrentColor({ clientX, clientY });
|
||||
|
||||
if (isHoldingPointerDown) {
|
||||
stableProps.onChange(
|
||||
colorPickerType,
|
||||
currentColor,
|
||||
stableProps.selectedElements,
|
||||
{ altKey },
|
||||
);
|
||||
}
|
||||
|
||||
colorPreviewDiv.style.background = currentColor;
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
stableProps.onCancel();
|
||||
};
|
||||
|
||||
const onSelect: Required<EyeDropperProperties>["onSelect"] = (
|
||||
color,
|
||||
event,
|
||||
) => {
|
||||
stableProps.onSelect(color, event);
|
||||
};
|
||||
|
||||
const pointerDownListener = (event: PointerEvent) => {
|
||||
isHoldingPointerDown = true;
|
||||
// NOTE we can't event.preventDefault() as that would stop
|
||||
// pointermove events
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
const pointerUpListener = (event: PointerEvent) => {
|
||||
isHoldingPointerDown = false;
|
||||
|
||||
// since we're not preventing default on pointerdown, the focus would
|
||||
// goes back to `body` so we want to refocus the editor container instead
|
||||
excalidrawContainer?.focus();
|
||||
|
||||
event.stopImmediatePropagation();
|
||||
event.preventDefault();
|
||||
|
||||
onSelect(getCurrentColor(event), event);
|
||||
};
|
||||
|
||||
const keyDownListener = (event: KeyboardEvent) => {
|
||||
if (event.key === KEYS.ESCAPE) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
eyeDropperContainer.tabIndex = -1;
|
||||
// focus container so we can listen on keydown events
|
||||
eyeDropperContainer.focus();
|
||||
|
||||
// init color preview else it would show only after the first mouse move
|
||||
mouseMoveListener({
|
||||
clientX: stableProps.app.lastViewportPosition.x,
|
||||
clientY: stableProps.app.lastViewportPosition.y,
|
||||
altKey: false,
|
||||
});
|
||||
|
||||
eyeDropperContainer.addEventListener(EVENT.KEYDOWN, keyDownListener);
|
||||
eyeDropperContainer.addEventListener(
|
||||
EVENT.POINTER_DOWN,
|
||||
pointerDownListener,
|
||||
);
|
||||
eyeDropperContainer.addEventListener(EVENT.POINTER_UP, pointerUpListener);
|
||||
window.addEventListener("pointermove", mouseMoveListener, {
|
||||
passive: true,
|
||||
});
|
||||
window.addEventListener(EVENT.BLUR, onCancel);
|
||||
|
||||
return () => {
|
||||
isHoldingPointerDown = false;
|
||||
eyeDropperContainer.removeEventListener(EVENT.KEYDOWN, keyDownListener);
|
||||
eyeDropperContainer.removeEventListener(
|
||||
EVENT.POINTER_DOWN,
|
||||
pointerDownListener,
|
||||
);
|
||||
eyeDropperContainer.removeEventListener(
|
||||
EVENT.POINTER_UP,
|
||||
pointerUpListener,
|
||||
);
|
||||
window.removeEventListener("pointermove", mouseMoveListener);
|
||||
window.removeEventListener(EVENT.BLUR, onCancel);
|
||||
};
|
||||
}, [
|
||||
stableProps,
|
||||
app.canvas,
|
||||
eyeDropperContainer,
|
||||
colorPickerType,
|
||||
excalidrawContainer,
|
||||
appState.offsetLeft,
|
||||
appState.offsetTop,
|
||||
]);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useOutsideClick(
|
||||
ref,
|
||||
() => {
|
||||
onCancel();
|
||||
},
|
||||
(event) => {
|
||||
if (
|
||||
event.target.closest(
|
||||
".excalidraw-eye-dropper-trigger, .excalidraw-eye-dropper-backdrop",
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// consider all other clicks as outside
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
if (!eyeDropperContainer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div ref={ref} className="excalidraw-eye-dropper-preview" />,
|
||||
eyeDropperContainer,
|
||||
);
|
||||
};
|
248
packages/excalidraw/components/FilledButton.scss
Normal file
248
packages/excalidraw/components/FilledButton.scss
Normal file
|
@ -0,0 +1,248 @@
|
|||
@import "../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.ExcButton {
|
||||
--text-color: transparent;
|
||||
--border-color: transparent;
|
||||
--back-color: transparent;
|
||||
|
||||
color: var(--text-color);
|
||||
background-color: var(--back-color);
|
||||
border-color: var(--border-color);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--color-surface-lowest);
|
||||
position: absolute;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
pointer-events: none;
|
||||
|
||||
.ExcButton__contents {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&,
|
||||
&__contents {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: nowrap;
|
||||
// needed because of .Spinner
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&--color-primary {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: var(--color-surface-lowest);
|
||||
--back-color: var(--color-primary);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-brand-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-brand-active);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-primary);
|
||||
--border-color: var(--color-primary);
|
||||
--back-color: transparent;
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-brand-hover);
|
||||
--border-color: var(--color-brand-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-brand-active);
|
||||
--border-color: var(--color-brand-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--color-danger {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: var(--color-danger-text);
|
||||
--back-color: var(--color-danger-dark);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-danger-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-danger-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-danger);
|
||||
--border-color: var(--color-danger);
|
||||
--back-color: transparent;
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-danger-darkest);
|
||||
--border-color: var(--color-danger-darkest);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-danger-darker);
|
||||
--border-color: var(--color-danger-darker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--color-muted {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: var(--island-bg-color);
|
||||
--back-color: var(--color-gray-50);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-gray-60);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-gray-80);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-muted-background);
|
||||
--border-color: var(--color-muted);
|
||||
--back-color: var(--island-bg-color);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-muted-background-darker);
|
||||
--border-color: var(--color-muted-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-muted-background-darker);
|
||||
--border-color: var(--color-muted-darkest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--color-warning {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: black;
|
||||
--back-color: var(--color-warning-dark);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-warning-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-warning-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-warning-dark);
|
||||
--border-color: var(--color-warning-dark);
|
||||
--back-color: var(--input-bg-color);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-warning-darker);
|
||||
--border-color: var(--color-warning-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-warning-darkest);
|
||||
--border-color: var(--color-warning-darkest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
border-radius: 0.5rem;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
|
||||
font-family: var(--font-family);
|
||||
|
||||
user-select: none;
|
||||
|
||||
transition: all 150ms ease-out;
|
||||
|
||||
&--size-large {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
min-height: 3rem;
|
||||
padding: 0.5rem 1.5rem;
|
||||
|
||||
letter-spacing: 0.4px;
|
||||
|
||||
.ExcButton__contents {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
&--size-medium {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
letter-spacing: normal;
|
||||
|
||||
.ExcButton__contents {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&--variant-icon {
|
||||
padding: 0.5rem 0.75rem;
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
&--fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
92
packages/excalidraw/components/FilledButton.tsx
Normal file
92
packages/excalidraw/components/FilledButton.tsx
Normal file
|
@ -0,0 +1,92 @@
|
|||
import React, { forwardRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import "./FilledButton.scss";
|
||||
import { AbortError } from "../errors";
|
||||
import Spinner from "./Spinner";
|
||||
import { isPromiseLike } from "../utils";
|
||||
|
||||
export type ButtonVariant = "filled" | "outlined" | "icon";
|
||||
export type ButtonColor = "primary" | "danger" | "warning" | "muted";
|
||||
export type ButtonSize = "medium" | "large";
|
||||
|
||||
export type FilledButtonProps = {
|
||||
label: string;
|
||||
|
||||
children?: React.ReactNode;
|
||||
onClick?: (event: React.MouseEvent) => void;
|
||||
|
||||
variant?: ButtonVariant;
|
||||
color?: ButtonColor;
|
||||
size?: ButtonSize;
|
||||
className?: string;
|
||||
fullWidth?: boolean;
|
||||
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
icon,
|
||||
onClick,
|
||||
label,
|
||||
variant = "filled",
|
||||
color = "primary",
|
||||
size = "medium",
|
||||
fullWidth,
|
||||
className,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const _onClick = async (event: React.MouseEvent) => {
|
||||
const ret = onClick?.(event);
|
||||
|
||||
if (isPromiseLike(ret)) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await ret;
|
||||
} catch (error: any) {
|
||||
if (!(error instanceof AbortError)) {
|
||||
throw error;
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
"ExcButton",
|
||||
`ExcButton--color-${color}`,
|
||||
`ExcButton--variant-${variant}`,
|
||||
`ExcButton--size-${size}`,
|
||||
{ "ExcButton--fullWidth": fullWidth },
|
||||
className,
|
||||
)}
|
||||
onClick={_onClick}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
ref={ref}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<div className="ExcButton__contents">
|
||||
{isLoading && <Spinner />}
|
||||
{icon && (
|
||||
<div className="ExcButton__icon" aria-hidden>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
{variant !== "icon" && (children ?? label)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
40
packages/excalidraw/components/FixedSideContainer.scss
Normal file
40
packages/excalidraw/components/FixedSideContainer.scss
Normal file
|
@ -0,0 +1,40 @@
|
|||
@import "../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.FixedSideContainer {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.FixedSideContainer > * {
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_top {
|
||||
left: var(--editor-container-padding);
|
||||
top: var(--editor-container-padding);
|
||||
right: var(--editor-container-padding);
|
||||
bottom: var(--editor-container-padding);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_top.zen-mode {
|
||||
right: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
/* TODO: if these are used, make sure to implement RTL support
|
||||
.FixedSideContainer_side_left {
|
||||
left: var(--space-factor);
|
||||
top: var(--space-factor);
|
||||
bottom: var(--space-factor);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_right {
|
||||
right: var(--space-factor);
|
||||
top: var(--space-factor);
|
||||
bottom: var(--space-factor);
|
||||
z-index: 3;
|
||||
}
|
||||
*/
|
26
packages/excalidraw/components/FixedSideContainer.tsx
Normal file
26
packages/excalidraw/components/FixedSideContainer.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import "./FixedSideContainer.scss";
|
||||
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type FixedSideContainerProps = {
|
||||
children: React.ReactNode;
|
||||
side: "top" | "left" | "right";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const FixedSideContainer = ({
|
||||
children,
|
||||
side,
|
||||
className,
|
||||
}: FixedSideContainerProps) => (
|
||||
<div
|
||||
className={clsx(
|
||||
"FixedSideContainer",
|
||||
`FixedSideContainer_side_${side}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
59
packages/excalidraw/components/FollowMode/FollowMode.scss
Normal file
59
packages/excalidraw/components/FollowMode/FollowMode.scss
Normal file
|
@ -0,0 +1,59 @@
|
|||
.excalidraw {
|
||||
.follow-mode {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
border: 2px solid var(--color-primary-hover);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
|
||||
&__badge {
|
||||
background-color: var(--color-primary-hover);
|
||||
color: var(--color-primary-light);
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
pointer-events: all;
|
||||
font-size: 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
&__label {
|
||||
display: flex;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__username {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
&__disconnect-btn {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--color-primary-darkest);
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
38
packages/excalidraw/components/FollowMode/FollowMode.tsx
Normal file
38
packages/excalidraw/components/FollowMode/FollowMode.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { UserToFollow } from "../../types";
|
||||
import { CloseIcon } from "../icons";
|
||||
import "./FollowMode.scss";
|
||||
|
||||
interface FollowModeProps {
|
||||
width: number;
|
||||
height: number;
|
||||
userToFollow: UserToFollow;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
const FollowMode = ({
|
||||
height,
|
||||
width,
|
||||
userToFollow,
|
||||
onDisconnect,
|
||||
}: FollowModeProps) => {
|
||||
return (
|
||||
<div className="follow-mode" style={{ width, height }}>
|
||||
<div className="follow-mode__badge">
|
||||
<div className="follow-mode__badge__label">
|
||||
Following{" "}
|
||||
<span
|
||||
className="follow-mode__badge__username"
|
||||
title={userToFollow.username}
|
||||
>
|
||||
{userToFollow.username}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={onDisconnect} className="follow-mode__disconnect-btn">
|
||||
{CloseIcon}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FollowMode;
|
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