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

This commit is contained in:
Aakansha Doshi
2023-06-05 11:46:26 +05:30
129 changed files with 5479 additions and 2658 deletions
+82 -14
View File
@@ -305,6 +305,7 @@ import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { actionWrapTextInContainer } from "../actions/actionBoundText";
import BraveMeasureTextError from "./BraveMeasureTextError";
import { convertToExcalidrawElements } from "../data/transform";
import { activeEyeDropperAtom } from "./EyeDropper";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@@ -367,8 +368,6 @@ export const useExcalidrawActionManager = () =>
let didTapTwice: boolean = false;
let tappedTwiceTimer = 0;
let cursorX = 0;
let cursorY = 0;
let isHoldingSpace: boolean = false;
let isPanning: boolean = false;
let isDraggingScrollBar: boolean = false;
@@ -426,7 +425,7 @@ class App extends React.Component<AppProps, AppState> {
hitLinkElement?: NonDeletedExcalidrawElement;
lastPointerDown: React.PointerEvent<HTMLCanvasElement> | null = null;
lastPointerUp: React.PointerEvent<HTMLElement> | PointerEvent | null = null;
lastScenePointer: { x: number; y: number } | null = null;
lastViewportPosition = { x: 0, y: 0 };
constructor(props: AppProps) {
super(props);
@@ -635,6 +634,7 @@ class App extends React.Component<AppProps, AppState> {
</LayerUI>
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" />
{selectedElement.length === 1 &&
!this.state.contextMenu &&
this.state.showHyperlinkPopup && (
@@ -725,6 +725,49 @@ class App extends React.Component<AppProps, AppState> {
}
};
private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
jotaiStore.set(activeEyeDropperAtom, {
swapPreviewOnAlt: true,
previewType: type === "stroke" ? "strokeColor" : "backgroundColor",
onSelect: (color, event) => {
const shouldUpdateStrokeColor =
(type === "background" && event.altKey) ||
(type === "stroke" && !event.altKey);
const selectedElements = getSelectedElements(
this.scene.getElementsIncludingDeleted(),
this.state,
);
if (
!selectedElements.length ||
this.state.activeTool.type !== "selection"
) {
if (shouldUpdateStrokeColor) {
this.setState({
currentItemStrokeColor: color,
});
} else {
this.setState({
currentItemBackgroundColor: color,
});
}
} else {
this.updateScene({
elements: this.scene.getElementsIncludingDeleted().map((el) => {
if (this.state.selectedElementIds[el.id]) {
return newElementWith(el, {
[shouldUpdateStrokeColor ? "strokeColor" : "backgroundColor"]:
color,
});
}
return el;
}),
});
}
},
keepOpenOnAlt: false,
});
};
private syncActionResult = withBatchedUpdates(
(actionResult: ActionResult) => {
if (this.unmounted || actionResult === false) {
@@ -1073,6 +1116,7 @@ class App extends React.Component<AppProps, AppState> {
this.unmounted = true;
this.removeEventListeners();
this.scene.destroy();
this.library.destroy();
clearTimeout(touchTimeout);
touchTimeout = 0;
}
@@ -1569,7 +1613,10 @@ class App extends React.Component<AppProps, AppState> {
return;
}
const elementUnderCursor = document.elementFromPoint(cursorX, cursorY);
const elementUnderCursor = document.elementFromPoint(
this.lastViewportPosition.x,
this.lastViewportPosition.y,
);
if (
event &&
(!(elementUnderCursor instanceof HTMLCanvasElement) ||
@@ -1596,7 +1643,10 @@ class App extends React.Component<AppProps, AppState> {
// prefer spreadsheet data over image file (MS Office/Libre Office)
if (isSupportedImageFile(file) && !data.spreadsheet) {
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
{ clientX: cursorX, clientY: cursorY },
{
clientX: this.lastViewportPosition.x,
clientY: this.lastViewportPosition.y,
},
this.state,
);
@@ -1659,13 +1709,13 @@ class App extends React.Component<AppProps, AppState> {
typeof opts.position === "object"
? opts.position.clientX
: opts.position === "cursor"
? cursorX
? this.lastViewportPosition.x
: this.state.width / 2 + this.state.offsetLeft;
const clientY =
typeof opts.position === "object"
? opts.position.clientY
: opts.position === "cursor"
? cursorY
? this.lastViewportPosition.y
: this.state.height / 2 + this.state.offsetTop;
const { x, y } = viewportCoordsToSceneCoords(
@@ -1749,7 +1799,10 @@ class App extends React.Component<AppProps, AppState> {
private addTextFromPaste(text: string, isPlainPaste = false) {
const { x, y } = viewportCoordsToSceneCoords(
{ clientX: cursorX, clientY: cursorY },
{
clientX: this.lastViewportPosition.x,
clientY: this.lastViewportPosition.y,
},
this.state,
);
@@ -2084,8 +2137,8 @@ class App extends React.Component<AppProps, AppState> {
private updateCurrentCursorPosition = withBatchedUpdates(
(event: MouseEvent) => {
cursorX = event.clientX;
cursorY = event.clientY;
this.lastViewportPosition.x = event.clientX;
this.lastViewportPosition.y = event.clientY;
},
);
@@ -2158,6 +2211,7 @@ class App extends React.Component<AppProps, AppState> {
event.shiftKey &&
event[KEYS.CTRL_OR_CMD]
) {
event.preventDefault();
this.setState({ openDialog: "imageExport" });
return;
}
@@ -2342,6 +2396,20 @@ class App extends React.Component<AppProps, AppState> {
) {
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
}
// eye dropper
// -----------------------------------------------------------------------
const lowerCased = event.key.toLocaleLowerCase();
const isPickingStroke = lowerCased === KEYS.S && event.shiftKey;
const isPickingBackground =
event.key === KEYS.I || (lowerCased === KEYS.G && event.shiftKey);
if (isPickingStroke || isPickingBackground) {
this.openEyeDropper({
type: isPickingStroke ? "stroke" : "background",
});
}
// -----------------------------------------------------------------------
},
);
@@ -2471,8 +2539,8 @@ class App extends React.Component<AppProps, AppState> {
this.setState((state) => ({
...getStateForZoom(
{
viewportX: cursorX,
viewportY: cursorY,
viewportX: this.lastViewportPosition.x,
viewportY: this.lastViewportPosition.y,
nextZoom: getNormalizedZoom(initialScale * event.scale),
},
state,
@@ -6468,8 +6536,8 @@ class App extends React.Component<AppProps, AppState> {
this.translateCanvas((state) => ({
...getStateForZoom(
{
viewportX: cursorX,
viewportY: cursorY,
viewportX: this.lastViewportPosition.x,
viewportY: this.lastViewportPosition.y,
nextZoom: getNormalizedZoom(newZoom),
},
state,
+61 -10
View File
@@ -2,15 +2,23 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { getColor } from "./ColorPicker";
import { useAtom } from "jotai";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { eyeDropperIcon } from "../icons";
import { jotaiScope } from "../../jotai";
import { KEYS } from "../../keys";
import { activeEyeDropperAtom } from "../EyeDropper";
import clsx from "clsx";
import { t } from "../../i18n";
import { useDevice } from "../App";
import { getShortcutKey } from "../../utils";
interface ColorInputProps {
color: string | null;
color: string;
onChange: (color: string) => void;
label: string;
}
export const ColorInput = ({ color, onChange, label }: ColorInputProps) => {
const device = useDevice();
const [innerValue, setInnerValue] = useState(color);
const [activeSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
@@ -34,7 +42,7 @@ export const ColorInput = ({ color, onChange, label }: ColorInputProps) => {
);
const inputRef = useRef<HTMLInputElement>(null);
const divRef = useRef<HTMLDivElement>(null);
const eyeDropperTriggerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (inputRef.current) {
@@ -42,8 +50,19 @@ export const ColorInput = ({ color, onChange, label }: ColorInputProps) => {
}
}, [activeSection]);
const [eyeDropperState, setEyeDropperState] = useAtom(
activeEyeDropperAtom,
jotaiScope,
);
useEffect(() => {
return () => {
setEyeDropperState(null);
};
}, [setEyeDropperState]);
return (
<label className="color-picker__input-label">
<div className="color-picker__input-label">
<div className="color-picker__input-hash">#</div>
<input
ref={activeSection === "hex" ? inputRef : undefined}
@@ -60,16 +79,48 @@ export const ColorInput = ({ color, onChange, label }: ColorInputProps) => {
}}
tabIndex={-1}
onFocus={() => setActiveColorPickerSection("hex")}
onKeyDown={(e) => {
if (e.key === KEYS.TAB) {
onKeyDown={(event) => {
if (event.key === KEYS.TAB) {
return;
} else if (event.key === KEYS.ESCAPE) {
eyeDropperTriggerRef.current?.focus();
}
if (e.key === KEYS.ESCAPE) {
divRef.current?.focus();
}
e.stopPropagation();
event.stopPropagation();
}}
/>
</label>
{/* TODO reenable on mobile with a better UX */}
{!device.isMobile && (
<>
<div
style={{
width: "1px",
height: "1.25rem",
backgroundColor: "var(--default-border-color)",
}}
/>
<div
ref={eyeDropperTriggerRef}
className={clsx("excalidraw-eye-dropper-trigger", {
selected: eyeDropperState,
})}
onClick={() =>
setEyeDropperState((s) =>
s
? null
: {
keepOpenOnAlt: false,
onSelect: (color) => onChange(color),
},
)
}
title={`${t(
"labels.eyeDropper",
)}${KEYS.I.toLocaleUpperCase()} or ${getShortcutKey("Alt")} `}
>
{eyeDropperIcon}
</div>
</>
)}
</div>
);
};
@@ -204,6 +204,7 @@
display: flex;
flex-direction: column;
gap: 0.75rem;
outline: none;
}
.color-picker-content--default {
+66 -11
View File
@@ -1,4 +1,4 @@
import { isTransparent } from "../../utils";
import { isTransparent, isWritableElement } from "../../utils";
import { ExcalidrawElement } from "../../element/types";
import { AppState } from "../../types";
import { TopPicks } from "./TopPicks";
@@ -12,12 +12,14 @@ import {
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 { jotaiScope } from "../../jotai";
import { ColorInput } from "./ColorInput";
import { useRef } from "react";
import { activeEyeDropperAtom } from "../EyeDropper";
import "./ColorPicker.scss";
import React from "react";
const isValidColor = (color: string) => {
const style = new Option().style;
@@ -40,9 +42,9 @@ export const getColor = (color: string): string | null => {
: null;
};
export interface ColorPickerProps {
interface ColorPickerProps {
type: ColorPickerType;
color: string | null;
color: string;
onChange: (color: string) => void;
label: string;
elements: readonly ExcalidrawElement[];
@@ -66,13 +68,17 @@ const ColorPickerPopupContent = ({
| "color"
| "onChange"
| "label"
| "label"
| "elements"
| "palette"
| "updateData"
>) => {
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
const [eyeDropperState, setEyeDropperState] = useAtom(
activeEyeDropperAtom,
jotaiScope,
);
const { container } = useExcalidrawContainer();
const { isMobile, isLandscape } = useDevice();
@@ -88,21 +94,42 @@ const ColorPickerPopupContent = ({
/>
</div>
);
const popoverRef = useRef<HTMLDivElement>(null);
const focusPickerContent = () => {
popoverRef.current
?.querySelector<HTMLDivElement>(".color-picker-content")
?.focus();
};
return (
<Popover.Portal container={container}>
<Popover.Content
ref={popoverRef}
className="focus-visible-none"
data-prevent-outside-click
onFocusOutside={(event) => {
focusPickerContent();
event.preventDefault();
}}
onPointerDownOutside={(event) => {
if (eyeDropperState) {
// prevent from closing if we click outside the popover
// while eyedropping (e.g. click when clicking the sidebar;
// the eye-dropper-backdrop is prevented downstream)
event.preventDefault();
}
}}
onCloseAutoFocus={(e) => {
e.preventDefault();
e.stopPropagation();
// return focus to excalidraw container
if (container) {
container.focus();
}
e.preventDefault();
e.stopPropagation();
updateData({ openPopup: null });
setActiveColorPickerSection(null);
}}
side={isMobile && !isLandscape ? "bottom" : "right"}
@@ -125,10 +152,38 @@ const ColorPickerPopupContent = ({
{palette ? (
<Picker
palette={palette}
color={color || null}
color={color}
onChange={(changedColor) => {
onChange(changedColor);
}}
onEyeDropperToggle={(force) => {
setEyeDropperState((state) => {
if (force) {
state = state || {
keepOpenOnAlt: true,
onSelect: onChange,
};
state.keepOpenOnAlt = true;
return state;
}
return force === false || state
? null
: {
keepOpenOnAlt: false,
onSelect: onChange,
};
});
}}
onEscape={(event) => {
if (eyeDropperState) {
setEyeDropperState(null);
} else if (isWritableElement(event.target)) {
focusPickerContent();
} else {
updateData({ openPopup: null });
}
}}
label={label}
type={type}
elements={elements}
@@ -157,7 +212,7 @@ const ColorPickerTrigger = ({
color,
type,
}: {
color: string | null;
color: string;
label: string;
type: ColorPickerType;
}) => {
@@ -6,7 +6,7 @@ import HotkeyLabel from "./HotkeyLabel";
interface CustomColorListProps {
colors: string[];
color: string | null;
color: string;
onChange: (color: string) => void;
label: string;
}
+36 -14
View File
@@ -12,7 +12,7 @@ import PickerHeading from "./PickerHeading";
import {
ColorPickerType,
activeColorPickerSectionAtom,
getColorNameAndShadeFromHex,
getColorNameAndShadeFromColor,
getMostUsedCustomColors,
isCustomColor,
} from "./colorPickerUtils";
@@ -21,9 +21,11 @@ import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
} from "../../colors";
import { KEYS } from "../../keys";
import { EVENT } from "../../constants";
interface PickerProps {
color: string | null;
color: string;
onChange: (color: string) => void;
label: string;
type: ColorPickerType;
@@ -31,6 +33,8 @@ interface PickerProps {
palette: ColorPaletteCustom;
updateData: (formData?: any) => void;
children?: React.ReactNode;
onEyeDropperToggle: (force?: boolean) => void;
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
}
export const Picker = ({
@@ -42,6 +46,8 @@ export const Picker = ({
palette,
updateData,
children,
onEyeDropperToggle,
onEscape,
}: PickerProps) => {
const [customColors] = React.useState(() => {
if (type === "canvasBackground") {
@@ -54,16 +60,15 @@ export const Picker = ({
activeColorPickerSectionAtom,
);
const colorObj = getColorNameAndShadeFromHex({
hex: color || "transparent",
const colorObj = getColorNameAndShadeFromColor({
color,
palette,
});
useEffect(() => {
if (!activeColorPickerSection) {
const isCustom = isCustomColor({ color, palette });
const isCustomButNotInList =
isCustom && !customColors.includes(color || "");
const isCustomButNotInList = isCustom && !customColors.includes(color);
setActiveColorPickerSection(
isCustomButNotInList
@@ -95,26 +100,43 @@ export const Picker = ({
if (colorObj?.shade != null) {
setActiveShade(colorObj.shade);
}
}, [colorObj]);
const keyup = (event: KeyboardEvent) => {
if (event.key === KEYS.ALT) {
onEyeDropperToggle(false);
}
};
document.addEventListener(EVENT.KEYUP, keyup, { capture: true });
return () => {
document.removeEventListener(EVENT.KEYUP, keyup, { capture: true });
};
}, [colorObj, onEyeDropperToggle]);
const pickerRef = React.useRef<HTMLDivElement>(null);
return (
<div role="dialog" aria-modal="true" aria-label={t("labels.colorPicker")}>
<div
onKeyDown={(e) => {
e.preventDefault();
e.stopPropagation();
colorPickerKeyNavHandler({
e,
ref={pickerRef}
onKeyDown={(event) => {
const handled = colorPickerKeyNavHandler({
event,
activeColorPickerSection,
palette,
hex: color,
color,
onChange,
onEyeDropperToggle,
customColors,
setActiveColorPickerSection,
updateData,
activeShade,
onEscape,
});
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}}
className="color-picker-content"
// to allow focusing by clicking but not by tabbing
@@ -4,7 +4,7 @@ import { useEffect, useRef } from "react";
import {
activeColorPickerSectionAtom,
colorPickerHotkeyBindings,
getColorNameAndShadeFromHex,
getColorNameAndShadeFromColor,
} from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
import { ColorPaletteCustom } from "../../colors";
@@ -12,7 +12,7 @@ import { t } from "../../i18n";
interface PickerColorListProps {
palette: ColorPaletteCustom;
color: string | null;
color: string;
onChange: (color: string) => void;
label: string;
activeShade: number;
@@ -25,8 +25,8 @@ const PickerColorList = ({
label,
activeShade,
}: PickerColorListProps) => {
const colorObj = getColorNameAndShadeFromHex({
hex: color || "transparent",
const colorObj = getColorNameAndShadeFromColor({
color: color || "transparent",
palette,
});
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
+4 -4
View File
@@ -3,21 +3,21 @@ import { useAtom } from "jotai";
import { useEffect, useRef } from "react";
import {
activeColorPickerSectionAtom,
getColorNameAndShadeFromHex,
getColorNameAndShadeFromColor,
} from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
import { t } from "../../i18n";
import { ColorPaletteCustom } from "../../colors";
interface ShadeListProps {
hex: string | null;
hex: string;
onChange: (color: string) => void;
palette: ColorPaletteCustom;
}
export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
const colorObj = getColorNameAndShadeFromHex({
hex: hex || "transparent",
const colorObj = getColorNameAndShadeFromColor({
color: hex || "transparent",
palette,
});
+1 -1
View File
@@ -9,7 +9,7 @@ import {
interface TopPicksProps {
onChange: (color: string) => void;
type: ColorPickerType;
activeColor: string | null;
activeColor: string;
topPicks?: readonly string[];
}
@@ -6,23 +6,23 @@ import {
MAX_CUSTOM_COLORS_USED_IN_CANVAS,
} from "../../colors";
export const getColorNameAndShadeFromHex = ({
export const getColorNameAndShadeFromColor = ({
palette,
hex,
color,
}: {
palette: ColorPaletteCustom;
hex: string;
color: string;
}): {
colorName: ColorPickerColor;
shade: number | null;
} | null => {
for (const [colorName, colorVal] of Object.entries(palette)) {
if (Array.isArray(colorVal)) {
const shade = colorVal.indexOf(hex);
const shade = colorVal.indexOf(color);
if (shade > -1) {
return { colorName: colorName as ColorPickerColor, shade };
}
} else if (colorVal === hex) {
} else if (colorVal === color) {
return { colorName: colorName as ColorPickerColor, shade: null };
}
}
@@ -39,12 +39,9 @@ export const isCustomColor = ({
color,
palette,
}: {
color: string | null;
color: string;
palette: ColorPaletteCustom;
}) => {
if (!color) {
return false;
}
const paletteValues = Object.values(palette).flat();
return !paletteValues.includes(color);
};
@@ -1,3 +1,4 @@
import { KEYS } from "../../keys";
import {
ColorPickerColor,
ColorPalette,
@@ -5,12 +6,11 @@ import {
COLORS_PER_ROW,
COLOR_PALETTE,
} from "../../colors";
import { KEYS } from "../../keys";
import { ValueOf } from "../../utility-types";
import {
ActiveColorPickerSectionAtomType,
colorPickerHotkeyBindings,
getColorNameAndShadeFromHex,
getColorNameAndShadeFromColor,
} from "./colorPickerUtils";
const arrowHandler = (
@@ -55,6 +55,9 @@ interface HotkeyHandlerProps {
activeShade: number;
}
/**
* @returns true if the event was handled
*/
const hotkeyHandler = ({
e,
colorObj,
@@ -63,7 +66,7 @@ const hotkeyHandler = ({
customColors,
setActiveColorPickerSection,
activeShade,
}: HotkeyHandlerProps) => {
}: HotkeyHandlerProps): boolean => {
if (colorObj?.shade != null) {
// shift + numpad is extremely messed up on windows apparently
if (
@@ -73,6 +76,7 @@ const hotkeyHandler = ({
const newShade = Number(e.code.slice(-1)) - 1;
onChange(palette[colorObj.colorName][newShade]);
setActiveColorPickerSection("shades");
return true;
}
}
@@ -81,6 +85,7 @@ const hotkeyHandler = ({
if (c) {
onChange(customColors[Number(e.key) - 1]);
setActiveColorPickerSection("custom");
return true;
}
}
@@ -93,14 +98,16 @@ const hotkeyHandler = ({
: paletteValue;
onChange(r);
setActiveColorPickerSection("baseColors");
return true;
}
return false;
};
interface ColorPickerKeyNavHandlerProps {
e: React.KeyboardEvent;
event: React.KeyboardEvent;
activeColorPickerSection: ActiveColorPickerSectionAtomType;
palette: ColorPaletteCustom;
hex: string | null;
color: string;
onChange: (color: string) => void;
customColors: string[];
setActiveColorPickerSection: (
@@ -108,27 +115,49 @@ interface ColorPickerKeyNavHandlerProps {
) => void;
updateData: (formData?: any) => void;
activeShade: number;
onEyeDropperToggle: (force?: boolean) => void;
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
}
/**
* @returns true if the event was handled
*/
export const colorPickerKeyNavHandler = ({
e,
event,
activeColorPickerSection,
palette,
hex,
color,
onChange,
customColors,
setActiveColorPickerSection,
updateData,
activeShade,
}: ColorPickerKeyNavHandlerProps) => {
if (e.key === KEYS.ESCAPE || !hex) {
updateData({ openPopup: null });
return;
onEyeDropperToggle,
onEscape,
}: ColorPickerKeyNavHandlerProps): boolean => {
if (event[KEYS.CTRL_OR_CMD]) {
return false;
}
const colorObj = getColorNameAndShadeFromHex({ hex, palette });
if (event.key === KEYS.ESCAPE) {
onEscape(event);
return true;
}
if (e.key === KEYS.TAB) {
// checkt using `key` to ignore combos with Alt modifier
if (event.key === KEYS.ALT) {
onEyeDropperToggle(true);
return true;
}
if (event.key === KEYS.I) {
onEyeDropperToggle();
return true;
}
const colorObj = getColorNameAndShadeFromColor({ color, palette });
if (event.key === KEYS.TAB) {
const sectionsMap: Record<
NonNullable<ActiveColorPickerSectionAtomType>,
boolean
@@ -147,7 +176,7 @@ export const colorPickerKeyNavHandler = ({
}, [] as ActiveColorPickerSectionAtomType[]);
const activeSectionIndex = sections.indexOf(activeColorPickerSection);
const indexOffset = e.shiftKey ? -1 : 1;
const indexOffset = event.shiftKey ? -1 : 1;
const nextSectionIndex =
activeSectionIndex + indexOffset > sections.length - 1
? 0
@@ -168,8 +197,8 @@ export const colorPickerKeyNavHandler = ({
Object.entries(palette) as [string, ValueOf<ColorPalette>][]
).find(([name, shades]) => {
if (Array.isArray(shades)) {
return shades.includes(hex);
} else if (shades === hex) {
return shades.includes(color);
} else if (shades === color) {
return name;
}
return null;
@@ -180,29 +209,34 @@ export const colorPickerKeyNavHandler = ({
}
}
e.preventDefault();
e.stopPropagation();
event.preventDefault();
event.stopPropagation();
return;
return true;
}
hotkeyHandler({
e,
colorObj,
onChange,
palette,
customColors,
setActiveColorPickerSection,
activeShade,
});
if (
hotkeyHandler({
e: event,
colorObj,
onChange,
palette,
customColors,
setActiveColorPickerSection,
activeShade,
})
) {
return true;
}
if (activeColorPickerSection === "shades") {
if (colorObj) {
const { shade } = colorObj;
const newShade = arrowHandler(e.key, shade, COLORS_PER_ROW);
const newShade = arrowHandler(event.key, shade, COLORS_PER_ROW);
if (newShade !== undefined) {
onChange(palette[colorObj.colorName][newShade]);
return true;
}
}
}
@@ -214,7 +248,7 @@ export const colorPickerKeyNavHandler = ({
const indexOfColorName = colorNames.indexOf(colorName);
const newColorIndex = arrowHandler(
e.key,
event.key,
indexOfColorName,
colorNames.length,
);
@@ -228,15 +262,16 @@ export const colorPickerKeyNavHandler = ({
? newColorNameValue[activeShade]
: newColorNameValue,
);
return true;
}
}
}
if (activeColorPickerSection === "custom") {
const indexOfColor = customColors.indexOf(hex);
const indexOfColor = customColors.indexOf(color);
const newColorIndex = arrowHandler(
e.key,
event.key,
indexOfColor,
customColors.length,
);
@@ -244,6 +279,9 @@ export const colorPickerKeyNavHandler = ({
if (newColorIndex !== undefined) {
const newColor = customColors[newColorIndex];
onChange(newColor);
return true;
}
}
return false;
};
+1 -1
View File
@@ -31,7 +31,7 @@ const ConfirmDialog = (props: Props) => {
return (
<Dialog
onCloseRequest={onCancel}
small={true}
size="small"
{...rest}
className={`confirm-dialog ${className}`}
>
+29
View File
@@ -14,4 +14,33 @@
padding: 0 0 0.75rem;
margin-bottom: 1.5rem;
}
.Dialog__close {
color: var(--color-gray-40);
margin: 0;
position: absolute;
top: 0.75rem;
right: 0.5rem;
border: 0;
background-color: transparent;
line-height: 0;
cursor: pointer;
&:hover {
color: var(--color-gray-60);
}
&:active {
color: var(--color-gray-40);
}
@include isMobile {
top: 1.25rem;
right: 1.25rem;
}
svg {
width: 1.5rem;
height: 1.5rem;
}
}
}
+19 -17
View File
@@ -12,7 +12,6 @@ import "./Dialog.scss";
import { back, CloseIcon } from "./icons";
import { Island } from "./Island";
import { Modal } from "./Modal";
import { AppState } from "../types";
import { queryFocusableElements } from "../utils";
import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
@@ -21,11 +20,10 @@ import { jotaiScope } from "../jotai";
export interface DialogProps {
children: React.ReactNode;
className?: string;
small?: boolean;
size?: "small" | "regular" | "wide";
onCloseRequest(): void;
title: React.ReactNode;
title: React.ReactNode | false;
autofocus?: boolean;
theme?: AppState["theme"];
closeOnClickOutside?: boolean;
}
@@ -33,6 +31,7 @@ export const Dialog = (props: DialogProps) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [lastActiveElement] = useState(document.activeElement);
const { id } = useExcalidrawContainer();
const device = useDevice();
useEffect(() => {
if (!islandNode) {
@@ -86,23 +85,26 @@ export const Dialog = (props: DialogProps) => {
<Modal
className={clsx("Dialog", props.className)}
labelledBy="dialog-title"
maxWidth={props.small ? 550 : 800}
maxWidth={
props.size === "wide" ? 1024 : props.size === "small" ? 550 : 800
}
onCloseRequest={onClose}
theme={props.theme}
closeOnClickOutside={props.closeOnClickOutside}
>
<Island ref={setIslandNode}>
<h2 id={`${id}-dialog-title`} className="Dialog__title">
<span className="Dialog__titleContent">{props.title}</span>
<button
className="Modal__close"
onClick={onClose}
title={t("buttons.close")}
aria-label={t("buttons.close")}
>
{useDevice().isMobile ? back : CloseIcon}
</button>
</h2>
{props.title && (
<h2 id={`${id}-dialog-title`} className="Dialog__title">
<span className="Dialog__titleContent">{props.title}</span>
</h2>
)}
<button
className="Dialog__close"
onClick={onClose}
title={t("buttons.close")}
aria-label={t("buttons.close")}
>
{device.isMobile ? back : CloseIcon}
</button>
<div className="Dialog__content">{props.children}</div>
</Island>
</Modal>
+1 -1
View File
@@ -28,7 +28,7 @@ export const ErrorDialog = ({
<>
{modalIsShown && (
<Dialog
small
size="small"
onCloseRequest={handleClose}
title={t("errorDialog.title")}
>
+48
View File
@@ -0,0 +1,48 @@
.excalidraw {
.excalidraw-eye-dropper-container,
.excalidraw-eye-dropper-backdrop {
position: absolute;
width: 100%;
height: 100%;
z-index: 2;
touch-action: none;
}
.excalidraw-eye-dropper-container {
pointer-events: none;
}
.excalidraw-eye-dropper-backdrop {
pointer-events: all;
}
.excalidraw-eye-dropper-preview {
pointer-events: none;
width: 3rem;
height: 3rem;
position: fixed;
z-index: 999999;
border-radius: 1rem;
border: 1px solid var(--default-border-color);
filter: var(--theme-filter);
}
.excalidraw-eye-dropper-trigger {
width: 1.25rem;
height: 1.25rem;
cursor: pointer;
padding: 4px;
margin-right: -4px;
margin-left: -2px;
border-radius: 0.5rem;
color: var(--icon-fill-color);
&:hover {
background: var(--button-hover-bg);
}
&.selected {
color: var(--color-primary);
background: var(--color-primary-light);
}
}
}
+217
View File
@@ -0,0 +1,217 @@
import { atom } from "jotai";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { COLOR_PALETTE, rgbToHex } from "../colors";
import { EVENT } from "../constants";
import { useUIAppState } from "../context/ui-appState";
import { mutateElement } from "../element/mutateElement";
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
import { useOutsideClick } from "../hooks/useOutsideClick";
import { KEYS } from "../keys";
import { invalidateShapeForElement } from "../renderer/renderElement";
import { getSelectedElements } from "../scene";
import Scene from "../scene/Scene";
import { useApp, useExcalidrawContainer, useExcalidrawElements } from "./App";
import "./EyeDropper.scss";
type EyeDropperProperties = {
keepOpenOnAlt: boolean;
swapPreviewOnAlt?: boolean;
onSelect?: (color: string, event: PointerEvent) => void;
previewType?: "strokeColor" | "backgroundColor";
};
export const activeEyeDropperAtom = atom<null | EyeDropperProperties>(null);
export const EyeDropper: React.FC<{
onCancel: () => void;
onSelect: Required<EyeDropperProperties>["onSelect"];
swapPreviewOnAlt?: EyeDropperProperties["swapPreviewOnAlt"];
previewType?: EyeDropperProperties["previewType"];
}> = ({
onCancel,
onSelect,
swapPreviewOnAlt,
previewType = "backgroundColor",
}) => {
const eyeDropperContainer = useCreatePortalContainer({
className: "excalidraw-eye-dropper-backdrop",
parentSelector: ".excalidraw-eye-dropper-container",
});
const appState = useUIAppState();
const elements = useExcalidrawElements();
const app = useApp();
const selectedElements = getSelectedElements(elements, appState);
const metaStuffRef = useRef({ selectedElements, app });
metaStuffRef.current.selectedElements = selectedElements;
metaStuffRef.current.app = app;
const { container: excalidrawContainer } = useExcalidrawContainer();
useEffect(() => {
const colorPreviewDiv = ref.current;
if (!colorPreviewDiv || !app.canvas || !eyeDropperContainer) {
return;
}
let currentColor = COLOR_PALETTE.black;
let isHoldingPointerDown = false;
const ctx = app.canvas.getContext("2d")!;
const mouseMoveListener = ({
clientX,
clientY,
altKey,
}: {
clientX: number;
clientY: number;
altKey: boolean;
}) => {
// FIXME swap offset when the preview gets outside viewport
colorPreviewDiv.style.top = `${clientY + 20}px`;
colorPreviewDiv.style.left = `${clientX + 20}px`;
const pixel = ctx.getImageData(
clientX * window.devicePixelRatio - appState.offsetLeft,
clientY * window.devicePixelRatio - appState.offsetTop,
1,
1,
).data;
currentColor = rgbToHex(pixel[0], pixel[1], pixel[2]);
if (isHoldingPointerDown) {
for (const element of metaStuffRef.current.selectedElements) {
mutateElement(
element,
{
[altKey && swapPreviewOnAlt
? previewType === "strokeColor"
? "backgroundColor"
: "strokeColor"
: previewType]: currentColor,
},
false,
);
invalidateShapeForElement(element);
}
Scene.getScene(
metaStuffRef.current.selectedElements[0],
)?.informMutation();
}
colorPreviewDiv.style.background = currentColor;
};
const pointerDownListener = (event: PointerEvent) => {
isHoldingPointerDown = true;
// NOTE we can't event.preventDefault() as that would stop
// pointermove events
event.stopImmediatePropagation();
};
const pointerUpListener = (event: PointerEvent) => {
isHoldingPointerDown = false;
// since we're not preventing default on pointerdown, the focus would
// goes back to `body` so we want to refocus the editor container instead
excalidrawContainer?.focus();
event.stopImmediatePropagation();
event.preventDefault();
onSelect(currentColor, event);
};
const keyDownListener = (event: KeyboardEvent) => {
if (event.key === KEYS.ESCAPE) {
event.preventDefault();
event.stopImmediatePropagation();
onCancel();
}
};
// -------------------------------------------------------------------------
eyeDropperContainer.tabIndex = -1;
// focus container so we can listen on keydown events
eyeDropperContainer.focus();
// init color preview else it would show only after the first mouse move
mouseMoveListener({
clientX: metaStuffRef.current.app.lastViewportPosition.x,
clientY: metaStuffRef.current.app.lastViewportPosition.y,
altKey: false,
});
eyeDropperContainer.addEventListener(EVENT.KEYDOWN, keyDownListener);
eyeDropperContainer.addEventListener(
EVENT.POINTER_DOWN,
pointerDownListener,
);
eyeDropperContainer.addEventListener(EVENT.POINTER_UP, pointerUpListener);
window.addEventListener("pointermove", mouseMoveListener, {
passive: true,
});
window.addEventListener(EVENT.BLUR, onCancel);
return () => {
isHoldingPointerDown = false;
eyeDropperContainer.removeEventListener(EVENT.KEYDOWN, keyDownListener);
eyeDropperContainer.removeEventListener(
EVENT.POINTER_DOWN,
pointerDownListener,
);
eyeDropperContainer.removeEventListener(
EVENT.POINTER_UP,
pointerUpListener,
);
window.removeEventListener("pointermove", mouseMoveListener);
window.removeEventListener(EVENT.BLUR, onCancel);
};
}, [
app.canvas,
eyeDropperContainer,
onCancel,
onSelect,
swapPreviewOnAlt,
previewType,
excalidrawContainer,
appState.offsetLeft,
appState.offsetTop,
]);
const ref = useRef<HTMLDivElement>(null);
useOutsideClick(
ref,
() => {
onCancel();
},
(event) => {
if (
event.target.closest(
".excalidraw-eye-dropper-trigger, .excalidraw-eye-dropper-backdrop",
)
) {
return true;
}
// consider all other clicks as outside
return false;
},
);
if (!eyeDropperContainer) {
return null;
}
return createPortal(
<div ref={ref} className="excalidraw-eye-dropper-preview" />,
eyeDropperContainer,
);
};
+95
View File
@@ -0,0 +1,95 @@
@import "../css/variables.module";
.excalidraw {
.ExcButton {
&--color-primary {
color: var(--input-bg-color);
--accent-color: var(--color-primary);
--accent-color-hover: var(--color-primary-darker);
--accent-color-active: var(--color-primary-darkest);
}
&--color-danger {
color: var(--input-bg-color);
--accent-color: var(--color-danger);
--accent-color-hover: #d65550;
--accent-color-active: #d1413c;
}
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
flex-wrap: nowrap;
border-radius: 0.5rem;
font-family: "Assistant";
user-select: none;
transition: all 150ms ease-out;
&--size-large {
font-weight: 400;
font-size: 0.875rem;
height: 3rem;
padding: 0.5rem 1.5rem;
gap: 0.75rem;
letter-spacing: 0.4px;
}
&--size-medium {
font-weight: 600;
font-size: 0.75rem;
height: 2.5rem;
padding: 0.5rem 1rem;
gap: 0.5rem;
letter-spacing: normal;
}
&--variant-filled {
background: var(--accent-color);
border: 1px solid transparent;
&:hover {
background: var(--accent-color-hover);
}
&:active {
background: var(--accent-color-active);
}
}
&--variant-outlined,
&--variant-icon {
border: 1px solid var(--accent-color);
color: var(--accent-color);
background: transparent;
&:hover {
border: 1px solid var(--accent-color-hover);
color: var(--accent-color-hover);
}
&:active {
border: 1px solid var(--accent-color-active);
color: var(--accent-color-active);
}
}
&--variant-icon {
padding: 0.5rem 0.75rem;
width: 3rem;
}
&__icon {
width: 1.25rem;
height: 1.25rem;
}
}
}
+61
View File
@@ -0,0 +1,61 @@
import React, { forwardRef } from "react";
import clsx from "clsx";
import "./FilledButton.scss";
export type ButtonVariant = "filled" | "outlined" | "icon";
export type ButtonColor = "primary" | "danger";
export type ButtonSize = "medium" | "large";
export type FilledButtonProps = {
label: string;
children?: React.ReactNode;
onClick?: () => void;
variant?: ButtonVariant;
color?: ButtonColor;
size?: ButtonSize;
className?: string;
startIcon?: React.ReactNode;
};
export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
(
{
children,
startIcon,
onClick,
label,
variant = "filled",
color = "primary",
size = "medium",
className,
},
ref,
) => {
return (
<button
className={clsx(
"ExcButton",
`ExcButton--color-${color}`,
`ExcButton--variant-${variant}`,
`ExcButton--size-${size}`,
className,
)}
onClick={onClick}
type="button"
aria-label={label}
ref={ref}
>
{startIcon && (
<div className="ExcButton__icon" aria-hidden>
{startIcon}
</div>
)}
{variant !== "icon" && (children ?? label)}
</button>
);
},
);
+4
View File
@@ -164,6 +164,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("toolBar.eraser")}
shortcuts={[KEYS.E, KEYS["0"]]}
/>
<Shortcut
label={t("labels.eyeDropper")}
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
/>
<Shortcut
label={t("helpDialog.editLineArrowPoints")}
shortcuts={[getShortcutKey("CtrlOrCmd+Enter")]}
+173
View File
@@ -0,0 +1,173 @@
@import "../css/variables.module";
.excalidraw {
--ImageExportModal-preview-border: #d6d6d6;
&.theme--dark {
--ImageExportModal-preview-border: #5c5c5c;
}
.ImageExportModal {
display: flex;
flex-direction: row;
justify-content: space-between;
& h3 {
font-family: "Assistant";
font-style: normal;
font-weight: 700;
font-size: 1.313rem;
line-height: 130%;
padding: 0;
margin: 0;
@include isMobile {
display: none;
}
}
& > h3 {
display: none;
@include isMobile {
display: block;
}
}
@include isMobile {
flex-direction: column;
height: calc(100vh - 5rem);
}
&__preview {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
height: 360px;
width: 55%;
margin-right: 1.5rem;
@include isMobile {
max-width: unset;
margin-right: unset;
width: 100%;
height: unset;
flex-grow: 1;
}
&__filename {
& > input {
margin-top: 1rem;
}
}
&__canvas {
box-sizing: border-box;
width: 100%;
height: 100%;
display: flex;
flex-grow: 1;
justify-content: center;
align-items: center;
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
border: 1px solid var(--ImageExportModal-preview-border);
border-radius: 12px;
overflow: hidden;
padding: 1rem;
& > canvas {
max-width: calc(100% - 2rem);
max-height: calc(100% - 2rem);
filter: none !important;
@include isMobile {
max-height: 100%;
}
}
@include isMobile {
margin-top: 24px;
max-width: unset;
}
}
}
&__settings {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 18px;
@include isMobile {
margin-left: unset;
margin-top: 1rem;
flex-direction: row;
gap: 6px 34px;
align-content: flex-start;
}
&__setting {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
@include isMobile {
flex-direction: column;
align-items: start;
justify-content: unset;
height: 52px;
}
&__label {
display: flex;
flex-direction: row;
align-items: center;
font-family: "Assistant";
font-weight: 600;
font-size: 1rem;
line-height: 150%;
& svg {
width: 20px;
height: 20px;
margin-left: 10px;
}
}
&__content {
display: flex;
height: 100%;
align-items: center;
}
}
&__buttons {
flex-grow: 1;
flex-wrap: wrap;
display: flex;
flex-direction: row;
gap: 11px;
align-items: flex-end;
align-content: flex-end;
@include isMobile {
padding-top: 32px;
flex-basis: 100%;
justify-content: center;
}
}
}
}
}
+231 -129
View File
@@ -1,25 +1,39 @@
import React, { useEffect, useRef, useState } from "react";
import type { ActionManager } from "../actions/manager";
import type { AppClassProperties, BinaryFiles, UIAppState } from "../types";
import {
actionExportWithDarkMode,
actionChangeExportBackground,
actionChangeExportEmbedScene,
actionChangeExportScale,
actionChangeProjectName,
} from "../actions/actionExport";
import { probablySupportsClipboardBlob } from "../clipboard";
import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { AppClassProperties, BinaryFiles, UIAppState } from "../types";
import { Dialog } from "./Dialog";
import { clipboard } from "./icons";
import Stack from "./Stack";
import OpenColor from "open-color";
import { CheckboxItem } from "./CheckboxItem";
import {
DEFAULT_EXPORT_PADDING,
EXPORT_IMAGE_TYPES,
isFirefox,
EXPORT_SCALES,
} from "../constants";
import { canvasToBlob } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import { ActionManager } from "../actions/manager";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas } from "../packages/utils";
import "./ExportDialog.scss";
import { copyIcon, downloadIcon, helpIcon } from "./icons";
import { Dialog } from "./Dialog";
import { RadioGroup } from "./RadioGroup";
import { Switch } from "./Switch";
import { Tooltip } from "./Tooltip";
import "./ImageExportDialog.scss";
import { useAppProps } from "./App";
import { FilledButton } from "./FilledButton";
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
@@ -36,50 +50,36 @@ export const ErrorCanvasPreview = () => {
);
};
export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[],
scale?: number,
) => void;
const ExportButton: React.FC<{
color: keyof OpenColor;
onClick: () => void;
title: string;
shade?: number;
children?: React.ReactNode;
}> = ({ children, title, onClick, color, shade = 6 }) => {
return (
<button
className="ExportDialog-imageExportButton"
style={{
["--button-color" as any]: OpenColor[color][shade],
["--button-color-darker" as any]: OpenColor[color][shade + 1],
["--button-color-darkest" as any]: OpenColor[color][shade + 2],
}}
title={title}
aria-label={title}
onClick={onClick}
>
{children}
</button>
);
};
const ImageExportModal = ({
elements,
appState,
files,
actionManager,
onExportImage,
}: {
type ImageExportModalProps = {
appState: UIAppState;
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
actionManager: ActionManager;
onExportImage: AppClassProperties["onExportImage"];
}) => {
};
const ImageExportModal = ({
appState,
elements,
files,
actionManager,
onExportImage,
}: ImageExportModalProps) => {
const appProps = useAppProps();
const [projectName, setProjectName] = useState(appState.name);
const someElementIsSelected = isSomeElementSelected(elements, appState);
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const [exportWithBackground, setExportWithBackground] = useState(
appState.exportBackground,
);
const [exportDarkMode, setExportDarkMode] = useState(
appState.exportWithDarkMode,
);
const [embedScene, setEmbedScene] = useState(appState.exportEmbedScene);
const [exportScale, setExportScale] = useState(appState.exportScale);
const previewRef = useRef<HTMLDivElement>(null);
const [renderError, setRenderError] = useState<Error | null>(null);
@@ -93,6 +93,7 @@ const ImageExportModal = ({
return;
}
const maxWidth = previewNode.offsetWidth;
const maxHeight = previewNode.offsetHeight;
if (!maxWidth) {
return;
}
@@ -101,7 +102,7 @@ const ImageExportModal = ({
appState,
files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight: maxWidth,
maxWidthOrHeight: Math.max(maxWidth, maxHeight),
})
.then((canvas) => {
setRenderError(null);
@@ -118,89 +119,190 @@ const ImageExportModal = ({
}, [appState, files, exportedElements]);
return (
<div className="ExportDialog">
<div className="ExportDialog__preview" ref={previewRef}>
{renderError && <ErrorCanvasPreview />}
</div>
{supportsContextFilters &&
actionManager.renderAction("exportWithDarkMode")}
<div style={{ display: "grid", gridTemplateColumns: "1fr" }}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(190px, 1fr))",
// dunno why this is needed, but when the items wrap it creates
// an overflow
overflow: "hidden",
}}
>
{actionManager.renderAction("changeExportBackground")}
{someElementIsSelected && (
<CheckboxItem
checked={exportSelected}
onChange={(checked) => setExportSelected(checked)}
>
{t("labels.onlySelected")}
</CheckboxItem>
<div className="ImageExportModal">
<h3>{t("imageExportDialog.header")}</h3>
<div className="ImageExportModal__preview">
<div className="ImageExportModal__preview__canvas" ref={previewRef}>
{renderError && <ErrorCanvasPreview />}
</div>
<div className="ImageExportModal__preview__filename">
{!nativeFileSystemSupported && (
<input
type="text"
className="TextInput"
value={projectName}
style={{ width: "30ch" }}
disabled={
typeof appProps.name !== "undefined" || appState.viewModeEnabled
}
onChange={(event) => {
setProjectName(event.target.value);
actionManager.executeAction(
actionChangeProjectName,
"ui",
event.target.value,
);
}}
/>
)}
{actionManager.renderAction("changeExportEmbedScene")}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}>
<Stack.Row gap={2}>
{actionManager.renderAction("changeExportScale")}
</Stack.Row>
<p style={{ marginLeft: "1em", userSelect: "none" }}>
{t("buttons.scale")}
</p>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: ".6em 0",
}}
>
{!nativeFileSystemSupported &&
actionManager.renderAction("changeProjectName")}
</div>
<Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
<ExportButton
color="indigo"
title={t("buttons.exportToPng")}
aria-label={t("buttons.exportToPng")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements)
}
>
PNG
</ExportButton>
<ExportButton
color="red"
title={t("buttons.exportToSvg")}
aria-label={t("buttons.exportToSvg")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements)
}
>
SVG
</ExportButton>
{/* firefox supports clipboard API under a flag,
so let's throw and tell people what they can do */}
{(probablySupportsClipboardBlob || isFirefox) && (
<ExportButton
title={t("buttons.copyPngToClipboard")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements)
}
color="gray"
shade={7}
<div className="ImageExportModal__settings">
<h3>{t("imageExportDialog.header")}</h3>
{someElementIsSelected && (
<ExportSetting
label={t("imageExportDialog.label.onlySelected")}
name="exportOnlySelected"
>
{clipboard}
</ExportButton>
<Switch
name="exportOnlySelected"
checked={exportSelected}
onChange={(checked) => {
setExportSelected(checked);
}}
/>
</ExportSetting>
)}
</Stack.Row>
<ExportSetting
label={t("imageExportDialog.label.withBackground")}
name="exportBackgroundSwitch"
>
<Switch
name="exportBackgroundSwitch"
checked={exportWithBackground}
onChange={(checked) => {
setExportWithBackground(checked);
actionManager.executeAction(
actionChangeExportBackground,
"ui",
checked,
);
}}
/>
</ExportSetting>
{supportsContextFilters && (
<ExportSetting
label={t("imageExportDialog.label.darkMode")}
name="exportDarkModeSwitch"
>
<Switch
name="exportDarkModeSwitch"
checked={exportDarkMode}
onChange={(checked) => {
setExportDarkMode(checked);
actionManager.executeAction(
actionExportWithDarkMode,
"ui",
checked,
);
}}
/>
</ExportSetting>
)}
<ExportSetting
label={t("imageExportDialog.label.embedScene")}
tooltip={t("imageExportDialog.tooltip.embedScene")}
name="exportEmbedSwitch"
>
<Switch
name="exportEmbedSwitch"
checked={embedScene}
onChange={(checked) => {
setEmbedScene(checked);
actionManager.executeAction(
actionChangeExportEmbedScene,
"ui",
checked,
);
}}
/>
</ExportSetting>
<ExportSetting
label={t("imageExportDialog.label.scale")}
name="exportScale"
>
<RadioGroup
name="exportScale"
value={exportScale}
onChange={(scale) => {
setExportScale(scale);
actionManager.executeAction(actionChangeExportScale, "ui", scale);
}}
choices={EXPORT_SCALES.map((scale) => ({
value: scale,
label: `${scale}\u00d7`,
}))}
/>
</ExportSetting>
<div className="ImageExportModal__settings__buttons">
<FilledButton
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.exportToPng")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements)
}
startIcon={downloadIcon}
>
{t("imageExportDialog.button.exportToPng")}
</FilledButton>
<FilledButton
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.exportToSvg")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements)
}
startIcon={downloadIcon}
>
{t("imageExportDialog.button.exportToSvg")}
</FilledButton>
{(probablySupportsClipboardBlob || isFirefox) && (
<FilledButton
className="ImageExportModal__settings__buttons__button"
label={t("imageExportDialog.title.copyPngToClipboard")}
onClick={() =>
onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements)
}
startIcon={copyIcon}
>
{t("imageExportDialog.button.copyPngToClipboard")}
</FilledButton>
)}
</div>
</div>
</div>
);
};
type ExportSettingProps = {
label: string;
children: React.ReactNode;
tooltip?: string;
name?: string;
};
const ExportSetting = ({
label,
children,
tooltip,
name,
}: ExportSettingProps) => {
return (
<div className="ImageExportModal__settings__setting" title={label}>
<label
htmlFor={name}
className="ImageExportModal__settings__setting__label"
>
{label}
{tooltip && (
<Tooltip label={tooltip} long={true}>
{helpIcon}
</Tooltip>
)}
</label>
<div className="ImageExportModal__settings__setting__content">
{children}
</div>
</div>
);
};
@@ -225,7 +327,7 @@ export const ImageExportDialog = ({
}
return (
<Dialog onCloseRequest={onCloseRequest} title={t("buttons.exportImage")}>
<Dialog onCloseRequest={onCloseRequest} size="wide" title={false}>
<ImageExportModal
elements={elements}
appState={appState}
+23 -2
View File
@@ -38,7 +38,7 @@ import { actionToggleStats } from "../actions/actionToggleStats";
import Footer from "./footer/Footer";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai";
import { Provider, useAtomValue } from "jotai";
import { Provider, useAtom, useAtomValue } from "jotai";
import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { HandButton } from "./HandButton";
@@ -47,6 +47,7 @@ import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
import { LibraryIcon } from "./icons";
import { UIAppStateContext } from "../context/ui-appState";
import { DefaultSidebar } from "./DefaultSidebar";
import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
import "./LayerUI.scss";
import "./Toolbar.scss";
@@ -120,6 +121,11 @@ const LayerUI = ({
const device = useDevice();
const tunnels = useInitializeTunnels();
const [eyeDropperState, setEyeDropperState] = useAtom(
activeEyeDropperAtom,
jotaiScope,
);
const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) {
return null;
@@ -350,6 +356,21 @@ const LayerUI = ({
{appState.errorMessage}
</ErrorDialog>
)}
{eyeDropperState && !device.isMobile && (
<EyeDropper
swapPreviewOnAlt={eyeDropperState.swapPreviewOnAlt}
previewType={eyeDropperState.previewType}
onCancel={() => {
setEyeDropperState(null);
}}
onSelect={(color, event) => {
setEyeDropperState((state) => {
return state?.keepOpenOnAlt && event.altKey ? state : null;
});
eyeDropperState?.onSelect?.(color, event);
}}
/>
)}
{appState.openDialog === "help" && (
<HelpDialog
onClose={() => {
@@ -371,7 +392,7 @@ const LayerUI = ({
}
/>
)}
{device.isMobile && (
{device.isMobile && !eyeDropperState && (
<MobileMenu
appState={appState}
elements={elements}
+81 -39
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback } from "react";
import React, { useState, useCallback, useMemo, useRef } from "react";
import Library, {
distributeLibraryItemsOnSquareGrid,
libraryItemsAtom,
@@ -27,6 +27,8 @@ import { useUIAppState } from "../context/ui-appState";
import "./LibraryMenu.scss";
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import { isShallowEqual } from "../utils";
import { NonDeletedExcalidrawElement } from "../element/types";
export const isLibraryMenuOpenAtom = atom(false);
@@ -42,7 +44,7 @@ export const LibraryMenuContent = ({
libraryReturnUrl,
library,
id,
appState,
theme,
selectedItems,
onSelectItems,
}: {
@@ -53,35 +55,47 @@ export const LibraryMenuContent = ({
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
library: Library;
id: string;
appState: UIAppState;
theme: UIAppState["theme"];
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
}) => {
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const addToLibrary = useCallback(
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
trackEvent("element", "addToLibrary", "ui");
if (elements.some((element) => element.type === "image")) {
return setAppState({
errorMessage: "Support for adding images to the library coming soon!",
const _onAddToLibrary = useCallback(
(elements: LibraryItem["elements"]) => {
const addToLibrary = async (
processedElements: LibraryItem["elements"],
libraryItems: LibraryItems,
) => {
trackEvent("element", "addToLibrary", "ui");
if (processedElements.some((element) => element.type === "image")) {
return setAppState({
errorMessage:
"Support for adding images to the library coming soon!",
});
}
const nextItems: LibraryItems = [
{
status: "unpublished",
elements: processedElements,
id: randomId(),
created: Date.now(),
},
...libraryItems,
];
onAddToLibrary();
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
}
const nextItems: LibraryItems = [
{
status: "unpublished",
elements,
id: randomId(),
created: Date.now(),
},
...libraryItems,
];
onAddToLibrary();
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
};
addToLibrary(elements, libraryItemsData.libraryItems);
},
[onAddToLibrary, library, setAppState],
[onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
);
const libraryItems = useMemo(
() => libraryItemsData.libraryItems,
[libraryItemsData],
);
if (
@@ -107,17 +121,15 @@ export const LibraryMenuContent = ({
<LibraryMenuWrapper>
<LibraryMenuItems
isLoading={libraryItemsData.status === "loading"}
libraryItems={libraryItemsData.libraryItems}
onAddToLibrary={(elements) =>
addToLibrary(elements, libraryItemsData.libraryItems)
}
libraryItems={libraryItems}
onAddToLibrary={_onAddToLibrary}
onInsertLibraryItems={onInsertLibraryItems}
pendingElements={pendingElements}
selectedItems={selectedItems}
onSelectItems={onSelectItems}
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={appState.theme}
theme={theme}
onSelectItems={onSelectItems}
selectedItems={selectedItems}
/>
{showBtn && (
<LibraryMenuControlButtons
@@ -125,13 +137,36 @@ export const LibraryMenuContent = ({
style={{ padding: "16px 12px 0 12px" }}
id={id}
libraryReturnUrl={libraryReturnUrl}
theme={appState.theme}
theme={theme}
/>
)}
</LibraryMenuWrapper>
);
};
const usePendingElementsMemo = (
appState: UIAppState,
elements: readonly NonDeletedExcalidrawElement[],
) => {
const create = () => getSelectedElements(elements, appState, true);
const val = useRef(create());
const prevAppState = useRef<UIAppState>(appState);
const prevElements = useRef(elements);
if (
!isShallowEqual(
appState.selectedElementIds,
prevAppState.current.selectedElementIds,
) ||
!isShallowEqual(elements, prevElements.current)
) {
val.current = create();
prevAppState.current = appState;
prevElements.current = elements;
}
return val.current;
};
/**
* This component is meant to be rendered inside <Sidebar.Tab/> inside our
* <DefaultSidebar/> or host apps Sidebar components.
@@ -142,8 +177,17 @@ export const LibraryMenu = () => {
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements();
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const memoizedLibrary = useMemo(() => library, [library]);
// BUG: pendingElements are still causing some unnecessary rerenders because clicking into canvas returns some ids even when no element is selected.
const pendingElements = usePendingElementsMemo(appState, elements);
const onInsertLibraryItems = useCallback(
(libraryItems: LibraryItems) => {
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
},
[onInsertElements],
);
const deselectItems = useCallback(() => {
setAppState({
@@ -154,16 +198,14 @@ export const LibraryMenu = () => {
return (
<LibraryMenuContent
pendingElements={getSelectedElements(elements, appState, true)}
onInsertLibraryItems={(libraryItems) => {
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
}}
pendingElements={pendingElements}
onInsertLibraryItems={onInsertLibraryItems}
onAddToLibrary={deselectItems}
setAppState={setAppState}
libraryReturnUrl={appProps.libraryReturnUrl}
library={library}
library={memoizedLibrary}
id={id}
appState={appState}
theme={appState.theme}
selectedItems={selectedItems}
onSelectItems={setSelectedItems}
/>
+34 -31
View File
@@ -24,6 +24,7 @@ import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { useUIAppState } from "../context/ui-appState";
import clsx from "clsx";
import { useLibraryCache } from "../hooks/useLibraryItemSvg";
const getSelectedItems = (
libraryItems: LibraryItems,
@@ -55,7 +56,7 @@ export const LibraryDropdownMenuButton: React.FC<{
jotaiScope,
);
const renderRemoveLibAlert = useCallback(() => {
const renderRemoveLibAlert = () => {
const content = selectedItems.length
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
: t("alerts.resetLibrary");
@@ -80,7 +81,7 @@ export const LibraryDropdownMenuButton: React.FC<{
<p>{content}</p>
</ConfirmDialog>
);
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
};
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
@@ -106,7 +107,7 @@ export const LibraryDropdownMenuButton: React.FC<{
onCloseRequest={() => setPublishLibSuccess(null)}
title={t("publishSuccessDialog.title")}
className="publish-library-success"
small={true}
size="small"
>
<p>
<Trans
@@ -136,20 +137,20 @@ export const LibraryDropdownMenuButton: React.FC<{
);
}, [setPublishLibSuccess, publishLibSuccess]);
const onPublishLibSuccess = useCallback(
(data: { url: string; authorName: string }, libraryItems: LibraryItems) => {
setShowPublishLibraryDialog(false);
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
const nextLibItems = libraryItems.slice();
nextLibItems.forEach((libItem) => {
if (selectedItems.includes(libItem.id)) {
libItem.status = "published";
}
});
library.setLibrary(nextLibItems);
},
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
);
const onPublishLibSuccess = (
data: { url: string; authorName: string },
libraryItems: LibraryItems,
) => {
setShowPublishLibraryDialog(false);
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
const nextLibItems = libraryItems.slice();
nextLibItems.forEach((libItem) => {
if (selectedItems.includes(libItem.id)) {
libItem.status = "published";
}
});
library.setLibrary(nextLibItems);
};
const onLibraryImport = async () => {
try {
@@ -280,27 +281,29 @@ export const LibraryDropdownMenu = ({
className?: string;
}) => {
const { library } = useApp();
const { clearLibraryCache, deleteItemsFromLibraryCache } = useLibraryCache();
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 removeFromLibrary = async (libraryItems: LibraryItems) => {
const nextItems = libraryItems.filter(
(item) => !selectedItems.includes(item.id),
);
library.setLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
const resetLibrary = useCallback(() => {
deleteItemsFromLibraryCache(selectedItems);
onSelectItems([]);
};
const resetLibrary = () => {
library.resetLibrary();
}, [library]);
clearLibraryCache();
};
return (
<LibraryDropdownMenuButton
+6
View File
@@ -73,6 +73,12 @@
}
}
&__grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-gap: 1rem;
}
.separator {
width: 100%;
display: flex;
+204 -192
View File
@@ -1,6 +1,11 @@
import React, { useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { serializeLibraryAsJSON } from "../data/json";
import { ExcalidrawElement, NonDeleted } from "../element/types";
import { t } from "../i18n";
import {
ExcalidrawProps,
@@ -8,202 +13,71 @@ import {
LibraryItems,
UIAppState,
} from "../types";
import { arrayToMap, chunk } from "../utils";
import { LibraryUnit } from "./LibraryUnit";
import { arrayToMap } from "../utils";
import Stack from "./Stack";
import { MIME_TYPES } from "../constants";
import Spinner from "./Spinner";
import { duplicateElements } from "../element/newElement";
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
import {
LibraryMenuSection,
LibraryMenuSectionGrid,
} from "./LibraryMenuSection";
import { useScrollPosition } from "../hooks/useScrollPosition";
import { useLibraryCache } from "../hooks/useLibraryItemSvg";
import "./LibraryMenuItems.scss";
const CELLS_PER_ROW = 4;
// using an odd number of items per batch so the rendering creates an irregular
// pattern which looks more organic
const ITEMS_RENDERED_PER_BATCH = 17;
// when render outputs cached we can render many more items per batch to
// speed it up
const CACHED_ITEMS_RENDERED_PER_BATCH = 64;
const LibraryMenuItems = ({
export default function LibraryMenuItems({
isLoading,
libraryItems,
onAddToLibrary,
onInsertLibraryItems,
pendingElements,
selectedItems,
onSelectItems,
theme,
id,
libraryReturnUrl,
onSelectItems,
selectedItems,
}: {
isLoading: boolean;
libraryItems: LibraryItems;
pendingElements: LibraryItem["elements"];
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
theme: UIAppState["theme"];
id: string;
}) => {
const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null
>(null);
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
}) {
const libraryContainerRef = useRef<HTMLDivElement>(null);
const scrollPosition = useScrollPosition<HTMLDivElement>(libraryContainerRef);
const onItemSelectToggle = (
id: LibraryItem["id"],
event: React.MouseEvent,
) => {
const shouldSelect = !selectedItems.includes(id);
const orderedItems = [...unpublishedItems, ...publishedItems];
if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) {
const rangeStart = orderedItems.findIndex(
(item) => item.id === lastSelectedItem,
);
const rangeEnd = orderedItems.findIndex((item) => item.id === id);
if (rangeStart === -1 || rangeEnd === -1) {
onSelectItems([...selectedItems, id]);
return;
}
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = orderedItems.reduce(
(acc: LibraryItem["id"][], item, idx) => {
if (
(idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id)
) {
acc.push(item.id);
}
return acc;
},
[],
);
onSelectItems(nextSelectedIds);
} else {
onSelectItems([...selectedItems, id]);
}
setLastSelectedItem(id);
} else {
setLastSelectedItem(null);
onSelectItems(selectedItems.filter((_id) => _id !== id));
// This effect has to be called only on first render, therefore `scrollPosition` isn't in the dependency array
useEffect(() => {
if (scrollPosition > 0) {
libraryContainerRef.current?.scrollTo(0, scrollPosition);
}
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const getInsertedElements = (id: string) => {
let targetElements;
if (selectedItems.includes(id)) {
targetElements = libraryItems.filter((item) =>
selectedItems.includes(item.id),
);
} else {
targetElements = libraryItems.filter((item) => item.id === id);
}
return targetElements.map((item) => {
return {
...item,
// duplicate each library item before inserting on canvas to confine
// ids and bindings to each library item. See #6465
elements: duplicateElements(item.elements, { randomizeSeed: true }),
};
});
};
const createLibraryItemCompo = (params: {
item:
| LibraryItem
| /* pending library item */ {
id: null;
elements: readonly NonDeleted<ExcalidrawElement>[];
}
| null;
onClick?: () => void;
key: string;
}) => {
return (
<Stack.Col key={params.key}>
<LibraryUnit
elements={params.item?.elements}
isPending={!params.item?.id && !!params.item?.elements}
onClick={params.onClick || (() => {})}
id={params.item?.id || null}
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
onToggle={onItemSelectToggle}
onDrag={(id, event) => {
event.dataTransfer.setData(
MIME_TYPES.excalidrawlib,
serializeLibraryAsJSON(getInsertedElements(id)),
);
}}
/>
</Stack.Col>
);
};
const renderLibrarySection = (
items: (
| LibraryItem
| /* pending library item */ {
id: null;
elements: readonly NonDeleted<ExcalidrawElement>[];
}
)[],
) => {
const _items = items.map((item) => {
if (item.id) {
return createLibraryItemCompo({
item,
onClick: () => onInsertLibraryItems(getInsertedElements(item.id)),
key: item.id,
});
}
return createLibraryItemCompo({
key: "__pending__item__",
item,
onClick: () => onAddToLibrary(pendingElements),
});
});
// ensure we render all empty cells if no items are present
let rows = chunk(_items, CELLS_PER_ROW);
if (!rows.length) {
rows = [[]];
}
return rows.map((rowItems, index, rows) => {
if (index === rows.length - 1) {
// pad row with empty cells
rowItems = rowItems.concat(
new Array(CELLS_PER_ROW - rowItems.length)
.fill(null)
.map((_, index) => {
return createLibraryItemCompo({
key: `empty_${index}`,
item: null,
});
}),
);
}
return (
<Stack.Row
align="center"
key={index}
className="library-menu-items-container__row"
>
{rowItems}
</Stack.Row>
);
});
};
const unpublishedItems = libraryItems.filter(
(item) => item.status !== "published",
const { svgCache } = useLibraryCache();
const unpublishedItems = useMemo(
() => libraryItems.filter((item) => item.status !== "published"),
[libraryItems],
);
const publishedItems = libraryItems.filter(
(item) => item.status === "published",
const publishedItems = useMemo(
() => libraryItems.filter((item) => item.status === "published"),
[libraryItems],
);
const showBtn = !libraryItems.length && !pendingElements.length;
@@ -213,6 +87,122 @@ const LibraryMenuItems = ({
!unpublishedItems.length &&
!publishedItems.length;
const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null
>(null);
const onItemSelectToggle = useCallback(
(id: LibraryItem["id"], event: React.MouseEvent) => {
const shouldSelect = !selectedItems.includes(id);
const orderedItems = [...unpublishedItems, ...publishedItems];
if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) {
const rangeStart = orderedItems.findIndex(
(item) => item.id === lastSelectedItem,
);
const rangeEnd = orderedItems.findIndex((item) => item.id === id);
if (rangeStart === -1 || rangeEnd === -1) {
onSelectItems([...selectedItems, id]);
return;
}
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = orderedItems.reduce(
(acc: LibraryItem["id"][], item, idx) => {
if (
(idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id)
) {
acc.push(item.id);
}
return acc;
},
[],
);
onSelectItems(nextSelectedIds);
} else {
onSelectItems([...selectedItems, id]);
}
setLastSelectedItem(id);
} else {
setLastSelectedItem(null);
onSelectItems(selectedItems.filter((_id) => _id !== id));
}
},
[
lastSelectedItem,
onSelectItems,
publishedItems,
selectedItems,
unpublishedItems,
],
);
const getInsertedElements = useCallback(
(id: string) => {
let targetElements;
if (selectedItems.includes(id)) {
targetElements = libraryItems.filter((item) =>
selectedItems.includes(item.id),
);
} else {
targetElements = libraryItems.filter((item) => item.id === id);
}
return targetElements.map((item) => {
return {
...item,
// duplicate each library item before inserting on canvas to confine
// ids and bindings to each library item. See #6465
elements: duplicateElements(item.elements, { randomizeSeed: true }),
};
});
},
[libraryItems, selectedItems],
);
const onItemDrag = useCallback(
(id: LibraryItem["id"], event: React.DragEvent) => {
event.dataTransfer.setData(
MIME_TYPES.excalidrawlib,
serializeLibraryAsJSON(getInsertedElements(id)),
);
},
[getInsertedElements],
);
const isItemSelected = useCallback(
(id: LibraryItem["id"] | null) => {
if (!id) {
return false;
}
return selectedItems.includes(id);
},
[selectedItems],
);
const onAddToLibraryClick = useCallback(() => {
onAddToLibrary(pendingElements);
}, [pendingElements, onAddToLibrary]);
const onItemClick = useCallback(
(id: LibraryItem["id"] | null) => {
if (id) {
onInsertLibraryItems(getInsertedElements(id));
}
},
[getInsertedElements, onInsertLibraryItems],
);
const itemsRenderedPerBatch =
svgCache.size >= libraryItems.length
? CACHED_ITEMS_RENDERED_PER_BATCH
: ITEMS_RENDERED_PER_BATCH;
return (
<div
className="library-menu-items-container"
@@ -239,6 +229,7 @@ const LibraryMenuItems = ({
flex: publishedItems.length > 0 ? 1 : "0 1 auto",
marginBottom: 0,
}}
ref={libraryContainerRef}
>
<>
{!isLibraryEmpty && (
@@ -258,28 +249,41 @@ const LibraryMenuItems = ({
<Spinner />
</div>
)}
<div className="library-menu-items-private-library-container">
{!pendingElements.length && !unpublishedItems.length ? (
<div className="library-menu-items__no-items">
<div className="library-menu-items__no-items__label">
{t("library.noItems")}
</div>
<div className="library-menu-items__no-items__hint">
{publishedItems.length > 0
? t("library.hint_emptyPrivateLibrary")
: t("library.hint_emptyLibrary")}
</div>
{!pendingElements.length && !unpublishedItems.length ? (
<div className="library-menu-items__no-items">
<div className="library-menu-items__no-items__label">
{t("library.noItems")}
</div>
) : (
renderLibrarySection([
// append pending library item
...(pendingElements.length
? [{ id: null, elements: pendingElements }]
: []),
...unpublishedItems,
])
)}
</div>
<div className="library-menu-items__no-items__hint">
{publishedItems.length > 0
? t("library.hint_emptyPrivateLibrary")
: t("library.hint_emptyLibrary")}
</div>
</div>
) : (
<LibraryMenuSectionGrid>
{pendingElements.length > 0 && (
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={[{ id: null, elements: pendingElements }]}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onAddToLibraryClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
)}
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={unpublishedItems}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onItemClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
</LibraryMenuSectionGrid>
)}
</>
<>
@@ -291,7 +295,17 @@ const LibraryMenuItems = ({
</div>
)}
{publishedItems.length > 0 ? (
renderLibrarySection(publishedItems)
<LibraryMenuSectionGrid>
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={publishedItems}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onItemClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
</LibraryMenuSectionGrid>
) : unpublishedItems.length > 0 ? (
<div
style={{
@@ -325,6 +339,4 @@ const LibraryMenuItems = ({
</Stack.Col>
</div>
);
};
export default LibraryMenuItems;
}
+77
View File
@@ -0,0 +1,77 @@
import React, { memo, ReactNode, useEffect, useState } from "react";
import { EmptyLibraryUnit, LibraryUnit } from "./LibraryUnit";
import { LibraryItem } from "../types";
import { ExcalidrawElement, NonDeleted } from "../element/types";
import { SvgCache } from "../hooks/useLibraryItemSvg";
import { useTransition } from "../hooks/useTransition";
type LibraryOrPendingItem = (
| LibraryItem
| /* pending library item */ {
id: null;
elements: readonly NonDeleted<ExcalidrawElement>[];
}
)[];
interface Props {
items: LibraryOrPendingItem;
onClick: (id: LibraryItem["id"] | null) => void;
onItemSelectToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void;
onItemDrag: (id: LibraryItem["id"], event: React.DragEvent) => void;
isItemSelected: (id: LibraryItem["id"] | null) => boolean;
svgCache: SvgCache;
itemsRenderedPerBatch: number;
}
export const LibraryMenuSectionGrid = ({
children,
}: {
children: ReactNode;
}) => {
return <div className="library-menu-items-container__grid">{children}</div>;
};
export const LibraryMenuSection = memo(
({
items,
onItemSelectToggle,
onItemDrag,
isItemSelected,
onClick,
svgCache,
itemsRenderedPerBatch,
}: Props) => {
const [, startTransition] = useTransition();
const [index, setIndex] = useState(0);
useEffect(() => {
if (index < items.length) {
startTransition(() => {
setIndex(index + itemsRenderedPerBatch);
});
}
}, [index, items.length, startTransition, itemsRenderedPerBatch]);
return (
<>
{items.map((item, i) => {
return i < index ? (
<LibraryUnit
elements={item?.elements}
isPending={!item?.id && !!item?.elements}
onClick={onClick}
svgCache={svgCache}
id={item?.id}
selected={isItemSelected(item.id)}
onToggle={onItemSelectToggle}
onDrag={onItemDrag}
key={item?.id ?? i}
/>
) : (
<EmptyLibraryUnit key={i} />
);
})}
</>
);
},
);
+35
View File
@@ -20,6 +20,27 @@
border-color: var(--color-primary);
border-width: 1px;
}
&--skeleton {
opacity: 0.5;
background: linear-gradient(
-45deg,
var(--color-gray-10),
var(--color-gray-20),
var(--color-gray-10)
);
background-size: 200% 200%;
animation: library-unit__skeleton-opacity-animation 0.2s linear;
}
}
&.theme--dark .library-unit--skeleton {
background-image: linear-gradient(
-45deg,
var(--color-gray-100),
var(--color-gray-80),
var(--color-gray-100)
);
}
.library-unit__dragger {
@@ -142,4 +163,18 @@
transform: scale(0.85);
}
}
@keyframes library-unit__skeleton-opacity-animation {
0% {
opacity: 0;
}
75% {
opacity: 0;
}
100% {
opacity: 0.5;
}
}
}
+89 -90
View File
@@ -1,108 +1,107 @@
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
import { memo, useEffect, useRef, useState } from "react";
import { useDevice } from "../components/App";
import { exportToSvg } from "../packages/utils";
import { LibraryItem } from "../types";
import "./LibraryUnit.scss";
import { CheckboxItem } from "./CheckboxItem";
import { PlusIcon } from "./icons";
import { COLOR_PALETTE } from "../colors";
import { SvgCache, useLibraryItemSvg } from "../hooks/useLibraryItemSvg";
export const LibraryUnit = ({
id,
elements,
isPending,
onClick,
selected,
onToggle,
onDrag,
}: {
id: LibraryItem["id"] | /** for pending item */ null;
elements?: LibraryItem["elements"];
isPending?: boolean;
onClick: () => void;
selected: boolean;
onToggle: (id: string, event: React.MouseEvent) => void;
onDrag: (id: string, event: React.DragEvent) => void;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const node = ref.current;
if (!node) {
return;
}
export const LibraryUnit = memo(
({
id,
elements,
isPending,
onClick,
selected,
onToggle,
onDrag,
svgCache,
}: {
id: LibraryItem["id"] | /** for pending item */ null;
elements?: LibraryItem["elements"];
isPending?: boolean;
onClick: (id: LibraryItem["id"] | null) => void;
selected: boolean;
onToggle: (id: string, event: React.MouseEvent) => void;
onDrag: (id: string, event: React.DragEvent) => void;
svgCache: SvgCache;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
const svg = useLibraryItemSvg(id, elements, svgCache);
(async () => {
if (!elements) {
useEffect(() => {
const node = ref.current;
if (!node) {
return;
}
const svg = await exportToSvg({
elements,
appState: {
exportBackground: false,
viewBackgroundColor: COLOR_PALETTE.white,
},
files: null,
});
svg.querySelector(".style-fonts")?.remove();
node.innerHTML = svg.outerHTML;
})();
return () => {
node.innerHTML = "";
};
}, [elements]);
if (svg) {
node.innerHTML = svg.outerHTML;
}
const [isHovered, setIsHovered] = useState(false);
const isMobile = useDevice().isMobile;
const adder = isPending && (
<div className="library-unit__adder">{PlusIcon}</div>
);
return () => {
node.innerHTML = "";
};
}, [svg]);
return (
<div
className={clsx("library-unit", {
"library-unit__active": elements,
"library-unit--hover": elements && isHovered,
"library-unit--selected": selected,
})}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
const [isHovered, setIsHovered] = useState(false);
const isMobile = useDevice().isMobile;
const adder = isPending && (
<div className="library-unit__adder">{PlusIcon}</div>
);
return (
<div
className={clsx("library-unit__dragger", {
"library-unit__pulse": !!isPending,
className={clsx("library-unit", {
"library-unit__active": elements,
"library-unit--hover": elements && isHovered,
"library-unit--selected": selected,
"library-unit--skeleton": !svg,
})}
ref={ref}
draggable={!!elements}
onClick={
!!elements || !!isPending
? (event) => {
if (id && event.shiftKey) {
onToggle(id, event);
} else {
onClick();
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div
className={clsx("library-unit__dragger", {
"library-unit__pulse": !!isPending,
})}
ref={ref}
draggable={!!elements}
onClick={
!!elements || !!isPending
? (event) => {
if (id && event.shiftKey) {
onToggle(id, event);
} else {
onClick(id);
}
}
}
: undefined
}
onDragStart={(event) => {
if (!id) {
event.preventDefault();
return;
: undefined
}
setIsHovered(false);
onDrag(id, event);
}}
/>
{adder}
{id && elements && (isHovered || isMobile || selected) && (
<CheckboxItem
checked={selected}
onChange={(checked, event) => onToggle(id, event)}
className="library-unit__checkbox"
onDragStart={(event) => {
if (!id) {
event.preventDefault();
return;
}
setIsHovered(false);
onDrag(id, event);
}}
/>
)}
</div>
);
};
{adder}
{id && elements && (isHovered || isMobile || selected) && (
<CheckboxItem
checked={selected}
onChange={(checked, event) => onToggle(id, event)}
className="library-unit__checkbox"
/>
)}
</div>
);
},
);
export const EmptyLibraryUnit = () => (
<div className="library-unit library-unit--skeleton" />
);
+15 -4
View File
@@ -24,13 +24,15 @@
}
.Modal__background {
position: absolute;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
background-color: rgba(#121212, 0.2);
animation: Modal__background__fade-in 0.125s linear forwards;
}
.Modal__content {
@@ -65,14 +67,23 @@
}
}
@keyframes Modal__content_fade-in {
@keyframes Modal__background__fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes Modal__content_fade-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
+7 -46
View File
@@ -1,12 +1,11 @@
import "./Modal.scss";
import React, { useState, useLayoutEffect, useRef } from "react";
import React from "react";
import { createPortal } from "react-dom";
import clsx from "clsx";
import { KEYS } from "../keys";
import { useExcalidrawContainer, useDevice } from "./App";
import { AppState } from "../types";
import { THEME } from "../constants";
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
export const Modal: React.FC<{
className?: string;
@@ -17,8 +16,10 @@ export const Modal: React.FC<{
theme?: AppState["theme"];
closeOnClickOutside?: boolean;
}> = (props) => {
const { theme = THEME.LIGHT, closeOnClickOutside = true } = props;
const modalRoot = useBodyRoot(theme);
const { closeOnClickOutside = true } = props;
const modalRoot = useCreatePortalContainer({
className: "excalidraw-modal-container",
});
if (!modalRoot) {
return null;
@@ -44,7 +45,7 @@ export const Modal: React.FC<{
<div
className="Modal__background"
onClick={closeOnClickOutside ? props.onCloseRequest : undefined}
></div>
/>
<div
className="Modal__content"
style={{ "--max-width": `${props.maxWidth}px` }}
@@ -56,43 +57,3 @@ export const Modal: React.FC<{
modalRoot,
);
};
const useBodyRoot = (theme: AppState["theme"]) => {
const [div, setDiv] = useState<HTMLDivElement | null>(null);
const device = useDevice();
const isMobileRef = useRef(device.isMobile);
isMobileRef.current = device.isMobile;
const { container: excalidrawContainer } = useExcalidrawContainer();
useLayoutEffect(() => {
if (div) {
div.classList.toggle("excalidraw--mobile", device.isMobile);
}
}, [div, device.isMobile]);
useLayoutEffect(() => {
const isDarkTheme =
!!excalidrawContainer?.classList.contains("theme--dark") ||
theme === "dark";
const div = document.createElement("div");
div.classList.add("excalidraw", "excalidraw-modal-container");
div.classList.toggle("excalidraw--mobile", isMobileRef.current);
if (isDarkTheme) {
div.classList.add("theme--dark");
div.classList.add("theme--dark-background-none");
}
document.body.appendChild(div);
setDiv(div);
return () => {
document.body.removeChild(div);
};
}, [excalidrawContainer, theme]);
return div;
};
+1 -1
View File
@@ -106,7 +106,7 @@ export const PasteChartDialog = ({
return (
<Dialog
small
size="small"
onCloseRequest={handleClose}
title={t("labels.pasteCharts")}
className={"PasteChartDialog"}
+4 -1
View File
@@ -12,6 +12,7 @@ type Props = {
onChange: (value: string) => void;
label: string;
isNameEditable: boolean;
ignoreFocus?: boolean;
};
export const ProjectName = (props: Props) => {
@@ -19,7 +20,9 @@ export const ProjectName = (props: Props) => {
const [fileName, setFileName] = useState<string>(props.value);
const handleBlur = (event: any) => {
focusNearestParent(event.target);
if (!props.ignoreFocus) {
focusNearestParent(event.target);
}
const value = event.target.value;
if (value !== props.value) {
props.onChange(value);
+100
View File
@@ -0,0 +1,100 @@
@import "../css/variables.module";
.excalidraw {
--RadioGroup-background: #ffffff;
--RadioGroup-border: var(--color-gray-30);
--RadioGroup-choice-color-off: var(--color-primary);
--RadioGroup-choice-color-off-hover: var(--color-primary-darkest);
--RadioGroup-choice-background-off: white;
--RadioGroup-choice-background-off-active: var(--color-gray-20);
--RadioGroup-choice-color-on: white;
--RadioGroup-choice-background-on: var(--color-primary);
--RadioGroup-choice-background-on-hover: var(--color-primary-darker);
--RadioGroup-choice-background-on-active: var(--color-primary-darkest);
&.theme--dark {
--RadioGroup-background: var(--color-gray-85);
--RadioGroup-border: var(--color-gray-70);
--RadioGroup-choice-background-off: var(--color-gray-85);
--RadioGroup-choice-background-off-active: var(--color-gray-70);
--RadioGroup-choice-color-on: var(--color-gray-85);
}
.RadioGroup {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 3px;
border-radius: 10px;
background: var(--RadioGroup-background);
border: 1px solid var(--RadioGroup-border);
&__choice {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 24px;
color: var(--RadioGroup-choice-color-off);
background: var(--RadioGroup-choice-background-off);
border-radius: 8px;
font-family: "Assistant";
font-style: normal;
font-weight: 600;
font-size: 0.75rem;
line-height: 100%;
user-select: none;
letter-spacing: 0.4px;
transition: all 75ms ease-out;
&:hover {
color: var(--RadioGroup-choice-color-off-hover);
}
&:active {
background: var(--RadioGroup-choice-background-off-active);
}
&.active {
color: var(--RadioGroup-choice-color-on);
background: var(--RadioGroup-choice-background-on);
&:hover {
background: var(--RadioGroup-choice-background-on-hover);
}
&:active {
background: var(--RadioGroup-choice-background-on-active);
}
}
& input {
z-index: 1;
position: absolute;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border-radius: 8px;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
cursor: pointer;
}
}
}
}
+42
View File
@@ -0,0 +1,42 @@
import clsx from "clsx";
import "./RadioGroup.scss";
export type RadioGroupChoice<T> = {
value: T;
label: string;
};
export type RadioGroupProps<T> = {
choices: RadioGroupChoice<T>[];
value: T;
onChange: (value: T) => void;
name: string;
};
export const RadioGroup = function <T>({
onChange,
value,
choices,
name,
}: RadioGroupProps<T>) {
return (
<div className="RadioGroup">
{choices.map((choice) => (
<div
className={clsx("RadioGroup__choice", {
active: choice.value === value,
})}
key={choice.label}
>
<input
name={name}
type="radio"
checked={choice.value === value}
onChange={() => onChange(choice.value)}
/>
{choice.label}
</div>
))}
</div>
);
};
+3 -32
View File
@@ -6,7 +6,6 @@ import React, {
forwardRef,
useImperativeHandle,
useCallback,
RefObject,
} from "react";
import { Island } from ".././Island";
import { atom, useSetAtom } from "jotai";
@@ -27,38 +26,10 @@ import { SidebarTabTriggers } from "./SidebarTabTriggers";
import { SidebarTabTrigger } from "./SidebarTabTrigger";
import { SidebarTabs } from "./SidebarTabs";
import { SidebarTab } from "./SidebarTab";
import { useUIAppState } from "../../context/ui-appState";
import { useOutsideClick } from "../../hooks/useOutsideClick";
import "./Sidebar.scss";
import { useUIAppState } from "../../context/ui-appState";
// 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;
}
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
@@ -133,7 +104,7 @@ export const SidebarInner = forwardRef(
setAppState({ openSidebar: null });
}, [setAppState]);
useOnClickOutside(
useOutsideClick(
islandRef,
useCallback(
(event) => {
+1
View File
@@ -15,6 +15,7 @@ $duration: 1.6s;
svg {
animation: rotate $duration linear infinite;
animation-delay: var(--spinner-delay);
transform-origin: center center;
}
+14 -1
View File
@@ -5,13 +5,26 @@ import "./Spinner.scss";
const Spinner = ({
size = "1em",
circleWidth = 8,
synchronized = false,
}: {
size?: string | number;
circleWidth?: number;
synchronized?: boolean;
}) => {
const mountTime = React.useRef(Date.now());
const mountDelay = -(mountTime.current % 1600);
return (
<div className="Spinner">
<svg viewBox="0 0 100 100" style={{ width: size, height: size }}>
<svg
viewBox="0 0 100 100"
style={{
width: size,
height: size,
// fix for remounting causing spinner flicker
["--spinner-delay" as any]: synchronized ? `${mountDelay}ms` : 0,
}}
>
<circle
cx="50"
cy="50"
+44 -45
View File
@@ -1,6 +1,6 @@
import "./Stack.scss";
import React from "react";
import React, { forwardRef } from "react";
import clsx from "clsx";
type StackProps = {
@@ -10,53 +10,52 @@ type StackProps = {
justifyContent?: "center" | "space-around" | "space-between";
className?: string | boolean;
style?: React.CSSProperties;
ref: React.RefObject<HTMLDivElement>;
};
const RowStack = ({
children,
gap,
align,
justifyContent,
className,
style,
}: StackProps) => {
return (
<div
className={clsx("Stack Stack_horizontal", className)}
style={{
"--gap": gap,
alignItems: align,
justifyContent,
...style,
}}
>
{children}
</div>
);
};
const RowStack = forwardRef(
(
{ children, gap, align, justifyContent, className, style }: StackProps,
ref: React.ForwardedRef<HTMLDivElement>,
) => {
return (
<div
className={clsx("Stack Stack_horizontal", className)}
style={{
"--gap": gap,
alignItems: align,
justifyContent,
...style,
}}
ref={ref}
>
{children}
</div>
);
},
);
const ColStack = ({
children,
gap,
align,
justifyContent,
className,
style,
}: StackProps) => {
return (
<div
className={clsx("Stack Stack_vertical", className)}
style={{
"--gap": gap,
justifyItems: align,
justifyContent,
...style,
}}
>
{children}
</div>
);
};
const ColStack = forwardRef(
(
{ children, gap, align, justifyContent, className, style }: StackProps,
ref: React.ForwardedRef<HTMLDivElement>,
) => {
return (
<div
className={clsx("Stack Stack_vertical", className)}
style={{
"--gap": gap,
justifyItems: align,
justifyContent,
...style,
}}
ref={ref}
>
{children}
</div>
);
},
);
export default {
Row: RowStack,
+116
View File
@@ -0,0 +1,116 @@
@import "../css/variables.module";
.excalidraw {
--Switch-disabled-color: #d6d6d6;
--Switch-track-background: white;
--Switch-thumb-background: #3d3d3d;
&.theme--dark {
--Switch-disabled-color: #5c5c5c;
--Switch-track-background: #242424;
--Switch-thumb-background: #b8b8b8;
}
.Switch {
position: relative;
box-sizing: border-box;
width: 40px;
height: 20px;
border-radius: 12px;
transition-property: background, border;
transition-duration: 150ms;
transition-timing-function: ease-out;
background: var(--Switch-track-background);
border: 1px solid var(--Switch-disabled-color);
&:hover {
background: var(--Switch-track-background);
border: 1px solid #999999;
}
&.toggled {
background: var(--color-primary);
border: 1px solid var(--color-primary);
&:hover {
background: var(--color-primary-darker);
border: 1px solid var(--color-primary-darker);
}
}
&.disabled {
background: var(--Switch-track-background);
border: 1px solid var(--Switch-disabled-color);
&.toggled {
background: var(--Switch-disabled-color);
border: 1px solid var(--Switch-disabled-color);
}
}
&:before {
content: "";
box-sizing: border-box;
display: block;
pointer-events: none;
position: absolute;
border-radius: 100%;
transition: all 150ms ease-out;
width: 10px;
height: 10px;
top: 4px;
left: 4px;
background: var(--Switch-thumb-background);
}
&:active:before {
width: 12px;
}
&.toggled:before {
width: 14px;
height: 14px;
left: 22px;
top: 2px;
background: var(--Switch-track-background);
}
&.toggled:active:before {
width: 16px;
left: 20px;
}
&.disabled:before {
background: var(--Switch-disabled-color);
}
&.disabled.toggled:before {
background: var(--color-gray-50);
}
& input {
width: 100%;
height: 100%;
margin: 0;
border-radius: 12px;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
cursor: pointer;
&:disabled {
cursor: unset;
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import clsx from "clsx";
import "./Switch.scss";
export type SwitchProps = {
name: string;
checked: boolean;
title?: string;
onChange: (value: boolean) => void;
disabled?: boolean;
};
export const Switch = ({
title,
name,
checked,
onChange,
disabled = false,
}: SwitchProps) => {
return (
<div className={clsx("Switch", { toggled: checked, disabled })}>
<input
name={name}
id={name}
title={title}
type="checkbox"
checked={checked}
disabled={disabled}
onChange={() => onChange(!checked)}
onKeyDown={(event) => {
if (event.key === " ") {
onChange(!checked);
}
}}
/>
</div>
);
};
+118
View File
@@ -0,0 +1,118 @@
@import "../css/variables.module";
.excalidraw {
--ExcTextField--color: var(--color-gray-80);
--ExcTextField--label-color: var(--color-gray-80);
--ExcTextField--background: white;
--ExcTextField--readonly--background: var(--color-gray-10);
--ExcTextField--readonly--color: var(--color-gray-80);
--ExcTextField--border: var(--color-gray-40);
--ExcTextField--border-hover: var(--color-gray-50);
--ExcTextField--placeholder: var(--color-gray-40);
&.theme--dark {
--ExcTextField--color: var(--color-gray-10);
--ExcTextField--label-color: var(--color-gray-20);
--ExcTextField--background: var(--color-gray-85);
--ExcTextField--readonly--background: var(--color-gray-80);
--ExcTextField--readonly--color: var(--color-gray-40);
--ExcTextField--border: var(--color-gray-70);
--ExcTextField--border-hover: var(--color-gray-60);
--ExcTextField--placeholder: var(--color-gray-80);
}
.ExcTextField {
&--fullWidth {
width: 100%;
flex-grow: 1;
}
&__label {
font-family: "Assistant";
font-style: normal;
font-weight: 600;
font-size: 0.875rem;
line-height: 150%;
color: var(--ExcTextField--label-color);
margin-bottom: 0.25rem;
user-select: none;
}
&__input {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 1rem;
height: 3rem;
background: var(--ExcTextField--background);
border: 1px solid var(--ExcTextField--border);
border-radius: 0.5rem;
&:not(&--readonly) {
&:hover {
border-color: var(--ExcTextField--border-hover);
}
&:active,
&:focus-within {
border-color: var(--color-primary);
}
}
& input {
display: flex;
align-items: center;
border: none;
outline: none;
padding: 0;
margin: 0;
height: 1.5rem;
color: var(--ExcTextField--color);
font-family: "Assistant";
font-style: normal;
font-weight: 400;
font-size: 1rem;
line-height: 150%;
text-overflow: ellipsis;
background: transparent;
width: 100%;
&::placeholder {
color: var(--ExcTextField--placeholder);
}
&:not(:focus) {
&:hover {
background-color: initial;
}
}
&:focus {
outline: initial;
box-shadow: initial;
}
}
&--readonly {
background: var(--ExcTextField--readonly--background);
border-color: transparent;
& input {
color: var(--ExcTextField--readonly--color);
}
}
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import { forwardRef, useRef, useImperativeHandle, KeyboardEvent } from "react";
import clsx from "clsx";
import "./TextField.scss";
export type TextFieldProps = {
value?: string;
onChange?: (value: string) => void;
onClick?: () => void;
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
readonly?: boolean;
fullWidth?: boolean;
label?: string;
placeholder?: string;
};
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
(
{ value, onChange, label, fullWidth, placeholder, readonly, onKeyDown },
ref,
) => {
const innerRef = useRef<HTMLInputElement | null>(null);
useImperativeHandle(ref, () => innerRef.current!);
return (
<div
className={clsx("ExcTextField", {
"ExcTextField--fullWidth": fullWidth,
})}
onClick={() => {
innerRef.current?.focus();
}}
>
<div className="ExcTextField__label">{label}</div>
<div
className={clsx("ExcTextField__input", {
"ExcTextField__input--readonly": readonly,
})}
>
<input
readOnly={readonly}
type="text"
value={value}
placeholder={placeholder}
ref={innerRef}
onChange={(event) => onChange?.(event.target.value)}
onKeyDown={onKeyDown}
/>
</div>
</div>
);
},
);
@@ -11,8 +11,6 @@
top: auto;
left: 0;
width: 100%;
display: flex;
flex-direction: column;
row-gap: 0.75rem;
.dropdown-menu-container {
@@ -1,11 +1,10 @@
import { useOutsideClick } from "../../hooks/useOutsideClick";
import { Island } from "../Island";
import { useDevice } from "../App";
import clsx from "clsx";
import Stack from "../Stack";
import React from "react";
import React, { useRef } from "react";
import { DropdownMenuContentPropsContext } from "./common";
import { useOutsideClick } from "../../hooks/useOutsideClick";
const MenuContent = ({
children,
@@ -24,7 +23,9 @@ const MenuContent = ({
style?: React.CSSProperties;
}) => {
const device = useDevice();
const menuRef = useOutsideClick(() => {
const menuRef = useRef<HTMLDivElement>(null);
useOutsideClick(menuRef, () => {
onClickOutside?.();
});
+66
View File
@@ -1550,3 +1550,69 @@ export const handIcon = createIcon(
</g>,
tablerIconProps,
);
export const downloadIcon = createIcon(
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2"></path>
<path d="M7 11l5 5l5 -5"></path>
<path d="M12 4l0 12"></path>
</>,
tablerIconProps,
);
export const copyIcon = createIcon(
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"></path>
<path d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2"></path>
</>,
tablerIconProps,
);
export const helpIcon = createIcon(
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"></path>
<path d="M12 17l0 .01"></path>
<path d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"></path>
</>,
tablerIconProps,
);
export const playerPlayIcon = createIcon(
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M7 4v16l13 -8z"></path>
</>,
tablerIconProps,
);
export const playerStopFilledIcon = createIcon(
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path
d="M17 4h-10a3 3 0 0 0 -3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3 -3v-10a3 3 0 0 0 -3 -3z"
strokeWidth="0"
fill="currentColor"
></path>
</>,
tablerIconProps,
);
export const tablerCheckIcon = createIcon(
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 12l5 5l10 -10"></path>
</>,
tablerIconProps,
);
export const eyeDropperIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M11 7l6 6"></path>
<path d="M4 16l11.7 -11.7a1 1 0 0 1 1.4 0l2.6 2.6a1 1 0 0 1 0 1.4l-11.7 11.7h-4v-4z"></path>
</g>,
tablerIconProps,
);