feat: eye dropper (#6615)
This commit is contained in:
parent
644685a5a8
commit
079aa72475
@ -34,7 +34,7 @@
|
|||||||
"i18next-browser-languagedetector": "6.1.4",
|
"i18next-browser-languagedetector": "6.1.4",
|
||||||
"idb-keyval": "6.0.3",
|
"idb-keyval": "6.0.3",
|
||||||
"image-blob-reduce": "3.0.1",
|
"image-blob-reduce": "3.0.1",
|
||||||
"jotai": "1.6.4",
|
"jotai": "1.13.1",
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
"nanoid": "3.3.3",
|
"nanoid": "3.3.3",
|
||||||
"open-color": "1.9.1",
|
"open-color": "1.9.1",
|
||||||
|
@ -119,8 +119,8 @@ const getFormValue = function <T>(
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
getAttribute: (element: ExcalidrawElement) => T,
|
getAttribute: (element: ExcalidrawElement) => T,
|
||||||
defaultValue?: T,
|
defaultValue: T,
|
||||||
): T | null {
|
): T {
|
||||||
const editingElement = appState.editingElement;
|
const editingElement = appState.editingElement;
|
||||||
const nonDeletedElements = getNonDeletedElements(elements);
|
const nonDeletedElements = getNonDeletedElements(elements);
|
||||||
return (
|
return (
|
||||||
@ -132,7 +132,7 @@ const getFormValue = function <T>(
|
|||||||
getAttribute,
|
getAttribute,
|
||||||
)
|
)
|
||||||
: defaultValue) ??
|
: defaultValue) ??
|
||||||
null
|
defaultValue
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -811,6 +811,7 @@ export const actionChangeTextAlign = register({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionChangeVerticalAlign = register({
|
export const actionChangeVerticalAlign = register({
|
||||||
name: "changeVerticalAlign",
|
name: "changeVerticalAlign",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
@ -865,16 +866,21 @@ export const actionChangeVerticalAlign = register({
|
|||||||
testId: "align-bottom",
|
testId: "align-bottom",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
value={getFormValue(elements, appState, (element) => {
|
value={getFormValue(
|
||||||
if (isTextElement(element) && element.containerId) {
|
elements,
|
||||||
return element.verticalAlign;
|
appState,
|
||||||
}
|
(element) => {
|
||||||
const boundTextElement = getBoundTextElement(element);
|
if (isTextElement(element) && element.containerId) {
|
||||||
if (boundTextElement) {
|
return element.verticalAlign;
|
||||||
return boundTextElement.verticalAlign;
|
}
|
||||||
}
|
const boundTextElement = getBoundTextElement(element);
|
||||||
return null;
|
if (boundTextElement) {
|
||||||
})}
|
return boundTextElement.verticalAlign;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
VERTICAL_ALIGN.MIDDLE,
|
||||||
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
@ -164,4 +164,7 @@ export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
|
|||||||
COLOR_PALETTE.red[index],
|
COLOR_PALETTE.red[index],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const rgbToHex = (r: number, g: number, b: number) =>
|
||||||
|
`#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
@ -304,6 +304,7 @@ import { jotaiStore } from "../jotai";
|
|||||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||||
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
||||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||||
|
import { activeEyeDropperAtom } from "./EyeDropper";
|
||||||
|
|
||||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||||
@ -366,8 +367,6 @@ export const useExcalidrawActionManager = () =>
|
|||||||
|
|
||||||
let didTapTwice: boolean = false;
|
let didTapTwice: boolean = false;
|
||||||
let tappedTwiceTimer = 0;
|
let tappedTwiceTimer = 0;
|
||||||
let cursorX = 0;
|
|
||||||
let cursorY = 0;
|
|
||||||
let isHoldingSpace: boolean = false;
|
let isHoldingSpace: boolean = false;
|
||||||
let isPanning: boolean = false;
|
let isPanning: boolean = false;
|
||||||
let isDraggingScrollBar: boolean = false;
|
let isDraggingScrollBar: boolean = false;
|
||||||
@ -425,7 +424,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
hitLinkElement?: NonDeletedExcalidrawElement;
|
hitLinkElement?: NonDeletedExcalidrawElement;
|
||||||
lastPointerDown: React.PointerEvent<HTMLCanvasElement> | null = null;
|
lastPointerDown: React.PointerEvent<HTMLCanvasElement> | null = null;
|
||||||
lastPointerUp: React.PointerEvent<HTMLElement> | PointerEvent | null = null;
|
lastPointerUp: React.PointerEvent<HTMLElement> | PointerEvent | null = null;
|
||||||
lastScenePointer: { x: number; y: number } | null = null;
|
lastViewportPosition = { x: 0, y: 0 };
|
||||||
|
|
||||||
constructor(props: AppProps) {
|
constructor(props: AppProps) {
|
||||||
super(props);
|
super(props);
|
||||||
@ -634,6 +633,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
</LayerUI>
|
</LayerUI>
|
||||||
<div className="excalidraw-textEditorContainer" />
|
<div className="excalidraw-textEditorContainer" />
|
||||||
<div className="excalidraw-contextMenuContainer" />
|
<div className="excalidraw-contextMenuContainer" />
|
||||||
|
<div className="excalidraw-eye-dropper-container" />
|
||||||
{selectedElement.length === 1 &&
|
{selectedElement.length === 1 &&
|
||||||
!this.state.contextMenu &&
|
!this.state.contextMenu &&
|
||||||
this.state.showHyperlinkPopup && (
|
this.state.showHyperlinkPopup && (
|
||||||
@ -724,6 +724,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(
|
private syncActionResult = withBatchedUpdates(
|
||||||
(actionResult: ActionResult) => {
|
(actionResult: ActionResult) => {
|
||||||
if (this.unmounted || actionResult === false) {
|
if (this.unmounted || actionResult === false) {
|
||||||
@ -1569,7 +1612,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const elementUnderCursor = document.elementFromPoint(cursorX, cursorY);
|
const elementUnderCursor = document.elementFromPoint(
|
||||||
|
this.lastViewportPosition.x,
|
||||||
|
this.lastViewportPosition.y,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
event &&
|
event &&
|
||||||
(!(elementUnderCursor instanceof HTMLCanvasElement) ||
|
(!(elementUnderCursor instanceof HTMLCanvasElement) ||
|
||||||
@ -1597,7 +1643,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
// prefer spreadsheet data over image file (MS Office/Libre Office)
|
// prefer spreadsheet data over image file (MS Office/Libre Office)
|
||||||
if (isSupportedImageFile(file) && !data.spreadsheet) {
|
if (isSupportedImageFile(file) && !data.spreadsheet) {
|
||||||
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||||
{ clientX: cursorX, clientY: cursorY },
|
{
|
||||||
|
clientX: this.lastViewportPosition.x,
|
||||||
|
clientY: this.lastViewportPosition.y,
|
||||||
|
},
|
||||||
this.state,
|
this.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1660,13 +1709,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
typeof opts.position === "object"
|
typeof opts.position === "object"
|
||||||
? opts.position.clientX
|
? opts.position.clientX
|
||||||
: opts.position === "cursor"
|
: opts.position === "cursor"
|
||||||
? cursorX
|
? this.lastViewportPosition.x
|
||||||
: this.state.width / 2 + this.state.offsetLeft;
|
: this.state.width / 2 + this.state.offsetLeft;
|
||||||
const clientY =
|
const clientY =
|
||||||
typeof opts.position === "object"
|
typeof opts.position === "object"
|
||||||
? opts.position.clientY
|
? opts.position.clientY
|
||||||
: opts.position === "cursor"
|
: opts.position === "cursor"
|
||||||
? cursorY
|
? this.lastViewportPosition.y
|
||||||
: this.state.height / 2 + this.state.offsetTop;
|
: this.state.height / 2 + this.state.offsetTop;
|
||||||
|
|
||||||
const { x, y } = viewportCoordsToSceneCoords(
|
const { x, y } = viewportCoordsToSceneCoords(
|
||||||
@ -1750,7 +1799,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
private addTextFromPaste(text: string, isPlainPaste = false) {
|
private addTextFromPaste(text: string, isPlainPaste = false) {
|
||||||
const { x, y } = viewportCoordsToSceneCoords(
|
const { x, y } = viewportCoordsToSceneCoords(
|
||||||
{ clientX: cursorX, clientY: cursorY },
|
{
|
||||||
|
clientX: this.lastViewportPosition.x,
|
||||||
|
clientY: this.lastViewportPosition.y,
|
||||||
|
},
|
||||||
this.state,
|
this.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -2083,8 +2135,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
private updateCurrentCursorPosition = withBatchedUpdates(
|
private updateCurrentCursorPosition = withBatchedUpdates(
|
||||||
(event: MouseEvent) => {
|
(event: MouseEvent) => {
|
||||||
cursorX = event.clientX;
|
this.lastViewportPosition.x = event.clientX;
|
||||||
cursorY = event.clientY;
|
this.lastViewportPosition.y = event.clientY;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -2342,6 +2394,20 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
) {
|
) {
|
||||||
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
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 +2537,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState((state) => ({
|
this.setState((state) => ({
|
||||||
...getStateForZoom(
|
...getStateForZoom(
|
||||||
{
|
{
|
||||||
viewportX: cursorX,
|
viewportX: this.lastViewportPosition.x,
|
||||||
viewportY: cursorY,
|
viewportY: this.lastViewportPosition.y,
|
||||||
nextZoom: getNormalizedZoom(initialScale * event.scale),
|
nextZoom: getNormalizedZoom(initialScale * event.scale),
|
||||||
},
|
},
|
||||||
state,
|
state,
|
||||||
@ -6468,8 +6534,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.translateCanvas((state) => ({
|
this.translateCanvas((state) => ({
|
||||||
...getStateForZoom(
|
...getStateForZoom(
|
||||||
{
|
{
|
||||||
viewportX: cursorX,
|
viewportX: this.lastViewportPosition.x,
|
||||||
viewportY: cursorY,
|
viewportY: this.lastViewportPosition.y,
|
||||||
nextZoom: getNormalizedZoom(newZoom),
|
nextZoom: getNormalizedZoom(newZoom),
|
||||||
},
|
},
|
||||||
state,
|
state,
|
||||||
|
@ -2,15 +2,23 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { getColor } from "./ColorPicker";
|
import { getColor } from "./ColorPicker";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||||
|
import { eyeDropperIcon } from "../icons";
|
||||||
|
import { jotaiScope } from "../../jotai";
|
||||||
import { KEYS } from "../../keys";
|
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 {
|
interface ColorInputProps {
|
||||||
color: string | null;
|
color: string;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ColorInput = ({ color, onChange, label }: ColorInputProps) => {
|
export const ColorInput = ({ color, onChange, label }: ColorInputProps) => {
|
||||||
|
const device = useDevice();
|
||||||
const [innerValue, setInnerValue] = useState(color);
|
const [innerValue, setInnerValue] = useState(color);
|
||||||
const [activeSection, setActiveColorPickerSection] = useAtom(
|
const [activeSection, setActiveColorPickerSection] = useAtom(
|
||||||
activeColorPickerSectionAtom,
|
activeColorPickerSectionAtom,
|
||||||
@ -34,7 +42,7 @@ export const ColorInput = ({ color, onChange, label }: ColorInputProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const divRef = useRef<HTMLDivElement>(null);
|
const eyeDropperTriggerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inputRef.current) {
|
if (inputRef.current) {
|
||||||
@ -42,8 +50,19 @@ export const ColorInput = ({ color, onChange, label }: ColorInputProps) => {
|
|||||||
}
|
}
|
||||||
}, [activeSection]);
|
}, [activeSection]);
|
||||||
|
|
||||||
|
const [eyeDropperState, setEyeDropperState] = useAtom(
|
||||||
|
activeEyeDropperAtom,
|
||||||
|
jotaiScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setEyeDropperState(null);
|
||||||
|
};
|
||||||
|
}, [setEyeDropperState]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className="color-picker__input-label">
|
<div className="color-picker__input-label">
|
||||||
<div className="color-picker__input-hash">#</div>
|
<div className="color-picker__input-hash">#</div>
|
||||||
<input
|
<input
|
||||||
ref={activeSection === "hex" ? inputRef : undefined}
|
ref={activeSection === "hex" ? inputRef : undefined}
|
||||||
@ -60,16 +79,48 @@ export const ColorInput = ({ color, onChange, label }: ColorInputProps) => {
|
|||||||
}}
|
}}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onFocus={() => setActiveColorPickerSection("hex")}
|
onFocus={() => setActiveColorPickerSection("hex")}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(event) => {
|
||||||
if (e.key === KEYS.TAB) {
|
if (event.key === KEYS.TAB) {
|
||||||
return;
|
return;
|
||||||
|
} else if (event.key === KEYS.ESCAPE) {
|
||||||
|
eyeDropperTriggerRef.current?.focus();
|
||||||
}
|
}
|
||||||
if (e.key === KEYS.ESCAPE) {
|
event.stopPropagation();
|
||||||
divRef.current?.focus();
|
|
||||||
}
|
|
||||||
e.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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-picker-content--default {
|
.color-picker-content--default {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { isTransparent } from "../../utils";
|
import { isTransparent, isWritableElement } from "../../utils";
|
||||||
import { ExcalidrawElement } from "../../element/types";
|
import { ExcalidrawElement } from "../../element/types";
|
||||||
import { AppState } from "../../types";
|
import { AppState } from "../../types";
|
||||||
import { TopPicks } from "./TopPicks";
|
import { TopPicks } from "./TopPicks";
|
||||||
@ -12,12 +12,14 @@ import {
|
|||||||
import { useDevice, useExcalidrawContainer } from "../App";
|
import { useDevice, useExcalidrawContainer } from "../App";
|
||||||
import { ColorTuple, COLOR_PALETTE, ColorPaletteCustom } from "../../colors";
|
import { ColorTuple, COLOR_PALETTE, ColorPaletteCustom } from "../../colors";
|
||||||
import PickerHeading from "./PickerHeading";
|
import PickerHeading from "./PickerHeading";
|
||||||
import { ColorInput } from "./ColorInput";
|
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { jotaiScope } from "../../jotai";
|
||||||
|
import { ColorInput } from "./ColorInput";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||||
|
|
||||||
import "./ColorPicker.scss";
|
import "./ColorPicker.scss";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
const isValidColor = (color: string) => {
|
const isValidColor = (color: string) => {
|
||||||
const style = new Option().style;
|
const style = new Option().style;
|
||||||
@ -40,9 +42,9 @@ export const getColor = (color: string): string | null => {
|
|||||||
: null;
|
: null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ColorPickerProps {
|
interface ColorPickerProps {
|
||||||
type: ColorPickerType;
|
type: ColorPickerType;
|
||||||
color: string | null;
|
color: string;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
label: string;
|
label: string;
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
@ -72,6 +74,11 @@ const ColorPickerPopupContent = ({
|
|||||||
>) => {
|
>) => {
|
||||||
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
||||||
|
|
||||||
|
const [eyeDropperState, setEyeDropperState] = useAtom(
|
||||||
|
activeEyeDropperAtom,
|
||||||
|
jotaiScope,
|
||||||
|
);
|
||||||
|
|
||||||
const { container } = useExcalidrawContainer();
|
const { container } = useExcalidrawContainer();
|
||||||
const { isMobile, isLandscape } = useDevice();
|
const { isMobile, isLandscape } = useDevice();
|
||||||
|
|
||||||
@ -87,23 +94,42 @@ const ColorPickerPopupContent = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const focusPickerContent = () => {
|
||||||
|
popoverRef.current
|
||||||
|
?.querySelector<HTMLDivElement>(".color-picker-content")
|
||||||
|
?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Portal container={container}>
|
<Popover.Portal container={container}>
|
||||||
<Popover.Content
|
<Popover.Content
|
||||||
|
ref={popoverRef}
|
||||||
className="focus-visible-none"
|
className="focus-visible-none"
|
||||||
data-prevent-outside-click
|
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) => {
|
onCloseAutoFocus={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
// return focus to excalidraw container
|
// return focus to excalidraw container
|
||||||
if (container) {
|
if (container) {
|
||||||
container.focus();
|
container.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateData({ openPopup: null });
|
updateData({ openPopup: null });
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
setActiveColorPickerSection(null);
|
setActiveColorPickerSection(null);
|
||||||
}}
|
}}
|
||||||
side={isMobile && !isLandscape ? "bottom" : "right"}
|
side={isMobile && !isLandscape ? "bottom" : "right"}
|
||||||
@ -126,10 +152,38 @@ const ColorPickerPopupContent = ({
|
|||||||
{palette ? (
|
{palette ? (
|
||||||
<Picker
|
<Picker
|
||||||
palette={palette}
|
palette={palette}
|
||||||
color={color || null}
|
color={color}
|
||||||
onChange={(changedColor) => {
|
onChange={(changedColor) => {
|
||||||
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}
|
label={label}
|
||||||
type={type}
|
type={type}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
@ -158,7 +212,7 @@ const ColorPickerTrigger = ({
|
|||||||
color,
|
color,
|
||||||
type,
|
type,
|
||||||
}: {
|
}: {
|
||||||
color: string | null;
|
color: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: ColorPickerType;
|
type: ColorPickerType;
|
||||||
}) => {
|
}) => {
|
||||||
|
@ -6,7 +6,7 @@ import HotkeyLabel from "./HotkeyLabel";
|
|||||||
|
|
||||||
interface CustomColorListProps {
|
interface CustomColorListProps {
|
||||||
colors: string[];
|
colors: string[];
|
||||||
color: string | null;
|
color: string;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@ import PickerHeading from "./PickerHeading";
|
|||||||
import {
|
import {
|
||||||
ColorPickerType,
|
ColorPickerType,
|
||||||
activeColorPickerSectionAtom,
|
activeColorPickerSectionAtom,
|
||||||
getColorNameAndShadeFromHex,
|
getColorNameAndShadeFromColor,
|
||||||
getMostUsedCustomColors,
|
getMostUsedCustomColors,
|
||||||
isCustomColor,
|
isCustomColor,
|
||||||
} from "./colorPickerUtils";
|
} from "./colorPickerUtils";
|
||||||
@ -21,9 +21,11 @@ import {
|
|||||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||||
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
||||||
} from "../../colors";
|
} from "../../colors";
|
||||||
|
import { KEYS } from "../../keys";
|
||||||
|
import { EVENT } from "../../constants";
|
||||||
|
|
||||||
interface PickerProps {
|
interface PickerProps {
|
||||||
color: string | null;
|
color: string;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
label: string;
|
label: string;
|
||||||
type: ColorPickerType;
|
type: ColorPickerType;
|
||||||
@ -31,6 +33,8 @@ interface PickerProps {
|
|||||||
palette: ColorPaletteCustom;
|
palette: ColorPaletteCustom;
|
||||||
updateData: (formData?: any) => void;
|
updateData: (formData?: any) => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
onEyeDropperToggle: (force?: boolean) => void;
|
||||||
|
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Picker = ({
|
export const Picker = ({
|
||||||
@ -42,6 +46,8 @@ export const Picker = ({
|
|||||||
palette,
|
palette,
|
||||||
updateData,
|
updateData,
|
||||||
children,
|
children,
|
||||||
|
onEyeDropperToggle,
|
||||||
|
onEscape,
|
||||||
}: PickerProps) => {
|
}: PickerProps) => {
|
||||||
const [customColors] = React.useState(() => {
|
const [customColors] = React.useState(() => {
|
||||||
if (type === "canvasBackground") {
|
if (type === "canvasBackground") {
|
||||||
@ -54,16 +60,15 @@ export const Picker = ({
|
|||||||
activeColorPickerSectionAtom,
|
activeColorPickerSectionAtom,
|
||||||
);
|
);
|
||||||
|
|
||||||
const colorObj = getColorNameAndShadeFromHex({
|
const colorObj = getColorNameAndShadeFromColor({
|
||||||
hex: color || "transparent",
|
color,
|
||||||
palette,
|
palette,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeColorPickerSection) {
|
if (!activeColorPickerSection) {
|
||||||
const isCustom = isCustomColor({ color, palette });
|
const isCustom = isCustomColor({ color, palette });
|
||||||
const isCustomButNotInList =
|
const isCustomButNotInList = isCustom && !customColors.includes(color);
|
||||||
isCustom && !customColors.includes(color || "");
|
|
||||||
|
|
||||||
setActiveColorPickerSection(
|
setActiveColorPickerSection(
|
||||||
isCustomButNotInList
|
isCustomButNotInList
|
||||||
@ -95,26 +100,43 @@ export const Picker = ({
|
|||||||
if (colorObj?.shade != null) {
|
if (colorObj?.shade != null) {
|
||||||
setActiveShade(colorObj.shade);
|
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 (
|
return (
|
||||||
<div role="dialog" aria-modal="true" aria-label={t("labels.colorPicker")}>
|
<div role="dialog" aria-modal="true" aria-label={t("labels.colorPicker")}>
|
||||||
<div
|
<div
|
||||||
onKeyDown={(e) => {
|
ref={pickerRef}
|
||||||
e.preventDefault();
|
onKeyDown={(event) => {
|
||||||
e.stopPropagation();
|
const handled = colorPickerKeyNavHandler({
|
||||||
|
event,
|
||||||
colorPickerKeyNavHandler({
|
|
||||||
e,
|
|
||||||
activeColorPickerSection,
|
activeColorPickerSection,
|
||||||
palette,
|
palette,
|
||||||
hex: color,
|
color,
|
||||||
onChange,
|
onChange,
|
||||||
|
onEyeDropperToggle,
|
||||||
customColors,
|
customColors,
|
||||||
setActiveColorPickerSection,
|
setActiveColorPickerSection,
|
||||||
updateData,
|
updateData,
|
||||||
activeShade,
|
activeShade,
|
||||||
|
onEscape,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="color-picker-content"
|
className="color-picker-content"
|
||||||
// to allow focusing by clicking but not by tabbing
|
// to allow focusing by clicking but not by tabbing
|
||||||
|
@ -4,7 +4,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import {
|
import {
|
||||||
activeColorPickerSectionAtom,
|
activeColorPickerSectionAtom,
|
||||||
colorPickerHotkeyBindings,
|
colorPickerHotkeyBindings,
|
||||||
getColorNameAndShadeFromHex,
|
getColorNameAndShadeFromColor,
|
||||||
} from "./colorPickerUtils";
|
} from "./colorPickerUtils";
|
||||||
import HotkeyLabel from "./HotkeyLabel";
|
import HotkeyLabel from "./HotkeyLabel";
|
||||||
import { ColorPaletteCustom } from "../../colors";
|
import { ColorPaletteCustom } from "../../colors";
|
||||||
@ -12,7 +12,7 @@ import { t } from "../../i18n";
|
|||||||
|
|
||||||
interface PickerColorListProps {
|
interface PickerColorListProps {
|
||||||
palette: ColorPaletteCustom;
|
palette: ColorPaletteCustom;
|
||||||
color: string | null;
|
color: string;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
label: string;
|
label: string;
|
||||||
activeShade: number;
|
activeShade: number;
|
||||||
@ -25,8 +25,8 @@ const PickerColorList = ({
|
|||||||
label,
|
label,
|
||||||
activeShade,
|
activeShade,
|
||||||
}: PickerColorListProps) => {
|
}: PickerColorListProps) => {
|
||||||
const colorObj = getColorNameAndShadeFromHex({
|
const colorObj = getColorNameAndShadeFromColor({
|
||||||
hex: color || "transparent",
|
color: color || "transparent",
|
||||||
palette,
|
palette,
|
||||||
});
|
});
|
||||||
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
|
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
|
||||||
|
@ -3,21 +3,21 @@ import { useAtom } from "jotai";
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
activeColorPickerSectionAtom,
|
activeColorPickerSectionAtom,
|
||||||
getColorNameAndShadeFromHex,
|
getColorNameAndShadeFromColor,
|
||||||
} from "./colorPickerUtils";
|
} from "./colorPickerUtils";
|
||||||
import HotkeyLabel from "./HotkeyLabel";
|
import HotkeyLabel from "./HotkeyLabel";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { ColorPaletteCustom } from "../../colors";
|
import { ColorPaletteCustom } from "../../colors";
|
||||||
|
|
||||||
interface ShadeListProps {
|
interface ShadeListProps {
|
||||||
hex: string | null;
|
hex: string;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
palette: ColorPaletteCustom;
|
palette: ColorPaletteCustom;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
|
export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
|
||||||
const colorObj = getColorNameAndShadeFromHex({
|
const colorObj = getColorNameAndShadeFromColor({
|
||||||
hex: hex || "transparent",
|
color: hex || "transparent",
|
||||||
palette,
|
palette,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
interface TopPicksProps {
|
interface TopPicksProps {
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
type: ColorPickerType;
|
type: ColorPickerType;
|
||||||
activeColor: string | null;
|
activeColor: string;
|
||||||
topPicks?: readonly string[];
|
topPicks?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,23 +6,23 @@ import {
|
|||||||
MAX_CUSTOM_COLORS_USED_IN_CANVAS,
|
MAX_CUSTOM_COLORS_USED_IN_CANVAS,
|
||||||
} from "../../colors";
|
} from "../../colors";
|
||||||
|
|
||||||
export const getColorNameAndShadeFromHex = ({
|
export const getColorNameAndShadeFromColor = ({
|
||||||
palette,
|
palette,
|
||||||
hex,
|
color,
|
||||||
}: {
|
}: {
|
||||||
palette: ColorPaletteCustom;
|
palette: ColorPaletteCustom;
|
||||||
hex: string;
|
color: string;
|
||||||
}): {
|
}): {
|
||||||
colorName: ColorPickerColor;
|
colorName: ColorPickerColor;
|
||||||
shade: number | null;
|
shade: number | null;
|
||||||
} | null => {
|
} | null => {
|
||||||
for (const [colorName, colorVal] of Object.entries(palette)) {
|
for (const [colorName, colorVal] of Object.entries(palette)) {
|
||||||
if (Array.isArray(colorVal)) {
|
if (Array.isArray(colorVal)) {
|
||||||
const shade = colorVal.indexOf(hex);
|
const shade = colorVal.indexOf(color);
|
||||||
if (shade > -1) {
|
if (shade > -1) {
|
||||||
return { colorName: colorName as ColorPickerColor, shade };
|
return { colorName: colorName as ColorPickerColor, shade };
|
||||||
}
|
}
|
||||||
} else if (colorVal === hex) {
|
} else if (colorVal === color) {
|
||||||
return { colorName: colorName as ColorPickerColor, shade: null };
|
return { colorName: colorName as ColorPickerColor, shade: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -39,12 +39,9 @@ export const isCustomColor = ({
|
|||||||
color,
|
color,
|
||||||
palette,
|
palette,
|
||||||
}: {
|
}: {
|
||||||
color: string | null;
|
color: string;
|
||||||
palette: ColorPaletteCustom;
|
palette: ColorPaletteCustom;
|
||||||
}) => {
|
}) => {
|
||||||
if (!color) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const paletteValues = Object.values(palette).flat();
|
const paletteValues = Object.values(palette).flat();
|
||||||
return !paletteValues.includes(color);
|
return !paletteValues.includes(color);
|
||||||
};
|
};
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { KEYS } from "../../keys";
|
||||||
import {
|
import {
|
||||||
ColorPickerColor,
|
ColorPickerColor,
|
||||||
ColorPalette,
|
ColorPalette,
|
||||||
@ -5,12 +6,11 @@ import {
|
|||||||
COLORS_PER_ROW,
|
COLORS_PER_ROW,
|
||||||
COLOR_PALETTE,
|
COLOR_PALETTE,
|
||||||
} from "../../colors";
|
} from "../../colors";
|
||||||
import { KEYS } from "../../keys";
|
|
||||||
import { ValueOf } from "../../utility-types";
|
import { ValueOf } from "../../utility-types";
|
||||||
import {
|
import {
|
||||||
ActiveColorPickerSectionAtomType,
|
ActiveColorPickerSectionAtomType,
|
||||||
colorPickerHotkeyBindings,
|
colorPickerHotkeyBindings,
|
||||||
getColorNameAndShadeFromHex,
|
getColorNameAndShadeFromColor,
|
||||||
} from "./colorPickerUtils";
|
} from "./colorPickerUtils";
|
||||||
|
|
||||||
const arrowHandler = (
|
const arrowHandler = (
|
||||||
@ -55,6 +55,9 @@ interface HotkeyHandlerProps {
|
|||||||
activeShade: number;
|
activeShade: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns true if the event was handled
|
||||||
|
*/
|
||||||
const hotkeyHandler = ({
|
const hotkeyHandler = ({
|
||||||
e,
|
e,
|
||||||
colorObj,
|
colorObj,
|
||||||
@ -63,7 +66,7 @@ const hotkeyHandler = ({
|
|||||||
customColors,
|
customColors,
|
||||||
setActiveColorPickerSection,
|
setActiveColorPickerSection,
|
||||||
activeShade,
|
activeShade,
|
||||||
}: HotkeyHandlerProps) => {
|
}: HotkeyHandlerProps): boolean => {
|
||||||
if (colorObj?.shade != null) {
|
if (colorObj?.shade != null) {
|
||||||
// shift + numpad is extremely messed up on windows apparently
|
// shift + numpad is extremely messed up on windows apparently
|
||||||
if (
|
if (
|
||||||
@ -73,6 +76,7 @@ const hotkeyHandler = ({
|
|||||||
const newShade = Number(e.code.slice(-1)) - 1;
|
const newShade = Number(e.code.slice(-1)) - 1;
|
||||||
onChange(palette[colorObj.colorName][newShade]);
|
onChange(palette[colorObj.colorName][newShade]);
|
||||||
setActiveColorPickerSection("shades");
|
setActiveColorPickerSection("shades");
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,6 +85,7 @@ const hotkeyHandler = ({
|
|||||||
if (c) {
|
if (c) {
|
||||||
onChange(customColors[Number(e.key) - 1]);
|
onChange(customColors[Number(e.key) - 1]);
|
||||||
setActiveColorPickerSection("custom");
|
setActiveColorPickerSection("custom");
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,14 +98,16 @@ const hotkeyHandler = ({
|
|||||||
: paletteValue;
|
: paletteValue;
|
||||||
onChange(r);
|
onChange(r);
|
||||||
setActiveColorPickerSection("baseColors");
|
setActiveColorPickerSection("baseColors");
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ColorPickerKeyNavHandlerProps {
|
interface ColorPickerKeyNavHandlerProps {
|
||||||
e: React.KeyboardEvent;
|
event: React.KeyboardEvent;
|
||||||
activeColorPickerSection: ActiveColorPickerSectionAtomType;
|
activeColorPickerSection: ActiveColorPickerSectionAtomType;
|
||||||
palette: ColorPaletteCustom;
|
palette: ColorPaletteCustom;
|
||||||
hex: string | null;
|
color: string;
|
||||||
onChange: (color: string) => void;
|
onChange: (color: string) => void;
|
||||||
customColors: string[];
|
customColors: string[];
|
||||||
setActiveColorPickerSection: (
|
setActiveColorPickerSection: (
|
||||||
@ -108,27 +115,49 @@ interface ColorPickerKeyNavHandlerProps {
|
|||||||
) => void;
|
) => void;
|
||||||
updateData: (formData?: any) => void;
|
updateData: (formData?: any) => void;
|
||||||
activeShade: number;
|
activeShade: number;
|
||||||
|
onEyeDropperToggle: (force?: boolean) => void;
|
||||||
|
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns true if the event was handled
|
||||||
|
*/
|
||||||
export const colorPickerKeyNavHandler = ({
|
export const colorPickerKeyNavHandler = ({
|
||||||
e,
|
event,
|
||||||
activeColorPickerSection,
|
activeColorPickerSection,
|
||||||
palette,
|
palette,
|
||||||
hex,
|
color,
|
||||||
onChange,
|
onChange,
|
||||||
customColors,
|
customColors,
|
||||||
setActiveColorPickerSection,
|
setActiveColorPickerSection,
|
||||||
updateData,
|
updateData,
|
||||||
activeShade,
|
activeShade,
|
||||||
}: ColorPickerKeyNavHandlerProps) => {
|
onEyeDropperToggle,
|
||||||
if (e.key === KEYS.ESCAPE || !hex) {
|
onEscape,
|
||||||
updateData({ openPopup: null });
|
}: ColorPickerKeyNavHandlerProps): boolean => {
|
||||||
return;
|
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<
|
const sectionsMap: Record<
|
||||||
NonNullable<ActiveColorPickerSectionAtomType>,
|
NonNullable<ActiveColorPickerSectionAtomType>,
|
||||||
boolean
|
boolean
|
||||||
@ -147,7 +176,7 @@ export const colorPickerKeyNavHandler = ({
|
|||||||
}, [] as ActiveColorPickerSectionAtomType[]);
|
}, [] as ActiveColorPickerSectionAtomType[]);
|
||||||
|
|
||||||
const activeSectionIndex = sections.indexOf(activeColorPickerSection);
|
const activeSectionIndex = sections.indexOf(activeColorPickerSection);
|
||||||
const indexOffset = e.shiftKey ? -1 : 1;
|
const indexOffset = event.shiftKey ? -1 : 1;
|
||||||
const nextSectionIndex =
|
const nextSectionIndex =
|
||||||
activeSectionIndex + indexOffset > sections.length - 1
|
activeSectionIndex + indexOffset > sections.length - 1
|
||||||
? 0
|
? 0
|
||||||
@ -168,8 +197,8 @@ export const colorPickerKeyNavHandler = ({
|
|||||||
Object.entries(palette) as [string, ValueOf<ColorPalette>][]
|
Object.entries(palette) as [string, ValueOf<ColorPalette>][]
|
||||||
).find(([name, shades]) => {
|
).find(([name, shades]) => {
|
||||||
if (Array.isArray(shades)) {
|
if (Array.isArray(shades)) {
|
||||||
return shades.includes(hex);
|
return shades.includes(color);
|
||||||
} else if (shades === hex) {
|
} else if (shades === color) {
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -180,29 +209,34 @@ export const colorPickerKeyNavHandler = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
e.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
hotkeyHandler({
|
if (
|
||||||
e,
|
hotkeyHandler({
|
||||||
colorObj,
|
e: event,
|
||||||
onChange,
|
colorObj,
|
||||||
palette,
|
onChange,
|
||||||
customColors,
|
palette,
|
||||||
setActiveColorPickerSection,
|
customColors,
|
||||||
activeShade,
|
setActiveColorPickerSection,
|
||||||
});
|
activeShade,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (activeColorPickerSection === "shades") {
|
if (activeColorPickerSection === "shades") {
|
||||||
if (colorObj) {
|
if (colorObj) {
|
||||||
const { shade } = 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) {
|
if (newShade !== undefined) {
|
||||||
onChange(palette[colorObj.colorName][newShade]);
|
onChange(palette[colorObj.colorName][newShade]);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -214,7 +248,7 @@ export const colorPickerKeyNavHandler = ({
|
|||||||
const indexOfColorName = colorNames.indexOf(colorName);
|
const indexOfColorName = colorNames.indexOf(colorName);
|
||||||
|
|
||||||
const newColorIndex = arrowHandler(
|
const newColorIndex = arrowHandler(
|
||||||
e.key,
|
event.key,
|
||||||
indexOfColorName,
|
indexOfColorName,
|
||||||
colorNames.length,
|
colorNames.length,
|
||||||
);
|
);
|
||||||
@ -228,15 +262,16 @@ export const colorPickerKeyNavHandler = ({
|
|||||||
? newColorNameValue[activeShade]
|
? newColorNameValue[activeShade]
|
||||||
: newColorNameValue,
|
: newColorNameValue,
|
||||||
);
|
);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeColorPickerSection === "custom") {
|
if (activeColorPickerSection === "custom") {
|
||||||
const indexOfColor = customColors.indexOf(hex);
|
const indexOfColor = customColors.indexOf(color);
|
||||||
|
|
||||||
const newColorIndex = arrowHandler(
|
const newColorIndex = arrowHandler(
|
||||||
e.key,
|
event.key,
|
||||||
indexOfColor,
|
indexOfColor,
|
||||||
customColors.length,
|
customColors.length,
|
||||||
);
|
);
|
||||||
@ -244,6 +279,9 @@ export const colorPickerKeyNavHandler = ({
|
|||||||
if (newColorIndex !== undefined) {
|
if (newColorIndex !== undefined) {
|
||||||
const newColor = customColors[newColorIndex];
|
const newColor = customColors[newColorIndex];
|
||||||
onChange(newColor);
|
onChange(newColor);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
@ -12,7 +12,6 @@ import "./Dialog.scss";
|
|||||||
import { back, CloseIcon } from "./icons";
|
import { back, CloseIcon } from "./icons";
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import { Modal } from "./Modal";
|
import { Modal } from "./Modal";
|
||||||
import { AppState } from "../types";
|
|
||||||
import { queryFocusableElements } from "../utils";
|
import { queryFocusableElements } from "../utils";
|
||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||||
@ -25,7 +24,6 @@ export interface DialogProps {
|
|||||||
onCloseRequest(): void;
|
onCloseRequest(): void;
|
||||||
title: React.ReactNode | false;
|
title: React.ReactNode | false;
|
||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
theme?: AppState["theme"];
|
|
||||||
closeOnClickOutside?: boolean;
|
closeOnClickOutside?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,7 +89,6 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
props.size === "wide" ? 1024 : props.size === "small" ? 550 : 800
|
props.size === "wide" ? 1024 : props.size === "small" ? 550 : 800
|
||||||
}
|
}
|
||||||
onCloseRequest={onClose}
|
onCloseRequest={onClose}
|
||||||
theme={props.theme}
|
|
||||||
closeOnClickOutside={props.closeOnClickOutside}
|
closeOnClickOutside={props.closeOnClickOutside}
|
||||||
>
|
>
|
||||||
<Island ref={setIslandNode}>
|
<Island ref={setIslandNode}>
|
||||||
|
48
src/components/EyeDropper.scss
Normal file
48
src/components/EyeDropper.scss
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
.excalidraw {
|
||||||
|
.excalidraw-eye-dropper-container,
|
||||||
|
.excalidraw-eye-dropper-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
215
src/components/EyeDropper.tsx
Normal file
215
src/components/EyeDropper.tsx
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
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,
|
||||||
|
clientY * window.devicePixelRatio,
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
};
|
@ -164,6 +164,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("toolBar.eraser")}
|
label={t("toolBar.eraser")}
|
||||||
shortcuts={[KEYS.E, KEYS["0"]]}
|
shortcuts={[KEYS.E, KEYS["0"]]}
|
||||||
/>
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.eyeDropper")}
|
||||||
|
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
|
||||||
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("helpDialog.editLineArrowPoints")}
|
label={t("helpDialog.editLineArrowPoints")}
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+Enter")]}
|
shortcuts={[getShortcutKey("CtrlOrCmd+Enter")]}
|
||||||
|
@ -38,7 +38,7 @@ import { actionToggleStats } from "../actions/actionToggleStats";
|
|||||||
import Footer from "./footer/Footer";
|
import Footer from "./footer/Footer";
|
||||||
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||||
import { jotaiScope } from "../jotai";
|
import { jotaiScope } from "../jotai";
|
||||||
import { Provider, useAtomValue } from "jotai";
|
import { Provider, useAtom, useAtomValue } from "jotai";
|
||||||
import MainMenu from "./main-menu/MainMenu";
|
import MainMenu from "./main-menu/MainMenu";
|
||||||
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
|
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
|
||||||
import { HandButton } from "./HandButton";
|
import { HandButton } from "./HandButton";
|
||||||
@ -47,6 +47,7 @@ import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
|
|||||||
import { LibraryIcon } from "./icons";
|
import { LibraryIcon } from "./icons";
|
||||||
import { UIAppStateContext } from "../context/ui-appState";
|
import { UIAppStateContext } from "../context/ui-appState";
|
||||||
import { DefaultSidebar } from "./DefaultSidebar";
|
import { DefaultSidebar } from "./DefaultSidebar";
|
||||||
|
import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
|
||||||
|
|
||||||
import "./LayerUI.scss";
|
import "./LayerUI.scss";
|
||||||
import "./Toolbar.scss";
|
import "./Toolbar.scss";
|
||||||
@ -120,6 +121,11 @@ const LayerUI = ({
|
|||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const tunnels = useInitializeTunnels();
|
const tunnels = useInitializeTunnels();
|
||||||
|
|
||||||
|
const [eyeDropperState, setEyeDropperState] = useAtom(
|
||||||
|
activeEyeDropperAtom,
|
||||||
|
jotaiScope,
|
||||||
|
);
|
||||||
|
|
||||||
const renderJSONExportDialog = () => {
|
const renderJSONExportDialog = () => {
|
||||||
if (!UIOptions.canvasActions.export) {
|
if (!UIOptions.canvasActions.export) {
|
||||||
return null;
|
return null;
|
||||||
@ -350,6 +356,21 @@ const LayerUI = ({
|
|||||||
{appState.errorMessage}
|
{appState.errorMessage}
|
||||||
</ErrorDialog>
|
</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" && (
|
{appState.openDialog === "help" && (
|
||||||
<HelpDialog
|
<HelpDialog
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@ -371,7 +392,7 @@ const LayerUI = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{device.isMobile && (
|
{device.isMobile && !eyeDropperState && (
|
||||||
<MobileMenu
|
<MobileMenu
|
||||||
appState={appState}
|
appState={appState}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import "./Modal.scss";
|
import "./Modal.scss";
|
||||||
|
|
||||||
import React, { useState, useLayoutEffect, useRef } from "react";
|
import React from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { useExcalidrawContainer, useDevice } from "./App";
|
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { THEME } from "../constants";
|
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
|
||||||
|
|
||||||
export const Modal: React.FC<{
|
export const Modal: React.FC<{
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -17,8 +16,10 @@ export const Modal: React.FC<{
|
|||||||
theme?: AppState["theme"];
|
theme?: AppState["theme"];
|
||||||
closeOnClickOutside?: boolean;
|
closeOnClickOutside?: boolean;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const { theme = THEME.LIGHT, closeOnClickOutside = true } = props;
|
const { closeOnClickOutside = true } = props;
|
||||||
const modalRoot = useBodyRoot(theme);
|
const modalRoot = useCreatePortalContainer({
|
||||||
|
className: "excalidraw-modal-container",
|
||||||
|
});
|
||||||
|
|
||||||
if (!modalRoot) {
|
if (!modalRoot) {
|
||||||
return null;
|
return null;
|
||||||
@ -56,43 +57,3 @@ export const Modal: React.FC<{
|
|||||||
modalRoot,
|
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;
|
|
||||||
};
|
|
||||||
|
@ -6,7 +6,6 @@ import React, {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useCallback,
|
useCallback,
|
||||||
RefObject,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Island } from ".././Island";
|
import { Island } from ".././Island";
|
||||||
import { atom, useSetAtom } from "jotai";
|
import { atom, useSetAtom } from "jotai";
|
||||||
@ -27,38 +26,10 @@ import { SidebarTabTriggers } from "./SidebarTabTriggers";
|
|||||||
import { SidebarTabTrigger } from "./SidebarTabTrigger";
|
import { SidebarTabTrigger } from "./SidebarTabTrigger";
|
||||||
import { SidebarTabs } from "./SidebarTabs";
|
import { SidebarTabs } from "./SidebarTabs";
|
||||||
import { SidebarTab } from "./SidebarTab";
|
import { SidebarTab } from "./SidebarTab";
|
||||||
|
import { useUIAppState } from "../../context/ui-appState";
|
||||||
|
import { useOutsideClick } from "../../hooks/useOutsideClick";
|
||||||
|
|
||||||
import "./Sidebar.scss";
|
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
|
* Flags whether the currently rendered Sidebar is docked or not, for use
|
||||||
@ -133,7 +104,7 @@ export const SidebarInner = forwardRef(
|
|||||||
setAppState({ openSidebar: null });
|
setAppState({ openSidebar: null });
|
||||||
}, [setAppState]);
|
}, [setAppState]);
|
||||||
|
|
||||||
useOnClickOutside(
|
useOutsideClick(
|
||||||
islandRef,
|
islandRef,
|
||||||
useCallback(
|
useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
import { useOutsideClick } from "../../hooks/useOutsideClick";
|
|
||||||
import { Island } from "../Island";
|
import { Island } from "../Island";
|
||||||
|
|
||||||
import { useDevice } from "../App";
|
import { useDevice } from "../App";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Stack from "../Stack";
|
import Stack from "../Stack";
|
||||||
import React from "react";
|
import React, { useRef } from "react";
|
||||||
import { DropdownMenuContentPropsContext } from "./common";
|
import { DropdownMenuContentPropsContext } from "./common";
|
||||||
|
import { useOutsideClick } from "../../hooks/useOutsideClick";
|
||||||
|
|
||||||
const MenuContent = ({
|
const MenuContent = ({
|
||||||
children,
|
children,
|
||||||
@ -24,7 +23,9 @@ const MenuContent = ({
|
|||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}) => {
|
}) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const menuRef = useOutsideClick(() => {
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useOutsideClick(menuRef, () => {
|
||||||
onClickOutside?.();
|
onClickOutside?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1607,3 +1607,12 @@ export const tablerCheckIcon = createIcon(
|
|||||||
</>,
|
</>,
|
||||||
tablerIconProps,
|
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,
|
||||||
|
);
|
||||||
|
@ -59,6 +59,7 @@ export enum EVENT {
|
|||||||
GESTURE_START = "gesturestart",
|
GESTURE_START = "gesturestart",
|
||||||
GESTURE_CHANGE = "gesturechange",
|
GESTURE_CHANGE = "gesturechange",
|
||||||
POINTER_MOVE = "pointermove",
|
POINTER_MOVE = "pointermove",
|
||||||
|
POINTER_DOWN = "pointerdown",
|
||||||
POINTER_UP = "pointerup",
|
POINTER_UP = "pointerup",
|
||||||
STATE_CHANGE = "statechange",
|
STATE_CHANGE = "statechange",
|
||||||
WHEEL = "wheel",
|
WHEEL = "wheel",
|
||||||
|
@ -834,7 +834,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
setErrorMessage={(errorMessage) => {
|
setErrorMessage={(errorMessage) => {
|
||||||
this.setState({ errorMessage });
|
this.setState({ errorMessage });
|
||||||
}}
|
}}
|
||||||
theme={this.excalidrawAPI.getAppState().theme}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
|
@ -2,7 +2,6 @@ import { useRef, useState } from "react";
|
|||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
|
||||||
import { copyTextToSystemClipboard } from "../../clipboard";
|
import { copyTextToSystemClipboard } from "../../clipboard";
|
||||||
import { AppState } from "../../types";
|
|
||||||
import { trackEvent } from "../../analytics";
|
import { trackEvent } from "../../analytics";
|
||||||
import { getFrame } from "../../utils";
|
import { getFrame } from "../../utils";
|
||||||
import { useI18n } from "../../i18n";
|
import { useI18n } from "../../i18n";
|
||||||
@ -46,7 +45,6 @@ export type RoomModalProps = {
|
|||||||
onRoomCreate: () => void;
|
onRoomCreate: () => void;
|
||||||
onRoomDestroy: () => void;
|
onRoomDestroy: () => void;
|
||||||
setErrorMessage: (message: string) => void;
|
setErrorMessage: (message: string) => void;
|
||||||
theme: AppState["theme"];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RoomModal = ({
|
export const RoomModal = ({
|
||||||
@ -210,12 +208,7 @@ export const RoomModal = ({
|
|||||||
|
|
||||||
const RoomDialog = (props: RoomModalProps) => {
|
const RoomDialog = (props: RoomModalProps) => {
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog size="small" onCloseRequest={props.handleClose} title={false}>
|
||||||
size="small"
|
|
||||||
onCloseRequest={props.handleClose}
|
|
||||||
title={false}
|
|
||||||
theme={props.theme}
|
|
||||||
>
|
|
||||||
<div className="RoomDialog">
|
<div className="RoomDialog">
|
||||||
<RoomModal {...props} />
|
<RoomModal {...props} />
|
||||||
</div>
|
</div>
|
||||||
|
49
src/hooks/useCreatePortalContainer.ts
Normal file
49
src/hooks/useCreatePortalContainer.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { useState, useRef, useLayoutEffect } from "react";
|
||||||
|
import { useDevice, useExcalidrawContainer } from "../components/App";
|
||||||
|
import { useUIAppState } from "../context/ui-appState";
|
||||||
|
|
||||||
|
export const useCreatePortalContainer = (opts?: {
|
||||||
|
className?: string;
|
||||||
|
parentSelector?: string;
|
||||||
|
}) => {
|
||||||
|
const [div, setDiv] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const device = useDevice();
|
||||||
|
const { theme } = useUIAppState();
|
||||||
|
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 container = opts?.parentSelector
|
||||||
|
? excalidrawContainer?.querySelector(opts.parentSelector)
|
||||||
|
: document.body;
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const div = document.createElement("div");
|
||||||
|
|
||||||
|
div.classList.add("excalidraw", ...(opts?.className?.split(/\s+/) || []));
|
||||||
|
div.classList.toggle("excalidraw--mobile", isMobileRef.current);
|
||||||
|
div.classList.toggle("theme--dark", theme === "dark");
|
||||||
|
|
||||||
|
container.appendChild(div);
|
||||||
|
|
||||||
|
setDiv(div);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
container.removeChild(div);
|
||||||
|
};
|
||||||
|
}, [excalidrawContainer, theme, opts?.className, opts?.parentSelector]);
|
||||||
|
|
||||||
|
return div;
|
||||||
|
};
|
@ -1,42 +1,86 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { EVENT } from "../constants";
|
||||||
|
|
||||||
export const useOutsideClick = (handler: (event: Event) => void) => {
|
export function useOutsideClick<T extends HTMLElement>(
|
||||||
const ref = useRef(null);
|
ref: React.RefObject<T>,
|
||||||
|
/** if performance is of concern, memoize the callback */
|
||||||
|
callback: (event: Event) => void,
|
||||||
|
/**
|
||||||
|
* Optional callback which is called on every click.
|
||||||
|
*
|
||||||
|
* Should return `true` if click should be considered as inside the container,
|
||||||
|
* and `false` if it falls outside and should call the `callback`.
|
||||||
|
*
|
||||||
|
* Returning `true` overrides the default behavior and `callback` won't be
|
||||||
|
* called.
|
||||||
|
*
|
||||||
|
* Returning `undefined` will fallback to the default behavior.
|
||||||
|
*/
|
||||||
|
isInside?: (
|
||||||
|
event: Event & { target: HTMLElement },
|
||||||
|
/** the element of the passed ref */
|
||||||
|
container: T,
|
||||||
|
) => boolean | undefined,
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
function onOutsideClick(event: Event) {
|
||||||
|
const _event = event as Event & { target: T };
|
||||||
|
|
||||||
useEffect(
|
if (!ref.current) {
|
||||||
() => {
|
return;
|
||||||
const listener = (event: Event) => {
|
}
|
||||||
const current = ref.current as HTMLElement | null;
|
|
||||||
|
|
||||||
// Do nothing if clicking ref's element or descendent elements
|
const isInsideOverride = isInside?.(_event, ref.current);
|
||||||
if (
|
|
||||||
!current ||
|
|
||||||
current.contains(event.target as Node) ||
|
|
||||||
[...document.querySelectorAll("[data-prevent-outside-click]")].some(
|
|
||||||
(el) => el.contains(event.target as Node),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handler(event);
|
if (isInsideOverride === true) {
|
||||||
};
|
return;
|
||||||
|
} else if (isInsideOverride === false) {
|
||||||
|
return callback(_event);
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("pointerdown", listener);
|
// clicked element is in the descenendant of the target container
|
||||||
document.addEventListener("touchstart", listener);
|
if (
|
||||||
return () => {
|
ref.current.contains(_event.target) ||
|
||||||
document.removeEventListener("pointerdown", listener);
|
// target is detached from DOM (happens when the element is removed
|
||||||
document.removeEventListener("touchstart", listener);
|
// on a pointerup event fired *before* this handler's pointerup is
|
||||||
};
|
// dispatched)
|
||||||
},
|
!document.documentElement.contains(_event.target)
|
||||||
// Add ref and handler to effect dependencies
|
) {
|
||||||
// It's worth noting that because passed in handler is a new ...
|
return;
|
||||||
// ... function on every render that will cause this effect ...
|
}
|
||||||
// ... callback/cleanup to run every render. It's not a big deal ...
|
|
||||||
// ... but to optimize you can wrap handler in useCallback before ...
|
|
||||||
// ... passing it into this hook.
|
|
||||||
[ref, handler],
|
|
||||||
);
|
|
||||||
|
|
||||||
return ref;
|
const isClickOnRadixPortal =
|
||||||
};
|
_event.target.closest("[data-radix-portal]") ||
|
||||||
|
// when radix popup is in "modal" mode, it disables pointer events on
|
||||||
|
// the `body` element, so the target element is going to be the `html`
|
||||||
|
// (note: this won't work if we selectively re-enable pointer events on
|
||||||
|
// specific elements as we do with navbar or excalidraw UI elements)
|
||||||
|
(_event.target === document.documentElement &&
|
||||||
|
document.body.style.pointerEvents === "none");
|
||||||
|
|
||||||
|
// if clicking on radix portal, assume it's a popup that
|
||||||
|
// should be considered as part of the UI. Obviously this is a terrible
|
||||||
|
// hack you can end up click on radix popups that outside the tree,
|
||||||
|
// but it works for most cases and the downside is minimal for now
|
||||||
|
if (isClickOnRadixPortal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// clicking on a container that ignores outside clicks
|
||||||
|
if (_event.target.closest("[data-prevent-outside-click]")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(_event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// note: don't use `click` because it often reports incorrect `event.target`
|
||||||
|
document.addEventListener(EVENT.POINTER_DOWN, onOutsideClick);
|
||||||
|
document.addEventListener(EVENT.TOUCH_START, onOutsideClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener(EVENT.POINTER_DOWN, onOutsideClick);
|
||||||
|
document.removeEventListener(EVENT.TOUCH_START, onOutsideClick);
|
||||||
|
};
|
||||||
|
}, [ref, callback, isInside]);
|
||||||
|
}
|
||||||
|
@ -123,7 +123,8 @@
|
|||||||
"unlockAll": "Unlock all"
|
"unlockAll": "Unlock all"
|
||||||
},
|
},
|
||||||
"statusPublished": "Published",
|
"statusPublished": "Published",
|
||||||
"sidebarLock": "Keep sidebar open"
|
"sidebarLock": "Keep sidebar open",
|
||||||
|
"eyeDropper": "Pick color from canvas"
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"noItems": "No items added yet...",
|
"noItems": "No items added yet...",
|
||||||
|
@ -439,6 +439,7 @@ export type AppClassProperties = {
|
|||||||
id: App["id"];
|
id: App["id"];
|
||||||
onInsertElements: App["onInsertElements"];
|
onInsertElements: App["onInsertElements"];
|
||||||
onExportImage: App["onExportImage"];
|
onExportImage: App["onExportImage"];
|
||||||
|
lastViewportPosition: App["lastViewportPosition"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PointerDownState = Readonly<{
|
export type PointerDownState = Readonly<{
|
||||||
|
@ -7143,10 +7143,10 @@ jiti@^1.17.2:
|
|||||||
resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.18.2.tgz#80c3ef3d486ebf2450d9335122b32d121f2a83cd"
|
resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.18.2.tgz#80c3ef3d486ebf2450d9335122b32d121f2a83cd"
|
||||||
integrity sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==
|
integrity sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==
|
||||||
|
|
||||||
jotai@1.6.4:
|
jotai@1.13.1:
|
||||||
version "1.6.4"
|
version "1.13.1"
|
||||||
resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.6.4.tgz#4d9904362c53c4293d32e21fb358d3de34b82912"
|
resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.13.1.tgz#20cc46454cbb39096b12fddfa635b873b3668236"
|
||||||
integrity sha512-XC0ExLhdE6FEBdIjKTe6kMlHaAUV/QiwN7vZond76gNr/WdcdonJOEW79+5t8u38sR41bJXi26B1dRi7cCRz9A==
|
integrity sha512-RUmH1S4vLsG3V6fbGlKzGJnLrDcC/HNb5gH2AeA9DzuJknoVxSGvvg8OBB7lke+gDc4oXmdVsaKn/xDUhWZ0vw==
|
||||||
|
|
||||||
js-sdsl@^4.1.4:
|
js-sdsl@^4.1.4:
|
||||||
version "4.4.0"
|
version "4.4.0"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user