mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
fix: merge branch 'master' into alex-kim-dev/flip-multiple-elements
This commit is contained in:
commit
54e007f987
193 changed files with 9684 additions and 8959 deletions
1
.npmrc
1
.npmrc
|
@ -1 +1,2 @@
|
|||
save-exact=true
|
||||
legacy-peer-deps=true
|
||||
|
|
|
@ -16,7 +16,6 @@ function App() {
|
|||
className="custom-footer"
|
||||
onClick={() => alert("This is dummy footer")}
|
||||
>
|
||||
{" "}
|
||||
custom footer
|
||||
</button>
|
||||
</Footer>
|
||||
|
|
|
@ -14,8 +14,7 @@ function App() {
|
|||
Item1
|
||||
</MainMenu.Item>
|
||||
<MainMenu.Item onSelect={() => window.alert("Item2")}>
|
||||
{" "}
|
||||
Item 2{" "}
|
||||
Item 2
|
||||
</MainMenu.Item>
|
||||
</MainMenu>
|
||||
</Excalidraw>
|
||||
|
@ -93,7 +92,6 @@ function App() {
|
|||
style={{ height: "2rem" }}
|
||||
onClick={() => window.alert("custom menu item")}
|
||||
>
|
||||
{" "}
|
||||
custom item
|
||||
</button>
|
||||
</MainMenu.ItemCustom>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
## renderTopRightUI
|
||||
|
||||
<pre>
|
||||
(isMobile: boolean, appState:{" "}
|
||||
(isMobile: boolean, appState:
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L95">
|
||||
AppState
|
||||
</a>
|
||||
|
@ -29,8 +29,7 @@ function App() {
|
|||
}}
|
||||
onClick={() => window.alert("This is dummy top right UI")}
|
||||
>
|
||||
{" "}
|
||||
Click me{" "}
|
||||
Click me
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
|
@ -55,8 +54,7 @@ function App() {
|
|||
<Excalidraw
|
||||
renderCustomStats={() => (
|
||||
<p style={{ color: "#70b1ec", fontWeight: "bold" }}>
|
||||
{" "}
|
||||
Dummy stats will be shown here{" "}
|
||||
Dummy stats will be shown here
|
||||
</p>
|
||||
)}
|
||||
/>
|
||||
|
@ -105,8 +103,7 @@ function App() {
|
|||
return (
|
||||
<div style={{ height: "500px" }}>
|
||||
<button className="custom-button" onClick={() => excalidrawAPI.toggleMenu("customSidebar")}>
|
||||
{" "}
|
||||
Toggle Custom Sidebar{" "}
|
||||
Toggle Custom Sidebar
|
||||
</button>
|
||||
<Excalidraw
|
||||
UIOptions={{ dockedSidebarBreakpoint: 100 }}
|
||||
|
|
|
@ -19,7 +19,8 @@
|
|||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@dwelle/tunnel-rat": "0.1.1",
|
||||
"@radix-ui/react-popover": "1.0.3",
|
||||
"@radix-ui/react-tabs": "1.0.2",
|
||||
"@sentry/browser": "6.2.5",
|
||||
"@sentry/integrations": "6.2.5",
|
||||
"@testing-library/jest-dom": "5.16.2",
|
||||
|
@ -51,7 +52,7 @@
|
|||
"roughjs": "4.5.2",
|
||||
"sass": "1.51.0",
|
||||
"socket.io-client": "2.3.1",
|
||||
"tunnel-rat": "0.1.0",
|
||||
"tunnel-rat": "0.1.2",
|
||||
"workbox-background-sync": "^6.5.4",
|
||||
"workbox-broadcast-update": "^6.5.4",
|
||||
"workbox-cacheable-response": "^6.5.4",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ColorPicker } from "../components/ColorPicker";
|
||||
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";
|
||||
|
@ -19,6 +19,7 @@ import {
|
|||
isEraserActive,
|
||||
isHandToolActive,
|
||||
} from "../appState";
|
||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
|
@ -35,24 +36,21 @@ export const actionChangeViewBackgroundColor = register({
|
|||
commitToHistory: !!value.viewBackgroundColor,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
||||
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<ColorPicker
|
||||
palette={null}
|
||||
topPicks={DEFAULT_CANVAS_BACKGROUND_PICKS}
|
||||
label={t("labels.canvasBackground")}
|
||||
type="canvasBackground"
|
||||
color={appState.viewBackgroundColor}
|
||||
onChange={(color) => updateData({ viewBackgroundColor: color })}
|
||||
isActive={appState.openPopup === "canvasColorPicker"}
|
||||
setActive={(active) =>
|
||||
updateData({ openPopup: active ? "canvasColorPicker" : null })
|
||||
}
|
||||
data-testid="canvas-background-picker"
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
updateData={updateData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
68
src/actions/actionElementLock.test.tsx
Normal file
68
src/actions/actionElementLock.test.tsx
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { Excalidraw } from "../packages/excalidraw/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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5,8 +5,11 @@ import { getSelectedElements } from "../scene";
|
|||
import { arrayToMap } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleLock = register({
|
||||
name: "toggleLock",
|
||||
const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.every((el) => !el.locked);
|
||||
|
||||
export const actionToggleElementLock = register({
|
||||
name: "toggleElementLock",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
|
@ -15,20 +18,21 @@ export const actionToggleLock = register({
|
|||
return false;
|
||||
}
|
||||
|
||||
const operation = getOperation(selectedElements);
|
||||
const nextLockState = shouldLock(selectedElements);
|
||||
const selectedElementsMap = arrayToMap(selectedElements);
|
||||
const lock = operation === "lock";
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (!selectedElementsMap.has(element.id)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return newElementWith(element, { locked: lock });
|
||||
return newElementWith(element, { locked: nextLockState });
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedLinearElement: lock ? null : appState.selectedLinearElement,
|
||||
selectedLinearElement: nextLockState
|
||||
? null
|
||||
: appState.selectedLinearElement,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
|
@ -41,7 +45,7 @@ export const actionToggleLock = register({
|
|||
: "labels.elementLock.lock";
|
||||
}
|
||||
|
||||
return getOperation(selected) === "lock"
|
||||
return shouldLock(selected)
|
||||
? "labels.elementLock.lockAll"
|
||||
: "labels.elementLock.unlockAll";
|
||||
},
|
||||
|
@ -55,6 +59,31 @@ export const actionToggleLock = register({
|
|||
},
|
||||
});
|
||||
|
||||
const getOperation = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock");
|
||||
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",
|
||||
});
|
|
@ -4,9 +4,8 @@ import { getNonDeletedElements } from "../element";
|
|||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { resizeMultipleElements } from "../element/resizeElements";
|
||||
import { AppState, PointerDownState } from "../types";
|
||||
import { updateBoundElements } from "../element/binding";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getCommonBoundingBox } from "../element/bounds";
|
||||
|
||||
const enableActionFlipHorizontal = (
|
||||
|
@ -33,7 +32,7 @@ export const actionFlipHorizontal = register({
|
|||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.shiftKey && event.code === "KeyH",
|
||||
keyTest: (event) => event.shiftKey && event.code === CODES.H,
|
||||
contextItemLabel: "labels.flipHorizontal",
|
||||
predicate: (elements, appState) =>
|
||||
enableActionFlipHorizontal(elements, appState),
|
||||
|
@ -50,7 +49,7 @@ export const actionFlipVertical = register({
|
|||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.shiftKey && event.code === "KeyV" && !event[KEYS.CTRL_OR_CMD],
|
||||
event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
|
||||
contextItemLabel: "labels.flipVertical",
|
||||
predicate: (elements, appState) =>
|
||||
enableActionFlipVertical(elements, appState),
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
import { AppState } from "../../src/types";
|
||||
import {
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_STROKE_PICKS,
|
||||
} from "../colors";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||
import { IconPicker } from "../components/IconPicker";
|
||||
// TODO barnabasmolnar/editor-redesign
|
||||
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
|
||||
|
@ -226,10 +232,12 @@ export const actionChangeStrokeColor = register({
|
|||
commitToHistory: !!value.currentItemStrokeColor,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||
<>
|
||||
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
||||
<ColorPicker
|
||||
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
|
||||
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
|
||||
type="elementStroke"
|
||||
label={t("labels.stroke")}
|
||||
color={getFormValue(
|
||||
|
@ -239,12 +247,9 @@ export const actionChangeStrokeColor = register({
|
|||
appState.currentItemStrokeColor,
|
||||
)}
|
||||
onChange={(color) => updateData({ currentItemStrokeColor: color })}
|
||||
isActive={appState.openPopup === "strokeColorPicker"}
|
||||
setActive={(active) =>
|
||||
updateData({ openPopup: active ? "strokeColorPicker" : null })
|
||||
}
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
updateData={updateData}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
|
@ -269,10 +274,12 @@ export const actionChangeBackgroundColor = register({
|
|||
commitToHistory: !!value.currentItemBackgroundColor,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||
<>
|
||||
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
||||
<ColorPicker
|
||||
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
|
||||
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
|
||||
type="elementBackground"
|
||||
label={t("labels.background")}
|
||||
color={getFormValue(
|
||||
|
@ -282,12 +289,9 @@ export const actionChangeBackgroundColor = register({
|
|||
appState.currentItemBackgroundColor,
|
||||
)}
|
||||
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
|
||||
isActive={appState.openPopup === "backgroundColorPicker"}
|
||||
setActive={(active) =>
|
||||
updateData({ openPopup: active ? "backgroundColorPicker" : null })
|
||||
}
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
updateData={updateData}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { t } from "../i18n";
|
||||
import { CODES } from "../keys";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
|
||||
import { fireEvent, render, screen } from "../tests/test-utils";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
togglePopover,
|
||||
} from "../tests/test-utils";
|
||||
import { copiedStyles } from "./actionStyles";
|
||||
|
||||
const { h } = window;
|
||||
|
@ -14,7 +19,14 @@ describe("actionStyles", () => {
|
|||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
});
|
||||
it("should copy & paste styles via keyboard", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
// https://github.com/floating-ui/floating-ui/issues/1908#issuecomment-1301553793
|
||||
// affects node v16+
|
||||
await act(async () => {});
|
||||
});
|
||||
|
||||
it("should copy & paste styles via keyboard", async () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
@ -24,10 +36,10 @@ describe("actionStyles", () => {
|
|||
mouse.up(20, 20);
|
||||
|
||||
// Change some styles of second rectangle
|
||||
UI.clickLabeledElement("Stroke");
|
||||
UI.clickLabeledElement(t("colors.c92a2a"));
|
||||
UI.clickLabeledElement("Background");
|
||||
UI.clickLabeledElement(t("colors.e64980"));
|
||||
togglePopover("Stroke");
|
||||
UI.clickOnTestId("color-red");
|
||||
togglePopover("Background");
|
||||
UI.clickOnTestId("color-blue");
|
||||
// Fill style
|
||||
fireEvent.click(screen.getByTitle("Cross-hatch"));
|
||||
// Stroke width
|
||||
|
@ -60,8 +72,8 @@ describe("actionStyles", () => {
|
|||
|
||||
const firstRect = API.getSelectedElement();
|
||||
expect(firstRect.id).toBe(h.elements[0].id);
|
||||
expect(firstRect.strokeColor).toBe("#c92a2a");
|
||||
expect(firstRect.backgroundColor).toBe("#e64980");
|
||||
expect(firstRect.strokeColor).toBe("#e03131");
|
||||
expect(firstRect.backgroundColor).toBe("#a5d8ff");
|
||||
expect(firstRect.fillStyle).toBe("cross-hatch");
|
||||
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
|
||||
expect(firstRect.strokeStyle).toBe("dotted");
|
||||
|
|
|
@ -84,5 +84,5 @@ export { actionToggleZenMode } from "./actionToggleZenMode";
|
|||
export { actionToggleStats } from "./actionToggleStats";
|
||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
export { actionLink } from "../element/Hyperlink";
|
||||
export { actionToggleLock } from "./actionToggleLock";
|
||||
export { actionToggleElementLock } from "./actionElementLock";
|
||||
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
||||
|
|
|
@ -34,7 +34,7 @@ export type ShortcutName =
|
|||
| "flipHorizontal"
|
||||
| "flipVertical"
|
||||
| "hyperlink"
|
||||
| "toggleLock"
|
||||
| "toggleElementLock"
|
||||
>
|
||||
| "saveScene"
|
||||
| "imageExport";
|
||||
|
@ -80,7 +80,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||
flipVertical: [getShortcutKey("Shift+V")],
|
||||
viewMode: [getShortcutKey("Alt+R")],
|
||||
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
|
||||
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
|
||||
toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
||||
|
|
|
@ -111,7 +111,8 @@ export type ActionName =
|
|||
| "unbindText"
|
||||
| "hyperlink"
|
||||
| "bindText"
|
||||
| "toggleLock"
|
||||
| "unlockAllElements"
|
||||
| "toggleElementLock"
|
||||
| "toggleLinearEditor"
|
||||
| "toggleEraserTool"
|
||||
| "toggleHandTool"
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import oc from "open-color";
|
||||
import { COLOR_PALETTE } from "./colors";
|
||||
import {
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
|
@ -58,7 +58,7 @@ export const getDefaultAppState = (): Omit<
|
|||
fileHandle: null,
|
||||
gridSize: null,
|
||||
isBindingEnabled: true,
|
||||
isSidebarDocked: false,
|
||||
defaultSidebarDockedPreference: false,
|
||||
isLoading: false,
|
||||
isResizing: false,
|
||||
isRotating: false,
|
||||
|
@ -84,7 +84,7 @@ export const getDefaultAppState = (): Omit<
|
|||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
toast: null,
|
||||
viewBackgroundColor: oc.white,
|
||||
viewBackgroundColor: COLOR_PALETTE.white,
|
||||
zenModeEnabled: false,
|
||||
zoom: {
|
||||
value: 1 as NormalizedZoomValue,
|
||||
|
@ -150,7 +150,11 @@ const APP_STATE_STORAGE_CONF = (<
|
|||
gridSize: { browser: true, export: true, server: true },
|
||||
height: { browser: false, export: false, server: false },
|
||||
isBindingEnabled: { browser: false, export: false, server: false },
|
||||
isSidebarDocked: { browser: true, 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 },
|
||||
|
|
|
@ -1,5 +1,14 @@
|
|||
import colors from "./colors";
|
||||
import { DEFAULT_FONT_SIZE, ENV } from "./constants";
|
||||
import {
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_CHART_COLOR_INDEX,
|
||||
getAllColorsSpecificShade,
|
||||
} from "./colors";
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
ENV,
|
||||
VERTICAL_ALIGN,
|
||||
} from "./constants";
|
||||
import { newElement, newLinearElement, newTextElement } from "./element";
|
||||
import { NonDeletedExcalidrawElement } from "./element/types";
|
||||
import { randomId } from "./random";
|
||||
|
@ -153,15 +162,22 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
|||
return result;
|
||||
};
|
||||
|
||||
const bgColors = colors.elementBackground.slice(
|
||||
2,
|
||||
colors.elementBackground.length,
|
||||
);
|
||||
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 = {
|
||||
strokeColor: colors.elementStroke[0],
|
||||
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 getChartDimentions = (spreadsheet: Spreadsheet) => {
|
||||
|
@ -322,7 +338,7 @@ const chartBaseElements = (
|
|||
y: y - chartHeight,
|
||||
width: chartWidth,
|
||||
height: chartHeight,
|
||||
strokeColor: colors.elementStroke[0],
|
||||
strokeColor: COLOR_PALETTE.black,
|
||||
fillStyle: "solid",
|
||||
opacity: 6,
|
||||
})
|
||||
|
|
|
@ -1,6 +1,17 @@
|
|||
import colors from "./colors";
|
||||
import {
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
||||
getAllColorsSpecificShade,
|
||||
} from "./colors";
|
||||
import { AppState } from "./types";
|
||||
|
||||
const BG_COLORS = getAllColorsSpecificShade(
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||
);
|
||||
const STROKE_COLORS = getAllColorsSpecificShade(
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
||||
);
|
||||
|
||||
export const getClientColors = (clientId: string, appState: AppState) => {
|
||||
if (appState?.collaborators) {
|
||||
const currentUser = appState.collaborators.get(clientId);
|
||||
|
@ -11,18 +22,19 @@ export const getClientColors = (clientId: string, appState: AppState) => {
|
|||
// Naive way of getting an integer out of the clientId
|
||||
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
|
||||
|
||||
// Skip transparent & gray colors
|
||||
const backgrounds = colors.elementBackground.slice(3);
|
||||
const strokes = colors.elementStroke.slice(3);
|
||||
return {
|
||||
background: backgrounds[sum % backgrounds.length],
|
||||
stroke: strokes[sum % strokes.length],
|
||||
background: BG_COLORS[sum % BG_COLORS.length],
|
||||
stroke: STROKE_COLORS[sum % STROKE_COLORS.length],
|
||||
};
|
||||
};
|
||||
|
||||
export const getClientInitials = (userName?: string | null) => {
|
||||
if (!userName?.trim()) {
|
||||
return "?";
|
||||
}
|
||||
return userName.trim()[0].toUpperCase();
|
||||
/**
|
||||
* 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();
|
||||
};
|
||||
|
|
183
src/colors.ts
183
src/colors.ts
|
@ -1,22 +1,167 @@
|
|||
import oc from "open-color";
|
||||
import { Merge } from "./utility-types";
|
||||
|
||||
const shades = (index: number) => [
|
||||
oc.red[index],
|
||||
oc.pink[index],
|
||||
oc.grape[index],
|
||||
oc.violet[index],
|
||||
oc.indigo[index],
|
||||
oc.blue[index],
|
||||
oc.cyan[index],
|
||||
oc.teal[index],
|
||||
oc.green[index],
|
||||
oc.lime[index],
|
||||
oc.yellow[index],
|
||||
oc.orange[index],
|
||||
];
|
||||
|
||||
export default {
|
||||
canvasBackground: [oc.white, oc.gray[0], oc.gray[1], ...shades(0)],
|
||||
elementBackground: ["transparent", oc.gray[4], oc.gray[6], ...shades(6)],
|
||||
elementStroke: [oc.black, oc.gray[8], oc.gray[7], ...shades(9)],
|
||||
// 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: string; white: string; transparent: string }
|
||||
>;
|
||||
|
||||
// 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;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
|
|
@ -14,7 +14,7 @@ import {
|
|||
hasText,
|
||||
} from "../scene";
|
||||
import { SHAPES } from "../shapes";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import { UIAppState, Zoom } from "../types";
|
||||
import {
|
||||
capitalizeString,
|
||||
isTransparent,
|
||||
|
@ -28,19 +28,20 @@ import { trackEvent } from "../analytics";
|
|||
import { hasBoundTextElement } from "../element/typeChecks";
|
||||
import clsx from "clsx";
|
||||
import { actionToggleZenMode } from "../actions";
|
||||
import "./Actions.scss";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import {
|
||||
shouldAllowVerticalAlign,
|
||||
suppportsHorizontalAlign,
|
||||
} from "../element/textElement";
|
||||
|
||||
import "./Actions.scss";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
elements,
|
||||
renderAction,
|
||||
}: {
|
||||
appState: AppState;
|
||||
appState: UIAppState;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
renderAction: ActionManager["renderAction"];
|
||||
}) => {
|
||||
|
@ -215,10 +216,10 @@ export const ShapesSwitcher = ({
|
|||
appState,
|
||||
}: {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
activeTool: AppState["activeTool"];
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
activeTool: UIAppState["activeTool"];
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||
appState: AppState;
|
||||
appState: UIAppState;
|
||||
}) => (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
|
|
|
@ -33,7 +33,7 @@ import {
|
|||
actionBindText,
|
||||
actionUngroup,
|
||||
actionLink,
|
||||
actionToggleLock,
|
||||
actionToggleElementLock,
|
||||
actionToggleLinearEditor,
|
||||
} from "../actions";
|
||||
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
|
||||
|
@ -59,6 +59,7 @@ import {
|
|||
ELEMENT_TRANSLATE_AMOUNT,
|
||||
ENV,
|
||||
EVENT,
|
||||
EXPORT_IMAGE_TYPES,
|
||||
GRID_SIZE,
|
||||
IMAGE_MIME_TYPES,
|
||||
IMAGE_RENDER_TIMEOUT,
|
||||
|
@ -82,7 +83,7 @@ import {
|
|||
VERTICAL_ALIGN,
|
||||
ZOOM_STEP,
|
||||
} from "../constants";
|
||||
import { loadFromBlob } from "../data";
|
||||
import { exportCanvas, loadFromBlob } from "../data";
|
||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||
import { restore, restoreElements } from "../data/restore";
|
||||
import {
|
||||
|
@ -210,6 +211,8 @@ import {
|
|||
PointerDownState,
|
||||
SceneData,
|
||||
Device,
|
||||
SidebarName,
|
||||
SidebarTabName,
|
||||
} from "../types";
|
||||
import {
|
||||
debounce,
|
||||
|
@ -235,6 +238,7 @@ import {
|
|||
getShortcutKey,
|
||||
isTransparent,
|
||||
easeToValuesRAF,
|
||||
muteFSAbortError,
|
||||
} from "../utils";
|
||||
import {
|
||||
ContextMenu,
|
||||
|
@ -249,6 +253,7 @@ import {
|
|||
generateIdFromFile,
|
||||
getDataURL,
|
||||
getFileFromEvent,
|
||||
isImageFileHandle,
|
||||
isSupportedImageFile,
|
||||
loadSceneOrLibraryFromBlob,
|
||||
normalizeFile,
|
||||
|
@ -288,6 +293,7 @@ import {
|
|||
isLocalLink,
|
||||
} from "../element/Hyperlink";
|
||||
import { shouldShowBoundingBox } from "../element/transformHandles";
|
||||
import { actionUnlockAllElements } from "../actions/actionElementLock";
|
||||
import { Fonts } from "../scene/Fonts";
|
||||
import { actionPaste } from "../actions/actionClipboard";
|
||||
import {
|
||||
|
@ -299,11 +305,15 @@ import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
|||
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||
|
||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||
|
||||
const deviceContextInitialValue = {
|
||||
isSmScreen: false,
|
||||
isMobile: false,
|
||||
isTouchScreen: false,
|
||||
canDeviceFitSidebar: false,
|
||||
isLandscape: false,
|
||||
};
|
||||
const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
|
||||
DeviceContext.displayName = "DeviceContext";
|
||||
|
@ -340,6 +350,8 @@ const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
|
|||
);
|
||||
ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
|
||||
|
||||
export const useApp = () => useContext(AppContext);
|
||||
export const useAppProps = () => useContext(AppPropsContext);
|
||||
export const useDevice = () => useContext<Device>(DeviceContext);
|
||||
export const useExcalidrawContainer = () =>
|
||||
useContext(ExcalidrawContainerContext);
|
||||
|
@ -400,7 +412,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
private nearestScrollableContainer: HTMLElement | Document | undefined;
|
||||
public library: AppClassProperties["library"];
|
||||
public libraryItemsFromStorage: LibraryItems | undefined;
|
||||
private id: string;
|
||||
public id: string;
|
||||
private history: History;
|
||||
private excalidrawContainerValue: {
|
||||
container: HTMLDivElement | null;
|
||||
|
@ -438,7 +450,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
showHyperlinkPopup: false,
|
||||
isSidebarDocked: false,
|
||||
defaultSidebarDockedPreference: false,
|
||||
};
|
||||
|
||||
this.id = nanoid();
|
||||
|
@ -469,7 +481,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
setActiveTool: this.setActiveTool,
|
||||
setCursor: this.setCursor,
|
||||
resetCursor: this.resetCursor,
|
||||
toggleMenu: this.toggleMenu,
|
||||
toggleSidebar: this.toggleSidebar,
|
||||
} as const;
|
||||
if (typeof excalidrawRef === "function") {
|
||||
excalidrawRef(api);
|
||||
|
@ -577,6 +589,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||
this.props.handleKeyboardGlobally ? undefined : this.onKeyDown
|
||||
}
|
||||
>
|
||||
<AppContext.Provider value={this}>
|
||||
<AppPropsContext.Provider value={this.props}>
|
||||
<ExcalidrawContainerContext.Provider
|
||||
value={this.excalidrawContainerValue}
|
||||
>
|
||||
|
@ -599,27 +613,16 @@ class App extends React.Component<AppProps, AppState> {
|
|||
onLockToggle={this.toggleLock}
|
||||
onPenModeToggle={this.togglePenMode}
|
||||
onHandToolToggle={this.onHandToolToggle}
|
||||
onInsertElements={(elements) =>
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements,
|
||||
position: "center",
|
||||
files: null,
|
||||
})
|
||||
}
|
||||
langCode={getLanguage().code}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
renderCustomSidebar={this.props.renderSidebar}
|
||||
showExitZenModeBtn={
|
||||
typeof this.props?.zenModeEnabled === "undefined" &&
|
||||
this.state.zenModeEnabled
|
||||
}
|
||||
libraryReturnUrl={this.props.libraryReturnUrl}
|
||||
UIOptions={this.props.UIOptions}
|
||||
focusContainer={this.focusContainer}
|
||||
library={this.library}
|
||||
id={this.id}
|
||||
onImageAction={this.onImageAction}
|
||||
onExportImage={this.onExportImage}
|
||||
renderWelcomeScreen={
|
||||
!this.state.isLoading &&
|
||||
this.state.showWelcomeScreen &&
|
||||
|
@ -659,19 +662,19 @@ class App extends React.Component<AppProps, AppState> {
|
|||
)}
|
||||
<main>{this.renderCanvas()}</main>
|
||||
</ExcalidrawActionManagerContext.Provider>
|
||||
</ExcalidrawElementsContext.Provider>{" "}
|
||||
</ExcalidrawElementsContext.Provider>
|
||||
</ExcalidrawAppStateContext.Provider>
|
||||
</ExcalidrawSetAppStateContext.Provider>
|
||||
</DeviceContext.Provider>
|
||||
</ExcalidrawContainerContext.Provider>
|
||||
</AppPropsContext.Provider>
|
||||
</AppContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public focusContainer: AppClassProperties["focusContainer"] = () => {
|
||||
if (this.props.autoFocus) {
|
||||
this.excalidrawContainerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
public getSceneElementsIncludingDeleted = () => {
|
||||
|
@ -682,6 +685,45 @@ class App extends React.Component<AppProps, AppState> {
|
|||
return this.scene.getNonDeletedElements();
|
||||
};
|
||||
|
||||
public onInsertElements = (elements: readonly ExcalidrawElement[]) => {
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements,
|
||||
position: "center",
|
||||
files: null,
|
||||
});
|
||||
};
|
||||
|
||||
public onExportImage = async (
|
||||
type: keyof typeof EXPORT_IMAGE_TYPES,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
) => {
|
||||
trackEvent("export", type, "ui");
|
||||
const fileHandle = await exportCanvas(
|
||||
type,
|
||||
elements,
|
||||
this.state,
|
||||
this.files,
|
||||
{
|
||||
exportBackground: this.state.exportBackground,
|
||||
name: this.state.name,
|
||||
viewBackgroundColor: this.state.viewBackgroundColor,
|
||||
},
|
||||
)
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
this.setState({ errorMessage: error.message });
|
||||
});
|
||||
|
||||
if (
|
||||
this.state.exportEmbedScene &&
|
||||
fileHandle &&
|
||||
isImageFileHandle(fileHandle)
|
||||
) {
|
||||
this.setState({ fileHandle });
|
||||
}
|
||||
};
|
||||
|
||||
private syncActionResult = withBatchedUpdates(
|
||||
(actionResult: ActionResult) => {
|
||||
if (this.unmounted || actionResult === false) {
|
||||
|
@ -906,6 +948,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
? this.props.UIOptions.dockedSidebarBreakpoint
|
||||
: MQ_RIGHT_SIDEBAR_MIN_WIDTH;
|
||||
this.device = updateObject(this.device, {
|
||||
isLandscape: width > height,
|
||||
isSmScreen: width < MQ_SM_MAX_WIDTH,
|
||||
isMobile:
|
||||
width < MQ_MAX_WIDTH_PORTRAIT ||
|
||||
|
@ -951,7 +994,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
this.scene.addCallback(this.onSceneUpdated);
|
||||
this.addEventListeners();
|
||||
|
||||
if (this.excalidrawContainerRef.current) {
|
||||
if (this.props.autoFocus && this.excalidrawContainerRef.current) {
|
||||
this.focusContainer();
|
||||
}
|
||||
|
||||
|
@ -1679,7 +1722,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
openSidebar:
|
||||
this.state.openSidebar &&
|
||||
this.device.canDeviceFitSidebar &&
|
||||
this.state.isSidebarDocked
|
||||
this.state.defaultSidebarDockedPreference
|
||||
? this.state.openSidebar
|
||||
: null,
|
||||
selectedElementIds: newElements.reduce(
|
||||
|
@ -2017,30 +2060,24 @@ class App extends React.Component<AppProps, AppState> {
|
|||
/**
|
||||
* @returns whether the menu was toggled on or off
|
||||
*/
|
||||
public toggleMenu = (
|
||||
type: "library" | "customSidebar",
|
||||
force?: boolean,
|
||||
): boolean => {
|
||||
if (type === "customSidebar" && !this.props.renderSidebar) {
|
||||
console.warn(
|
||||
`attempting to toggle "customSidebar", but no "props.renderSidebar" is defined`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (type === "library" || type === "customSidebar") {
|
||||
let nextValue;
|
||||
public toggleSidebar = ({
|
||||
name,
|
||||
tab,
|
||||
force,
|
||||
}: {
|
||||
name: SidebarName;
|
||||
tab?: SidebarTabName;
|
||||
force?: boolean;
|
||||
}): boolean => {
|
||||
let nextName;
|
||||
if (force === undefined) {
|
||||
nextValue = this.state.openSidebar === type ? null : type;
|
||||
nextName = this.state.openSidebar?.name === name ? null : name;
|
||||
} else {
|
||||
nextValue = force ? type : null;
|
||||
nextName = force ? name : null;
|
||||
}
|
||||
this.setState({ openSidebar: nextValue });
|
||||
this.setState({ openSidebar: nextName ? { name: nextName, tab } : null });
|
||||
|
||||
return !!nextValue;
|
||||
}
|
||||
|
||||
return false;
|
||||
return !!nextName;
|
||||
};
|
||||
|
||||
private updateCurrentCursorPosition = withBatchedUpdates(
|
||||
|
@ -2288,11 +2325,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||
(hasBackground(this.state.activeTool.type) ||
|
||||
selectedElements.some((element) => hasBackground(element.type)))
|
||||
) {
|
||||
this.setState({ openPopup: "backgroundColorPicker" });
|
||||
this.setState({ openPopup: "elementBackground" });
|
||||
event.stopPropagation();
|
||||
}
|
||||
if (event.key === KEYS.S) {
|
||||
this.setState({ openPopup: "strokeColorPicker" });
|
||||
this.setState({ openPopup: "elementStroke" });
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
@ -6348,6 +6385,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
copyText,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionSelectAll,
|
||||
actionUnlockAllElements,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionToggleGridMode,
|
||||
actionToggleZenMode,
|
||||
|
@ -6394,7 +6432,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
actionToggleLinearEditor,
|
||||
actionLink,
|
||||
actionDuplicateSelection,
|
||||
actionToggleLock,
|
||||
actionToggleElementLock,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionDeleteSelected,
|
||||
];
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import "./Avatar.scss";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { getClientInitials } from "../clients";
|
||||
import { getNameInitial } from "../clients";
|
||||
|
||||
type AvatarProps = {
|
||||
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
|
@ -12,7 +12,7 @@ type AvatarProps = {
|
|||
};
|
||||
|
||||
export const Avatar = ({ color, onClick, name, src }: AvatarProps) => {
|
||||
const shortName = getClientInitials(name);
|
||||
const shortName = getNameInitial(name);
|
||||
const [error, setError] = useState(false);
|
||||
const loadImg = !error && src;
|
||||
const style = loadImg ? undefined : { background: color };
|
||||
|
|
|
@ -1,39 +1,40 @@
|
|||
import { t } from "../i18n";
|
||||
import Trans from "./Trans";
|
||||
|
||||
const BraveMeasureTextError = () => {
|
||||
return (
|
||||
<div data-testid="brave-measure-text-error">
|
||||
<p>
|
||||
{t("errors.brave_measure_text_error.start")}
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{t("errors.brave_measure_text_error.aggressive_block_fingerprint")}
|
||||
</span>{" "}
|
||||
{t("errors.brave_measure_text_error.setting_enabled")}.
|
||||
<br />
|
||||
<br />
|
||||
{t("errors.brave_measure_text_error.break")}{" "}
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{t("errors.brave_measure_text_error.text_elements")}
|
||||
</span>{" "}
|
||||
{t("errors.brave_measure_text_error.in_your_drawings")}.
|
||||
<Trans
|
||||
i18nKey="errors.brave_measure_text_error.line1"
|
||||
bold={(el) => <span style={{ fontWeight: 600 }}>{el}</span>}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
{t("errors.brave_measure_text_error.strongly_recommend")}{" "}
|
||||
<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">
|
||||
{" "}
|
||||
{t("errors.brave_measure_text_error.steps")}
|
||||
</a>{" "}
|
||||
{t("errors.brave_measure_text_error.how")}.
|
||||
{el}
|
||||
</a>
|
||||
)}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
{t("errors.brave_measure_text_error.disable_setting")}{" "}
|
||||
<Trans
|
||||
i18nKey="errors.brave_measure_text_error.line4"
|
||||
issueLink={(el) => (
|
||||
<a href="https://github.com/excalidraw/excalidraw/issues/new">
|
||||
{t("errors.brave_measure_text_error.issue")}
|
||||
</a>{" "}
|
||||
{t("errors.brave_measure_text_error.write")}{" "}
|
||||
<a href="https://discord.gg/UexuTaE">
|
||||
{t("errors.brave_measure_text_error.discord")}
|
||||
{el}
|
||||
</a>
|
||||
.
|
||||
)}
|
||||
discordLink={(el) => <a href="https://discord.gg/UexuTaE">{el}.</a>}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
import clsx from "clsx";
|
||||
import { composeEventHandlers } from "../utils";
|
||||
import "./Button.scss";
|
||||
|
||||
interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
type?: "button" | "submit" | "reset";
|
||||
onSelect: () => any;
|
||||
/** whether button is in active state */
|
||||
selected?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
@ -15,18 +19,18 @@ interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
|
|||
export const Button = ({
|
||||
type = "button",
|
||||
onSelect,
|
||||
selected,
|
||||
children,
|
||||
className = "",
|
||||
...rest
|
||||
}: ButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
onClick={(event) => {
|
||||
onClick={composeEventHandlers(rest.onClick, (event) => {
|
||||
onSelect();
|
||||
rest.onClick?.(event);
|
||||
}}
|
||||
})}
|
||||
type={type}
|
||||
className={`excalidraw-button ${className}`}
|
||||
className={clsx("excalidraw-button", className, { selected })}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
|
|
|
@ -1,430 +0,0 @@
|
|||
import React from "react";
|
||||
import { Popover } from "./Popover";
|
||||
import { isTransparent } from "../utils";
|
||||
|
||||
import "./ColorPicker.scss";
|
||||
import { isArrowKey, KEYS } from "../keys";
|
||||
import { t, getLanguage } from "../i18n";
|
||||
import { isWritableElement } from "../utils";
|
||||
import colors from "../colors";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
|
||||
const MAX_CUSTOM_COLORS = 5;
|
||||
const MAX_DEFAULT_COLORS = 15;
|
||||
|
||||
export const getCustomColors = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
type: "elementBackground" | "elementStroke",
|
||||
) => {
|
||||
const customColors: string[] = [];
|
||||
const updatedElements = elements
|
||||
.filter((element) => !element.isDeleted)
|
||||
.sort((ele1, ele2) => ele2.updated - ele1.updated);
|
||||
|
||||
let index = 0;
|
||||
const elementColorTypeMap = {
|
||||
elementBackground: "backgroundColor",
|
||||
elementStroke: "strokeColor",
|
||||
};
|
||||
const colorType = elementColorTypeMap[type] as
|
||||
| "backgroundColor"
|
||||
| "strokeColor";
|
||||
while (
|
||||
index < updatedElements.length &&
|
||||
customColors.length < MAX_CUSTOM_COLORS
|
||||
) {
|
||||
const element = updatedElements[index];
|
||||
|
||||
if (
|
||||
customColors.length < MAX_CUSTOM_COLORS &&
|
||||
isCustomColor(element[colorType], type) &&
|
||||
!customColors.includes(element[colorType])
|
||||
) {
|
||||
customColors.push(element[colorType]);
|
||||
}
|
||||
index++;
|
||||
}
|
||||
return customColors;
|
||||
};
|
||||
|
||||
const isCustomColor = (
|
||||
color: string,
|
||||
type: "elementBackground" | "elementStroke",
|
||||
) => {
|
||||
return !colors[type].includes(color);
|
||||
};
|
||||
|
||||
const isValidColor = (color: string) => {
|
||||
const style = new Option().style;
|
||||
style.color = color;
|
||||
return !!style.color;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// This is a narrow reimplementation of the awesome react-color Twitter component
|
||||
// https://github.com/casesandberg/react-color/blob/master/src/components/twitter/Twitter.js
|
||||
|
||||
// Unfortunately, we can't detect keyboard layout in the browser. So this will
|
||||
// only work well for QWERTY but not AZERTY or others...
|
||||
const keyBindings = [
|
||||
["1", "2", "3", "4", "5"],
|
||||
["q", "w", "e", "r", "t"],
|
||||
["a", "s", "d", "f", "g"],
|
||||
["z", "x", "c", "v", "b"],
|
||||
].flat();
|
||||
|
||||
const Picker = ({
|
||||
colors,
|
||||
color,
|
||||
onChange,
|
||||
onClose,
|
||||
label,
|
||||
showInput = true,
|
||||
type,
|
||||
elements,
|
||||
}: {
|
||||
colors: string[];
|
||||
color: string | null;
|
||||
onChange: (color: string) => void;
|
||||
onClose: () => void;
|
||||
label: string;
|
||||
showInput: boolean;
|
||||
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
||||
elements: readonly ExcalidrawElement[];
|
||||
}) => {
|
||||
const firstItem = React.useRef<HTMLButtonElement>();
|
||||
const activeItem = React.useRef<HTMLButtonElement>();
|
||||
const gallery = React.useRef<HTMLDivElement>();
|
||||
const colorInput = React.useRef<HTMLInputElement>();
|
||||
|
||||
const [customColors] = React.useState(() => {
|
||||
if (type === "canvasBackground") {
|
||||
return [];
|
||||
}
|
||||
return getCustomColors(elements, type);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
// After the component is first mounted focus on first input
|
||||
if (activeItem.current) {
|
||||
activeItem.current.focus();
|
||||
} else if (colorInput.current) {
|
||||
colorInput.current.focus();
|
||||
} else if (gallery.current) {
|
||||
gallery.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
let handled = false;
|
||||
if (isArrowKey(event.key)) {
|
||||
handled = true;
|
||||
const { activeElement } = document;
|
||||
const isRTL = getLanguage().rtl;
|
||||
let isCustom = false;
|
||||
let index = Array.prototype.indexOf.call(
|
||||
gallery.current!.querySelector(".color-picker-content--default")
|
||||
?.children,
|
||||
activeElement,
|
||||
);
|
||||
if (index === -1) {
|
||||
index = Array.prototype.indexOf.call(
|
||||
gallery.current!.querySelector(".color-picker-content--canvas-colors")
|
||||
?.children,
|
||||
activeElement,
|
||||
);
|
||||
if (index !== -1) {
|
||||
isCustom = true;
|
||||
}
|
||||
}
|
||||
const parentElement = isCustom
|
||||
? gallery.current?.querySelector(".color-picker-content--canvas-colors")
|
||||
: gallery.current?.querySelector(".color-picker-content--default");
|
||||
|
||||
if (parentElement && index !== -1) {
|
||||
const length = parentElement.children.length - (showInput ? 1 : 0);
|
||||
const nextIndex =
|
||||
event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT)
|
||||
? (index + 1) % length
|
||||
: event.key === (isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT)
|
||||
? (length + index - 1) % length
|
||||
: !isCustom && event.key === KEYS.ARROW_DOWN
|
||||
? (index + 5) % length
|
||||
: !isCustom && event.key === KEYS.ARROW_UP
|
||||
? (length + index - 5) % length
|
||||
: index;
|
||||
(parentElement.children[nextIndex] as HTMLElement | undefined)?.focus();
|
||||
}
|
||||
event.preventDefault();
|
||||
} else if (
|
||||
keyBindings.includes(event.key.toLowerCase()) &&
|
||||
!event[KEYS.CTRL_OR_CMD] &&
|
||||
!event.altKey &&
|
||||
!isWritableElement(event.target)
|
||||
) {
|
||||
handled = true;
|
||||
const index = keyBindings.indexOf(event.key.toLowerCase());
|
||||
const isCustom = index >= MAX_DEFAULT_COLORS;
|
||||
const parentElement = isCustom
|
||||
? gallery?.current?.querySelector(
|
||||
".color-picker-content--canvas-colors",
|
||||
)
|
||||
: gallery?.current?.querySelector(".color-picker-content--default");
|
||||
const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index;
|
||||
(
|
||||
parentElement?.children[actualIndex] as HTMLElement | undefined
|
||||
)?.focus();
|
||||
|
||||
event.preventDefault();
|
||||
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
||||
handled = true;
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
if (handled) {
|
||||
event.nativeEvent.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const renderColors = (colors: Array<string>, custom: boolean = false) => {
|
||||
return colors.map((_color, i) => {
|
||||
const _colorWithoutHash = _color.replace("#", "");
|
||||
const keyBinding = custom
|
||||
? keyBindings[i + MAX_DEFAULT_COLORS]
|
||||
: keyBindings[i];
|
||||
const label = custom
|
||||
? _colorWithoutHash
|
||||
: t(`colors.${_colorWithoutHash}`);
|
||||
return (
|
||||
<button
|
||||
className="color-picker-swatch"
|
||||
onClick={(event) => {
|
||||
(event.currentTarget as HTMLButtonElement).focus();
|
||||
onChange(_color);
|
||||
}}
|
||||
title={`${label}${
|
||||
!isTransparent(_color) ? ` (${_color})` : ""
|
||||
} — ${keyBinding.toUpperCase()}`}
|
||||
aria-label={label}
|
||||
aria-keyshortcuts={keyBindings[i]}
|
||||
style={{ color: _color }}
|
||||
key={_color}
|
||||
ref={(el) => {
|
||||
if (!custom && el && i === 0) {
|
||||
firstItem.current = el;
|
||||
}
|
||||
if (el && _color === color) {
|
||||
activeItem.current = el;
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
onChange(_color);
|
||||
}}
|
||||
>
|
||||
{isTransparent(_color) ? (
|
||||
<div className="color-picker-transparent"></div>
|
||||
) : undefined}
|
||||
<span className="color-picker-keybinding">{keyBinding}</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`color-picker color-picker-type-${type}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("labels.colorPicker")}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="color-picker-triangle color-picker-triangle-shadow"></div>
|
||||
<div className="color-picker-triangle"></div>
|
||||
<div
|
||||
className="color-picker-content"
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
gallery.current = el;
|
||||
}
|
||||
}}
|
||||
// to allow focusing by clicking but not by tabbing
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="color-picker-content--default">
|
||||
{renderColors(colors)}
|
||||
</div>
|
||||
{!!customColors.length && (
|
||||
<div className="color-picker-content--canvas">
|
||||
<span className="color-picker-content--canvas-title">
|
||||
{t("labels.canvasColors")}
|
||||
</span>
|
||||
<div className="color-picker-content--canvas-colors">
|
||||
{renderColors(customColors, true)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showInput && (
|
||||
<ColorInput
|
||||
color={color}
|
||||
label={label}
|
||||
onChange={(color) => {
|
||||
onChange(color);
|
||||
}}
|
||||
ref={colorInput}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ColorInput = React.forwardRef(
|
||||
(
|
||||
{
|
||||
color,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
color: string | null;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [innerValue, setInnerValue] = React.useState(color);
|
||||
const inputRef = React.useRef(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setInnerValue(color);
|
||||
}, [color]);
|
||||
|
||||
React.useImperativeHandle(ref, () => inputRef.current);
|
||||
|
||||
const changeColor = React.useCallback(
|
||||
(inputValue: string) => {
|
||||
const value = inputValue.toLowerCase();
|
||||
const color = getColor(value);
|
||||
if (color) {
|
||||
onChange(color);
|
||||
}
|
||||
setInnerValue(value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<label className="color-input-container">
|
||||
<div className="color-picker-hash">#</div>
|
||||
<input
|
||||
spellCheck={false}
|
||||
className="color-picker-input"
|
||||
aria-label={label}
|
||||
onChange={(event) => changeColor(event.target.value)}
|
||||
value={(innerValue || "").replace(/^#/, "")}
|
||||
onBlur={() => setInnerValue(color)}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ColorInput.displayName = "ColorInput";
|
||||
|
||||
export const ColorPicker = ({
|
||||
type,
|
||||
color,
|
||||
onChange,
|
||||
label,
|
||||
isActive,
|
||||
setActive,
|
||||
elements,
|
||||
appState,
|
||||
}: {
|
||||
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
||||
color: string | null;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
setActive: (active: boolean) => void;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
}) => {
|
||||
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
||||
const coords = pickerButton.current?.getBoundingClientRect();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="color-picker-control-container">
|
||||
<div className="color-picker-label-swatch-container">
|
||||
<button
|
||||
className="color-picker-label-swatch"
|
||||
aria-label={label}
|
||||
style={color ? { "--swatch-color": color } : undefined}
|
||||
onClick={() => setActive(!isActive)}
|
||||
ref={pickerButton}
|
||||
/>
|
||||
</div>
|
||||
<ColorInput
|
||||
color={color}
|
||||
label={label}
|
||||
onChange={(color) => {
|
||||
onChange(color);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<React.Suspense fallback="">
|
||||
{isActive ? (
|
||||
<div
|
||||
className="color-picker-popover-container"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: coords?.top,
|
||||
left: coords?.right,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<Popover
|
||||
onCloseRequest={(event) =>
|
||||
event.target !== pickerButton.current && setActive(false)
|
||||
}
|
||||
>
|
||||
<Picker
|
||||
colors={colors[type]}
|
||||
color={color || null}
|
||||
onChange={(changedColor) => {
|
||||
onChange(changedColor);
|
||||
}}
|
||||
onClose={() => {
|
||||
setActive(false);
|
||||
pickerButton.current?.focus();
|
||||
}}
|
||||
label={label}
|
||||
showInput={false}
|
||||
type={type}
|
||||
elements={elements}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
) : null}
|
||||
</React.Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
75
src/components/ColorPicker/ColorInput.tsx
Normal file
75
src/components/ColorPicker/ColorInput.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getColor } from "./ColorPicker";
|
||||
import { useAtom } from "jotai";
|
||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||
import { KEYS } from "../../keys";
|
||||
|
||||
interface ColorInputProps {
|
||||
color: string | null;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const ColorInput = ({ color, onChange, label }: ColorInputProps) => {
|
||||
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 divRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [activeSection]);
|
||||
|
||||
return (
|
||||
<label 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={(e) => {
|
||||
if (e.key === KEYS.TAB) {
|
||||
return;
|
||||
}
|
||||
if (e.key === KEYS.ESCAPE) {
|
||||
divRef.current?.focus();
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
|
@ -1,6 +1,134 @@
|
|||
@import "../css/variables.module";
|
||||
@import "../../css/variables.module";
|
||||
|
||||
.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);
|
||||
|
@ -72,11 +200,17 @@
|
|||
}
|
||||
}
|
||||
|
||||
.color-picker-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.color-picker-content--default {
|
||||
padding: 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, auto);
|
||||
grid-gap: 0.5rem;
|
||||
grid-template-columns: repeat(5, 1.875rem);
|
||||
grid-gap: 0.25rem;
|
||||
border-radius: 4px;
|
||||
|
||||
&:focus {
|
||||
|
@ -178,6 +312,27 @@
|
|||
}
|
||||
}
|
||||
|
||||
.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%;
|
235
src/components/ColorPicker/ColorPicker.tsx
Normal file
235
src/components/ColorPicker/ColorPicker.tsx
Normal file
|
@ -0,0 +1,235 @@
|
|||
import { isTransparent } 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 { ColorInput } from "./ColorInput";
|
||||
import { t } from "../../i18n";
|
||||
import clsx from "clsx";
|
||||
|
||||
import "./ColorPicker.scss";
|
||||
import React from "react";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export interface ColorPickerProps {
|
||||
type: ColorPickerType;
|
||||
color: string | null;
|
||||
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"
|
||||
| "label"
|
||||
| "elements"
|
||||
| "palette"
|
||||
| "updateData"
|
||||
>) => {
|
||||
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
||||
|
||||
const { container } = useExcalidrawContainer();
|
||||
const { isMobile, isLandscape } = useDevice();
|
||||
|
||||
const colorInputJSX = (
|
||||
<div>
|
||||
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
|
||||
<ColorInput
|
||||
color={color}
|
||||
label={label}
|
||||
onChange={(color) => {
|
||||
onChange(color);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover.Portal container={container}>
|
||||
<Popover.Content
|
||||
className="focus-visible-none"
|
||||
data-prevent-outside-click
|
||||
onCloseAutoFocus={(e) => {
|
||||
// return focus to excalidraw container
|
||||
if (container) {
|
||||
container.focus();
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setActiveColorPickerSection(null);
|
||||
}}
|
||||
side={isMobile && !isLandscape ? "bottom" : "right"}
|
||||
align={isMobile && !isLandscape ? "center" : "start"}
|
||||
alignOffset={-16}
|
||||
sideOffset={20}
|
||||
style={{
|
||||
zIndex: 9999,
|
||||
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 || null}
|
||||
onChange={(changedColor) => {
|
||||
onChange(changedColor);
|
||||
}}
|
||||
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 | null;
|
||||
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>
|
||||
);
|
||||
};
|
63
src/components/ColorPicker/CustomColorList.tsx
Normal file
63
src/components/ColorPicker/CustomColorList.tsx
Normal file
|
@ -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 | null;
|
||||
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
src/components/ColorPicker/HotkeyLabel.tsx
Normal file
29
src/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;
|
156
src/components/ColorPicker/Picker.tsx
Normal file
156
src/components/ColorPicker/Picker.tsx
Normal file
|
@ -0,0 +1,156 @@
|
|||
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,
|
||||
getColorNameAndShadeFromHex,
|
||||
getMostUsedCustomColors,
|
||||
isCustomColor,
|
||||
} from "./colorPickerUtils";
|
||||
import {
|
||||
ColorPaletteCustom,
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
||||
} from "../../colors";
|
||||
|
||||
interface PickerProps {
|
||||
color: string | null;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
type: ColorPickerType;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
palette: ColorPaletteCustom;
|
||||
updateData: (formData?: any) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Picker = ({
|
||||
color,
|
||||
onChange,
|
||||
label,
|
||||
type,
|
||||
elements,
|
||||
palette,
|
||||
updateData,
|
||||
children,
|
||||
}: PickerProps) => {
|
||||
const [customColors] = React.useState(() => {
|
||||
if (type === "canvasBackground") {
|
||||
return [];
|
||||
}
|
||||
return getMostUsedCustomColors(elements, type, palette);
|
||||
});
|
||||
|
||||
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
|
||||
activeColorPickerSectionAtom,
|
||||
);
|
||||
|
||||
const colorObj = getColorNameAndShadeFromHex({
|
||||
hex: color || "transparent",
|
||||
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);
|
||||
}
|
||||
}, [colorObj]);
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-modal="true" aria-label={t("labels.colorPicker")}>
|
||||
<div
|
||||
onKeyDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
colorPickerKeyNavHandler({
|
||||
e,
|
||||
activeColorPickerSection,
|
||||
palette,
|
||||
hex: color,
|
||||
onChange,
|
||||
customColors,
|
||||
setActiveColorPickerSection,
|
||||
updateData,
|
||||
activeShade,
|
||||
});
|
||||
}}
|
||||
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>
|
||||
);
|
||||
};
|
86
src/components/ColorPicker/PickerColorList.tsx
Normal file
86
src/components/ColorPicker/PickerColorList.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
import clsx from "clsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
activeColorPickerSectionAtom,
|
||||
colorPickerHotkeyBindings,
|
||||
getColorNameAndShadeFromHex,
|
||||
} from "./colorPickerUtils";
|
||||
import HotkeyLabel from "./HotkeyLabel";
|
||||
import { ColorPaletteCustom } from "../../colors";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
interface PickerColorListProps {
|
||||
palette: ColorPaletteCustom;
|
||||
color: string | null;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
activeShade: number;
|
||||
}
|
||||
|
||||
const PickerColorList = ({
|
||||
palette,
|
||||
color,
|
||||
onChange,
|
||||
label,
|
||||
activeShade,
|
||||
}: PickerColorListProps) => {
|
||||
const colorObj = getColorNameAndShadeFromHex({
|
||||
hex: 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+/, "")}`, 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;
|
7
src/components/ColorPicker/PickerHeading.tsx
Normal file
7
src/components/ColorPicker/PickerHeading.tsx
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { ReactNode } from "react";
|
||||
|
||||
const PickerHeading = ({ children }: { children: ReactNode }) => (
|
||||
<div className="color-picker__heading">{children}</div>
|
||||
);
|
||||
|
||||
export default PickerHeading;
|
105
src/components/ColorPicker/ShadeList.tsx
Normal file
105
src/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,
|
||||
getColorNameAndShadeFromHex,
|
||||
} from "./colorPickerUtils";
|
||||
import HotkeyLabel from "./HotkeyLabel";
|
||||
import { t } from "../../i18n";
|
||||
import { ColorPaletteCustom } from "../../colors";
|
||||
|
||||
interface ShadeListProps {
|
||||
hex: string | null;
|
||||
onChange: (color: string) => void;
|
||||
palette: ColorPaletteCustom;
|
||||
}
|
||||
|
||||
export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
|
||||
const colorObj = getColorNameAndShadeFromHex({
|
||||
hex: 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>
|
||||
);
|
||||
};
|
64
src/components/ColorPicker/TopPicks.tsx
Normal file
64
src/components/ColorPicker/TopPicks.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
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 | null;
|
||||
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)}
|
||||
>
|
||||
<div className="color-picker__button-outline" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
139
src/components/ColorPicker/colorPickerUtils.ts
Normal file
139
src/components/ColorPicker/colorPickerUtils.ts
Normal file
|
@ -0,0 +1,139 @@
|
|||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { atom } from "jotai";
|
||||
import {
|
||||
ColorPickerColor,
|
||||
ColorPaletteCustom,
|
||||
MAX_CUSTOM_COLORS_USED_IN_CANVAS,
|
||||
} from "../../colors";
|
||||
|
||||
export const getColorNameAndShadeFromHex = ({
|
||||
palette,
|
||||
hex,
|
||||
}: {
|
||||
palette: ColorPaletteCustom;
|
||||
hex: string;
|
||||
}): {
|
||||
colorName: ColorPickerColor;
|
||||
shade: number | null;
|
||||
} | null => {
|
||||
for (const [colorName, colorVal] of Object.entries(palette)) {
|
||||
if (Array.isArray(colorVal)) {
|
||||
const shade = colorVal.indexOf(hex);
|
||||
if (shade > -1) {
|
||||
return { colorName: colorName as ColorPickerColor, shade };
|
||||
}
|
||||
} else if (colorVal === hex) {
|
||||
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 | null;
|
||||
palette: ColorPaletteCustom;
|
||||
}) => {
|
||||
if (!color) {
|
||||
return false;
|
||||
}
|
||||
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";
|
249
src/components/ColorPicker/keyboardNavHandlers.ts
Normal file
249
src/components/ColorPicker/keyboardNavHandlers.ts
Normal file
|
@ -0,0 +1,249 @@
|
|||
import {
|
||||
ColorPickerColor,
|
||||
ColorPalette,
|
||||
ColorPaletteCustom,
|
||||
COLORS_PER_ROW,
|
||||
COLOR_PALETTE,
|
||||
} from "../../colors";
|
||||
import { KEYS } from "../../keys";
|
||||
import { ValueOf } from "../../utility-types";
|
||||
import {
|
||||
ActiveColorPickerSectionAtomType,
|
||||
colorPickerHotkeyBindings,
|
||||
getColorNameAndShadeFromHex,
|
||||
} 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;
|
||||
}
|
||||
|
||||
const hotkeyHandler = ({
|
||||
e,
|
||||
colorObj,
|
||||
onChange,
|
||||
palette,
|
||||
customColors,
|
||||
setActiveColorPickerSection,
|
||||
activeShade,
|
||||
}: HotkeyHandlerProps) => {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
};
|
||||
|
||||
interface ColorPickerKeyNavHandlerProps {
|
||||
e: React.KeyboardEvent;
|
||||
activeColorPickerSection: ActiveColorPickerSectionAtomType;
|
||||
palette: ColorPaletteCustom;
|
||||
hex: string | null;
|
||||
onChange: (color: string) => void;
|
||||
customColors: string[];
|
||||
setActiveColorPickerSection: (
|
||||
update: React.SetStateAction<ActiveColorPickerSectionAtomType>,
|
||||
) => void;
|
||||
updateData: (formData?: any) => void;
|
||||
activeShade: number;
|
||||
}
|
||||
|
||||
export const colorPickerKeyNavHandler = ({
|
||||
e,
|
||||
activeColorPickerSection,
|
||||
palette,
|
||||
hex,
|
||||
onChange,
|
||||
customColors,
|
||||
setActiveColorPickerSection,
|
||||
updateData,
|
||||
activeShade,
|
||||
}: ColorPickerKeyNavHandlerProps) => {
|
||||
if (e.key === KEYS.ESCAPE || !hex) {
|
||||
updateData({ openPopup: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const colorObj = getColorNameAndShadeFromHex({ hex, palette });
|
||||
|
||||
if (e.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 = e.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(hex);
|
||||
} else if (shades === hex) {
|
||||
return name;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!baseColorName) {
|
||||
onChange(COLOR_PALETTE.black);
|
||||
}
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
hotkeyHandler({
|
||||
e,
|
||||
colorObj,
|
||||
onChange,
|
||||
palette,
|
||||
customColors,
|
||||
setActiveColorPickerSection,
|
||||
activeShade,
|
||||
});
|
||||
|
||||
if (activeColorPickerSection === "shades") {
|
||||
if (colorObj) {
|
||||
const { shade } = colorObj;
|
||||
const newShade = arrowHandler(e.key, shade, COLORS_PER_ROW);
|
||||
|
||||
if (newShade !== undefined) {
|
||||
onChange(palette[colorObj.colorName][newShade]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeColorPickerSection === "baseColors") {
|
||||
if (colorObj) {
|
||||
const { colorName } = colorObj;
|
||||
const colorNames = Object.keys(palette) as (keyof ColorPalette)[];
|
||||
const indexOfColorName = colorNames.indexOf(colorName);
|
||||
|
||||
const newColorIndex = arrowHandler(
|
||||
e.key,
|
||||
indexOfColorName,
|
||||
colorNames.length,
|
||||
);
|
||||
|
||||
if (newColorIndex !== undefined) {
|
||||
const newColorName = colorNames[newColorIndex];
|
||||
const newColorNameValue = palette[newColorName];
|
||||
|
||||
onChange(
|
||||
Array.isArray(newColorNameValue)
|
||||
? newColorNameValue[activeShade]
|
||||
: newColorNameValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeColorPickerSection === "custom") {
|
||||
const indexOfColor = customColors.indexOf(hex);
|
||||
|
||||
const newColorIndex = arrowHandler(
|
||||
e.key,
|
||||
indexOfColor,
|
||||
customColors.length,
|
||||
);
|
||||
|
||||
if (newColorIndex !== undefined) {
|
||||
const newColor = customColors[newColorIndex];
|
||||
onChange(newColor);
|
||||
}
|
||||
}
|
||||
};
|
|
@ -4,8 +4,8 @@ import { Dialog, DialogProps } from "./Dialog";
|
|||
import "./ConfirmDialog.scss";
|
||||
import DialogActionButton from "./DialogActionButton";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
|
||||
import { useExcalidrawSetAppState } from "./App";
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||
import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App";
|
||||
import { jotaiScope } from "../jotai";
|
||||
|
||||
interface Props extends Omit<DialogProps, "onCloseRequest"> {
|
||||
|
@ -26,6 +26,7 @@ const ConfirmDialog = (props: Props) => {
|
|||
} = props;
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
|
||||
const { container } = useExcalidrawContainer();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
@ -42,6 +43,7 @@ const ConfirmDialog = (props: Props) => {
|
|||
setAppState({ openMenu: null });
|
||||
setIsLibraryMenuOpen(false);
|
||||
onCancel();
|
||||
container?.focus();
|
||||
}}
|
||||
/>
|
||||
<DialogActionButton
|
||||
|
@ -50,6 +52,7 @@ const ConfirmDialog = (props: Props) => {
|
|||
setAppState({ openMenu: null });
|
||||
setIsLibraryMenuOpen(false);
|
||||
onConfirm();
|
||||
container?.focus();
|
||||
}}
|
||||
actionType="danger"
|
||||
/>
|
||||
|
|
144
src/components/DefaultSidebar.test.tsx
Normal file
144
src/components/DefaultSidebar.test.tsx
Normal file
|
@ -0,0 +1,144 @@
|
|||
import React from "react";
|
||||
import { DEFAULT_SIDEBAR } from "../constants";
|
||||
import { DefaultSidebar } from "../packages/excalidraw/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
src/components/DefaultSidebar.tsx
Normal file
118
src/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,
|
||||
},
|
||||
);
|
|
@ -15,7 +15,7 @@ import { Modal } from "./Modal";
|
|||
import { AppState } from "../types";
|
||||
import { queryFocusableElements } from "../utils";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||
import { jotaiScope } from "../jotai";
|
||||
|
||||
export interface DialogProps {
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
import { t } from "../i18n";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import "./HintViewer.scss";
|
||||
import { AppState, Device } from "../types";
|
||||
import { Device, UIAppState } from "../types";
|
||||
import {
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
|
@ -13,8 +11,10 @@ import {
|
|||
import { getShortcutKey } from "../utils";
|
||||
import { isEraserActive } from "../appState";
|
||||
|
||||
import "./HintViewer.scss";
|
||||
|
||||
interface HintViewerProps {
|
||||
appState: AppState;
|
||||
appState: UIAppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
isMobile: boolean;
|
||||
device: Device;
|
||||
|
@ -29,7 +29,7 @@ const getHints = ({
|
|||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const multiMode = appState.multiElement !== null;
|
||||
|
||||
if (appState.openSidebar === "library" && !device.canDeviceFitSidebar) {
|
||||
if (appState.openSidebar && !device.canDeviceFitSidebar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
@ -4,18 +4,23 @@ import { canvasToBlob } from "../data/blob";
|
|||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import { AppClassProperties, BinaryFiles, UIAppState } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { clipboard } from "./icons";
|
||||
import Stack from "./Stack";
|
||||
import "./ExportDialog.scss";
|
||||
import OpenColor from "open-color";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
import { DEFAULT_EXPORT_PADDING, isFirefox } from "../constants";
|
||||
import {
|
||||
DEFAULT_EXPORT_PADDING,
|
||||
EXPORT_IMAGE_TYPES,
|
||||
isFirefox,
|
||||
} from "../constants";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { exportToCanvas } from "../packages/utils";
|
||||
|
||||
import "./ExportDialog.scss";
|
||||
|
||||
const supportsContextFilters =
|
||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||
|
||||
|
@ -64,21 +69,14 @@ const ImageExportModal = ({
|
|||
elements,
|
||||
appState,
|
||||
files,
|
||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||
actionManager,
|
||||
onExportToPng,
|
||||
onExportToSvg,
|
||||
onExportToClipboard,
|
||||
onExportImage,
|
||||
}: {
|
||||
appState: AppState;
|
||||
appState: UIAppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
exportPadding?: number;
|
||||
actionManager: ActionManager;
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
onCloseRequest: () => void;
|
||||
onExportImage: AppClassProperties["onExportImage"];
|
||||
}) => {
|
||||
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
||||
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
||||
|
@ -89,10 +87,6 @@ const ImageExportModal = ({
|
|||
? getSelectedElements(elements, appState, true)
|
||||
: elements;
|
||||
|
||||
useEffect(() => {
|
||||
setExportSelected(someElementIsSelected);
|
||||
}, [someElementIsSelected]);
|
||||
|
||||
useEffect(() => {
|
||||
const previewNode = previewRef.current;
|
||||
if (!previewNode) {
|
||||
|
@ -106,7 +100,7 @@ const ImageExportModal = ({
|
|||
elements: exportedElements,
|
||||
appState,
|
||||
files,
|
||||
exportPadding,
|
||||
exportPadding: DEFAULT_EXPORT_PADDING,
|
||||
maxWidthOrHeight: maxWidth,
|
||||
})
|
||||
.then((canvas) => {
|
||||
|
@ -121,7 +115,7 @@ const ImageExportModal = ({
|
|||
console.error(error);
|
||||
setRenderError(error);
|
||||
});
|
||||
}, [appState, files, exportedElements, exportPadding]);
|
||||
}, [appState, files, exportedElements]);
|
||||
|
||||
return (
|
||||
<div className="ExportDialog">
|
||||
|
@ -176,7 +170,9 @@ const ImageExportModal = ({
|
|||
color="indigo"
|
||||
title={t("buttons.exportToPng")}
|
||||
aria-label={t("buttons.exportToPng")}
|
||||
onClick={() => onExportToPng(exportedElements)}
|
||||
onClick={() =>
|
||||
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements)
|
||||
}
|
||||
>
|
||||
PNG
|
||||
</ExportButton>
|
||||
|
@ -184,7 +180,9 @@ const ImageExportModal = ({
|
|||
color="red"
|
||||
title={t("buttons.exportToSvg")}
|
||||
aria-label={t("buttons.exportToSvg")}
|
||||
onClick={() => onExportToSvg(exportedElements)}
|
||||
onClick={() =>
|
||||
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements)
|
||||
}
|
||||
>
|
||||
SVG
|
||||
</ExportButton>
|
||||
|
@ -193,7 +191,9 @@ const ImageExportModal = ({
|
|||
{(probablySupportsClipboardBlob || isFirefox) && (
|
||||
<ExportButton
|
||||
title={t("buttons.copyPngToClipboard")}
|
||||
onClick={() => onExportToClipboard(exportedElements)}
|
||||
onClick={() =>
|
||||
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements)
|
||||
}
|
||||
color="gray"
|
||||
shade={7}
|
||||
>
|
||||
|
@ -208,45 +208,31 @@ const ImageExportModal = ({
|
|||
export const ImageExportDialog = ({
|
||||
elements,
|
||||
appState,
|
||||
setAppState,
|
||||
files,
|
||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||
actionManager,
|
||||
onExportToPng,
|
||||
onExportToSvg,
|
||||
onExportToClipboard,
|
||||
onExportImage,
|
||||
onCloseRequest,
|
||||
}: {
|
||||
appState: AppState;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
appState: UIAppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
exportPadding?: number;
|
||||
actionManager: ActionManager;
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
onExportImage: AppClassProperties["onExportImage"];
|
||||
onCloseRequest: () => void;
|
||||
}) => {
|
||||
const handleClose = React.useCallback(() => {
|
||||
setAppState({ openDialog: null });
|
||||
}, [setAppState]);
|
||||
if (appState.openDialog !== "imageExport") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{appState.openDialog === "imageExport" && (
|
||||
<Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}>
|
||||
<Dialog onCloseRequest={onCloseRequest} title={t("buttons.exportImage")}>
|
||||
<ImageExportModal
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
exportPadding={exportPadding}
|
||||
actionManager={actionManager}
|
||||
onExportToPng={onExportToPng}
|
||||
onExportToSvg={onExportToSvg}
|
||||
onExportToClipboard={onExportToClipboard}
|
||||
onCloseRequest={handleClose}
|
||||
onExportImage={onExportImage}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from "react";
|
|||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { AppState, ExportOpts, BinaryFiles } from "../types";
|
||||
import { ExportOpts, BinaryFiles, UIAppState } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { exportToFileIcon, LinkIcon } from "./icons";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
@ -28,7 +28,7 @@ const JSONExportModal = ({
|
|||
exportOpts,
|
||||
canvas,
|
||||
}: {
|
||||
appState: AppState;
|
||||
appState: UIAppState;
|
||||
files: BinaryFiles;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
actionManager: ActionManager;
|
||||
|
@ -96,12 +96,12 @@ export const JSONExportDialog = ({
|
|||
setAppState,
|
||||
}: {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appState: AppState;
|
||||
appState: UIAppState;
|
||||
files: BinaryFiles;
|
||||
actionManager: ActionManager;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
}) => {
|
||||
const handleClose = React.useCallback(() => {
|
||||
setAppState({ openDialog: null });
|
||||
|
|
|
@ -1,18 +1,23 @@
|
|||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
|
||||
import { exportCanvas } from "../data";
|
||||
import { CLASSES, DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_WIDTH } from "../constants";
|
||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { Language, t } from "../i18n";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
||||
import { isShallowEqual, muteFSAbortError } from "../utils";
|
||||
import {
|
||||
AppProps,
|
||||
AppState,
|
||||
ExcalidrawProps,
|
||||
BinaryFiles,
|
||||
UIAppState,
|
||||
AppClassProperties,
|
||||
} from "../types";
|
||||
import { capitalizeString, isShallowEqual } from "../utils";
|
||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { ErrorDialog } from "./ErrorDialog";
|
||||
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
||||
import { ImageExportDialog } from "./ImageExportDialog";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
import { HintViewer } from "./HintViewer";
|
||||
import { Island } from "./Island";
|
||||
|
@ -24,32 +29,31 @@ import { Section } from "./Section";
|
|||
import { HelpDialog } from "./HelpDialog";
|
||||
import Stack from "./Stack";
|
||||
import { UserList } from "./UserList";
|
||||
import Library from "../data/library";
|
||||
import { JSONExportDialog } from "./JSONExportDialog";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
import { LibraryMenu } from "./LibraryMenu";
|
||||
|
||||
import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
import { PenModeButton } from "./PenModeButton";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useDevice } from "../components/App";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions/actionToggleStats";
|
||||
import Footer from "./footer/Footer";
|
||||
import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
|
||||
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { Provider, useAtom } from "jotai";
|
||||
import { Provider, useAtomValue } from "jotai";
|
||||
import MainMenu from "./main-menu/MainMenu";
|
||||
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
|
||||
import { HandButton } from "./HandButton";
|
||||
import { isHandToolActive } from "../appState";
|
||||
import { TunnelsContext, useInitializeTunnels } from "./context/tunnels";
|
||||
import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
|
||||
import { LibraryIcon } from "./icons";
|
||||
import { UIAppStateContext } from "../context/ui-appState";
|
||||
import { DefaultSidebar } from "./DefaultSidebar";
|
||||
|
||||
import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
appState: AppState;
|
||||
appState: UIAppState;
|
||||
files: BinaryFiles;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
|
@ -57,18 +61,13 @@ interface LayerUIProps {
|
|||
onLockToggle: () => void;
|
||||
onHandToolToggle: () => void;
|
||||
onPenModeToggle: () => void;
|
||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||
showExitZenModeBtn: boolean;
|
||||
langCode: Language["code"];
|
||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
onExportImage: AppClassProperties["onExportImage"];
|
||||
renderWelcomeScreen: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
@ -109,17 +108,12 @@ const LayerUI = ({
|
|||
onLockToggle,
|
||||
onHandToolToggle,
|
||||
onPenModeToggle,
|
||||
onInsertElements,
|
||||
showExitZenModeBtn,
|
||||
renderTopRightUI,
|
||||
renderCustomStats,
|
||||
renderCustomSidebar,
|
||||
libraryReturnUrl,
|
||||
UIOptions,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
onImageAction,
|
||||
onExportImage,
|
||||
renderWelcomeScreen,
|
||||
children,
|
||||
}: LayerUIProps) => {
|
||||
|
@ -149,46 +143,14 @@ const LayerUI = ({
|
|||
return null;
|
||||
}
|
||||
|
||||
const createExporter =
|
||||
(type: ExportType): ExportCB =>
|
||||
async (exportedElements) => {
|
||||
trackEvent("export", type, "ui");
|
||||
const fileHandle = await exportCanvas(
|
||||
type,
|
||||
exportedElements,
|
||||
appState,
|
||||
files,
|
||||
{
|
||||
exportBackground: appState.exportBackground,
|
||||
name: appState.name,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
},
|
||||
)
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
|
||||
if (
|
||||
appState.exportEmbedScene &&
|
||||
fileHandle &&
|
||||
isImageFileHandle(fileHandle)
|
||||
) {
|
||||
setAppState({ fileHandle });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ImageExportDialog
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
files={files}
|
||||
actionManager={actionManager}
|
||||
onExportToPng={createExporter("png")}
|
||||
onExportToSvg={createExporter("svg")}
|
||||
onExportToClipboard={createExporter("clipboard")}
|
||||
onExportImage={onExportImage}
|
||||
onCloseRequest={() => setAppState({ openDialog: null })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -197,8 +159,8 @@ const LayerUI = ({
|
|||
<div style={{ position: "relative" }}>
|
||||
{/* wrapping to Fragment stops React from occasionally complaining
|
||||
about identical Keys */}
|
||||
<tunnels.mainMenuTunnel.Out />
|
||||
{renderWelcomeScreen && <tunnels.welcomeScreenMenuHintTunnel.Out />}
|
||||
<tunnels.MainMenuTunnel.Out />
|
||||
{renderWelcomeScreen && <tunnels.WelcomeScreenMenuHintTunnel.Out />}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
@ -250,7 +212,7 @@ const LayerUI = ({
|
|||
{(heading: React.ReactNode) => (
|
||||
<div style={{ position: "relative" }}>
|
||||
{renderWelcomeScreen && (
|
||||
<tunnels.welcomeScreenToolbarHintTunnel.Out />
|
||||
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
|
||||
)}
|
||||
<Stack.Col gap={4} align="start">
|
||||
<Stack.Row
|
||||
|
@ -324,8 +286,11 @@ const LayerUI = ({
|
|||
>
|
||||
<UserList collaborators={appState.collaborators} />
|
||||
{renderTopRightUI?.(device.isMobile, appState)}
|
||||
{!appState.viewModeEnabled && (
|
||||
<LibraryButton appState={appState} setAppState={setAppState} />
|
||||
{!appState.viewModeEnabled &&
|
||||
// hide button when sidebar docked
|
||||
(!isSidebarDocked ||
|
||||
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
|
||||
<tunnels.DefaultSidebarTriggerTunnel.Out />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -334,21 +299,21 @@ const LayerUI = ({
|
|||
};
|
||||
|
||||
const renderSidebars = () => {
|
||||
return appState.openSidebar === "customSidebar" ? (
|
||||
renderCustomSidebar?.() || null
|
||||
) : appState.openSidebar === "library" ? (
|
||||
<LibraryMenu
|
||||
appState={appState}
|
||||
onInsertElements={onInsertElements}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
id={id}
|
||||
return (
|
||||
<DefaultSidebar
|
||||
__fallback
|
||||
onDock={(docked) => {
|
||||
trackEvent(
|
||||
"sidebar",
|
||||
`toggleDock (${docked ? "dock" : "undock"})`,
|
||||
`(${device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
);
|
||||
};
|
||||
|
||||
const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope);
|
||||
const isSidebarDocked = useAtomValue(isSidebarDockedAtom, jotaiScope);
|
||||
|
||||
const layerUIJSX = (
|
||||
<>
|
||||
|
@ -360,6 +325,23 @@ const LayerUI = ({
|
|||
tunneled away. We only render tunneled components that actually
|
||||
have defaults when host do not render anything. */}
|
||||
<DefaultMainMenu UIOptions={UIOptions} />
|
||||
<DefaultSidebar.Trigger
|
||||
__fallback
|
||||
icon={LibraryIcon}
|
||||
title={capitalizeString(t("toolBar.library"))}
|
||||
onToggle={(open) => {
|
||||
if (open) {
|
||||
trackEvent(
|
||||
"sidebar",
|
||||
`${DEFAULT_SIDEBAR.name} (open)`,
|
||||
`button (${device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
tab={DEFAULT_SIDEBAR.defaultTab}
|
||||
>
|
||||
{t("toolBar.library")}
|
||||
</DefaultSidebar.Trigger>
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
|
||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||
|
@ -382,7 +364,6 @@ const LayerUI = ({
|
|||
<PasteChartDialog
|
||||
setAppState={setAppState}
|
||||
appState={appState}
|
||||
onInsertChart={onInsertElements}
|
||||
onClose={() =>
|
||||
setAppState({
|
||||
pasteDialog: { shown: false, data: null },
|
||||
|
@ -410,7 +391,6 @@ const LayerUI = ({
|
|||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!device.isMobile && (
|
||||
<>
|
||||
<div
|
||||
|
@ -422,15 +402,14 @@ const LayerUI = ({
|
|||
!isTextElement(appState.editingElement)),
|
||||
})}
|
||||
style={
|
||||
((appState.openSidebar === "library" &&
|
||||
appState.isSidebarDocked) ||
|
||||
hostSidebarCounters.docked) &&
|
||||
appState.openSidebar &&
|
||||
isSidebarDocked &&
|
||||
device.canDeviceFitSidebar
|
||||
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{renderWelcomeScreen && <tunnels.welcomeScreenCenterTunnel.Out />}
|
||||
{renderWelcomeScreen && <tunnels.WelcomeScreenCenterTunnel.Out />}
|
||||
{renderFixedSideContainer()}
|
||||
<Footer
|
||||
appState={appState}
|
||||
|
@ -453,9 +432,9 @@ const LayerUI = ({
|
|||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState({
|
||||
setAppState((appState) => ({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
});
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
|
@ -469,19 +448,25 @@ const LayerUI = ({
|
|||
);
|
||||
|
||||
return (
|
||||
<UIAppStateContext.Provider value={appState}>
|
||||
<Provider scope={tunnels.jotaiScope}>
|
||||
<TunnelsContext.Provider value={tunnels}>
|
||||
{layerUIJSX}
|
||||
</TunnelsContext.Provider>
|
||||
</Provider>
|
||||
</UIAppStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const stripIrrelevantAppStateProps = (
|
||||
appState: AppState,
|
||||
): Partial<AppState> => {
|
||||
const { suggestedBindings, startBoundElement, cursorButton, ...ret } =
|
||||
appState;
|
||||
const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => {
|
||||
const {
|
||||
suggestedBindings,
|
||||
startBoundElement,
|
||||
cursorButton,
|
||||
scrollX,
|
||||
scrollY,
|
||||
...ret
|
||||
} = appState;
|
||||
return ret;
|
||||
};
|
||||
|
||||
|
@ -491,24 +476,19 @@ const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
|
|||
return false;
|
||||
}
|
||||
|
||||
const {
|
||||
canvas: _prevCanvas,
|
||||
// not stable, but shouldn't matter in our case
|
||||
onInsertElements: _prevOnInsertElements,
|
||||
appState: prevAppState,
|
||||
...prev
|
||||
} = prevProps;
|
||||
const {
|
||||
canvas: _nextCanvas,
|
||||
onInsertElements: _nextOnInsertElements,
|
||||
appState: nextAppState,
|
||||
...next
|
||||
} = nextProps;
|
||||
const { canvas: _prevCanvas, appState: prevAppState, ...prev } = prevProps;
|
||||
const { canvas: _nextCanvas, appState: nextAppState, ...next } = nextProps;
|
||||
|
||||
return (
|
||||
isShallowEqual(
|
||||
stripIrrelevantAppStateProps(prevAppState),
|
||||
stripIrrelevantAppStateProps(nextAppState),
|
||||
// asserting AppState because we're being passed the whole AppState
|
||||
// but resolve to only the UI-relevant props
|
||||
stripIrrelevantAppStateProps(prevAppState as AppState),
|
||||
stripIrrelevantAppStateProps(nextAppState as AppState),
|
||||
{
|
||||
selectedElementIds: isShallowEqual,
|
||||
selectedGroupIds: isShallowEqual,
|
||||
},
|
||||
) && isShallowEqual(prev, next)
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
@import "../css/variables.module";
|
||||
|
||||
.library-button {
|
||||
@include outlineButtonStyles;
|
||||
|
||||
background-color: var(--island-bg-color);
|
||||
|
||||
width: auto;
|
||||
height: var(--lg-button-size);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
line-height: 0;
|
||||
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.4px;
|
||||
|
||||
svg {
|
||||
width: var(--lg-icon-size);
|
||||
height: var(--lg-icon-size);
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: none;
|
||||
|
||||
@media screen and (min-width: 1024px) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
import React from "react";
|
||||
import { t } from "../i18n";
|
||||
import { AppState } from "../types";
|
||||
import { capitalizeString } from "../utils";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useDevice } from "./App";
|
||||
import "./LibraryButton.scss";
|
||||
import { LibraryIcon } from "./icons";
|
||||
|
||||
export const LibraryButton: React.FC<{
|
||||
appState: AppState;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
isMobile?: boolean;
|
||||
}> = ({ appState, setAppState, isMobile }) => {
|
||||
const device = useDevice();
|
||||
const showLabel = !isMobile;
|
||||
|
||||
// TODO barnabasmolnar/redesign
|
||||
// not great, toolbar jumps in a jarring manner
|
||||
if (appState.isSidebarDocked && appState.openSidebar === "library") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<label title={`${capitalizeString(t("toolBar.library"))}`}>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
name="editor-library"
|
||||
onChange={(event) => {
|
||||
document
|
||||
.querySelector(".layer-ui__wrapper")
|
||||
?.classList.remove("animate");
|
||||
const isOpen = event.target.checked;
|
||||
setAppState({ openSidebar: isOpen ? "library" : null });
|
||||
// track only openings
|
||||
if (isOpen) {
|
||||
trackEvent(
|
||||
"library",
|
||||
"toggleLibrary (open)",
|
||||
`toolbar (${device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
checked={appState.openSidebar === "library"}
|
||||
aria-label={capitalizeString(t("toolBar.library"))}
|
||||
aria-keyshortcuts="0"
|
||||
/>
|
||||
<div className="library-button">
|
||||
<div>{LibraryIcon}</div>
|
||||
{showLabel && (
|
||||
<div className="library-button__label">{t("toolBar.library")}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
|
@ -1,38 +1,11 @@
|
|||
@import "open-color/open-color";
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__library-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layer-ui__library {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
flex: 1 1 auto;
|
||||
|
||||
.layer-ui__library-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 2px 0 15px 0;
|
||||
.Spinner {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
// 2px from the left to account for focus border of left-most button
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__sidebar {
|
||||
.library-menu-items-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.library-actions-counter {
|
||||
|
@ -87,10 +60,27 @@
|
|||
}
|
||||
}
|
||||
|
||||
.library-menu-browse-button {
|
||||
margin: 1rem auto;
|
||||
.library-menu-control-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.625rem;
|
||||
position: relative;
|
||||
|
||||
padding: 0.875rem 1rem;
|
||||
&--at-bottom::before {
|
||||
content: "";
|
||||
width: calc(100% - 1.5rem);
|
||||
height: 1px;
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
background: var(--sidebar-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.library-menu-browse-button {
|
||||
flex: 1;
|
||||
|
||||
height: var(--lg-button-size);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -122,34 +112,39 @@
|
|||
}
|
||||
}
|
||||
|
||||
.library-menu-browse-button--mobile {
|
||||
min-height: 22px;
|
||||
margin-left: auto;
|
||||
a {
|
||||
padding-right: 0;
|
||||
}
|
||||
&.excalidraw--mobile .library-menu-browse-button {
|
||||
height: var(--default-button-size);
|
||||
}
|
||||
|
||||
.layer-ui__sidebar__header .dropdown-menu {
|
||||
&.dropdown-menu--mobile {
|
||||
top: 100%;
|
||||
}
|
||||
.dropdown-menu-container {
|
||||
--gap: 0;
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
.layer-ui__library .dropdown-menu {
|
||||
width: auto;
|
||||
top: initial;
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
left: initial;
|
||||
bottom: 100%;
|
||||
margin-bottom: 0.625rem;
|
||||
|
||||
.dropdown-menu-container {
|
||||
width: 196px;
|
||||
box-shadow: var(--library-dropdown-shadow);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__library .library-menu-dropdown-container {
|
||||
position: relative;
|
||||
|
||||
&--in-heading {
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 0.75rem;
|
||||
z-index: 1;
|
||||
|
||||
.dropdown-menu {
|
||||
top: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,76 +1,38 @@
|
|||
import {
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
RefObject,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import React, { useState, useCallback } from "react";
|
||||
import Library, {
|
||||
distributeLibraryItemsOnSquareGrid,
|
||||
libraryItemsAtom,
|
||||
} from "../data/library";
|
||||
import { t } from "../i18n";
|
||||
import { randomId } from "../random";
|
||||
import { LibraryItems, LibraryItem, AppState, ExcalidrawProps } from "../types";
|
||||
|
||||
import "./LibraryMenu.scss";
|
||||
import {
|
||||
LibraryItems,
|
||||
LibraryItem,
|
||||
ExcalidrawProps,
|
||||
UIAppState,
|
||||
} from "../types";
|
||||
import LibraryMenuItems from "./LibraryMenuItems";
|
||||
import { EVENT } from "../constants";
|
||||
import { KEYS } from "../keys";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useAtom } from "jotai";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import Spinner from "./Spinner";
|
||||
import {
|
||||
useDevice,
|
||||
useApp,
|
||||
useAppProps,
|
||||
useExcalidrawElements,
|
||||
useExcalidrawSetAppState,
|
||||
} from "./App";
|
||||
import { Sidebar } from "./Sidebar/Sidebar";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { LibraryMenuHeader } from "./LibraryMenuHeaderContent";
|
||||
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
|
||||
import { useUIAppState } from "../context/ui-appState";
|
||||
|
||||
const useOnClickOutside = (
|
||||
ref: RefObject<HTMLElement>,
|
||||
cb: (event: MouseEvent) => void,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const listener = (event: MouseEvent) => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
import "./LibraryMenu.scss";
|
||||
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
||||
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
(ref.current.contains(event.target) ||
|
||||
!document.body.contains(event.target))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
export const isLibraryMenuOpenAtom = atom(false);
|
||||
|
||||
cb(event);
|
||||
const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
return <div className="layer-ui__library">{children}</div>;
|
||||
};
|
||||
document.addEventListener("pointerdown", listener, false);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", listener);
|
||||
};
|
||||
}, [ref, cb]);
|
||||
};
|
||||
|
||||
const LibraryMenuWrapper = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ children: React.ReactNode }
|
||||
>(({ children }, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="layer-ui__library">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const LibraryMenuContent = ({
|
||||
onInsertLibraryItems,
|
||||
|
@ -87,11 +49,11 @@ export const LibraryMenuContent = ({
|
|||
pendingElements: LibraryItem["elements"];
|
||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||
onAddToLibrary: () => void;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
library: Library;
|
||||
id: string;
|
||||
appState: AppState;
|
||||
appState: UIAppState;
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||
}) => {
|
||||
|
@ -158,7 +120,9 @@ export const LibraryMenuContent = ({
|
|||
theme={appState.theme}
|
||||
/>
|
||||
{showBtn && (
|
||||
<LibraryMenuBrowseButton
|
||||
<LibraryMenuControlButtons
|
||||
className="library-menu-control-buttons--at-bottom"
|
||||
style={{ padding: "16px 12px 0 12px" }}
|
||||
id={id}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
theme={appState.theme}
|
||||
|
@ -168,71 +132,18 @@ export const LibraryMenuContent = ({
|
|||
);
|
||||
};
|
||||
|
||||
export const LibraryMenu: React.FC<{
|
||||
appState: AppState;
|
||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
}> = ({
|
||||
appState,
|
||||
onInsertElements,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
}) => {
|
||||
/**
|
||||
* This component is meant to be rendered inside <Sidebar.Tab/> inside our
|
||||
* <DefaultSidebar/> or host apps Sidebar components.
|
||||
*/
|
||||
export const LibraryMenu = () => {
|
||||
const { library, id, onInsertElements } = useApp();
|
||||
const appProps = useAppProps();
|
||||
const appState = useUIAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const elements = useExcalidrawElements();
|
||||
const device = useDevice();
|
||||
|
||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const closeLibrary = useCallback(() => {
|
||||
const isDialogOpen = !!document.querySelector(".Dialog");
|
||||
|
||||
// Prevent closing if any dialog is open
|
||||
if (isDialogOpen) {
|
||||
return;
|
||||
}
|
||||
setAppState({ openSidebar: null });
|
||||
}, [setAppState]);
|
||||
|
||||
useOnClickOutside(
|
||||
ref,
|
||||
useCallback(
|
||||
(event) => {
|
||||
// If click on the library icon, do nothing so that LibraryButton
|
||||
// can toggle library menu
|
||||
if ((event.target as Element).closest(".ToolIcon__library")) {
|
||||
return;
|
||||
}
|
||||
if (!appState.isSidebarDocked || !device.canDeviceFitSidebar) {
|
||||
closeLibrary();
|
||||
}
|
||||
},
|
||||
[closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === KEYS.ESCAPE &&
|
||||
(!appState.isSidebarDocked || !device.canDeviceFitSidebar)
|
||||
) {
|
||||
closeLibrary();
|
||||
}
|
||||
};
|
||||
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
};
|
||||
}, [closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar]);
|
||||
|
||||
const deselectItems = useCallback(() => {
|
||||
setAppState({
|
||||
|
@ -241,55 +152,7 @@ export const LibraryMenu: React.FC<{
|
|||
});
|
||||
}, [setAppState]);
|
||||
|
||||
const removeFromLibrary = useCallback(
|
||||
async (libraryItems: LibraryItems) => {
|
||||
const nextItems = libraryItems.filter(
|
||||
(item) => !selectedItems.includes(item.id),
|
||||
);
|
||||
library.setLibrary(nextItems).catch(() => {
|
||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||
});
|
||||
setSelectedItems([]);
|
||||
},
|
||||
[library, setAppState, selectedItems, setSelectedItems],
|
||||
);
|
||||
|
||||
const resetLibrary = useCallback(() => {
|
||||
library.resetLibrary();
|
||||
focusContainer();
|
||||
}, [library, focusContainer]);
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
__isInternal
|
||||
// necessary to remount when switching between internal
|
||||
// and custom (host app) sidebar, so that the `props.onClose`
|
||||
// is colled correctly
|
||||
key="library"
|
||||
className="layer-ui__library-sidebar"
|
||||
initialDockedState={appState.isSidebarDocked}
|
||||
onDock={(docked) => {
|
||||
trackEvent(
|
||||
"library",
|
||||
`toggleLibraryDock (${docked ? "dock" : "undock"})`,
|
||||
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<Sidebar.Header className="layer-ui__library-header">
|
||||
<LibraryMenuHeader
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={setSelectedItems}
|
||||
library={library}
|
||||
onRemoveFromLibrary={() =>
|
||||
removeFromLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
resetLibrary={resetLibrary}
|
||||
/>
|
||||
</Sidebar.Header>
|
||||
<LibraryMenuContent
|
||||
pendingElements={getSelectedElements(elements, appState, true)}
|
||||
onInsertLibraryItems={(libraryItems) => {
|
||||
|
@ -297,13 +160,12 @@ export const LibraryMenu: React.FC<{
|
|||
}}
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
libraryReturnUrl={appProps.libraryReturnUrl}
|
||||
library={library}
|
||||
id={id}
|
||||
appState={appState}
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={setSelectedItems}
|
||||
/>
|
||||
</Sidebar>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { VERSIONS } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import { ExcalidrawProps, UIAppState } from "../types";
|
||||
|
||||
const LibraryMenuBrowseButton = ({
|
||||
theme,
|
||||
|
@ -8,7 +8,7 @@ const LibraryMenuBrowseButton = ({
|
|||
libraryReturnUrl,
|
||||
}: {
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
theme: AppState["theme"];
|
||||
theme: UIAppState["theme"];
|
||||
id: string;
|
||||
}) => {
|
||||
const referrer =
|
||||
|
|
33
src/components/LibraryMenuControlButtons.tsx
Normal file
33
src/components/LibraryMenuControlButtons.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { ExcalidrawProps, UIAppState } from "../types";
|
||||
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const LibraryMenuControlButtons = ({
|
||||
libraryReturnUrl,
|
||||
theme,
|
||||
id,
|
||||
style,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
theme: UIAppState["theme"];
|
||||
id: string;
|
||||
style: React.CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("library-menu-control-buttons", className)}
|
||||
style={style}
|
||||
>
|
||||
<LibraryMenuBrowseButton
|
||||
id={id}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
theme={theme}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,8 +1,11 @@
|
|||
import React, { useCallback, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import Trans from "./Trans";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { LibraryItem, LibraryItems, UIAppState } from "../types";
|
||||
import { useApp, useExcalidrawSetAppState } from "./App";
|
||||
import { saveLibraryAsJSON } from "../data/json";
|
||||
import Library, { libraryItemsAtom } from "../data/library";
|
||||
import { t } from "../i18n";
|
||||
import { AppState, LibraryItem, LibraryItems } from "../types";
|
||||
import {
|
||||
DotsIcon,
|
||||
ExportIcon,
|
||||
|
@ -13,29 +16,29 @@ import {
|
|||
import { ToolButton } from "./ToolButton";
|
||||
import { fileOpen } from "../data/filesystem";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { useAtom } from "jotai";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
import PublishLibrary from "./PublishLibrary";
|
||||
import { Dialog } from "./Dialog";
|
||||
|
||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||
|
||||
export const isLibraryMenuOpenAtom = atom(false);
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||
import { useUIAppState } from "../context/ui-appState";
|
||||
import clsx from "clsx";
|
||||
|
||||
const getSelectedItems = (
|
||||
libraryItems: LibraryItems,
|
||||
selectedItems: LibraryItem["id"][],
|
||||
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
||||
|
||||
export const LibraryMenuHeader: React.FC<{
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
export const LibraryDropdownMenuButton: React.FC<{
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
selectedItems: LibraryItem["id"][];
|
||||
library: Library;
|
||||
onRemoveFromLibrary: () => void;
|
||||
resetLibrary: () => void;
|
||||
onSelectItems: (items: LibraryItem["id"][]) => void;
|
||||
appState: AppState;
|
||||
appState: UIAppState;
|
||||
className?: string;
|
||||
}> = ({
|
||||
setAppState,
|
||||
selectedItems,
|
||||
|
@ -44,12 +47,14 @@ export const LibraryMenuHeader: React.FC<{
|
|||
resetLibrary,
|
||||
onSelectItems,
|
||||
appState,
|
||||
className,
|
||||
}) => {
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
|
||||
isLibraryMenuOpenAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
|
||||
const renderRemoveLibAlert = useCallback(() => {
|
||||
const content = selectedItems.length
|
||||
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
|
||||
|
@ -104,16 +109,19 @@ export const LibraryMenuHeader: React.FC<{
|
|||
small={true}
|
||||
>
|
||||
<p>
|
||||
{t("publishSuccessDialog.content", {
|
||||
authorName: publishLibSuccess!.authorName,
|
||||
})}{" "}
|
||||
<Trans
|
||||
i18nKey="publishSuccessDialog.content"
|
||||
authorName={publishLibSuccess!.authorName}
|
||||
link={(el) => (
|
||||
<a
|
||||
href={publishLibSuccess?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("publishSuccessDialog.link")}
|
||||
{el}
|
||||
</a>
|
||||
)}
|
||||
/>
|
||||
</p>
|
||||
<ToolButton
|
||||
type="button"
|
||||
|
@ -181,7 +189,6 @@ export const LibraryMenuHeader: React.FC<{
|
|||
return (
|
||||
<DropdownMenu open={isLibraryMenuOpen}>
|
||||
<DropdownMenu.Trigger
|
||||
className="Sidebar__dropdown-btn"
|
||||
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
|
||||
>
|
||||
{DotsIcon}
|
||||
|
@ -230,8 +237,9 @@ export const LibraryMenuHeader: React.FC<{
|
|||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<div className={clsx("library-menu-dropdown-container", className)}>
|
||||
{renderLibraryMenu()}
|
||||
{selectedItems.length > 0 && (
|
||||
<div className="library-actions-counter">{selectedItems.length}</div>
|
||||
|
@ -261,3 +269,51 @@ export const LibraryMenuHeader: React.FC<{
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LibraryDropdownMenu = ({
|
||||
selectedItems,
|
||||
onSelectItems,
|
||||
className,
|
||||
}: {
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { library } = useApp();
|
||||
const appState = useUIAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||
|
||||
const removeFromLibrary = useCallback(
|
||||
async (libraryItems: LibraryItems) => {
|
||||
const nextItems = libraryItems.filter(
|
||||
(item) => !selectedItems.includes(item.id),
|
||||
);
|
||||
library.setLibrary(nextItems).catch(() => {
|
||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||
});
|
||||
onSelectItems([]);
|
||||
},
|
||||
[library, setAppState, selectedItems, onSelectItems],
|
||||
);
|
||||
|
||||
const resetLibrary = useCallback(() => {
|
||||
library.resetLibrary();
|
||||
}, [library]);
|
||||
|
||||
return (
|
||||
<LibraryDropdownMenuButton
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={onSelectItems}
|
||||
library={library}
|
||||
onRemoveFromLibrary={() =>
|
||||
removeFromLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
resetLibrary={resetLibrary}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
}
|
||||
|
||||
.library-menu-items-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
|
@ -35,10 +36,14 @@
|
|||
height: 100%;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid var(--sidebar-border-color);
|
||||
|
||||
position: relative;
|
||||
|
||||
& > div {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
|
@ -47,7 +52,7 @@
|
|||
|
||||
&__items {
|
||||
row-gap: 0.5rem;
|
||||
padding: var(--container-padding-y) var(--container-padding-x);
|
||||
padding: var(--container-padding-y) 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
@ -59,9 +64,12 @@
|
|||
font-size: 1.125rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.75rem;
|
||||
width: 100%;
|
||||
padding-right: 4rem; // due to dropdown button
|
||||
box-sizing: border-box;
|
||||
|
||||
&--excal {
|
||||
margin-top: 2.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -75,4 +83,11 @@
|
|||
color: var(--text-primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.library-menu-items-private-library-container {
|
||||
// so that when you toggle between pending item and no items, there's
|
||||
// no layout shift (this is hardcoded and works only with ENG locale)
|
||||
min-height: 3.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,17 +2,22 @@ import React, { useState } from "react";
|
|||
import { serializeLibraryAsJSON } from "../data/json";
|
||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { AppState, ExcalidrawProps, LibraryItem, LibraryItems } from "../types";
|
||||
import {
|
||||
ExcalidrawProps,
|
||||
LibraryItem,
|
||||
LibraryItems,
|
||||
UIAppState,
|
||||
} from "../types";
|
||||
import { arrayToMap, chunk } from "../utils";
|
||||
import { LibraryUnit } from "./LibraryUnit";
|
||||
import Stack from "./Stack";
|
||||
|
||||
import "./LibraryMenuItems.scss";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import Spinner from "./Spinner";
|
||||
import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
|
||||
import clsx from "clsx";
|
||||
import { duplicateElements } from "../element/newElement";
|
||||
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
||||
import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
|
||||
|
||||
import "./LibraryMenuItems.scss";
|
||||
|
||||
const CELLS_PER_ROW = 4;
|
||||
|
||||
|
@ -36,7 +41,7 @@ const LibraryMenuItems = ({
|
|||
selectedItems: LibraryItem["id"][];
|
||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
theme: AppState["theme"];
|
||||
theme: UIAppState["theme"];
|
||||
id: string;
|
||||
}) => {
|
||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||
|
@ -201,11 +206,12 @@ const LibraryMenuItems = ({
|
|||
(item) => item.status === "published",
|
||||
);
|
||||
|
||||
const showBtn =
|
||||
!libraryItems.length &&
|
||||
const showBtn = !libraryItems.length && !pendingElements.length;
|
||||
|
||||
const isLibraryEmpty =
|
||||
!pendingElements.length &&
|
||||
!unpublishedItems.length &&
|
||||
!publishedItems.length &&
|
||||
!pendingElements.length;
|
||||
!publishedItems.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -215,9 +221,16 @@ const LibraryMenuItems = ({
|
|||
unpublishedItems.length ||
|
||||
publishedItems.length
|
||||
? { justifyContent: "flex-start" }
|
||||
: {}
|
||||
: { borderBottom: 0 }
|
||||
}
|
||||
>
|
||||
{!isLibraryEmpty && (
|
||||
<LibraryDropdownMenu
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={onSelectItems}
|
||||
className="library-menu-dropdown-container--in-heading"
|
||||
/>
|
||||
)}
|
||||
<Stack.Col
|
||||
className="library-menu-items-container__items"
|
||||
align="start"
|
||||
|
@ -228,10 +241,7 @@ const LibraryMenuItems = ({
|
|||
}}
|
||||
>
|
||||
<>
|
||||
<div>
|
||||
{(pendingElements.length > 0 ||
|
||||
unpublishedItems.length > 0 ||
|
||||
publishedItems.length > 0) && (
|
||||
{!isLibraryEmpty && (
|
||||
<div className="library-menu-items-container__header">
|
||||
{t("labels.personalLib")}
|
||||
</div>
|
||||
|
@ -248,14 +258,10 @@ const LibraryMenuItems = ({
|
|||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="library-menu-items-private-library-container">
|
||||
{!pendingElements.length && !unpublishedItems.length ? (
|
||||
<div className="library-menu-items__no-items">
|
||||
<div
|
||||
className={clsx({
|
||||
"library-menu-items__no-items__label": showBtn,
|
||||
})}
|
||||
>
|
||||
<div className="library-menu-items__no-items__label">
|
||||
{t("library.noItems")}
|
||||
</div>
|
||||
<div className="library-menu-items__no-items__hint">
|
||||
|
@ -273,6 +279,7 @@ const LibraryMenuItems = ({
|
|||
...unpublishedItems,
|
||||
])
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
<>
|
||||
|
@ -303,11 +310,17 @@ const LibraryMenuItems = ({
|
|||
</>
|
||||
|
||||
{showBtn && (
|
||||
<LibraryMenuBrowseButton
|
||||
<LibraryMenuControlButtons
|
||||
style={{ padding: "16px 0", width: "100%" }}
|
||||
id={id}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
theme={theme}
|
||||
>
|
||||
<LibraryDropdownMenu
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={onSelectItems}
|
||||
/>
|
||||
</LibraryMenuControlButtons>
|
||||
)}
|
||||
</Stack.Col>
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import clsx from "clsx";
|
||||
import oc from "open-color";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDevice } from "../components/App";
|
||||
import { exportToSvg } from "../packages/utils";
|
||||
|
@ -7,6 +6,7 @@ import { LibraryItem } from "../types";
|
|||
import "./LibraryUnit.scss";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
import { PlusIcon } from "./icons";
|
||||
import { COLOR_PALETTE } from "../colors";
|
||||
|
||||
export const LibraryUnit = ({
|
||||
id,
|
||||
|
@ -40,7 +40,7 @@ export const LibraryUnit = ({
|
|||
elements,
|
||||
appState: {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
viewBackgroundColor: COLOR_PALETTE.white,
|
||||
},
|
||||
files: null,
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { AppState, Device, ExcalidrawProps } from "../types";
|
||||
import { AppState, Device, ExcalidrawProps, UIAppState } from "../types";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { t } from "../i18n";
|
||||
import Stack from "./Stack";
|
||||
|
@ -13,16 +13,15 @@ import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
|||
import { Section } from "./Section";
|
||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||
import { LockButton } from "./LockButton";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
import { PenModeButton } from "./PenModeButton";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions";
|
||||
import { HandButton } from "./HandButton";
|
||||
import { isHandToolActive } from "../appState";
|
||||
import { useTunnels } from "./context/tunnels";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
|
||||
type MobileMenuProps = {
|
||||
appState: AppState;
|
||||
appState: UIAppState;
|
||||
actionManager: ActionManager;
|
||||
renderJSONExportDialog: () => React.ReactNode;
|
||||
renderImageExportDialog: () => React.ReactNode;
|
||||
|
@ -36,7 +35,7 @@ type MobileMenuProps = {
|
|||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
renderTopRightUI?: (
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
appState: UIAppState,
|
||||
) => JSX.Element | null;
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
renderSidebars: () => JSX.Element | null;
|
||||
|
@ -60,11 +59,15 @@ export const MobileMenu = ({
|
|||
device,
|
||||
renderWelcomeScreen,
|
||||
}: MobileMenuProps) => {
|
||||
const { welcomeScreenCenterTunnel, mainMenuTunnel } = useTunnels();
|
||||
const {
|
||||
WelcomeScreenCenterTunnel,
|
||||
MainMenuTunnel,
|
||||
DefaultSidebarTriggerTunnel,
|
||||
} = useTunnels();
|
||||
const renderToolbar = () => {
|
||||
return (
|
||||
<FixedSideContainer side="top" className="App-top-bar">
|
||||
{renderWelcomeScreen && <welcomeScreenCenterTunnel.Out />}
|
||||
{renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />}
|
||||
<Section heading="shapes">
|
||||
{(heading: React.ReactNode) => (
|
||||
<Stack.Col gap={4} align="center">
|
||||
|
@ -88,11 +91,7 @@ export const MobileMenu = ({
|
|||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||
<div className="mobile-misc-tools-container">
|
||||
{!appState.viewModeEnabled && (
|
||||
<LibraryButton
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
isMobile
|
||||
/>
|
||||
<DefaultSidebarTriggerTunnel.Out />
|
||||
)}
|
||||
<PenModeButton
|
||||
checked={appState.penMode}
|
||||
|
@ -132,14 +131,14 @@ export const MobileMenu = ({
|
|||
if (appState.viewModeEnabled) {
|
||||
return (
|
||||
<div className="App-toolbar-content">
|
||||
<mainMenuTunnel.Out />
|
||||
<MainMenuTunnel.Out />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="App-toolbar-content">
|
||||
<mainMenuTunnel.Out />
|
||||
<MainMenuTunnel.Out />
|
||||
{actionManager.renderAction("toggleEditMenu")}
|
||||
{actionManager.renderAction("undo")}
|
||||
{actionManager.renderAction("redo")}
|
||||
|
@ -190,13 +189,13 @@ export const MobileMenu = ({
|
|||
{renderAppToolbar()}
|
||||
{appState.scrolledOutside &&
|
||||
!appState.openMenu &&
|
||||
appState.openSidebar !== "library" && (
|
||||
!appState.openSidebar && (
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState({
|
||||
setAppState((appState) => ({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
});
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
|
|
|
@ -5,8 +5,10 @@ import { ChartElements, renderSpreadsheet, Spreadsheet } from "../charts";
|
|||
import { ChartType } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { AppState, LibraryItem } from "../types";
|
||||
import { UIAppState } from "../types";
|
||||
import { useApp } from "./App";
|
||||
import { Dialog } from "./Dialog";
|
||||
|
||||
import "./PasteChartDialog.scss";
|
||||
|
||||
type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void;
|
||||
|
@ -78,13 +80,12 @@ export const PasteChartDialog = ({
|
|||
setAppState,
|
||||
appState,
|
||||
onClose,
|
||||
onInsertChart,
|
||||
}: {
|
||||
appState: AppState;
|
||||
appState: UIAppState;
|
||||
onClose: () => void;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
onInsertChart: (elements: LibraryItem["elements"]) => void;
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
}) => {
|
||||
const { onInsertElements } = useApp();
|
||||
const handleClose = React.useCallback(() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
|
@ -92,7 +93,7 @@ export const PasteChartDialog = ({
|
|||
}, [onClose]);
|
||||
|
||||
const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
|
||||
onInsertChart(elements);
|
||||
onInsertElements(elements);
|
||||
trackEvent("magic", "chart", chartType);
|
||||
setAppState({
|
||||
currentChartType: chartType,
|
||||
|
|
|
@ -5,6 +5,7 @@ import { focusNearestParent } from "../utils";
|
|||
|
||||
import "./ProjectName.scss";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
|
@ -26,7 +27,7 @@ export const ProjectName = (props: Props) => {
|
|||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
if (event.key === KEYS.ENTER) {
|
||||
event.preventDefault();
|
||||
if (event.nativeEvent.isComposing || event.keyCode === 229) {
|
||||
return;
|
||||
|
|
|
@ -3,8 +3,9 @@ import OpenColor from "open-color";
|
|||
|
||||
import { Dialog } from "./Dialog";
|
||||
import { t } from "../i18n";
|
||||
import Trans from "./Trans";
|
||||
|
||||
import { AppState, LibraryItems, LibraryItem } from "../types";
|
||||
import { LibraryItems, LibraryItem, UIAppState } from "../types";
|
||||
import { exportToCanvas, exportToSvg } from "../packages/utils";
|
||||
import {
|
||||
EXPORT_DATA_TYPES,
|
||||
|
@ -135,7 +136,7 @@ const SingleLibraryItem = ({
|
|||
onRemove,
|
||||
}: {
|
||||
libItem: LibraryItem;
|
||||
appState: AppState;
|
||||
appState: UIAppState;
|
||||
index: number;
|
||||
onChange: (val: string, index: number) => void;
|
||||
onRemove: (id: string) => void;
|
||||
|
@ -231,7 +232,7 @@ const PublishLibrary = ({
|
|||
}: {
|
||||
onClose: () => void;
|
||||
libraryItems: LibraryItems;
|
||||
appState: AppState;
|
||||
appState: UIAppState;
|
||||
onSuccess: (data: {
|
||||
url: string;
|
||||
authorName: string;
|
||||
|
@ -402,26 +403,32 @@ const PublishLibrary = ({
|
|||
{shouldRenderForm ? (
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="publish-library-note">
|
||||
{t("publishDialog.noteDescription.pre")}
|
||||
<Trans
|
||||
i18nKey="publishDialog.noteDescription"
|
||||
link={(el) => (
|
||||
<a
|
||||
href="https://libraries.excalidraw.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("publishDialog.noteDescription.link")}
|
||||
</a>{" "}
|
||||
{t("publishDialog.noteDescription.post")}
|
||||
{el}
|
||||
</a>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className="publish-library-note">
|
||||
{t("publishDialog.noteGuidelines.pre")}
|
||||
<Trans
|
||||
i18nKey="publishDialog.noteGuidelines"
|
||||
link={(el) => (
|
||||
<a
|
||||
href="https://github.com/excalidraw/excalidraw-libraries#guidelines"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("publishDialog.noteGuidelines.link")}
|
||||
{el}
|
||||
</a>
|
||||
{t("publishDialog.noteGuidelines.post")}
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div className="publish-library-note">
|
||||
|
@ -515,15 +522,18 @@ const PublishLibrary = ({
|
|||
/>
|
||||
</label>
|
||||
<span className="publish-library-note">
|
||||
{t("publishDialog.noteLicense.pre")}
|
||||
<Trans
|
||||
i18nKey="publishDialog.noteLicense"
|
||||
link={(el) => (
|
||||
<a
|
||||
href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("publishDialog.noteLicense.link")}
|
||||
{el}
|
||||
</a>
|
||||
{t("publishDialog.noteLicense.post")}
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="publish-library__buttons">
|
||||
|
|
|
@ -2,67 +2,26 @@
|
|||
@import "../../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.Sidebar {
|
||||
&__close-btn,
|
||||
&__pin-btn,
|
||||
&__dropdown-btn {
|
||||
@include outlineButtonStyles;
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
padding: 0;
|
||||
|
||||
svg {
|
||||
width: var(--lg-icon-size);
|
||||
height: var(--lg-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
&__pin-btn {
|
||||
&--pinned {
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
|
||||
svg {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background-color: var(--color-primary-darker);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
.Sidebar {
|
||||
&__pin-btn {
|
||||
&--pinned {
|
||||
svg {
|
||||
color: var(--color-gray-90);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__sidebar {
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 5;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
background-color: var(--sidebar-bg-color);
|
||||
box-shadow: var(--sidebar-shadow);
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 0;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
background-color: var(--sidebar-bg-color);
|
||||
|
||||
box-shadow: var(--sidebar-shadow);
|
||||
|
||||
&--docked {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
@ -77,52 +36,139 @@
|
|||
border-right: 1px solid var(--sidebar-border-color);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
.Island {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
// ---------------------------- sidebar header ------------------------------
|
||||
|
||||
.ToolIcon__icon__close {
|
||||
.Modal__close {
|
||||
width: calc(var(--space-factor) * 7);
|
||||
height: calc(var(--space-factor) * 7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.Island {
|
||||
--padding: 0;
|
||||
background-color: var(--island-bg-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: calc(var(--padding) * var(--space-factor));
|
||||
position: relative;
|
||||
transition: box-shadow 0.5s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__sidebar__header {
|
||||
.sidebar__header {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--sidebar-border-color);
|
||||
padding: 1rem 0.75rem;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
width: calc(100% - 1.5rem);
|
||||
height: 1px;
|
||||
background: var(--sidebar-border-color);
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__sidebar__header__buttons {
|
||||
.sidebar__header__buttons {
|
||||
gap: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
margin-left: auto;
|
||||
|
||||
button {
|
||||
@include outlineButtonStyles;
|
||||
--button-bg: transparent;
|
||||
border: 0 !important;
|
||||
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
padding: 0;
|
||||
|
||||
svg {
|
||||
width: var(--lg-icon-size);
|
||||
height: var(--lg-icon-size);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--button-hover-bg, var(--island-bg-color));
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar__dock.selected {
|
||||
svg {
|
||||
stroke: var(--color-primary);
|
||||
fill: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- sidebar tabs ------------------------------
|
||||
|
||||
.sidebar-tabs-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
padding: 1rem 0;
|
||||
|
||||
[role="tabpanel"] {
|
||||
flex: 1;
|
||||
outline: none;
|
||||
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[role="tabpanel"][data-state="inactive"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
[role="tablist"] {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-tabs-root > .sidebar__header {
|
||||
padding-top: 0;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sidebar-tab-trigger {
|
||||
--button-width: auto;
|
||||
--button-bg: transparent;
|
||||
--button-hover-bg: transparent;
|
||||
--button-active-bg: var(--color-primary);
|
||||
--button-hover-color: var(--color-primary);
|
||||
--button-hover-border: var(--color-primary);
|
||||
|
||||
&[data-state="active"] {
|
||||
--button-bg: var(--color-primary);
|
||||
--button-hover-bg: var(--color-primary-darker);
|
||||
--button-hover-color: var(--color-icon-white);
|
||||
--button-border: var(--color-primary);
|
||||
color: var(--color-icon-white);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- default sidebar ------------------------------
|
||||
|
||||
.default-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.sidebar-triggers {
|
||||
$padding: 2px;
|
||||
$border: 1px;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: $padding;
|
||||
// offset by padding + border to vertically center the list with sibling
|
||||
// buttons (both from top and bototm, due to flex layout)
|
||||
margin-top: -#{$padding + $border};
|
||||
margin-bottom: -#{$padding + $border};
|
||||
border: $border solid var(--sidebar-border-color);
|
||||
background: var(--default-bg-color);
|
||||
border-radius: 0.625rem;
|
||||
|
||||
.sidebar-tab-trigger {
|
||||
height: var(--lg-button-size);
|
||||
width: var(--lg-button-size);
|
||||
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import React from "react";
|
||||
import { DEFAULT_SIDEBAR } from "../../constants";
|
||||
import { Excalidraw, Sidebar } from "../../packages/excalidraw/index";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
GlobalTestState,
|
||||
queryAllByTestId,
|
||||
queryByTestId,
|
||||
render,
|
||||
|
@ -10,54 +11,66 @@ import {
|
|||
withExcalidrawDimensions,
|
||||
} from "../../tests/test-utils";
|
||||
|
||||
export const assertSidebarDockButton = async <T extends boolean>(
|
||||
hasDockButton: T,
|
||||
): Promise<
|
||||
T extends false
|
||||
? { dockButton: null; sidebar: HTMLElement }
|
||||
: { dockButton: HTMLElement; sidebar: HTMLElement }
|
||||
> => {
|
||||
const sidebar =
|
||||
GlobalTestState.renderResult.container.querySelector<HTMLElement>(
|
||||
".sidebar",
|
||||
);
|
||||
expect(sidebar).not.toBe(null);
|
||||
const dockButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||
if (hasDockButton) {
|
||||
expect(dockButton).not.toBe(null);
|
||||
return { dockButton: dockButton!, sidebar: sidebar! } as any;
|
||||
}
|
||||
expect(dockButton).toBe(null);
|
||||
return { dockButton: null, sidebar: sidebar! } as any;
|
||||
};
|
||||
|
||||
export const assertExcalidrawWithSidebar = async (
|
||||
sidebar: React.ReactNode,
|
||||
name: string,
|
||||
test: () => void,
|
||||
) => {
|
||||
await render(
|
||||
<Excalidraw initialData={{ appState: { openSidebar: { name } } }}>
|
||||
{sidebar}
|
||||
</Excalidraw>,
|
||||
);
|
||||
await withExcalidrawDimensions({ width: 1920, height: 1080 }, test);
|
||||
};
|
||||
|
||||
describe("Sidebar", () => {
|
||||
describe("General behavior", () => {
|
||||
it("should render custom sidebar", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar>
|
||||
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
|
||||
>
|
||||
<Sidebar name="customSidebar">
|
||||
<div id="test-sidebar-content">42</div>
|
||||
</Sidebar>
|
||||
)}
|
||||
/>,
|
||||
</Excalidraw>,
|
||||
);
|
||||
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).not.toBe(null);
|
||||
});
|
||||
|
||||
it("should render custom sidebar header", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar>
|
||||
<Sidebar.Header>
|
||||
<div id="test-sidebar-header-content">42</div>
|
||||
</Sidebar.Header>
|
||||
</Sidebar>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const node = container.querySelector("#test-sidebar-header-content");
|
||||
expect(node).not.toBe(null);
|
||||
// make sure we don't render the default fallback header,
|
||||
// just the custom one
|
||||
expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
|
||||
});
|
||||
|
||||
it("should render only one sidebar and prefer the custom one", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar>
|
||||
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
|
||||
>
|
||||
<Sidebar name="customSidebar">
|
||||
<div id="test-sidebar-content">42</div>
|
||||
</Sidebar>
|
||||
)}
|
||||
/>,
|
||||
</Excalidraw>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
@ -66,233 +79,18 @@ describe("Sidebar", () => {
|
|||
expect(node).not.toBe(null);
|
||||
|
||||
// make sure only one sidebar is rendered
|
||||
const sidebars = container.querySelectorAll(".layer-ui__sidebar");
|
||||
const sidebars = container.querySelectorAll(".sidebar");
|
||||
expect(sidebars.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should always render custom sidebar with close button & close on click", async () => {
|
||||
const onClose = jest.fn();
|
||||
const CustomExcalidraw = () => {
|
||||
return (
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar className="test-sidebar" onClose={onClose}>
|
||||
hello
|
||||
</Sidebar>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const { container } = await render(<CustomExcalidraw />);
|
||||
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-close")!;
|
||||
expect(closeButton).not.toBe(null);
|
||||
|
||||
fireEvent.click(closeButton);
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(null);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render custom sidebar with dock (irrespective of onDock prop)", async () => {
|
||||
const CustomExcalidraw = () => {
|
||||
return (
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar className="test-sidebar">hello</Sidebar>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const { container } = await render(<CustomExcalidraw />);
|
||||
|
||||
// should show dock button when the sidebar fits to be docked
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
await withExcalidrawDimensions({ width: 1920, height: 1080 }, () => {
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||
expect(closeButton).not.toBe(null);
|
||||
});
|
||||
|
||||
// should not show dock button when the sidebar does not fit to be docked
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
await withExcalidrawDimensions({ width: 400, height: 1080 }, () => {
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||
expect(closeButton).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
it("should support controlled docking", async () => {
|
||||
let _setDockable: (dockable: boolean) => void = null!;
|
||||
|
||||
const CustomExcalidraw = () => {
|
||||
const [dockable, setDockable] = React.useState(false);
|
||||
_setDockable = setDockable;
|
||||
return (
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar
|
||||
className="test-sidebar"
|
||||
docked={false}
|
||||
dockable={dockable}
|
||||
>
|
||||
hello
|
||||
</Sidebar>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const { container } = await render(<CustomExcalidraw />);
|
||||
|
||||
await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
|
||||
// should not show dock button when `dockable` is `false`
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
act(() => {
|
||||
_setDockable(false);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||
expect(closeButton).toBe(null);
|
||||
});
|
||||
|
||||
// should show dock button when `dockable` is `true`, even if `docked`
|
||||
// prop is set
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
act(() => {
|
||||
_setDockable(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
|
||||
expect(closeButton).not.toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should support controlled docking", async () => {
|
||||
let _setDocked: (docked?: boolean) => void = null!;
|
||||
|
||||
const CustomExcalidraw = () => {
|
||||
const [docked, setDocked] = React.useState<boolean | undefined>();
|
||||
_setDocked = setDocked;
|
||||
return (
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: "customSidebar" } }}
|
||||
renderSidebar={() => (
|
||||
<Sidebar className="test-sidebar" docked={docked}>
|
||||
hello
|
||||
</Sidebar>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const { container } = await render(<CustomExcalidraw />);
|
||||
|
||||
const { h } = window;
|
||||
|
||||
await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
|
||||
const dockButton = await waitFor(() => {
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const dockBotton = queryByTestId(sidebar!, "sidebar-dock");
|
||||
expect(dockBotton).not.toBe(null);
|
||||
return dockBotton!;
|
||||
});
|
||||
|
||||
const dockButtonInput = dockButton.querySelector("input")!;
|
||||
|
||||
// should not show dock button when `dockable` is `false`
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
expect(h.state.isSidebarDocked).toBe(false);
|
||||
|
||||
fireEvent.click(dockButtonInput);
|
||||
await waitFor(() => {
|
||||
expect(h.state.isSidebarDocked).toBe(true);
|
||||
expect(dockButtonInput).toBeChecked();
|
||||
});
|
||||
|
||||
fireEvent.click(dockButtonInput);
|
||||
await waitFor(() => {
|
||||
expect(h.state.isSidebarDocked).toBe(false);
|
||||
expect(dockButtonInput).not.toBeChecked();
|
||||
});
|
||||
|
||||
// shouldn't update `appState.isSidebarDocked` when the sidebar
|
||||
// is controlled (`docked` prop is set), as host apps should handle
|
||||
// the state themselves
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
act(() => {
|
||||
_setDocked(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dockButtonInput).toBeChecked();
|
||||
expect(h.state.isSidebarDocked).toBe(false);
|
||||
expect(dockButtonInput).toBeChecked();
|
||||
});
|
||||
|
||||
fireEvent.click(dockButtonInput);
|
||||
await waitFor(() => {
|
||||
expect(h.state.isSidebarDocked).toBe(false);
|
||||
expect(dockButtonInput).toBeChecked();
|
||||
});
|
||||
|
||||
// the `appState.isSidebarDocked` should remain untouched when
|
||||
// `props.docked` is set to `false`, and user toggles
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
act(() => {
|
||||
_setDocked(false);
|
||||
h.setState({ isSidebarDocked: true });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.state.isSidebarDocked).toBe(true);
|
||||
expect(dockButtonInput).not.toBeChecked();
|
||||
});
|
||||
|
||||
fireEvent.click(dockButtonInput);
|
||||
await waitFor(() => {
|
||||
expect(dockButtonInput).not.toBeChecked();
|
||||
expect(h.state.isSidebarDocked).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should toggle sidebar using props.toggleMenu()", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
renderSidebar={() => (
|
||||
<Sidebar>
|
||||
<Excalidraw>
|
||||
<Sidebar name="customSidebar">
|
||||
<div id="test-sidebar-content">42</div>
|
||||
</Sidebar>
|
||||
)}
|
||||
/>,
|
||||
</Excalidraw>,
|
||||
);
|
||||
|
||||
// sidebar isn't rendered initially
|
||||
|
@ -304,7 +102,7 @@ describe("Sidebar", () => {
|
|||
|
||||
// toggle sidebar on
|
||||
// -------------------------------------------------------------------------
|
||||
expect(window.h.app.toggleMenu("customSidebar")).toBe(true);
|
||||
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
|
@ -313,7 +111,7 @@ describe("Sidebar", () => {
|
|||
|
||||
// toggle sidebar off
|
||||
// -------------------------------------------------------------------------
|
||||
expect(window.h.app.toggleMenu("customSidebar")).toBe(false);
|
||||
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(false);
|
||||
|
||||
await waitFor(() => {
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
|
@ -322,7 +120,9 @@ describe("Sidebar", () => {
|
|||
|
||||
// force-toggle sidebar off (=> still hidden)
|
||||
// -------------------------------------------------------------------------
|
||||
expect(window.h.app.toggleMenu("customSidebar", false)).toBe(false);
|
||||
expect(
|
||||
window.h.app.toggleSidebar({ name: "customSidebar", force: false }),
|
||||
).toBe(false);
|
||||
|
||||
await waitFor(() => {
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
|
@ -331,8 +131,12 @@ describe("Sidebar", () => {
|
|||
|
||||
// force-toggle sidebar on
|
||||
// -------------------------------------------------------------------------
|
||||
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
|
||||
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
|
||||
expect(
|
||||
window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
|
||||
).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
|
@ -341,15 +145,187 @@ describe("Sidebar", () => {
|
|||
|
||||
// toggle library (= hide custom sidebar)
|
||||
// -------------------------------------------------------------------------
|
||||
expect(window.h.app.toggleMenu("library")).toBe(true);
|
||||
expect(window.h.app.toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const node = container.querySelector("#test-sidebar-content");
|
||||
expect(node).toBe(null);
|
||||
|
||||
// make sure only one sidebar is rendered
|
||||
const sidebars = container.querySelectorAll(".layer-ui__sidebar");
|
||||
const sidebars = container.querySelectorAll(".sidebar");
|
||||
expect(sidebars.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("<Sidebar.Header/>", () => {
|
||||
it("should render custom sidebar header", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
|
||||
>
|
||||
<Sidebar name="customSidebar">
|
||||
<Sidebar.Header>
|
||||
<div id="test-sidebar-header-content">42</div>
|
||||
</Sidebar.Header>
|
||||
</Sidebar>
|
||||
</Excalidraw>,
|
||||
);
|
||||
|
||||
const node = container.querySelector("#test-sidebar-header-content");
|
||||
expect(node).not.toBe(null);
|
||||
// make sure we don't render the default fallback header,
|
||||
// just the custom one
|
||||
expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
|
||||
});
|
||||
|
||||
it("should not render <Sidebar.Header> for custom sidebars by default", async () => {
|
||||
const CustomExcalidraw = () => {
|
||||
return (
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
appState: { openSidebar: { name: "customSidebar" } },
|
||||
}}
|
||||
>
|
||||
<Sidebar name="customSidebar" className="test-sidebar">
|
||||
hello
|
||||
</Sidebar>
|
||||
</Excalidraw>
|
||||
);
|
||||
};
|
||||
|
||||
const { container } = await render(<CustomExcalidraw />);
|
||||
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-close");
|
||||
expect(closeButton).toBe(null);
|
||||
});
|
||||
|
||||
it("<Sidebar.Header> should render close button", async () => {
|
||||
const onStateChange = jest.fn();
|
||||
const CustomExcalidraw = () => {
|
||||
return (
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
appState: { openSidebar: { name: "customSidebar" } },
|
||||
}}
|
||||
>
|
||||
<Sidebar
|
||||
name="customSidebar"
|
||||
className="test-sidebar"
|
||||
onStateChange={onStateChange}
|
||||
>
|
||||
<Sidebar.Header />
|
||||
</Sidebar>
|
||||
</Excalidraw>
|
||||
);
|
||||
};
|
||||
|
||||
const { container } = await render(<CustomExcalidraw />);
|
||||
|
||||
// initial open
|
||||
expect(onStateChange).toHaveBeenCalledWith({ name: "customSidebar" });
|
||||
|
||||
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
|
||||
expect(sidebar).not.toBe(null);
|
||||
const closeButton = queryByTestId(sidebar!, "sidebar-close")!;
|
||||
expect(closeButton).not.toBe(null);
|
||||
|
||||
fireEvent.click(closeButton);
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(
|
||||
null,
|
||||
);
|
||||
expect(onStateChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Docking behavior", () => {
|
||||
it("shouldn't be user-dockable if `onDock` not supplied", async () => {
|
||||
await assertExcalidrawWithSidebar(
|
||||
<Sidebar name="customSidebar">
|
||||
<Sidebar.Header />
|
||||
</Sidebar>,
|
||||
"customSidebar",
|
||||
async () => {
|
||||
await assertSidebarDockButton(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("shouldn't be user-dockable if `onDock` not supplied & `docked={true}`", async () => {
|
||||
await assertExcalidrawWithSidebar(
|
||||
<Sidebar name="customSidebar" docked={true}>
|
||||
<Sidebar.Header />
|
||||
</Sidebar>,
|
||||
"customSidebar",
|
||||
async () => {
|
||||
await assertSidebarDockButton(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("shouldn't be user-dockable if `onDock` not supplied & docked={false}`", async () => {
|
||||
await assertExcalidrawWithSidebar(
|
||||
<Sidebar name="customSidebar" docked={false}>
|
||||
<Sidebar.Header />
|
||||
</Sidebar>,
|
||||
"customSidebar",
|
||||
async () => {
|
||||
await assertSidebarDockButton(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should be user-dockable when both `onDock` and `docked` supplied", async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
|
||||
>
|
||||
<Sidebar
|
||||
name="customSidebar"
|
||||
className="test-sidebar"
|
||||
onDock={() => {}}
|
||||
docked
|
||||
>
|
||||
<Sidebar.Header />
|
||||
</Sidebar>
|
||||
</Excalidraw>,
|
||||
);
|
||||
|
||||
await withExcalidrawDimensions(
|
||||
{ width: 1920, height: 1080 },
|
||||
async () => {
|
||||
await assertSidebarDockButton(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("shouldn't be user-dockable when only `onDock` supplied w/o `docked`", async () => {
|
||||
await render(
|
||||
<Excalidraw
|
||||
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
|
||||
>
|
||||
<Sidebar
|
||||
name="customSidebar"
|
||||
className="test-sidebar"
|
||||
onDock={() => {}}
|
||||
>
|
||||
<Sidebar.Header />
|
||||
</Sidebar>
|
||||
</Excalidraw>,
|
||||
);
|
||||
|
||||
await withExcalidrawDimensions(
|
||||
{ width: 1920, height: 1080 },
|
||||
async () => {
|
||||
await assertSidebarDockButton(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,151 +1,246 @@
|
|||
import {
|
||||
import React, {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useCallback,
|
||||
RefObject,
|
||||
} from "react";
|
||||
import { Island } from ".././Island";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { atom, useSetAtom } from "jotai";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import {
|
||||
SidebarPropsContext,
|
||||
SidebarProps,
|
||||
SidebarPropsContextValue,
|
||||
} from "./common";
|
||||
|
||||
import { SidebarHeaderComponents } from "./SidebarHeader";
|
||||
import { SidebarHeader } from "./SidebarHeader";
|
||||
import clsx from "clsx";
|
||||
import { useDevice, useExcalidrawSetAppState } from "../App";
|
||||
import { updateObject } from "../../utils";
|
||||
import { KEYS } from "../../keys";
|
||||
import { EVENT } from "../../constants";
|
||||
import { SidebarTrigger } from "./SidebarTrigger";
|
||||
import { SidebarTabTriggers } from "./SidebarTabTriggers";
|
||||
import { SidebarTabTrigger } from "./SidebarTabTrigger";
|
||||
import { SidebarTabs } from "./SidebarTabs";
|
||||
import { SidebarTab } from "./SidebarTab";
|
||||
|
||||
import "./Sidebar.scss";
|
||||
import clsx from "clsx";
|
||||
import { useExcalidrawSetAppState } from "../App";
|
||||
import { updateObject } from "../../utils";
|
||||
import { useUIAppState } from "../../context/ui-appState";
|
||||
|
||||
/** using a counter instead of boolean to handle race conditions where
|
||||
* the host app may render (mount/unmount) multiple different sidebar */
|
||||
export const hostSidebarCountersAtom = atom({ rendered: 0, docked: 0 });
|
||||
// FIXME replace this with the implem from ColorPicker once it's merged
|
||||
const useOnClickOutside = (
|
||||
ref: RefObject<HTMLElement>,
|
||||
cb: (event: MouseEvent) => void,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const listener = (event: MouseEvent) => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
export const Sidebar = Object.assign(
|
||||
forwardRef(
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
(ref.current.contains(event.target) ||
|
||||
!document.body.contains(event.target))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
cb(event);
|
||||
};
|
||||
document.addEventListener("pointerdown", listener, false);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", listener);
|
||||
};
|
||||
}, [ref, cb]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Flags whether the currently rendered Sidebar is docked or not, for use
|
||||
* in upstream components that need to act on this (e.g. LayerUI to shift the
|
||||
* UI). We use an atom because of potential host app sidebars (for the default
|
||||
* sidebar we could just read from appState.defaultSidebarDockedPreference).
|
||||
*
|
||||
* Since we can only render one Sidebar at a time, we can use a simple flag.
|
||||
*/
|
||||
export const isSidebarDockedAtom = atom(false);
|
||||
|
||||
export const SidebarInner = forwardRef(
|
||||
(
|
||||
{
|
||||
name,
|
||||
children,
|
||||
onClose,
|
||||
onDock,
|
||||
docked,
|
||||
/** Undocumented, may be removed later. Generally should either be
|
||||
* `props.docked` or `appState.isSidebarDocked`. Currently serves to
|
||||
* prevent unwanted animation of the shadow if initially docked. */
|
||||
//
|
||||
// NOTE we'll want to remove this after we sort out how to subscribe to
|
||||
// individual appState properties
|
||||
initialDockedState = docked,
|
||||
dockable = true,
|
||||
className,
|
||||
__isInternal,
|
||||
}: SidebarProps<{
|
||||
// NOTE sidebars we use internally inside the editor must have this flag set.
|
||||
// It indicates that this sidebar should have lower precedence over host
|
||||
// sidebars, if both are open.
|
||||
/** @private internal */
|
||||
__isInternal?: boolean;
|
||||
}>,
|
||||
...rest
|
||||
}: SidebarProps & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">,
|
||||
ref: React.ForwardedRef<HTMLDivElement>,
|
||||
) => {
|
||||
const [hostSidebarCounters, setHostSidebarCounters] = useAtom(
|
||||
hostSidebarCountersAtom,
|
||||
jotaiScope,
|
||||
if (process.env.NODE_ENV === "development" && onDock && docked == null) {
|
||||
console.warn(
|
||||
"Sidebar: `docked` must be set when `onDock` is supplied for the sidebar to be user-dockable. To hide this message, either pass `docked` or remove `onDock`",
|
||||
);
|
||||
}
|
||||
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
const [isDockedFallback, setIsDockedFallback] = useState(
|
||||
docked ?? initialDockedState ?? false,
|
||||
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom, jotaiScope);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setIsSidebarDockedAtom(!!docked);
|
||||
return () => {
|
||||
setIsSidebarDockedAtom(false);
|
||||
};
|
||||
}, [setIsSidebarDockedAtom, docked]);
|
||||
|
||||
const headerPropsRef = useRef<SidebarPropsContextValue>(
|
||||
{} as SidebarPropsContextValue,
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (docked === undefined) {
|
||||
// ugly hack to get initial state out of AppState without subscribing
|
||||
// to it as a whole (once we have granular subscriptions, we'll move
|
||||
// to that)
|
||||
//
|
||||
// NOTE this means that is updated `state.isSidebarDocked` changes outside
|
||||
// of this compoent, it won't be reflected here. Currently doesn't happen.
|
||||
setAppState((state) => {
|
||||
setIsDockedFallback(state.isSidebarDocked);
|
||||
// bail from update
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}, [setAppState, docked]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!__isInternal) {
|
||||
setHostSidebarCounters((s) => ({
|
||||
rendered: s.rendered + 1,
|
||||
docked: isDockedFallback ? s.docked + 1 : s.docked,
|
||||
}));
|
||||
return () => {
|
||||
setHostSidebarCounters((s) => ({
|
||||
rendered: s.rendered - 1,
|
||||
docked: isDockedFallback ? s.docked - 1 : s.docked,
|
||||
}));
|
||||
};
|
||||
}
|
||||
}, [__isInternal, setHostSidebarCounters, isDockedFallback]);
|
||||
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
onCloseRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const headerPropsRef = useRef<SidebarPropsContextValue>({});
|
||||
headerPropsRef.current.onClose = () => {
|
||||
headerPropsRef.current.onCloseRequest = () => {
|
||||
setAppState({ openSidebar: null });
|
||||
};
|
||||
headerPropsRef.current.onDock = (isDocked) => {
|
||||
if (docked === undefined) {
|
||||
setAppState({ isSidebarDocked: isDocked });
|
||||
setIsDockedFallback(isDocked);
|
||||
}
|
||||
onDock?.(isDocked);
|
||||
};
|
||||
headerPropsRef.current.onDock = (isDocked) => onDock?.(isDocked);
|
||||
// renew the ref object if the following props change since we want to
|
||||
// rerender. We can't pass down as component props manually because
|
||||
// the <Sidebar.Header/> can be rendered upsream.
|
||||
// the <Sidebar.Header/> can be rendered upstream.
|
||||
headerPropsRef.current = updateObject(headerPropsRef.current, {
|
||||
docked: docked ?? isDockedFallback,
|
||||
dockable,
|
||||
docked,
|
||||
// explicit prop to rerender on update
|
||||
shouldRenderDockButton: !!onDock && docked != null,
|
||||
});
|
||||
|
||||
if (hostSidebarCounters.rendered > 0 && __isInternal) {
|
||||
return null;
|
||||
const islandRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return islandRef.current!;
|
||||
});
|
||||
|
||||
const device = useDevice();
|
||||
|
||||
const closeLibrary = useCallback(() => {
|
||||
const isDialogOpen = !!document.querySelector(".Dialog");
|
||||
|
||||
// Prevent closing if any dialog is open
|
||||
if (isDialogOpen) {
|
||||
return;
|
||||
}
|
||||
setAppState({ openSidebar: null });
|
||||
}, [setAppState]);
|
||||
|
||||
useOnClickOutside(
|
||||
islandRef,
|
||||
useCallback(
|
||||
(event) => {
|
||||
// If click on the library icon, do nothing so that LibraryButton
|
||||
// can toggle library menu
|
||||
if ((event.target as Element).closest(".sidebar-trigger")) {
|
||||
return;
|
||||
}
|
||||
if (!docked || !device.canDeviceFitSidebar) {
|
||||
closeLibrary();
|
||||
}
|
||||
},
|
||||
[closeLibrary, docked, device.canDeviceFitSidebar],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === KEYS.ESCAPE &&
|
||||
(!docked || !device.canDeviceFitSidebar)
|
||||
) {
|
||||
closeLibrary();
|
||||
}
|
||||
};
|
||||
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
};
|
||||
}, [closeLibrary, docked, device.canDeviceFitSidebar]);
|
||||
|
||||
return (
|
||||
<Island
|
||||
className={clsx(
|
||||
"layer-ui__sidebar",
|
||||
{ "layer-ui__sidebar--docked": isDockedFallback },
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
className={clsx("sidebar", { "sidebar--docked": docked }, className)}
|
||||
ref={islandRef}
|
||||
>
|
||||
<SidebarPropsContext.Provider value={headerPropsRef.current}>
|
||||
<SidebarHeaderComponents.Context>
|
||||
<SidebarHeaderComponents.Component __isFallback />
|
||||
{children}
|
||||
</SidebarHeaderComponents.Context>
|
||||
</SidebarPropsContext.Provider>
|
||||
</Island>
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
SidebarInner.displayName = "SidebarInner";
|
||||
|
||||
export const Sidebar = Object.assign(
|
||||
forwardRef((props: SidebarProps, ref: React.ForwardedRef<HTMLDivElement>) => {
|
||||
const appState = useUIAppState();
|
||||
|
||||
const { onStateChange } = props;
|
||||
|
||||
const refPrevOpenSidebar = useRef(appState.openSidebar);
|
||||
useEffect(() => {
|
||||
if (
|
||||
// closing sidebar
|
||||
((!appState.openSidebar &&
|
||||
refPrevOpenSidebar?.current?.name === props.name) ||
|
||||
// opening current sidebar
|
||||
(appState.openSidebar?.name === props.name &&
|
||||
refPrevOpenSidebar?.current?.name !== props.name) ||
|
||||
// switching tabs or switching to a different sidebar
|
||||
refPrevOpenSidebar.current?.name === props.name) &&
|
||||
appState.openSidebar !== refPrevOpenSidebar.current
|
||||
) {
|
||||
onStateChange?.(
|
||||
appState.openSidebar?.name !== props.name
|
||||
? null
|
||||
: appState.openSidebar,
|
||||
);
|
||||
}
|
||||
refPrevOpenSidebar.current = appState.openSidebar;
|
||||
}, [appState.openSidebar, onStateChange, props.name]);
|
||||
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
// We want to render in the next tick (hence `mounted` flag) so that it's
|
||||
// guaranteed to happen after unmount of the previous sidebar (in case the
|
||||
// previous sidebar is mounted after the next one). This is necessary to
|
||||
// prevent flicker of subcomponents that support fallbacks
|
||||
// (e.g. SidebarHeader). This is because we're using flags to determine
|
||||
// whether prefer the fallback component or not (otherwise both will render
|
||||
// initially), and the flag won't be reset in time if the unmount order
|
||||
// it not correct.
|
||||
//
|
||||
// Alternative, and more general solution would be to namespace the fallback
|
||||
// HoC so that state is not shared between subcomponents when the wrapping
|
||||
// component is of the same type (e.g. Sidebar -> SidebarHeader).
|
||||
const shouldRender = mounted && appState.openSidebar?.name === props.name;
|
||||
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <SidebarInner {...props} ref={ref} key={props.name} />;
|
||||
}),
|
||||
{
|
||||
Header: SidebarHeaderComponents.Component,
|
||||
Header: SidebarHeader,
|
||||
TabTriggers: SidebarTabTriggers,
|
||||
TabTrigger: SidebarTabTrigger,
|
||||
Tabs: SidebarTabs,
|
||||
Tab: SidebarTab,
|
||||
Trigger: SidebarTrigger,
|
||||
},
|
||||
);
|
||||
Sidebar.displayName = "Sidebar";
|
||||
|
|
|
@ -4,86 +4,54 @@ import { t } from "../../i18n";
|
|||
import { useDevice } from "../App";
|
||||
import { SidebarPropsContext } from "./common";
|
||||
import { CloseIcon, PinIcon } from "../icons";
|
||||
import { withUpstreamOverride } from "../hoc/withUpstreamOverride";
|
||||
import { Tooltip } from "../Tooltip";
|
||||
import { Button } from "../Button";
|
||||
|
||||
export const SidebarDockButton = (props: {
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="layer-ui__sidebar-dock-button" data-testid="sidebar-dock">
|
||||
<Tooltip label={t("labels.sidebarLock")}>
|
||||
<label
|
||||
className={clsx(
|
||||
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
|
||||
`ToolIcon_size_medium`,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
onChange={props.onChange}
|
||||
checked={props.checked}
|
||||
aria-label={t("labels.sidebarLock")}
|
||||
/>{" "}
|
||||
<div
|
||||
className={clsx("Sidebar__pin-btn", {
|
||||
"Sidebar__pin-btn--pinned": props.checked,
|
||||
})}
|
||||
tabIndex={0}
|
||||
>
|
||||
{PinIcon}
|
||||
</div>{" "}
|
||||
</label>{" "}
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const _SidebarHeader: React.FC<{
|
||||
export const SidebarHeader = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}> = ({ children, className }) => {
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
const props = useContext(SidebarPropsContext);
|
||||
|
||||
const renderDockButton = !!(device.canDeviceFitSidebar && props.dockable);
|
||||
const renderCloseButton = !!props.onClose;
|
||||
const renderDockButton = !!(
|
||||
device.canDeviceFitSidebar && props.shouldRenderDockButton
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("layer-ui__sidebar__header", className)}
|
||||
className={clsx("sidebar__header", className)}
|
||||
data-testid="sidebar-header"
|
||||
>
|
||||
{children}
|
||||
{(renderDockButton || renderCloseButton) && (
|
||||
<div className="layer-ui__sidebar__header__buttons">
|
||||
<div className="sidebar__header__buttons">
|
||||
{renderDockButton && (
|
||||
<SidebarDockButton
|
||||
checked={!!props.docked}
|
||||
onChange={() => {
|
||||
props.onDock?.(!props.docked);
|
||||
}}
|
||||
/>
|
||||
<Tooltip label={t("labels.sidebarLock")}>
|
||||
<Button
|
||||
onSelect={() => props.onDock?.(!props.docked)}
|
||||
selected={!!props.docked}
|
||||
className="sidebar__dock"
|
||||
data-testid="sidebar-dock"
|
||||
aria-label={t("labels.sidebarLock")}
|
||||
>
|
||||
{PinIcon}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{renderCloseButton && (
|
||||
<button
|
||||
<Button
|
||||
data-testid="sidebar-close"
|
||||
className="Sidebar__close-btn"
|
||||
onClick={props.onClose}
|
||||
className="sidebar__close"
|
||||
onSelect={props.onCloseRequest}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{CloseIcon}
|
||||
</button>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const [Context, Component] = withUpstreamOverride(_SidebarHeader);
|
||||
|
||||
/** @private */
|
||||
export const SidebarHeaderComponents = { Context, Component };
|
||||
SidebarHeader.displayName = "SidebarHeader";
|
||||
|
|
18
src/components/Sidebar/SidebarTab.tsx
Normal file
18
src/components/Sidebar/SidebarTab.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import * as RadixTabs from "@radix-ui/react-tabs";
|
||||
import { SidebarTabName } from "../../types";
|
||||
|
||||
export const SidebarTab = ({
|
||||
tab,
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
tab: SidebarTabName;
|
||||
children: React.ReactNode;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) => {
|
||||
return (
|
||||
<RadixTabs.Content {...rest} value={tab}>
|
||||
{children}
|
||||
</RadixTabs.Content>
|
||||
);
|
||||
};
|
||||
SidebarTab.displayName = "SidebarTab";
|
26
src/components/Sidebar/SidebarTabTrigger.tsx
Normal file
26
src/components/Sidebar/SidebarTabTrigger.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import * as RadixTabs from "@radix-ui/react-tabs";
|
||||
import { SidebarTabName } from "../../types";
|
||||
|
||||
export const SidebarTabTrigger = ({
|
||||
children,
|
||||
tab,
|
||||
onSelect,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
tab: SidebarTabName;
|
||||
onSelect?: React.ReactEventHandler<HTMLButtonElement> | undefined;
|
||||
} & Omit<React.HTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||
return (
|
||||
<RadixTabs.Trigger value={tab} asChild onSelect={onSelect}>
|
||||
<button
|
||||
type={"button"}
|
||||
className={`excalidraw-button sidebar-tab-trigger`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</RadixTabs.Trigger>
|
||||
);
|
||||
};
|
||||
SidebarTabTrigger.displayName = "SidebarTabTrigger";
|
16
src/components/Sidebar/SidebarTabTriggers.tsx
Normal file
16
src/components/Sidebar/SidebarTabTriggers.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import * as RadixTabs from "@radix-ui/react-tabs";
|
||||
|
||||
export const SidebarTabTriggers = ({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & Omit<
|
||||
React.RefAttributes<HTMLDivElement>,
|
||||
"onSelect"
|
||||
>) => {
|
||||
return (
|
||||
<RadixTabs.List className="sidebar-triggers" {...rest}>
|
||||
{children}
|
||||
</RadixTabs.List>
|
||||
);
|
||||
};
|
||||
SidebarTabTriggers.displayName = "SidebarTabTriggers";
|
36
src/components/Sidebar/SidebarTabs.tsx
Normal file
36
src/components/Sidebar/SidebarTabs.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import * as RadixTabs from "@radix-ui/react-tabs";
|
||||
import { useUIAppState } from "../../context/ui-appState";
|
||||
import { useExcalidrawSetAppState } from "../App";
|
||||
|
||||
export const SidebarTabs = ({
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
} & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">) => {
|
||||
const appState = useUIAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
if (!appState.openSidebar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { name } = appState.openSidebar;
|
||||
|
||||
return (
|
||||
<RadixTabs.Root
|
||||
className="sidebar-tabs-root"
|
||||
value={appState.openSidebar.tab}
|
||||
onValueChange={(tab) =>
|
||||
setAppState((state) => ({
|
||||
...state,
|
||||
openSidebar: { ...state.openSidebar, name, tab },
|
||||
}))
|
||||
}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</RadixTabs.Root>
|
||||
);
|
||||
};
|
||||
SidebarTabs.displayName = "SidebarTabs";
|
34
src/components/Sidebar/SidebarTrigger.scss
Normal file
34
src/components/Sidebar/SidebarTrigger.scss
Normal file
|
@ -0,0 +1,34 @@
|
|||
@import "../../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.sidebar-trigger {
|
||||
@include outlineButtonStyles;
|
||||
|
||||
background-color: var(--island-bg-color);
|
||||
|
||||
width: auto;
|
||||
height: var(--lg-button-size);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
line-height: 0;
|
||||
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.4px;
|
||||
|
||||
svg {
|
||||
width: var(--lg-icon-size);
|
||||
height: var(--lg-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
.default-sidebar-trigger .sidebar-trigger__label {
|
||||
display: none;
|
||||
|
||||
@media screen and (min-width: 1024px) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
45
src/components/Sidebar/SidebarTrigger.tsx
Normal file
45
src/components/Sidebar/SidebarTrigger.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { useExcalidrawSetAppState } from "../App";
|
||||
import { SidebarTriggerProps } from "./common";
|
||||
import { useUIAppState } from "../../context/ui-appState";
|
||||
import clsx from "clsx";
|
||||
|
||||
import "./SidebarTrigger.scss";
|
||||
|
||||
export const SidebarTrigger = ({
|
||||
name,
|
||||
tab,
|
||||
icon,
|
||||
title,
|
||||
children,
|
||||
onToggle,
|
||||
className,
|
||||
style,
|
||||
}: SidebarTriggerProps) => {
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const appState = useUIAppState();
|
||||
|
||||
return (
|
||||
<label title={title}>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
onChange={(event) => {
|
||||
document
|
||||
.querySelector(".layer-ui__wrapper")
|
||||
?.classList.remove("animate");
|
||||
const isOpen = event.target.checked;
|
||||
setAppState({ openSidebar: isOpen ? { name, tab } : null });
|
||||
onToggle?.(isOpen);
|
||||
}}
|
||||
checked={appState.openSidebar?.name === name}
|
||||
aria-label={title}
|
||||
aria-keyshortcuts="0"
|
||||
/>
|
||||
<div className={clsx("sidebar-trigger", className)} style={style}>
|
||||
{icon && <div>{icon}</div>}
|
||||
{children && <div className="sidebar-trigger__label">{children}</div>}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
SidebarTrigger.displayName = "SidebarTrigger";
|
|
@ -1,23 +1,41 @@
|
|||
import React from "react";
|
||||
import { AppState, SidebarName, SidebarTabName } from "../../types";
|
||||
|
||||
export type SidebarTriggerProps = {
|
||||
name: SidebarName;
|
||||
tab?: SidebarTabName;
|
||||
icon?: JSX.Element;
|
||||
children?: React.ReactNode;
|
||||
title?: string;
|
||||
className?: string;
|
||||
onToggle?: (open: boolean) => void;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export type SidebarProps<P = {}> = {
|
||||
name: SidebarName;
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Called on sidebar close (either by user action or by the editor).
|
||||
* Called on sidebar open/close or tab change.
|
||||
*/
|
||||
onStateChange?: (state: AppState["openSidebar"]) => void;
|
||||
/**
|
||||
* supply alongside `docked` prop in order to make the Sidebar user-dockable
|
||||
*/
|
||||
onClose?: () => void | boolean;
|
||||
/** if not supplied, sidebar won't be dockable */
|
||||
onDock?: (docked: boolean) => void;
|
||||
docked?: boolean;
|
||||
initialDockedState?: boolean;
|
||||
dockable?: boolean;
|
||||
className?: string;
|
||||
// NOTE sidebars we use internally inside the editor must have this flag set.
|
||||
// It indicates that this sidebar should have lower precedence over host
|
||||
// sidebars, if both are open.
|
||||
/** @private internal */
|
||||
__fallback?: boolean;
|
||||
} & P;
|
||||
|
||||
export type SidebarPropsContextValue = Pick<
|
||||
SidebarProps,
|
||||
"onClose" | "onDock" | "docked" | "dockable"
|
||||
>;
|
||||
"onDock" | "docked"
|
||||
> & { onCloseRequest: () => void; shouldRenderDockButton: boolean };
|
||||
|
||||
export const SidebarPropsContext =
|
||||
React.createContext<SidebarPropsContextValue>({});
|
||||
React.createContext<SidebarPropsContextValue>({} as SidebarPropsContextValue);
|
||||
|
|
|
@ -3,14 +3,14 @@ import { getCommonBounds } from "../element/bounds";
|
|||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { getTargetElements } from "../scene";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import { ExcalidrawProps, UIAppState } from "../types";
|
||||
import { CloseIcon } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import "./Stats.scss";
|
||||
|
||||
export const Stats = (props: {
|
||||
appState: AppState;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
appState: UIAppState;
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onClose: () => void;
|
||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React from "react";
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import { t } from "../i18n";
|
||||
import Trans from "./Trans";
|
||||
|
||||
interface TopErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
|
@ -74,13 +75,17 @@ export class TopErrorBoundary extends React.Component<
|
|||
<div className="ErrorSplash excalidraw">
|
||||
<div className="ErrorSplash-messageContainer">
|
||||
<div className="ErrorSplash-paragraph bigger align-center">
|
||||
{t("errorSplash.headingMain_pre")}
|
||||
<button onClick={() => window.location.reload()}>
|
||||
{t("errorSplash.headingMain_button")}
|
||||
</button>
|
||||
<Trans
|
||||
i18nKey="errorSplash.headingMain"
|
||||
button={(el) => (
|
||||
<button onClick={() => window.location.reload()}>{el}</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ErrorSplash-paragraph align-center">
|
||||
{t("errorSplash.clearCanvasMessage")}
|
||||
<Trans
|
||||
i18nKey="errorSplash.clearCanvasMessage"
|
||||
button={(el) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
try {
|
||||
|
@ -91,8 +96,10 @@ export class TopErrorBoundary extends React.Component<
|
|||
}
|
||||
}}
|
||||
>
|
||||
{t("errorSplash.clearCanvasMessage_button")}
|
||||
{el}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<br />
|
||||
<div className="smaller">
|
||||
<span role="img" aria-label="warning">
|
||||
|
@ -106,16 +113,17 @@ export class TopErrorBoundary extends React.Component<
|
|||
</div>
|
||||
<div>
|
||||
<div className="ErrorSplash-paragraph">
|
||||
{t("errorSplash.trackedToSentry_pre")}
|
||||
{this.state.sentryEventId}
|
||||
{t("errorSplash.trackedToSentry_post")}
|
||||
{t("errorSplash.trackedToSentry", {
|
||||
eventId: this.state.sentryEventId,
|
||||
})}
|
||||
</div>
|
||||
<div className="ErrorSplash-paragraph">
|
||||
{t("errorSplash.openIssueMessage_pre")}
|
||||
<button onClick={() => this.createGithubIssue()}>
|
||||
{t("errorSplash.openIssueMessage_button")}
|
||||
</button>
|
||||
{t("errorSplash.openIssueMessage_post")}
|
||||
<Trans
|
||||
i18nKey="errorSplash.openIssueMessage"
|
||||
button={(el) => (
|
||||
<button onClick={() => this.createGithubIssue()}>{el}</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ErrorSplash-paragraph">
|
||||
<div className="ErrorSplash-details">
|
||||
|
|
67
src/components/Trans.test.tsx
Normal file
67
src/components/Trans.test.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { render } from "@testing-library/react";
|
||||
|
||||
import fallbackLangData from "../locales/en.json";
|
||||
|
||||
import Trans from "./Trans";
|
||||
|
||||
describe("Test <Trans/>", () => {
|
||||
it("should translate the the strings correctly", () => {
|
||||
//@ts-ignore
|
||||
fallbackLangData.transTest = {
|
||||
key1: "Hello {{audience}}",
|
||||
key2: "Please <link>click the button</link> to continue.",
|
||||
key3: "Please <link>click {{location}}</link> to continue.",
|
||||
key4: "Please <link>click <bold>{{location}}</bold></link> to continue.",
|
||||
key5: "Please <connect-link>click the button</connect-link> to continue.",
|
||||
};
|
||||
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<div data-testid="test1">
|
||||
<Trans i18nKey="transTest.key1" audience="world" />
|
||||
</div>
|
||||
<div data-testid="test2">
|
||||
<Trans
|
||||
i18nKey="transTest.key2"
|
||||
link={(el) => <a href="https://example.com">{el}</a>}
|
||||
/>
|
||||
</div>
|
||||
<div data-testid="test3">
|
||||
<Trans
|
||||
i18nKey="transTest.key3"
|
||||
link={(el) => <a href="https://example.com">{el}</a>}
|
||||
location="the button"
|
||||
/>
|
||||
</div>
|
||||
<div data-testid="test4">
|
||||
<Trans
|
||||
i18nKey="transTest.key4"
|
||||
link={(el) => <a href="https://example.com">{el}</a>}
|
||||
location="the button"
|
||||
bold={(el) => <strong>{el}</strong>}
|
||||
/>
|
||||
</div>
|
||||
<div data-testid="test5">
|
||||
<Trans
|
||||
i18nKey="transTest.key5"
|
||||
connect-link={(el) => <a href="https://example.com">{el}</a>}
|
||||
/>
|
||||
</div>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(getByTestId("test1").innerHTML).toEqual("Hello world");
|
||||
expect(getByTestId("test2").innerHTML).toEqual(
|
||||
`Please <a href="https://example.com">click the button</a> to continue.`,
|
||||
);
|
||||
expect(getByTestId("test3").innerHTML).toEqual(
|
||||
`Please <a href="https://example.com">click the button</a> to continue.`,
|
||||
);
|
||||
expect(getByTestId("test4").innerHTML).toEqual(
|
||||
`Please <a href="https://example.com">click <strong>the button</strong></a> to continue.`,
|
||||
);
|
||||
expect(getByTestId("test5").innerHTML).toEqual(
|
||||
`Please <a href="https://example.com">click the button</a> to continue.`,
|
||||
);
|
||||
});
|
||||
});
|
169
src/components/Trans.tsx
Normal file
169
src/components/Trans.tsx
Normal file
|
@ -0,0 +1,169 @@
|
|||
import React from "react";
|
||||
|
||||
import { useI18n } from "../i18n";
|
||||
|
||||
// Used for splitting i18nKey into tokens in Trans component
|
||||
// Example:
|
||||
// "Please <link>click {{location}}</link> to continue.".split(SPLIT_REGEX).filter(Boolean)
|
||||
// produces
|
||||
// ["Please ", "<link>", "click ", "{{location}}", "</link>", " to continue."]
|
||||
const SPLIT_REGEX = /({{[\w-]+}})|(<[\w-]+>)|(<\/[\w-]+>)/g;
|
||||
// Used for extracting "location" from "{{location}}"
|
||||
const KEY_REGEXP = /{{([\w-]+)}}/;
|
||||
// Used for extracting "link" from "<link>"
|
||||
const TAG_START_REGEXP = /<([\w-]+)>/;
|
||||
// Used for extracting "link" from "</link>"
|
||||
const TAG_END_REGEXP = /<\/([\w-]+)>/;
|
||||
|
||||
const getTransChildren = (
|
||||
format: string,
|
||||
props: {
|
||||
[key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode);
|
||||
},
|
||||
): React.ReactNode[] => {
|
||||
const stack: { name: string; children: React.ReactNode[] }[] = [
|
||||
{
|
||||
name: "",
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
||||
format
|
||||
.split(SPLIT_REGEX)
|
||||
.filter(Boolean)
|
||||
.forEach((match) => {
|
||||
const tagStartMatch = match.match(TAG_START_REGEXP);
|
||||
const tagEndMatch = match.match(TAG_END_REGEXP);
|
||||
const keyMatch = match.match(KEY_REGEXP);
|
||||
|
||||
if (tagStartMatch !== null) {
|
||||
// The match is <tag>. Set the tag name as the name if it's one of the
|
||||
// props, e.g. for "Please <link>click the button</link> to continue"
|
||||
// tagStartMatch[1] = "link" and props contain "link" then it will be
|
||||
// pushed to stack.
|
||||
const name = tagStartMatch[1];
|
||||
if (props.hasOwnProperty(name)) {
|
||||
stack.push({
|
||||
name,
|
||||
children: [],
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
`Trans: missed to pass in prop ${name} for interpolating ${format}`,
|
||||
);
|
||||
}
|
||||
} else if (tagEndMatch !== null) {
|
||||
// If tag end match is found, this means we need to replace the content with
|
||||
// its actual value in prop e.g. format = "Please <link>click the
|
||||
// button</link> to continue", tagEndMatch is for "</link>", stack last item name =
|
||||
// "link" and props.link = (el) => <a
|
||||
// href="https://example.com">{el}</a> then its prop value will be
|
||||
// pushed to "link"'s children so on DOM when rendering it's rendered as
|
||||
// <a href="https://example.com">click the button</a>
|
||||
const name = tagEndMatch[1];
|
||||
if (name === stack[stack.length - 1].name) {
|
||||
const item = stack.pop()!;
|
||||
const itemChildren = React.createElement(
|
||||
React.Fragment,
|
||||
{},
|
||||
...item.children,
|
||||
);
|
||||
const fn = props[item.name];
|
||||
if (typeof fn === "function") {
|
||||
stack[stack.length - 1].children.push(fn(itemChildren));
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`Trans: unexpected end tag ${match} for interpolating ${format}`,
|
||||
);
|
||||
}
|
||||
} else if (keyMatch !== null) {
|
||||
// The match is for {{key}}. Check if the key is present in props and set
|
||||
// the prop value as children of last stack item e.g. format = "Hello
|
||||
// {{name}}", key = "name" and props.name = "Excalidraw" then its prop
|
||||
// value will be pushed to "name"'s children so it's rendered on DOM as
|
||||
// "Hello Excalidraw"
|
||||
const name = keyMatch[1];
|
||||
if (props.hasOwnProperty(name)) {
|
||||
stack[stack.length - 1].children.push(props[name] as React.ReactNode);
|
||||
} else {
|
||||
console.warn(
|
||||
`Trans: key ${name} not in props for interpolating ${format}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// If none of cases match means we just need to push the string
|
||||
// to stack eg - "Hello {{name}} Whats up?" "Hello", "Whats up" will be pushed
|
||||
stack[stack.length - 1].children.push(match);
|
||||
}
|
||||
});
|
||||
|
||||
if (stack.length !== 1) {
|
||||
console.warn(`Trans: stack not empty for interpolating ${format}`);
|
||||
}
|
||||
|
||||
return stack[0].children;
|
||||
};
|
||||
|
||||
/*
|
||||
Trans component is used for translating JSX.
|
||||
|
||||
```json
|
||||
{
|
||||
"example1": "Hello {{audience}}",
|
||||
"example2": "Please <link>click the button</link> to continue.",
|
||||
"example3": "Please <link>click {{location}}</link> to continue.",
|
||||
"example4": "Please <link>click <bold>{{location}}</bold></link> to continue.",
|
||||
}
|
||||
```
|
||||
|
||||
```jsx
|
||||
<Trans i18nKey="example1" audience="world" />
|
||||
|
||||
<Trans
|
||||
i18nKey="example2"
|
||||
connectLink={(el) => <a href="https://example.com">{el}</a>}
|
||||
/>
|
||||
|
||||
<Trans
|
||||
i18nKey="example3"
|
||||
connectLink={(el) => <a href="https://example.com">{el}</a>}
|
||||
location="the button"
|
||||
/>
|
||||
|
||||
<Trans
|
||||
i18nKey="example4"
|
||||
connectLink={(el) => <a href="https://example.com">{el}</a>}
|
||||
location="the button"
|
||||
bold={(el) => <strong>{el}</strong>}
|
||||
/>
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
Hello world
|
||||
Please <a href="https://example.com">click the button</a> to continue.
|
||||
Please <a href="https://example.com">click the button</a> to continue.
|
||||
Please <a href="https://example.com">click <strong>the button</strong></a> to continue.
|
||||
```
|
||||
*/
|
||||
const Trans = ({
|
||||
i18nKey,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
i18nKey: string;
|
||||
[key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode);
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
// This is needed to avoid unique key error in list which gets rendered from getTransChildren
|
||||
return React.createElement(
|
||||
React.Fragment,
|
||||
{},
|
||||
...getTransChildren(t(i18nKey), props),
|
||||
);
|
||||
};
|
||||
|
||||
export default Trans;
|
|
@ -6,58 +6,45 @@ exports[`Test <App/> should show error modal when using brave and measureText AP
|
|||
>
|
||||
<p>
|
||||
Looks like you are using Brave browser with the
|
||||
|
||||
<span
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Aggressively Block Fingerprinting
|
||||
</span>
|
||||
|
||||
setting enabled
|
||||
.
|
||||
<br />
|
||||
<br />
|
||||
setting enabled.
|
||||
</p>
|
||||
<p>
|
||||
This could result in breaking the
|
||||
|
||||
<span
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Text Elements
|
||||
</span>
|
||||
|
||||
in your drawings
|
||||
.
|
||||
in your drawings.
|
||||
</p>
|
||||
<p>
|
||||
We strongly recommend disabling this setting. You can follow
|
||||
|
||||
<a
|
||||
href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser"
|
||||
>
|
||||
|
||||
these steps
|
||||
</a>
|
||||
|
||||
on how to do so
|
||||
.
|
||||
on how to do so.
|
||||
</p>
|
||||
<p>
|
||||
If disabling this setting doesn't fix the display of text elements, please open an
|
||||
|
||||
<a
|
||||
href="https://github.com/excalidraw/excalidraw/issues/new"
|
||||
>
|
||||
issue
|
||||
</a>
|
||||
|
||||
on our GitHub, or write us on
|
||||
|
||||
<a
|
||||
href="https://discord.gg/UexuTaE"
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
import React from "react";
|
||||
import tunnel from "@dwelle/tunnel-rat";
|
||||
|
||||
type Tunnel = ReturnType<typeof tunnel>;
|
||||
|
||||
type TunnelsContextValue = {
|
||||
mainMenuTunnel: Tunnel;
|
||||
welcomeScreenMenuHintTunnel: Tunnel;
|
||||
welcomeScreenToolbarHintTunnel: Tunnel;
|
||||
welcomeScreenHelpHintTunnel: Tunnel;
|
||||
welcomeScreenCenterTunnel: Tunnel;
|
||||
footerCenterTunnel: Tunnel;
|
||||
jotaiScope: symbol;
|
||||
};
|
||||
|
||||
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
|
||||
|
||||
export const useTunnels = () => React.useContext(TunnelsContext);
|
||||
|
||||
export const useInitializeTunnels = () => {
|
||||
return React.useMemo((): TunnelsContextValue => {
|
||||
return {
|
||||
mainMenuTunnel: tunnel(),
|
||||
welcomeScreenMenuHintTunnel: tunnel(),
|
||||
welcomeScreenToolbarHintTunnel: tunnel(),
|
||||
welcomeScreenHelpHintTunnel: tunnel(),
|
||||
welcomeScreenCenterTunnel: tunnel(),
|
||||
footerCenterTunnel: tunnel(),
|
||||
jotaiScope: Symbol(),
|
||||
};
|
||||
}, []);
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import { useOutsideClickHook } from "../../hooks/useOutsideClick";
|
||||
import { useOutsideClick } from "../../hooks/useOutsideClick";
|
||||
import { Island } from "../Island";
|
||||
|
||||
import { useDevice } from "../App";
|
||||
|
@ -24,7 +24,7 @@ const MenuContent = ({
|
|||
style?: React.CSSProperties;
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
const menuRef = useOutsideClickHook(() => {
|
||||
const menuRef = useOutsideClick(() => {
|
||||
onClickOutside?.();
|
||||
});
|
||||
|
||||
|
@ -48,7 +48,7 @@ const MenuContent = ({
|
|||
<Island
|
||||
className="dropdown-menu-container"
|
||||
padding={2}
|
||||
style={{ zIndex: 1 }}
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
{children}
|
||||
</Island>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import clsx from "clsx";
|
||||
import { useDevice, useExcalidrawAppState } from "../App";
|
||||
import { useUIAppState } from "../../context/ui-appState";
|
||||
import { useDevice } from "../App";
|
||||
|
||||
const MenuTrigger = ({
|
||||
className = "",
|
||||
|
@ -10,7 +11,7 @@ const MenuTrigger = ({
|
|||
children: React.ReactNode;
|
||||
onToggle: () => void;
|
||||
}) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
const appState = useUIAppState();
|
||||
const device = useDevice();
|
||||
const classNames = clsx(
|
||||
`dropdown-menu-button ${className}`,
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import clsx from "clsx";
|
||||
import { actionShortcuts } from "../../actions";
|
||||
import { ActionManager } from "../../actions/manager";
|
||||
import { AppState } from "../../types";
|
||||
import {
|
||||
ExitZenModeAction,
|
||||
FinalizeAction,
|
||||
|
@ -9,10 +8,11 @@ import {
|
|||
ZoomActions,
|
||||
} from "../Actions";
|
||||
import { useDevice } from "../App";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
import { HelpButton } from "../HelpButton";
|
||||
import { Section } from "../Section";
|
||||
import Stack from "../Stack";
|
||||
import { UIAppState } from "../../types";
|
||||
|
||||
const Footer = ({
|
||||
appState,
|
||||
|
@ -20,12 +20,12 @@ const Footer = ({
|
|||
showExitZenModeBtn,
|
||||
renderWelcomeScreen,
|
||||
}: {
|
||||
appState: AppState;
|
||||
appState: UIAppState;
|
||||
actionManager: ActionManager;
|
||||
showExitZenModeBtn: boolean;
|
||||
renderWelcomeScreen: boolean;
|
||||
}) => {
|
||||
const { footerCenterTunnel, welcomeScreenHelpHintTunnel } = useTunnels();
|
||||
const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels();
|
||||
|
||||
const device = useDevice();
|
||||
const showFinalize =
|
||||
|
@ -70,14 +70,14 @@ const Footer = ({
|
|||
</Section>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
<footerCenterTunnel.Out />
|
||||
<FooterCenterTunnel.Out />
|
||||
<div
|
||||
className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", {
|
||||
"transition-right disable-pointerEvents": appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
{renderWelcomeScreen && <welcomeScreenHelpHintTunnel.Out />}
|
||||
{renderWelcomeScreen && <WelcomeScreenHelpHintTunnel.Out />}
|
||||
<HelpButton
|
||||
onClick={() => actionManager.executeAction(actionShortcuts)}
|
||||
/>
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import clsx from "clsx";
|
||||
import { useExcalidrawAppState } from "../App";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
import "./FooterCenter.scss";
|
||||
import { useUIAppState } from "../../context/ui-appState";
|
||||
|
||||
const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
|
||||
const { footerCenterTunnel } = useTunnels();
|
||||
const appState = useExcalidrawAppState();
|
||||
const { FooterCenterTunnel } = useTunnels();
|
||||
const appState = useUIAppState();
|
||||
return (
|
||||
<footerCenterTunnel.In>
|
||||
<FooterCenterTunnel.In>
|
||||
<div
|
||||
className={clsx("footer-center zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||
|
@ -16,7 +16,7 @@ const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
|
|||
>
|
||||
{children}
|
||||
</div>
|
||||
</footerCenterTunnel.In>
|
||||
</FooterCenterTunnel.In>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
100
src/components/hoc/withInternalFallback.test.tsx
Normal file
100
src/components/hoc/withInternalFallback.test.tsx
Normal file
|
@ -0,0 +1,100 @@
|
|||
import { render, queryAllByTestId } from "../../tests/test-utils";
|
||||
import { Excalidraw, MainMenu } from "../../packages/excalidraw/index";
|
||||
|
||||
describe("Test internal component fallback rendering", () => {
|
||||
it("should render only one menu per excalidraw instance (custom menu first scenario)", async () => {
|
||||
const { container } = await render(
|
||||
<div>
|
||||
<Excalidraw>
|
||||
<MainMenu>test</MainMenu>
|
||||
</Excalidraw>
|
||||
<Excalidraw />
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
||||
|
||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||
".excalidraw-container",
|
||||
);
|
||||
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("should render only one menu per excalidraw instance (default menu first scenario)", async () => {
|
||||
const { container } = await render(
|
||||
<div>
|
||||
<Excalidraw />
|
||||
<Excalidraw>
|
||||
<MainMenu>test</MainMenu>
|
||||
</Excalidraw>
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
||||
|
||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||
".excalidraw-container",
|
||||
);
|
||||
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("should render only one menu per excalidraw instance (two custom menus scenario)", async () => {
|
||||
const { container } = await render(
|
||||
<div>
|
||||
<Excalidraw>
|
||||
<MainMenu>test</MainMenu>
|
||||
</Excalidraw>
|
||||
<Excalidraw>
|
||||
<MainMenu>test</MainMenu>
|
||||
</Excalidraw>
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
||||
|
||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||
".excalidraw-container",
|
||||
);
|
||||
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("should render only one menu per excalidraw instance (two default menus scenario)", async () => {
|
||||
const { container } = await render(
|
||||
<div>
|
||||
<Excalidraw />
|
||||
<Excalidraw />
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
||||
|
||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||
".excalidraw-container",
|
||||
);
|
||||
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
|
@ -1,17 +1,12 @@
|
|||
import { atom, useAtom } from "jotai";
|
||||
import React, { useLayoutEffect } from "react";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import React, { useLayoutEffect, useRef } from "react";
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
|
||||
export const withInternalFallback = <P,>(
|
||||
componentName: string,
|
||||
Component: React.FC<P>,
|
||||
) => {
|
||||
const counterAtom = atom(0);
|
||||
// flag set on initial render to tell the fallback component to skip the
|
||||
// render until mount counter are initialized. This is because the counter
|
||||
// is initialized in an effect, and thus we could end rendering both
|
||||
// components at the same time until counter is initialized.
|
||||
let preferHost = false;
|
||||
const renderAtom = atom(0);
|
||||
|
||||
const WrapperComponent: React.FC<
|
||||
P & {
|
||||
|
@ -19,26 +14,52 @@ export const withInternalFallback = <P,>(
|
|||
}
|
||||
> = (props) => {
|
||||
const { jotaiScope } = useTunnels();
|
||||
const [counter, setCounter] = useAtom(counterAtom, jotaiScope);
|
||||
// for rerenders
|
||||
const [, setCounter] = useAtom(renderAtom, jotaiScope);
|
||||
// for initial & subsequent renders. Tracked as component state
|
||||
// due to excalidraw multi-instance scanerios.
|
||||
const metaRef = useRef({
|
||||
// flag set on initial render to tell the fallback component to skip the
|
||||
// render until mount counter are initialized. This is because the counter
|
||||
// is initialized in an effect, and thus we could end rendering both
|
||||
// components at the same time until counter is initialized.
|
||||
preferHost: false,
|
||||
counter: 0,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setCounter((counter) => counter + 1);
|
||||
const meta = metaRef.current;
|
||||
setCounter((c) => {
|
||||
const next = c + 1;
|
||||
meta.counter = next;
|
||||
|
||||
return next;
|
||||
});
|
||||
return () => {
|
||||
setCounter((counter) => counter - 1);
|
||||
setCounter((c) => {
|
||||
const next = c - 1;
|
||||
meta.counter = next;
|
||||
if (!next) {
|
||||
meta.preferHost = false;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
}, [setCounter]);
|
||||
|
||||
if (!props.__fallback) {
|
||||
preferHost = true;
|
||||
metaRef.current.preferHost = true;
|
||||
}
|
||||
|
||||
// ensure we don't render fallback and host components at the same time
|
||||
if (
|
||||
// either before the counters are initialized
|
||||
(!counter && props.__fallback && preferHost) ||
|
||||
(!metaRef.current.counter &&
|
||||
props.__fallback &&
|
||||
metaRef.current.preferHost) ||
|
||||
// or after the counters are initialized, and both are rendered
|
||||
// (this is the default when host renders as well)
|
||||
(counter > 1 && props.__fallback)
|
||||
(metaRef.current.counter > 1 && props.__fallback)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
import React, {
|
||||
useMemo,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
createContext,
|
||||
} from "react";
|
||||
|
||||
export const withUpstreamOverride = <P,>(Component: React.ComponentType<P>) => {
|
||||
type ContextValue = [boolean, React.Dispatch<React.SetStateAction<boolean>>];
|
||||
|
||||
const DefaultComponentContext = createContext<ContextValue>([
|
||||
false,
|
||||
() => {},
|
||||
]);
|
||||
|
||||
const ComponentContext: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [isRenderedUpstream, setIsRenderedUpstream] = useState(false);
|
||||
const contextValue: ContextValue = useMemo(
|
||||
() => [isRenderedUpstream, setIsRenderedUpstream],
|
||||
[isRenderedUpstream],
|
||||
);
|
||||
|
||||
return (
|
||||
<DefaultComponentContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</DefaultComponentContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultComponent = (
|
||||
props: P & {
|
||||
// indicates whether component should render when not rendered upstream
|
||||
/** @private internal */
|
||||
__isFallback?: boolean;
|
||||
},
|
||||
) => {
|
||||
const [isRenderedUpstream, setIsRenderedUpstream] = useContext(
|
||||
DefaultComponentContext,
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!props.__isFallback) {
|
||||
setIsRenderedUpstream(true);
|
||||
return () => setIsRenderedUpstream(false);
|
||||
}
|
||||
}, [props.__isFallback, setIsRenderedUpstream]);
|
||||
|
||||
if (props.__isFallback && isRenderedUpstream) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Component {...props} />;
|
||||
};
|
||||
if (Component.name) {
|
||||
DefaultComponent.displayName = `${Component.name}_upstreamOverrideWrapper`;
|
||||
ComponentContext.displayName = `${Component.name}_upstreamOverrideContextWrapper`;
|
||||
}
|
||||
|
||||
return [ComponentContext, DefaultComponent] as const;
|
||||
};
|
|
@ -3,9 +3,9 @@ import { usersIcon } from "../icons";
|
|||
import { Button } from "../Button";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { useExcalidrawAppState } from "../App";
|
||||
|
||||
import "./LiveCollaborationTrigger.scss";
|
||||
import { useUIAppState } from "../../context/ui-appState";
|
||||
|
||||
const LiveCollaborationTrigger = ({
|
||||
isCollaborating,
|
||||
|
@ -15,7 +15,7 @@ const LiveCollaborationTrigger = ({
|
|||
isCollaborating: boolean;
|
||||
onSelect: () => void;
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
const appState = useUIAppState();
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
|
||||
import { useI18n } from "../../i18n";
|
||||
import {
|
||||
useExcalidrawAppState,
|
||||
useExcalidrawSetAppState,
|
||||
useExcalidrawActionManager,
|
||||
} from "../App";
|
||||
import { useExcalidrawSetAppState, useExcalidrawActionManager } from "../App";
|
||||
import {
|
||||
ExportIcon,
|
||||
ExportImageIcon,
|
||||
|
@ -32,6 +28,7 @@ import clsx from "clsx";
|
|||
import { useSetAtom } from "jotai";
|
||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import { useUIAppState } from "../../context/ui-appState";
|
||||
|
||||
export const LoadScene = () => {
|
||||
const { t } = useI18n();
|
||||
|
@ -139,7 +136,7 @@ ClearCanvas.displayName = "ClearCanvas";
|
|||
|
||||
export const ToggleTheme = () => {
|
||||
const { t } = useI18n();
|
||||
const appState = useExcalidrawAppState();
|
||||
const appState = useUIAppState();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
if (!actionManager.isActionEnabled(actionToggleTheme)) {
|
||||
|
@ -172,7 +169,7 @@ ToggleTheme.displayName = "ToggleTheme";
|
|||
|
||||
export const ChangeCanvasBackground = () => {
|
||||
const { t } = useI18n();
|
||||
const appState = useExcalidrawAppState();
|
||||
const appState = useUIAppState();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
if (appState.viewModeEnabled) {
|
||||
|
|
|
@ -1,9 +1,5 @@
|
|||
import React from "react";
|
||||
import {
|
||||
useDevice,
|
||||
useExcalidrawAppState,
|
||||
useExcalidrawSetAppState,
|
||||
} from "../App";
|
||||
import { useDevice, useExcalidrawSetAppState } from "../App";
|
||||
import DropdownMenu from "../dropdownMenu/DropdownMenu";
|
||||
|
||||
import * as DefaultItems from "./DefaultItems";
|
||||
|
@ -13,7 +9,8 @@ import { t } from "../../i18n";
|
|||
import { HamburgerMenuIcon } from "../icons";
|
||||
import { withInternalFallback } from "../hoc/withInternalFallback";
|
||||
import { composeEventHandlers } from "../../utils";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
import { useUIAppState } from "../../context/ui-appState";
|
||||
|
||||
const MainMenu = Object.assign(
|
||||
withInternalFallback(
|
||||
|
@ -28,16 +25,16 @@ const MainMenu = Object.assign(
|
|||
*/
|
||||
onSelect?: (event: Event) => void;
|
||||
}) => {
|
||||
const { mainMenuTunnel } = useTunnels();
|
||||
const { MainMenuTunnel } = useTunnels();
|
||||
const device = useDevice();
|
||||
const appState = useExcalidrawAppState();
|
||||
const appState = useUIAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const onClickOutside = device.isMobile
|
||||
? undefined
|
||||
: () => setAppState({ openMenu: null });
|
||||
|
||||
return (
|
||||
<mainMenuTunnel.In>
|
||||
<MainMenuTunnel.In>
|
||||
<DropdownMenu open={appState.openMenu === "canvas"}>
|
||||
<DropdownMenu.Trigger
|
||||
onToggle={() => {
|
||||
|
@ -66,7 +63,7 @@ const MainMenu = Object.assign(
|
|||
)}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
</mainMenuTunnel.In>
|
||||
</MainMenuTunnel.In>
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
import { actionLoadScene, actionShortcuts } from "../../actions";
|
||||
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
|
||||
import { t, useI18n } from "../../i18n";
|
||||
import {
|
||||
useDevice,
|
||||
useExcalidrawActionManager,
|
||||
useExcalidrawAppState,
|
||||
} from "../App";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { useDevice, useExcalidrawActionManager } from "../App";
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
import { ExcalLogo, HelpIcon, LoadIcon, usersIcon } from "../icons";
|
||||
import { useUIAppState } from "../../context/ui-appState";
|
||||
|
||||
const WelcomeScreenMenuItemContent = ({
|
||||
icon,
|
||||
|
@ -89,9 +86,9 @@ const WelcomeScreenMenuItemLink = ({
|
|||
WelcomeScreenMenuItemLink.displayName = "WelcomeScreenMenuItemLink";
|
||||
|
||||
const Center = ({ children }: { children?: React.ReactNode }) => {
|
||||
const { welcomeScreenCenterTunnel } = useTunnels();
|
||||
const { WelcomeScreenCenterTunnel } = useTunnels();
|
||||
return (
|
||||
<welcomeScreenCenterTunnel.In>
|
||||
<WelcomeScreenCenterTunnel.In>
|
||||
<div className="welcome-screen-center">
|
||||
{children || (
|
||||
<>
|
||||
|
@ -104,7 +101,7 @@ const Center = ({ children }: { children?: React.ReactNode }) => {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
</welcomeScreenCenterTunnel.In>
|
||||
</WelcomeScreenCenterTunnel.In>
|
||||
);
|
||||
};
|
||||
Center.displayName = "Center";
|
||||
|
@ -148,7 +145,7 @@ const MenuItemHelp = () => {
|
|||
MenuItemHelp.displayName = "MenuItemHelp";
|
||||
|
||||
const MenuItemLoadScene = () => {
|
||||
const appState = useExcalidrawAppState();
|
||||
const appState = useUIAppState();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
if (appState.viewModeEnabled) {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { t } from "../../i18n";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
import {
|
||||
WelcomeScreenHelpArrow,
|
||||
WelcomeScreenMenuArrow,
|
||||
|
@ -7,44 +7,44 @@ import {
|
|||
} from "../icons";
|
||||
|
||||
const MenuHint = ({ children }: { children?: React.ReactNode }) => {
|
||||
const { welcomeScreenMenuHintTunnel } = useTunnels();
|
||||
const { WelcomeScreenMenuHintTunnel } = useTunnels();
|
||||
return (
|
||||
<welcomeScreenMenuHintTunnel.In>
|
||||
<WelcomeScreenMenuHintTunnel.In>
|
||||
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
|
||||
{WelcomeScreenMenuArrow}
|
||||
<div className="welcome-screen-decor-hint__label">
|
||||
{children || t("welcomeScreen.defaults.menuHint")}
|
||||
</div>
|
||||
</div>
|
||||
</welcomeScreenMenuHintTunnel.In>
|
||||
</WelcomeScreenMenuHintTunnel.In>
|
||||
);
|
||||
};
|
||||
MenuHint.displayName = "MenuHint";
|
||||
|
||||
const ToolbarHint = ({ children }: { children?: React.ReactNode }) => {
|
||||
const { welcomeScreenToolbarHintTunnel } = useTunnels();
|
||||
const { WelcomeScreenToolbarHintTunnel } = useTunnels();
|
||||
return (
|
||||
<welcomeScreenToolbarHintTunnel.In>
|
||||
<WelcomeScreenToolbarHintTunnel.In>
|
||||
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
|
||||
<div className="welcome-screen-decor-hint__label">
|
||||
{children || t("welcomeScreen.defaults.toolbarHint")}
|
||||
</div>
|
||||
{WelcomeScreenTopToolbarArrow}
|
||||
</div>
|
||||
</welcomeScreenToolbarHintTunnel.In>
|
||||
</WelcomeScreenToolbarHintTunnel.In>
|
||||
);
|
||||
};
|
||||
ToolbarHint.displayName = "ToolbarHint";
|
||||
|
||||
const HelpHint = ({ children }: { children?: React.ReactNode }) => {
|
||||
const { welcomeScreenHelpHintTunnel } = useTunnels();
|
||||
const { WelcomeScreenHelpHintTunnel } = useTunnels();
|
||||
return (
|
||||
<welcomeScreenHelpHintTunnel.In>
|
||||
<WelcomeScreenHelpHintTunnel.In>
|
||||
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
|
||||
<div>{children || t("welcomeScreen.defaults.helpHint")}</div>
|
||||
{WelcomeScreenHelpArrow}
|
||||
</div>
|
||||
</welcomeScreenHelpHintTunnel.In>
|
||||
</WelcomeScreenHelpHintTunnel.In>
|
||||
);
|
||||
};
|
||||
HelpHint.displayName = "HelpHint";
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import cssVariables from "./css/variables.module.scss";
|
||||
import { AppProps } from "./types";
|
||||
import { ExcalidrawElement, FontFamilyValues } from "./element/types";
|
||||
import oc from "open-color";
|
||||
import { COLOR_PALETTE } from "./colors";
|
||||
|
||||
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
export const isWindows = /^Win/.test(navigator.platform);
|
||||
|
@ -131,6 +131,12 @@ export const MIME_TYPES = {
|
|||
...IMAGE_MIME_TYPES,
|
||||
} as const;
|
||||
|
||||
export const EXPORT_IMAGE_TYPES = {
|
||||
png: "png",
|
||||
svg: "svg",
|
||||
clipboard: "clipboard",
|
||||
} as const;
|
||||
|
||||
export const EXPORT_DATA_TYPES = {
|
||||
excalidraw: "excalidraw",
|
||||
excalidrawClipboard: "excalidraw/clipboard",
|
||||
|
@ -266,8 +272,8 @@ export const DEFAULT_ELEMENT_PROPS: {
|
|||
opacity: ExcalidrawElement["opacity"];
|
||||
locked: ExcalidrawElement["locked"];
|
||||
} = {
|
||||
strokeColor: oc.black,
|
||||
backgroundColor: "transparent",
|
||||
strokeColor: COLOR_PALETTE.black,
|
||||
backgroundColor: COLOR_PALETTE.transparent,
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
|
@ -275,3 +281,10 @@ export const DEFAULT_ELEMENT_PROPS: {
|
|||
opacity: 100,
|
||||
locked: false,
|
||||
};
|
||||
|
||||
export const LIBRARY_SIDEBAR_TAB = "library";
|
||||
|
||||
export const DEFAULT_SIDEBAR = {
|
||||
name: "default",
|
||||
defaultTab: LIBRARY_SIDEBAR_TAB,
|
||||
} as const;
|
||||
|
|
36
src/context/tunnels.ts
Normal file
36
src/context/tunnels.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import React from "react";
|
||||
import tunnel from "tunnel-rat";
|
||||
|
||||
export type Tunnel = ReturnType<typeof tunnel>;
|
||||
|
||||
type TunnelsContextValue = {
|
||||
MainMenuTunnel: Tunnel;
|
||||
WelcomeScreenMenuHintTunnel: Tunnel;
|
||||
WelcomeScreenToolbarHintTunnel: Tunnel;
|
||||
WelcomeScreenHelpHintTunnel: Tunnel;
|
||||
WelcomeScreenCenterTunnel: Tunnel;
|
||||
FooterCenterTunnel: Tunnel;
|
||||
DefaultSidebarTriggerTunnel: Tunnel;
|
||||
DefaultSidebarTabTriggersTunnel: Tunnel;
|
||||
jotaiScope: symbol;
|
||||
};
|
||||
|
||||
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
|
||||
|
||||
export const useTunnels = () => React.useContext(TunnelsContext);
|
||||
|
||||
export const useInitializeTunnels = () => {
|
||||
return React.useMemo((): TunnelsContextValue => {
|
||||
return {
|
||||
MainMenuTunnel: tunnel(),
|
||||
WelcomeScreenMenuHintTunnel: tunnel(),
|
||||
WelcomeScreenToolbarHintTunnel: tunnel(),
|
||||
WelcomeScreenHelpHintTunnel: tunnel(),
|
||||
WelcomeScreenCenterTunnel: tunnel(),
|
||||
FooterCenterTunnel: tunnel(),
|
||||
DefaultSidebarTriggerTunnel: tunnel(),
|
||||
DefaultSidebarTabTriggersTunnel: tunnel(),
|
||||
jotaiScope: Symbol(),
|
||||
};
|
||||
}, []);
|
||||
};
|
5
src/context/ui-appState.ts
Normal file
5
src/context/ui-appState.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import React from "react";
|
||||
import { UIAppState } from "../types";
|
||||
|
||||
export const UIAppStateContext = React.createContext<UIAppState>(null!);
|
||||
export const useUIAppState = () => React.useContext(UIAppStateContext);
|
|
@ -538,6 +538,10 @@
|
|||
height: 3px;
|
||||
}
|
||||
|
||||
select::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 10px;
|
||||
|
@ -567,7 +571,7 @@
|
|||
border-radius: 0;
|
||||
}
|
||||
|
||||
.library-button {
|
||||
.default-sidebar-trigger {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -78,10 +78,13 @@
|
|||
|
||||
--color-selection: #6965db;
|
||||
|
||||
--color-icon-white: #{$oc-white};
|
||||
|
||||
--color-primary: #6965db;
|
||||
--color-primary-darker: #5b57d1;
|
||||
--color-primary-darkest: #4a47b1;
|
||||
--color-primary-light: #e3e2fe;
|
||||
--color-primary-light-darker: #d7d5ff;
|
||||
|
||||
--color-gray-10: #f5f5f5;
|
||||
--color-gray-20: #ebebeb;
|
||||
|
@ -161,10 +164,13 @@
|
|||
// will be inverted to a lighter color.
|
||||
--color-selection: #3530c4;
|
||||
|
||||
--color-icon-white: var(--color-gray-90);
|
||||
|
||||
--color-primary: #a8a5ff;
|
||||
--color-primary-darker: #b2aeff;
|
||||
--color-primary-darkest: #beb9ff;
|
||||
--color-primary-light: #4f4d6f;
|
||||
--color-primary-light-darker: #43415e;
|
||||
|
||||
--color-text-warning: var(--color-gray-80);
|
||||
|
||||
|
|
|
@ -72,7 +72,14 @@
|
|||
|
||||
&:hover {
|
||||
background-color: var(--button-hover-bg, var(--island-bg-color));
|
||||
border-color: var(--button-hover-border, var(--default-border-color));
|
||||
border-color: var(
|
||||
--button-hover-border,
|
||||
var(--button-border, var(--default-border-color))
|
||||
);
|
||||
color: var(
|
||||
--button-hover-color,
|
||||
var(--button-color, var(--text-primary-color, inherit))
|
||||
);
|
||||
}
|
||||
|
||||
&:active {
|
||||
|
@ -81,11 +88,14 @@
|
|||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--color-primary-light);
|
||||
border-color: var(--color-primary-light);
|
||||
background-color: var(--button-selected-bg, var(--color-primary-light));
|
||||
border-color: var(--button-selected-border, var(--color-primary-light));
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-light);
|
||||
background-color: var(
|
||||
--button-selected-hover-bg,
|
||||
var(--color-primary-light)
|
||||
);
|
||||
}
|
||||
|
||||
svg {
|
||||
|
|
|
@ -14,7 +14,14 @@ import { getCommonBoundingBox } from "../element/bounds";
|
|||
import { AbortError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { URL_HASH_KEYS, URL_QUERY_KEYS, APP_NAME, EVENT } from "../constants";
|
||||
import {
|
||||
URL_HASH_KEYS,
|
||||
URL_QUERY_KEYS,
|
||||
APP_NAME,
|
||||
EVENT,
|
||||
DEFAULT_SIDEBAR,
|
||||
LIBRARY_SIDEBAR_TAB,
|
||||
} from "../constants";
|
||||
|
||||
export const libraryItemsAtom = atom<{
|
||||
status: "loading" | "loaded";
|
||||
|
@ -148,7 +155,9 @@ class Library {
|
|||
defaultStatus?: "unpublished" | "published";
|
||||
}): Promise<LibraryItems> => {
|
||||
if (openLibraryMenu) {
|
||||
this.app.setState({ openSidebar: "library" });
|
||||
this.app.setState({
|
||||
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: LIBRARY_SIDEBAR_TAB },
|
||||
});
|
||||
}
|
||||
|
||||
return this.setLibrary(() => {
|
||||
|
@ -174,6 +183,13 @@ class Library {
|
|||
}),
|
||||
)
|
||||
) {
|
||||
if (prompt) {
|
||||
// focus container if we've prompted. We focus conditionally
|
||||
// lest `props.autoFocus` is disabled (in which case we should
|
||||
// focus only on user action such as prompt confirm)
|
||||
this.app.focusContainer();
|
||||
}
|
||||
|
||||
if (merge) {
|
||||
resolve(mergeLibraryItems(this.lastLibraryItems, nextItems));
|
||||
} else {
|
||||
|
@ -186,8 +202,6 @@ class Library {
|
|||
reject(error);
|
||||
}
|
||||
});
|
||||
}).finally(() => {
|
||||
this.app.focusContainer();
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -27,19 +27,20 @@ import {
|
|||
PRECEDING_ELEMENT_KEY,
|
||||
FONT_FAMILY,
|
||||
ROUNDNESS,
|
||||
DEFAULT_SIDEBAR,
|
||||
} from "../constants";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { bumpVersion } from "../element/mutateElement";
|
||||
import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils";
|
||||
import { arrayToMap } from "../utils";
|
||||
import oc from "open-color";
|
||||
import { MarkOptional, Mutable } from "../utility-types";
|
||||
import {
|
||||
detectLineHeight,
|
||||
getDefaultLineHeight,
|
||||
measureBaseline,
|
||||
} from "../element/textElement";
|
||||
import { COLOR_PALETTE } from "../colors";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
|
@ -118,8 +119,8 @@ const restoreElementWithProperties = <
|
|||
angle: element.angle || 0,
|
||||
x: extra.x ?? element.x ?? 0,
|
||||
y: extra.y ?? element.y ?? 0,
|
||||
strokeColor: element.strokeColor || oc.black,
|
||||
backgroundColor: element.backgroundColor || "transparent",
|
||||
strokeColor: element.strokeColor || COLOR_PALETTE.black,
|
||||
backgroundColor: element.backgroundColor || COLOR_PALETTE.transparent,
|
||||
width: element.width || 0,
|
||||
height: element.height || 0,
|
||||
seed: element.seed ?? 1,
|
||||
|
@ -431,21 +432,15 @@ const LegacyAppStateMigrations: {
|
|||
defaultAppState: ReturnType<typeof getDefaultAppState>,
|
||||
) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]];
|
||||
} = {
|
||||
isLibraryOpen: (appState, defaultAppState) => {
|
||||
isSidebarDocked: (appState, defaultAppState) => {
|
||||
return [
|
||||
"openSidebar",
|
||||
"isLibraryOpen" in appState
|
||||
? appState.isLibraryOpen
|
||||
? "library"
|
||||
: null
|
||||
: coalesceAppStateValue("openSidebar", appState, defaultAppState),
|
||||
];
|
||||
},
|
||||
isLibraryMenuDocked: (appState, defaultAppState) => {
|
||||
return [
|
||||
"isSidebarDocked",
|
||||
appState.isLibraryMenuDocked ??
|
||||
coalesceAppStateValue("isSidebarDocked", appState, defaultAppState),
|
||||
"defaultSidebarDockedPreference",
|
||||
appState.isSidebarDocked ??
|
||||
coalesceAppStateValue(
|
||||
"defaultSidebarDockedPreference",
|
||||
appState,
|
||||
defaultAppState,
|
||||
),
|
||||
];
|
||||
},
|
||||
};
|
||||
|
@ -517,13 +512,10 @@ export const restoreAppState = (
|
|||
: appState.zoom?.value
|
||||
? appState.zoom
|
||||
: defaultAppState.zoom,
|
||||
// when sidebar docked and user left it open in last session,
|
||||
// keep it open. If not docked, keep it closed irrespective of last state.
|
||||
openSidebar:
|
||||
nextAppState.openSidebar === "library"
|
||||
? nextAppState.isSidebarDocked
|
||||
? "library"
|
||||
: null
|
||||
// string (legacy)
|
||||
typeof (appState.openSidebar as any as string) === "string"
|
||||
? { name: DEFAULT_SIDEBAR.name }
|
||||
: nextAppState.openSidebar,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -25,10 +25,8 @@ export interface ExportedDataState {
|
|||
* Don't consume on its own.
|
||||
*/
|
||||
export type LegacyAppState = {
|
||||
/** @deprecated #5663 TODO remove 22-12-15 */
|
||||
isLibraryOpen: [boolean, "openSidebar"];
|
||||
/** @deprecated #5663 TODO remove 22-12-15 */
|
||||
isLibraryMenuDocked: [boolean, "isSidebarDocked"];
|
||||
/** @deprecated #6213 TODO remove 23-06-01 */
|
||||
isSidebarDocked: [boolean, "defaultSidebarDockedPreference"];
|
||||
};
|
||||
|
||||
export interface ImportedDataState {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { AppState, ExcalidrawProps, Point } from "../types";
|
||||
import { AppState, ExcalidrawProps, Point, UIAppState } from "../types";
|
||||
import {
|
||||
getShortcutKey,
|
||||
sceneCoordsToViewportCoords,
|
||||
|
@ -297,10 +297,11 @@ export const getContextMenuLabel = (
|
|||
: "labels.link.create";
|
||||
return label;
|
||||
};
|
||||
|
||||
export const getLinkHandleFromCoords = (
|
||||
[x1, y1, x2, y2]: Bounds,
|
||||
angle: number,
|
||||
appState: AppState,
|
||||
appState: UIAppState,
|
||||
): [x: number, y: number, width: number, height: number] => {
|
||||
const size = DEFAULT_LINK_SIZE;
|
||||
const linkWidth = size / appState.zoom.value;
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { AppState } from "../types";
|
||||
import { NonDeletedExcalidrawElement } from "./types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { UIAppState } from "../types";
|
||||
|
||||
export const showSelectedShapeActions = (
|
||||
appState: AppState,
|
||||
appState: UIAppState,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
) =>
|
||||
Boolean(
|
||||
|
|
|
@ -1521,7 +1521,7 @@ describe("textWysiwyg", () => {
|
|||
roundness: {
|
||||
type: 3,
|
||||
},
|
||||
strokeColor: "#000000",
|
||||
strokeColor: "#1e1e1e",
|
||||
strokeStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
type: "rectangle",
|
||||
|
|
|
@ -254,7 +254,6 @@ export const textWysiwyg = ({
|
|||
const initialSelectionStart = editable.selectionStart;
|
||||
const initialSelectionEnd = editable.selectionEnd;
|
||||
const initialLength = editable.value.length;
|
||||
editable.value = updatedTextElement.originalText;
|
||||
|
||||
// restore cursor position after value updated so it doesn't
|
||||
// go to the end of text when container auto expanded
|
||||
|
@ -358,6 +357,7 @@ export const textWysiwyg = ({
|
|||
overflowWrap: "break-word",
|
||||
boxSizing: "content-box",
|
||||
});
|
||||
editable.value = element.originalText;
|
||||
updateWysiwygStyle();
|
||||
|
||||
if (onChange) {
|
||||
|
@ -636,20 +636,46 @@ export const textWysiwyg = ({
|
|||
// in that same tick.
|
||||
const target = event?.target;
|
||||
|
||||
const isTargetColorPicker =
|
||||
target instanceof HTMLInputElement &&
|
||||
target.closest(".color-picker-input") &&
|
||||
isWritableElement(target);
|
||||
const isTargetPickerTrigger =
|
||||
target instanceof HTMLElement &&
|
||||
target.classList.contains("active-color");
|
||||
|
||||
setTimeout(() => {
|
||||
editable.onblur = handleSubmit;
|
||||
if (target && isTargetColorPicker) {
|
||||
target.onblur = () => {
|
||||
|
||||
if (isTargetPickerTrigger) {
|
||||
const callback = (
|
||||
mutationList: MutationRecord[],
|
||||
observer: MutationObserver,
|
||||
) => {
|
||||
const radixIsRemoved = mutationList.find(
|
||||
(mutation) =>
|
||||
mutation.removedNodes.length > 0 &&
|
||||
(mutation.removedNodes[0] as HTMLElement).dataset
|
||||
?.radixPopperContentWrapper !== undefined,
|
||||
);
|
||||
|
||||
if (radixIsRemoved) {
|
||||
// should work without this in theory
|
||||
// and i think it does actually but radix probably somewhere,
|
||||
// somehow sets the focus elsewhere
|
||||
setTimeout(() => {
|
||||
editable.focus();
|
||||
};
|
||||
});
|
||||
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(callback);
|
||||
|
||||
observer.observe(document.querySelector(".excalidraw-container")!, {
|
||||
childList: true,
|
||||
});
|
||||
}
|
||||
|
||||
// case: clicking on the same property → no change → no update → no focus
|
||||
if (!isTargetColorPicker) {
|
||||
if (!isTargetPickerTrigger) {
|
||||
editable.focus();
|
||||
}
|
||||
});
|
||||
|
@ -657,16 +683,16 @@ export const textWysiwyg = ({
|
|||
|
||||
// prevent blur when changing properties from the menu
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
const isTargetColorPicker =
|
||||
event.target instanceof HTMLInputElement &&
|
||||
event.target.closest(".color-picker-input") &&
|
||||
isWritableElement(event.target);
|
||||
const isTargetPickerTrigger =
|
||||
event.target instanceof HTMLElement &&
|
||||
event.target.classList.contains("active-color");
|
||||
|
||||
if (
|
||||
((event.target instanceof HTMLElement ||
|
||||
event.target instanceof SVGElement) &&
|
||||
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
|
||||
!isWritableElement(event.target)) ||
|
||||
isTargetColorPicker
|
||||
isTargetPickerTrigger
|
||||
) {
|
||||
editable.onblur = null;
|
||||
window.addEventListener("pointerup", bindBlurEvent);
|
||||
|
@ -680,7 +706,7 @@ export const textWysiwyg = ({
|
|||
const unbindUpdate = Scene.getScene(element)!.addCallback(() => {
|
||||
updateWysiwygStyle();
|
||||
const isColorPickerActive = !!document.activeElement?.closest(
|
||||
".color-picker-input",
|
||||
".color-picker-content",
|
||||
);
|
||||
if (!isColorPickerActive) {
|
||||
editable.focus();
|
||||
|
|
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